diff --git a/brain.md b/brain.md index 6e6a99b..19bce53 100644 --- a/brain.md +++ b/brain.md @@ -1,7 +1,7 @@ # Fleet — 코드베이스 브레인 (자동 생성) > `npm run brain` 로 `src/` 에서 자동 추출한 구조 지도다. **코드를 탐색하기 전에 이 파일을 먼저 읽어** 토큰을 아껴라. -> 87 files · 222 import wires · 47 IPC channels · 생성 2026-07-09T06:08 UTC +> 87 files · 222 import wires · 47 IPC channels · 생성 2026-07-13T09:03 UTC > 표기: `파일 — 역할 · →의존 · ←피의존`. id 는 `main/core/` 생략(예: `session/manager`). ## 레이어 (위 → 아래로 흐름) @@ -62,7 +62,7 @@ - **renderer/components/AddAiWizard** - →의존: renderer/bridge/errors, renderer/components/authBanners, shared/cliAuthInstallMeta, shared/types · ←피의존: renderer/components/SessionsPanel · 491줄 - **renderer/components/SessionsPanel** — 어떤 AI를 쓸지 등록하고 설정하는 세션 관리 화면 _컴퓨터에 깔린 클로드·코덱스 같은 CLI 도구를 감지해 등록하거나, API 키를 넣어 Anthropic·OpenAI·Google AI를 추가하고, 각 AI가 잘하는 역할과 외부 도구(MCP) 연결도 지정합니다._ - - →의존: renderer/bridge/errors, renderer/bridge/hydration, renderer/components/AddAiWizard, shared/types · ←피의존: renderer/App · 306줄 + - →의존: renderer/bridge/errors, renderer/bridge/hydration, renderer/components/AddAiWizard, shared/types · ←피의존: renderer/App · 310줄 - **renderer/bridge/errors** - →의존: renderer/bridge/ws-bridge · ←피의존: renderer/components/AddAiWizard, renderer/components/ProjectPanel, renderer/components/SessionsPanel · 25줄 - **renderer/components/ChatPanel** — 여러 AI와 한 작업방에서 대화하고 자동 토론을 시키는 채팅 화면 _작업방을 만들어 사용자가 메시지를 보내고 특정 AI에게 묻거나 여러 AI를 자동으로 토론시킬 수 있으며, AI 답변이 한 글자씩 실시간으로 흘러나오는 모습을 말풍선으로 보여줍니다. 탭을 떠났다 돌아와도 진행 중이던 대화가 사라지지 않게 상태를 되살립니다._ diff --git a/docs/superpowers/plans/2026-07-13-issue221-mobile-responsive-plan.md b/docs/superpowers/plans/2026-07-13-issue221-mobile-responsive-plan.md new file mode 100644 index 0000000..f27816f --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-issue221-mobile-responsive-plan.md @@ -0,0 +1,184 @@ +# #221 웹모드 렌더러 전체 모바일 반응형 — 구현 계획 (판사 패널 합성판) + +- **스펙(권위)**: `docs/superpowers/specs/2026-07-13-issue221-mobile-responsive-design.md` (r3 · Codex 체크포인트 clean) +- **날짜**: 2026-07-13 + +## 판사 패널 판정 요약 (fleet-planner ×3 → fleet-plan-judge ×2) + +| 판사(렌즈 그룹) | A(리스크) | B(MVP) | C(계약) | 승자 | +| --- | --- | --- | --- | --- | +| 공백 그룹(동적 검증 실효·커버리지·과설계) | 48 | 40 | **50** | C | +| Codex 강점 그룹(계약 drift·경계값·ripple·기존 테스트) | 51 | 41 | **56** | C | + +**판정: 승자 일치 = 초안 C(계약 우선) 골격 채택.** 근거 — C만이 표본 검증에서 전부 참으로 확인된 +실존 결함 4건을 적발(기존 C2 `flex-wrap` 핀 공허 · `.update-banner` fixed→scrollWidth 단언 blind · +`.rooms` 비-버튼 자식 처리 공백 · approval-hold 절대 기하 단언 파손 리스크). 이식(A: RED 정직성 +메타규칙·`test.fail()` 규율·`:606` 스윕·1280 기준선·`.summary` 정정 기록 / B: 미감 시작값 표·의도적 +위반 mutation 검증·라이브 체크리스트 3항목)과 판사 공통 결함 보강 3건(프로젝트 시드 확장·배너 e2e +기계화·스크롤 성능 항목) 및 C 자체 수정 1건(R10 핀 완화 — 스펙 §6-3 충돌 해소)을 반영했다. +초안 B의 "기존 web e2e가 데스크톱 무회귀 겸임" 가정은 두 판사 모두 반증(approval-hold/handoff가 +390×844 폰 뷰포트) — 본 계획은 명시적 데스크톱 가드(R1)를 채택한다. + +- 검증 명령 약칭: `VERIFY` = `npm run verify`(7게이트) · `E2E-WEB` = `npm run build && npx playwright test --project=web` · `E2E-ALL` = `npm run test:e2e`. +- 브랜치: PR1 = 현 `feat/issue221-mobile-responsive` 승계 · PR2 = 별도 브랜치. PR1 본문 `Part of #221` · PR2 본문 `Closes #221`. +- **표기 정정(스펙 대비·이식 A)**: 스펙 §2/§6의 `pre.summary`는 실물 셀렉터 `.summary`(styles.css:935 — JSX `
`)를 가리킨다. 구현·핀은 `.summary`를 대상으로 한다(`pre.summary` 신설 금지 — 특이도 상승으로 캐스케이드 변경). PR2 마감 시 이슈 코멘트로 기록.
+
+## 공통 규율 (전 태스크)
+
+- diff 규율 = 스펙 §6: "diff ⊆ {640px 블록} ∪ {열거 예외}". 각 예외는 PR 본문에 데스크톱 no-op 근거 기재.
+- **RED 정직성 메타규칙(이식 A)**: 구현 0줄 상태에서 RED여야 할 단언이 PASS면 그 단언을 blind로
+  판정하고 조사·교체한다(예: scrollWidth 단언이 스크롤 컨테이너를 잘못 잡은 경우).
+- **RED 커밋 규율(이식 A)**: RED 상태로 커밋되는 e2e 케이스는 `test.fail()` 마킹(실패=정상 기계 선언
+  · GREEN 전환 시 자동 경보) — GREEN 화 태스크에서 해제.
+- **사후 단언 mutation 규율(이식 B)**: 스타일 완료 후 작성돼 즉시 GREEN인 단언은 의도적 위반 삽입으로
+  RED 1회 확인 후 원복(핀 실효성 증명). **위반 스캐폴딩은 커밋 금지 — 로컬 확인 후 완전 원복**(Codex 계획 1R 노트).
+- **미감 수치 시작값(이식 B)** — 핀 금지·라이브 실측 조정 대상: topbar gap 12px·topbar padding 10px
+  14px·main padding 14px·body 14px·캡션(chip/field-label/eyebrow) 11px·자간 0.08~0.12em급.
+  계약 수치(핀 대상)는 640px 브레이크포인트·44px·16px·dvh 병기·additive safe-area뿐.
+- brain: src 커밋 → `npm run brain` 재생성 → 별도 커밋(모든 src 변경 후 최종 — lint-staged stale 함정).
+  푸시 전 `npm run format:check`.
+- 640px 신규 규칙은 기존 C2 블록(:1094)과 **별도 블록**으로 신설(C2 블록 내부 무수정 — 예외:
+  `.modal-actions` safe-area 1줄). 통합 리팩터링 금지.
+
+## 1. 계약 목록
+
+### A. 스펙 명시 계약 → 게이트
+
+| ID | 계약 | 게이트 | PR |
+|---|---|---|---|
+| **G1** | 폰(390×844) 가로 스크롤 0 — `.main` 기준 | e2e: 탭별 `.main.scrollWidth <= .main.clientWidth` — PR1=세션 탭만, 채팅/프로젝트는 `test.fail()`+사유 주석, PR2 활성화 | 1→2 |
+| **G2** | topbar/footer 오버플로 0 | e2e: `documentElement.scrollWidth <= innerWidth`(3탭·PR1부터) | 1 |
+| **G3** | 내비 3탭 폰 폭 수용 | e2e: `.nav-btn` ×3 boundingBox 뷰포트 사각형 안 — **360×740 컨텍스트 포함**(스펙 "360px 수용" 문면) | 1 |
+| **G4** | 터치 타깃 ≥44px | e2e: PR1=`.nav-btn`·위저드 버튼·`.btn` height≥44 / PR2=`.room-btn`·`.ask-btn`·`button.chip`. 아이콘성(채팅 `+`)은 width≥44도 | 1→2 |
+| **G5** | 폼 필드 computed 16px(iOS 줌) | e2e: `getComputedStyle` — input·select·textarea 표본(새 방 이름·composer·goal textarea) | 1·2 |
+| **G6** | dvh 병기·safe-area additive | vitest CSS-핀(신설 `src/renderer/styles.responsive.test.ts`): ① vh 선행·dvh 후행 순서 regex(`.app`·`.chat`) ② `env(safe-area-inset` 전 출현이 `max(`/`calc(` 내부 + 출현 ≥4 하한(vacuous 방지) ③ 대상 4셀렉터(`.topbar`·`.footer`·`.update-banner`·`.modal-actions`) 각각에 존재 ④ `padding: env(` 단독 치환 부재 네거티브 단언 | 1 |
+| **G7** | viewport meta 확장 | vitest: `viewport-fit=cover`·`interactive-widget=resizes-content` 존재 + **CSP meta 불변**(형제 계약) | 1 |
+| **G8** | `.summary` 긴 토큰(전 뷰포트 결함 수정) | vitest 핀: `.summary`에 `overflow-wrap: break-word` + e2e(PR2): 완주 러너(`FLEET_E2E_RUNNER:'complete'`) 요약 표시 후 G1 커버(요약이 짧으면 evaluate로 긴 무공백 문자열 주입 — 주석 의무) | 1핀·2e2e |
+| **G9** | `.chat`·`.project-layout` 폰 1열 | vitest 핀(640px 블록 내 `1fr`) + e2e G1 활성화 | 2 |
+| **G10** | `.rooms` 칩 스트립 하드 계약(채팅) | e2e: UI 경로(`새 방 이름` fill + `+` 버튼(.btn.btn-sm — '만들기' 아님) 반복)로 방 3개+ 생성·개수 ≥4 선단언 → `.chat .rooms` `scrollWidth > clientWidth` → 실 스크롤 후 마지막 `.room-btn` boundingBox 뷰포트내 → 클릭 → `data-active` 전이 | 2 |
+| **G10b** | `.rooms` 칩 스트립 하드 계약(프로젝트 — 판사 공통 결함 보강 · **Codex 계획 1R 반영: 생성 메커니즘 확정**) | **UI 경로 + `complete` 러너**: mobile e2e 전용 서버를 `FLEET_E2E_RUNNER: 'complete'`(web-orchestration:70 선례)로 기동 → goal 입력+실행을 3회+ 반복(결정론 완주·hang 러너 in-flight 오염 없음) → 프로젝트 탭 `.rooms`에 G10 동형 단언. **근거**: `FleetEngine`엔 공개 `createProject`가 없고(`listProjects`/`runProjectFlow`만), `seedE2eFixtures(engine)`는 store 미접근 — 시드 확장은 엔진 표면 확대 또는 boot 배선 파급이라 기각. 스펙 파일별 독립 서버(startFleetWebServer)라 기존 e2e 무영향·`src/main/e2e.ts` 무변경. 런 3회 러닝타임이 과중하면(§6 리스크 표) 2건+지연 계약으로 완화하고 기록 | 2 |
+| **G11** | `.grid-2` 폰 1열(elicitation:395·manual:534) | vitest 핀 + e2e: goal 입력 가능(manual 경로) — elicitation은 런 중 발화라 핀+라이브 | 2 |
+| **G12** | 채팅 composer 뷰포트내 앵커 | e2e: 방 선택 후 composer boundingBox 뷰포트 사각형 안 | 2 |
+| **G13** | verify·기존 e2e 전량 무회귀 | `VERIFY` + `E2E-ALL` — 각 PR 머지 전 | 1·2 |
+
+### B. 암묵 파급 → 계약 승격
+
+| ID | 파급(실측) | 계약화 |
+|---|---|---|
+| **R1** | 데스크톱 무회귀가 사람 리뷰 위임 — 기존 web e2e 중 approval-hold/handoff는 **390×844 폰 뷰포트**(데스크톱 겸임 가정 반증·판사 양측 확인) | e2e 데스크톱 가드: **641×900**(경계 최소 초과)에서 `.topbar` computed gap 28px·body 13px(PR1)·`.chat`/`.project-layout` `232px` 칼럼(PR2) + **1280×800 대표 해상도 1컨텍스트**(이식 A) |
+| **R2** | 기존 C2 `flex-wrap: *wrap` 핀(ApprovalModal.test.tsx:678)은 css 전문 매치라 `.tool-steps`:838·`.chiprow`:873에 이미 매치 = **기공허** | PR1에서 640px 블록 substring 추출 헬퍼로 스코프 강화(`.modal-actions` 규칙 한정). 강화분은 즉시 GREEN → mutation 규율 1회 적용(이식 B) |
+| **R3** | approval-hold e2e의 절대 기하 단언(:97-101 `x≤1`·`width≥388`·`840≤bottom≤846`) — `.modal-actions` safe-area가 기하를 바꾸면 파손 | safe-area는 `max(기존값, env(...))`만(에뮬 env()=0 → 기하 불변) + 예외 구현 직후 approval-hold/handoff 표적 재실행 |
+| **R4** | `.rooms` 형제 2곳(ChatPanel:355·ProjectPanel:356) + ChatPanel `.rooms` 자식에 **비-버튼**(eyebrow·방 생성 `.row`) 포함 | 스트립 CSS에 비-버튼 자식 처리 명시(`flex: 0 0 auto`·input min-width — CSS-only) · 양 패널 e2e(G10·G10b) |
+| **R5** | `.update-banner`(ConnectionBanner 공유)는 `position: fixed` → G2 단언이 구조적으로 blind | vitest 핀(폭 캡 존재) + **e2e 기계화(판사 공통 결함 보강)**: web-orchestration:46-63 단절 하니스 재사용 — 배너 표시 중 `boundingBox.width ≤ 390` 단언(과중하면 핀+라이브 강등·기록) |
+| **R6** | 인라인 고정폭 **5곳**: ProjectPanel:444(220)·:456(160)·ChatPanel:458(64)·SessionsPanel:257(minWidth 116)·**ProjectPanel:606(minWidth 62 — 이식 A·무해 판정 기록)** | 640px 블록 `.row{flex-wrap:wrap}` + `max-width:100%`(인라인 width를 이기는 별개 속성) |
+| **R7** | `.wizard` 래퍼로 SessionsPanel 줄수 변경 → brain.md stale | `VERIFY`의 brain:check + 공통 규율 순서 |
+| **R8** | 래퍼가 기존 테스트 파손? | 실측: AddAiWizard/SessionsPanel test는 role/label 쿼리만 — **무수정 green 유지가 무회귀 신호**(수정 필요 발생 = §5 위반 신호) |
+| **R9** | approval-handoff(390×844)가 PR1 셸 변경 영향권 | `E2E-WEB` 전량 게이트(신규 단언 불요) |
+| **R10** | reduced-motion — 기존 전역 블록이 신규 애니 자동 커버 | vitest 핀: **640px 블록 밖** `@media (prefers-reduced-motion` 출현 = 기존 2곳(:991·:1149) 고정(판사 양측 지적 반영 — 블록 안 중첩은 스펙 §6-3 허용이라 자유) |
+
+## 2. PR1 태스크 — 전역 레이어 + 앱 셸 + 위저드 (`Part of #221`)
+
+> 순서: T1·T2(게이트 수립·RED) → T3(예외 구현) → T4(640px 셸) → T5(위저드) → T6(봉인).
+> 판정기 선행 근거(초안 A): 구현이 들어간 뒤에는 "RED 미발화가 구현 덕인지 단언 blind인지" 구분 불가.
+
+### T1 — vitest 계약 하네스 신설 + 기존 핀 강화 (RED)
+- **파일**: `src/renderer/styles.responsive.test.ts`(신설 — ApprovalModal.test.tsx:670 `readFileSync` 패턴·index.html도 로드) · `src/renderer/components/ApprovalModal.test.tsx`(R2 스코프 강화만·완화 금지).
+- **블록 추출 헬퍼 강건성(Codex 계획 1R 노트)**: `@media (max-width: 640px)` 블록이 T4 이후 **2개**(C2 승인 블록 + 신설 셸 블록)가 되므로, naive first-match 추출은 승인 블록만 검사하는 blind가 된다 — 블록 식별을 내부 마커 셀렉터(예: C2=`.modal-overlay`·셸=`.topbar`) 또는 전 블록 수집 후 합집합 검사로 설계.
+- **RED**: G6·G7·G8핀·R5핀·R6핀·R10 전부 FAIL 확인. R2 강화분은 즉시 GREEN → mutation 1회.
+- **검증**: `npx vitest run src/renderer/styles.responsive.test.ts src/renderer/components/ApprovalModal.test.tsx`
+
+### T2 — e2e 하네스 신설 `e2e/mobile-responsive.web.e2e.ts` (RED)
+- **구성**: `startFleetWebServer` 재사용·`emulateMedia({reducedMotion:'reduce'})`·측정 전 `document.fonts.ready` 대기. ① 390×844: 3탭 G2 + 세션 탭 G1(채팅/프로젝트 `test.fail()`+사유 주석) + G3 + G4(PR1 표적) + G5 ② 360×740: G3 재단언 ③ 641×900 + 1280×800: R1 데스크톱 가드(즉시 GREEN — 현행 값 핀).
+- **RED**: topbar 오버플로·`.nav-btn` ≈30px(:216 패딩 7px)로 실발화 확인. **PASS인 단언은 메타규칙 발동**(blind 조사·교체).
+- **검증**: `npm run build && npx playwright test e2e/mobile-responsive.web.e2e.ts --project=web`
+
+### T3 — §6 열거 예외 구현 (T1 부분 GREEN)
+- **파일**: `index.html`(:5 meta 확장) · `styles.css`(`.app`:159·`.chat`:631 dvh 병기 / safe-area additive: `.topbar`·`.footer`·`.update-banner`·`.modal-actions`(C2 블록 내 1줄) / `.summary`:935 overflow-wrap).
+- **GREEN**: T1 예외 핀 + **직후 `npx playwright test e2e/approval-hold.web.e2e.ts e2e/approval-handoff.web.e2e.ts --project=web`**(R3 기하 불변 즉시 확인).
+- **커밋**: 예외 전용 단일 커밋(롤백 단위 — 초안 A 근거: 전 뷰포트 diff는 revert 대상이 명확해야).
+
+### T4 — ≤640px 셸 블록 신설 (T2 GREEN 본체)
+- **파일**: `styles.css` — C2 블록과 별도 신규 640px 블록: `.topbar`(gap·padding 축소·`.brand .tag` 숨김·`.live` 축소) / `.nav-btn` 44px / `.main`·`.footer` padding 축소 / 타이포 스케일(시작값 표) / 터치 타깃 전역(`.btn`·`.btn-sm`·`.room-btn`·`.ask-btn`·`button.chip`·`.field` min-height 44 + 아이콘성 min-width 44) / 폼 16px / `.row{flex-wrap:wrap}`+`max-width:100%`(R6) / `.update-banner` 폭 캡(R5).
+- **GREEN**: T2 전 단언(641/1280 가드 포함). 데스크톱 body 13px 불변은 R1이 핀.
+- **검증**: T2 명령 + `npx vitest run src/renderer/styles.responsive.test.ts`
+
+### T5 — `.wizard` 래퍼 + 위저드 모바일 스타일 (G4 위저드 GREEN)
+- **파일**: `SessionsPanel.tsx:153` → `
`(유일 마크업 변경) · `styles.css` 640px 블록(`.wizard` 하위 bare input/button/label 폭 100%·44px·간격) · `SessionsPanel.test.tsx`(래퍼 존재 단언 — RED 먼저). +- **검증**: `npx vitest run src/renderer/components/`(R8 — AddAiWizard/SessionsPanel test 무수정 green) + T2 e2e 재실행(`test.fail()` 세션 탭 위저드 표적 해제). + +### T6 — 무회귀 봉인·brain·PR 본문 +- `E2E-ALL` → `VERIFY` → brain 재생성·별도 커밋 → PR 본문: §6 예외별 no-op 근거 표 + 실측 브라우저/OS 버전. +- **잔여**: 라이브 폰 실측(§5 체크리스트 PR1) → 자체 적대 리뷰(§6 diff 렌즈) → PR open → Codex 대기 + (**폴링: `.reviews`+`.comments`+reactions 3채널·login `chatgpt-codex-connector[bot]`·since=트리거 created_at**) → 스레드 resolve → 사용자 확인 → squash. + +## 3. PR2 태스크 — 패널 3 + 폼 (`Closes #221`) + +### T1 — e2e 계약 확장 (RED) +- ① PR1 `test.fail()` 해제(채팅·프로젝트 `.main` — RED 실발화 확인: `.chat` 232px 강제) ② G10 하드 계약 ③ **G10b: `complete` 러너 서버에서 UI 경로로 프로젝트 3회+ 생성·완주 후 스트립 단언**(Codex 계획 1R — `src/main/e2e.ts` 무변경·엔진 API 무확대·`isE2EActive` 가드 불변. G8 e2e와 같은 `complete` 서버 인스턴스를 공유해 러닝타임 절약) ④ G11 ⑤ G12 ⑥ G8 e2e(완주 러너 — G10b와 서버 공유) ⑦ R1 확장(641·1280에서 `.chat`/`.project-layout` 232px) ⑧ R5 e2e(단절 하니스 배너 폭). +- **구현 노트(Codex 계획 2R)**: `complete` 러너 케이스는 기본(hang) 서버와 **별도 describe/서버 수명주기**로 분리(추론 용이성) · 프로젝트 3회+ 루프는 web-orchestration 패턴대로 **각 런 완주(`done`)·버튼 재활성 대기 후 다음 런** · "시드 무확장" 제약은 UI 경로가 과도하게 느리거나 flake일 때만 구현 리뷰에서 재론. +- **검증**: `npm run build && npx playwright test e2e/mobile-responsive.web.e2e.ts --project=web` → 신규 RED 확인. + +### T2 — vitest 핀 확장 (RED) +- `styles.responsive.test.ts`: 640px 블록 내 `.chat`/`.project-layout` `1fr`·`.rooms` `overflow-x: auto`·`.grid-2` 1열. + +### T3 — 채팅/프로젝트 스택 + `.rooms` 스트립 (GREEN 코어) +- `styles.css` 640px 블록: `.chat`·`.project-layout` → `1fr`·`.chat` height dvh 재계산(160px 상수는 + 타이포 상향 후 실측 재결정 — C 충돌 5·`min-height:460px` 상호작용 포함) / `.rooms` → + `display:flex; overflow-x:auto` + **비-버튼 자식 처리**(R4: eyebrow·생성 `.row`에 `flex:0 0 auto`· + input min-width) / `.room-btn` `flex:0 0 auto`·max-width 캡·기존 ellipsis 유지. +- **ripple**: `.project-layout .room-btn`(styles.css:708 justify+`.proj-status`)이 스트립에서도 유지 — + web-orchestration:86 `.proj-status` 단언 무회귀(`E2E-WEB` 전량). + +### T4 — 프로젝트 폼·로그·세션 패널·CLI/MCP 폼 (GREEN 잔여) +- `.grid-2` 1열 / `.log`·`.summary` 오버플로 정돈 / `.line-item` 랩(SessionsPanel:166 인라인 flexWrap + 기존재 — CSS 승격만·JSX 무변) / 03/04 섹션 `.field`·textarea 폭 100%·라벨 줄바꿈. + +### T5 — 봉인·brain·이슈 마감 +- `E2E-ALL` → `VERIFY` → brain(PR2는 src 변경 0 예상 — `brain:check`로 확인만) → PR 본문(no-op + 근거·브라우저/OS 버전) → 이슈에 `.summary` 표기·그래프 뷰 stale 정정 코멘트 → 라이브 실측(§5 PR2) + → 자체 적대 리뷰 → PR open(`Closes #221`) → Codex → 사용자 확인 → squash. + +## 4. 계약 충돌/해소 (승계 + 판정 반영) + +1. C2 블록 vs 신설 블록 — 의도적 분리·통합 금지(R2·R3 게이트가 감시). +2. R10 — 640px 블록 밖 출현 고정으로 완화(§6-3 허용 존중 — 판사 양측 지적). +3. G2 vs fixed 배너 — R5 핀+e2e로 봉합. +4. G4 규칙 선행(PR1) vs 단언 후행(PR2) — 블록 내부라 무해·명시 규율. +5. 타이포 상향 × `.chat` 160px 매직넘버 — PR2 T3 실측 재결정. + +## 5. 라이브 폰 실측 체크리스트 (기계 검증 불가 잔여 계약) + +**PR1**(실 CF Access 터널·브라우저/OS 버전 PR 본문 기록): +- [ ] 3탭 가로 스크롤 없음·탭 전환(오탭 없음) +- [ ] 주소창 표시/숨김 전환 시 footer 안정(dvh 실효 — 이식 B) +- [ ] 재접속 배너(백그라운드 탭 복귀) 폰 폭 안 표시(R5 잔여) +- [ ] 노치/홈바 기기 safe-area 겹침 없음 + **C2 승인 바텀시트 무회귀**(viewport-fit=cover 효과 — destructive 승인 1회 유발 확인) +- [ ] 위저드 세션 등록 완주·입력 포커스 시 iOS 자동 줌 미발생 +- [ ] 빈 상태(세션 0) 셸 확인(이식 B — e2e 미커버) +- [ ] **스크롤 버벅임 체감 확인**(판사 A 공백 발견: `background-attachment: fixed`(:116)+fixed 비네팅(:123) — jank 시 640px 블록에서 `background-attachment: scroll` 강등 커밋) + +**PR2**(= 이슈 완료 정의): +- [ ] 세션 개시·프로젝트 런 모니터·설정 가로 스크롤 없이 조작 +- [ ] **키보드 연 상태 채팅 입력 — 필수·생략 불가**(iOS 실기기) + **Android Chrome에서 `interactive-widget` 실효 확인**(판사 보강 — 키보드가 layout viewport를 줄여 composer 가시) +- [ ] 칩 스트립 실손가락 스크롤·마지막 룸 도달(모멘텀 체감) +- [ ] elicitation 폼(런 중 발화) 1열 표시 +- [ ] `.summary` 긴 토큰 줄바꿈 실확인 +- [ ] 데스크톱 와이드 병행 시각 스팟체크(이식 B) + +## 6. 리스크·롤백 + +| 리스크 | 방어 | 롤백 | +|---|---|---| +| 데스크톱 회귀 | R1 기계 가드(641·1280)+§6 diff 규율+기존 e2e 전량 | 640px 블록 삭제=즉시 원상(로직 diff는 래퍼 1줄) | +| C2 표면 파손 | R2 강화·R3 표적 재실행·C2 블록 무수정 | `.modal-actions` 1줄 revert | +| e2e flake | fonts.ready+reducedMotion 전 테스트 공통 | — | +| G10b 런 3회 러닝타임 과중 | `complete` 러너는 결정론 즉시 완주 + G8과 서버 공유 — 과중 시 2건+지연 계약 완화·기록 | mobile e2e 케이스만 조정(제품 코드 무영향) | +| 라이브서 레이아웃 버그(C2 선례) | 각 PR 라이브 관문·미감 수치 미핀(조정 diff 최소) | 값 조정 커밋(테스트 불변) | +| brain stale | R7 규율 | 재생성 재커밋 | +| e2e 러닝타임 증가(판사 인지 항목) | 컨텍스트 4종(390/360/641/1280)·완주 러너 — 현 web 4파일 규모라 수용, PR2 T5에서 실측 시간 기록 | — | + +## 7. 실행 인계 + +superpowers:subagent-driven-development 로 태스크 단위 실행(태스크별 적대 리뷰는 자체 +fleet-finder/refuter — 스펙 §8). 체크포인트: 본 계획을 이슈 #221 코멘트로 게재 + `@codex review` +(순수 한 줄) → clean 후 PR1 T1 착수. diff --git a/docs/superpowers/specs/2026-07-13-issue221-mobile-responsive-design.md b/docs/superpowers/specs/2026-07-13-issue221-mobile-responsive-design.md new file mode 100644 index 0000000..6e43601 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-issue221-mobile-responsive-design.md @@ -0,0 +1,180 @@ +# #221 웹모드 렌더러 전체 모바일 반응형 — 설계 스펙 + +- **이슈**: #221 (tier:next · area:renderer · enhancement) +- **날짜**: 2026-07-13 (r3 — r2: 로컬 fleet-refuter 4렌즈 P1×1·P2×8 반영 / r3: Codex 체크포인트 1R 3건 반영 — 위저드 PR1 이동·`.rooms` e2e 하드 계약·키보드 실측 필수화) +- **선행**: #216 C2(승인 카드 바텀시트 — PR #220)가 유일한 폰 분기. 본 작업은 그 패턴을 앱 전체로 확장. +- **전략 결정(사용자 승인)**: CSS-only 미디어쿼리 · 2-PR 분할 · 채팅 탭 포함. + +## 1. 배경 / 문제 + +Phase B(#197) 웹모드는 데스크톱 렌더러를 반응형 없이 재사용했다. `src/renderer/styles.css`(1,177줄)의 +폭 기반 미디어쿼리는 C2 승인 모달 `@media (max-width: 640px)` 하나뿐. 폰 브라우저(실 CF Access 터널)에서 +topbar·패널·폼이 데스크톱 비율로 렌더돼 가로 스크롤·작은 텍스트·좁은 터치 타깃으로 실사용이 어렵다. +v3(#193) 목표 "어디서든 브라우저로"는 승인 카드만이 아니라 앱 전체가 폰에서 쓸 만해야 성립한다. + +## 2. 현황 실측 (2026-07-13 코드 기준) + +- **앱은 이미 탭 기반** — `App.tsx`(118줄): topbar(브랜드+세그먼트 내비 3탭+라이브 카운터) → main(탭별 패널 1개) → footer. 앱 수준 사이드바 없음 → 햄버거/바텀탭 등 모바일 내비 신설 불필요(이슈 본문의 해당 항목은 과대 스코프). +- **스크롤 아키텍처(검증 설계의 전제)**: `body { overflow: hidden }`(styles.css:119) + `.main { overflow: auto }`(:277) — 패널 계층의 가로 오버플로는 전부 `.main`이 트랩하고 **문서 루트 scrollWidth에 도달하지 않는다**. 가로 스크롤 단언은 `.main`(및 내부 스크롤박스) 기준이어야 한다(§8). +- **폰에서 깨지는 근본 원인 목록**: + - `.app { height: 100vh }` — 모바일 주소창 수축 미대응(dvh 필요). 단 문서 스크롤이 없는 셸이라 주소창이 접히지 않아 dvh≈svh로 안정적. + - `.topbar { gap: 28px; padding: 16px 26px }` + `.brand .tag`(자간 0.26em 캡션) — ≤400px 오버플로. + - `.chat { grid-template-columns: 232px 1fr; height: calc(100vh - 160px) }` — 사이드바 강제 + vh. + - `.project-layout { grid-template-columns: 232px 1fr }` — 동일. + - `.grid-2 { grid-template-columns: 1fr 1fr }` — 폼 2열 강제(사용처는 ProjectPanel의 elicitation·manual 역할 배정 — 전역 규칙으로 처리). + - `.main { padding: 26px }` · `.wrap { max-width: 1180px }` — 폰에서 여백 과다. + - **타이포**: body 13px·`.chip`/`.field-label` 10px(자간 0.18~0.26em) — 이슈가 명시한 "텍스트가 작고" 표면. + - 터치 타깃: `.nav-btn`(7px 패딩)·`.btn-sm`·`.room-btn`·`.ask-btn`(채팅 주 조작 ≈30px)·`button.chip`(capability 토글 ≈20px) 등 44px 미달. + - **긴 무공백 토큰**: `pre.summary`(:935)는 `pre-wrap`만 있고 `overflow-wrap` 없음 — LLM 요약의 긴 경로/URL이 04—SUMMARY 패널을 뚫고 `.main` 가로 스크롤 직행(§3 직접 위반 표면). + - **인라인 고정폭**: `.row`(flex-wrap 없음) 안 인라인 `width:220/160`(ProjectPanel)·`width:64`(ChatPanel)·`minWidth:116`(SessionsPanel) — 인라인 style은 미디어쿼리 셀렉터로 못 이기지만, `flex-wrap: wrap` + `max-width: 100%`(width와 별개 속성이라 인라인 width를 이긴다)로 우회 가능. + - **ConnectionBanner(웹 전용)**: `.update-banner` 클래스 재사용(App.tsx:113·styles.css:1155 — fixed·bottom 48px·max-width 없음·safe-area 없음). 폰 백그라운드 탭 전환마다 WS 재접속 배너가 뜨는 최빈 표면인데 폭 제약이 없다. + - `AddAiWizard.tsx`(491줄): 스텝별 early-return으로 bare `
` 루트가 **4개**(:148·:166·:183·:399) — 스타일 훅 부재. +- **viewport meta는 이미 존재**(`width=device-width, initial-scale=1.0`) — `viewport-fit=cover`·`interactive-widget` 미포함. +- **이슈 본문 정정**: "채널/그래프 뷰(`force-graph` 터치·크기 대응)"는 stale — `force-graph`는 + `scripts/brain/build.mjs`(dev 시각화 `fleet-brain.html`, gitignore) 전용이며 앱 renderer에서 미사용 + (레포 전수 grep: package.json + scripts/ 뿐, src/ 0건). 스코프 제외(이슈 코멘트로 정정 기록). +- **e2e 기반 존재**: playwright `web` 프로젝트(`playwright.config.ts` testDir `./e2e` · testMatch `*.web.e2e.ts`) + `e2e/web-server.ts`(FLEET_E2E=1 시드: CLI 세션 2·룸 1·워크스페이스). 컨텍스트/페이지 단위 뷰포트·reduced-motion 에뮬 선례가 이미 있다(approval-handoff·approval-hold). + +## 3. 목표 + +폰 브라우저(≤640px)에서 세션 개시 · 프로젝트 런 모니터 · 채팅 · 설정(위저드·CLI/MCP 폼)이 +**가로 스크롤 없이 읽히고 조작 가능**. **>640px 뷰포트**(Electron 데스크톱·와이드 웹)는 무회귀 +(좁힌 데스크톱 창이 640px 이하로 내려가면 폰 레이아웃을 받는 것은 C2가 이미 출하한 동작 — 회귀 아님). + +## 4. 비목표 / 알려진 한계 + +- 데스크톱(>640px) UX 변경(무회귀가 계약 — §6 원칙으로 구조 보장). +- 네이티브 앱/PWA/Web Push(#226 별도). +- 승인 카드(#216 C2 완료 — 기존 640px 블록 재사용·중복 구현 금지). 단 **C2 폰 무회귀도 계약에 포함**: `viewport-fit=cover` 도입이 바텀시트를 홈 인디케이터와 겹치게 하므로 `.modal-actions`(sticky bottom)에 safe-area 패딩을 함께 추가한다(§7 PR1). +- 그래프 뷰(§2 정정 — 앱 표면 아님). +- `UpdateBanner`(Electron 전용 표면 — App.tsx:112 `runtime === 'electron'` 게이팅 실물 확인, 웹 미노출). +- 모바일 내비 신설(햄버거/바텀탭) — 기존 3탭 세그먼트가 폰 폭에 수용됨(§2). +- **폰 랜드스케이프(가로)**: 논리 폭 852~932px > 640 → 데스크톱 레이아웃 수신 + 16px 폼 규칙 이탈로 iOS 자동 줌 재발 가능. 세로 사용이 지배적 시나리오(외출 중 승인·모니터)라 수용 — 알려진 한계로 명시. +- **iOS 소프트 키보드 CSS-only 한계**: iOS Safari는 키보드가 visual viewport만 축소(layout viewport·dvh 불변)하고 `interactive-widget`을 미구현 — 채팅 입력창은 브라우저의 focus 자동 스크롤에 의존한다(§5 CSS-only 전략과 VisualViewport JS 해법이 충돌 — 채택 안 함). 재평가 트리거: WebKit이 `interactive-widget` 출하 시. + +## 5. 전략 결정 (스파이크 해소) + +**CSS-only 미디어쿼리** (사용자 승인 · C2 선례 유지): + +- 단일 컴포넌트 · JS 분기 0 · 모바일 전용 뷰 없음. +- 근거: 앱이 이미 탭 기반이라 레이아웃 재구성이 "사이드바→스택·2열→1열" 수준(§2)이고, + C2가 같은 접근(미디어쿼리 단독)으로 폰 바텀시트를 성립시킨 실증이 있다. +- 마크업 변경은 **스타일 훅 래퍼 1곳만** 허용: `SessionsPanel.tsx:153`의 `` 마운트를 + `.wizard` 래퍼 div로 감싼다(위저드 루트가 4개라 각 루트에 붙이는 것보다 1곳 변경이 §5 원칙에 부합. + AddAiWizard.test.tsx는 getByRole/label 쿼리만 사용·스냅샷 0건이라 무회귀). 로직/구조 변경 금지. + +## 6. 설계 원칙 (무회귀의 구조적 보장) + +1. **모든 폰 분기는 `@media (max-width: 640px)` 블록 안에만.** 미디어쿼리 밖 데스크톱 CSS diff는 + 아래 **열거 예외**만 허용하고, 각 예외는 "데스크톱에서 no-op"임을 diff에서 확인한다: + - `100vh → 100vh; 100dvh` 폴백 병기(`.app`·`.chat` — 데스크톱은 dvh≡vh). + - viewport meta 확장(`viewport-fit=cover`·`interactive-widget=resizes-content` — 데스크톱 크로뮴은 meta viewport 미반영). + - safe-area 패딩 — 반드시 **additive 패턴**(`max(기존값, env(safe-area-inset-*))` 또는 + `calc(기존값 + env(...))`)으로만. `padding: env(...)` 치환 금지(env 미지원/inset 0 환경에서 + 기존 패딩이 붕괴한다). 대상: `.topbar`·`.footer`·`.update-banner`(ConnectionBanner 공유)· + `.modal-actions`(C2 바텀시트 홈바 겹침 방지). + - `pre.summary`에 `overflow-wrap: break-word` 추가(전 뷰포트 공통의 결함 수정 — 데스크톱에서도 + 현재 긴 토큰이 패널을 뚫는 버그이므로 무회귀 예외가 아니라 수정). + + > 원칙의 정확한 명제: "diff ⊆ {640px 블록} ∪ {위 열거 예외}" — 블록 내부는 >640px에 완전 비활성 + > (기계 판별), 예외는 리뷰에서 사람이 no-op 여부를 판별한다. +2. **브레이크포인트는 640px 단일**(C2와 동일) — `pointer: coarse` 등 이원 기준 금지(테스트 결정론· + e2e 뷰포트 에뮬과 1:1 대응). +3. **C2 패턴 재사용**: 가로 칩 스트립(`overflow-x: auto` — `.modal-chips` 선례·iOS 13+ 모멘텀 기본이라 + 추가 속성 불요), dvh. **reduced-motion은 기존 전역 블록(styles.css:991)이 신규 애니도 자동 커버** — + 새 reduced-motion 블록을 미디어쿼리 밖에 추가하지 않는다(필요 시 640px 블록 안 중첩만). +4. 주석·네이밍은 기존 styles.css 한국어 관용구를 따른다. + +## 7. PR 분할 및 각 PR 범위 + +### PR 1 — 전역 레이어 + 앱 셸 (`Part of #221`) + +- **뷰포트 기반**: `.app`·`.chat`의 `100vh` → dvh 폴백 병기. `index.html` viewport에 + `viewport-fit=cover`·`interactive-widget=resizes-content`(Android 키보드 대응·iOS는 무시라 무해) 추가. + safe-area additive 패딩: `.topbar`·`.footer`·`.update-banner`·`.modal-actions`(§6 예외 목록). +- **`pre.summary` overflow-wrap 수정**(§6 예외 — 전 뷰포트 결함). +- **≤640px 블록(신설, 셸+전역)**: + - `.topbar`: gap 28→12px급·padding 축소, `.brand .tag` 숨김, `.live` 축소(카운터 유지·라벨 축약). + - `.nav-btn`: 터치 타깃 min-height 44px·패딩 조정(3탭이 360px 폭에 수용되는지 e2e 단언). + - `.main` padding 26→12~14px, `.footer` padding 축소. + - **타이포 스케일**(이슈 계약): body 13→14px급 상향, `.chip`/`.field-label`/`.eyebrow` 10→11px급· + 과대 자간 축소 — 세부 값은 라이브 실측 조정(§10). + - 전역 터치 타깃: `.btn`·`.btn-sm`·`.room-btn`·`.ask-btn`·`button.chip`·`.field` min-height 44px + **+ 아이콘성 타깃은 min-width 44px**(HIG 양방향 — 텍스트 버튼은 패딩으로 폭 충족). + - `.field`·`input`·`select`·`textarea` font-size 16px(iOS 자동 줌 방지 — computed 기준). + - `.row { flex-wrap: wrap }` + 인라인 고정폭 필드에 `max-width: 100%`(인라인 width를 이기는 별개 속성). + - `.update-banner` 폭 제약(`max-width: calc(100vw - 24px)` 급) — ConnectionBanner 공유 표면. +- **AddAiWizard 최소 모바일 스타일(PR2→PR1 이동 — Codex 1R)**: `.wizard` 래퍼(§5) + ≤640px에서 + `.wizard` 하위 bare input/button/label 폭 100%·터치 타깃 44px·간격 정돈. 근거: 세션 탭의 첫 + 인터랙티브 표면이 위저드라, 이를 PR2로 미루면 PR1의 "세션 탭 가로 스크롤 0·터치 타깃" e2e + 게이트가 위저드 제외 시 false-green·포함 시 즉시 fail — 게이트 정직성을 위해 PR1에 포함. +- **e2e 하네스(신설)**: `e2e/mobile-responsive.web.e2e.ts`(playwright testDir=`./e2e` — `tests/e2e/` 아님) + — 뷰포트 390×844·reduced-motion 에뮬·측정 전 `document.fonts.ready` 대기(폰트 스왑 폭 변동 흡수). + 단언: ① 3탭 각각 **`.main.scrollWidth <= .main.clientWidth`**(패널 오버플로는 `.main`이 트랩하므로 + documentElement 단언은 blind — §2 스크롤 아키텍처) + `documentElement.scrollWidth <= innerWidth` + (topbar/footer 커버) ② topbar 내비 3버튼 전부 **뷰포트내 앵커**(C2 교훈: boundingBox가 뷰포트 + 사각형 안 — y+h 단언은 오프스크린 통과) ③ 주요 터치 타깃 높이 ≥44px. + 단, PR1 시점에 `.chat`·`.project-layout`은 미수정이라 ①의 `.main` 단언은 **세션 탭만** 강제하고 + 채팅/프로젝트 탭은 PR2에서 활성화(테스트에 skip 사유 주석 — false-green 방지). +- **완료 정의**: verify green + 기존 e2e 무회귀 + 신설 e2e green + **라이브 폰 실측**(셸 수준: + 가로 스크롤 없음·탭 전환 가능·재접속 배너 정상). + +### PR 2 — 패널 3개 + 폼 (`Closes #221`) + +- **채팅/프로젝트 공통 패턴**: ≤640px에서 `.chat`·`.project-layout` → `grid-template-columns: 1fr`. + `.rooms`(룸/프로젝트 목록) → 가로 칩 스트립(`display:flex; overflow-x:auto` — C2 `.modal-chips` 선례). + `.chat` height → dvh 기반 재계산(키보드 한계는 §4 명시 — Android는 `interactive-widget`으로 대응). +- **프로젝트 패널**: `.grid-2` → 1열(사용처 = ProjectPanel elicitation·manual 역할 배정 — 전역 규칙로 처리). + 진행 로그(`.log`)·요약(`pre.summary`)의 가로 오버플로 정돈. +- **세션 패널**: `.line-item` 랩 정돈(칩·배지·삭제 버튼 겹침 방지). 위저드는 PR1에서 완료(§7 PR1). +- **CLI/MCP 설정 폼**(SessionsPanel 03/04 섹션): `.field`·textarea 폭 100%·라벨 줄바꿈. +- **e2e 확장**: PR1의 `.main` 단언을 채팅/프로젝트 탭에 활성화 + 패널별 단언 — 프로젝트 폼 입력 + 가능·채팅 입력창 뷰포트내 앵커. **칩 스트립 하드 계약(Codex 1R)**: UI 경로로 방 3개+ 추가 생성 후 + `.rooms.scrollWidth > .rooms.clientWidth` 단언(오버플로 실존 — 시드 룸 1개의 vacuous 방지) → + 스트립을 스크롤해 **마지막 룸 버튼이 도달·클릭 가능**함을 단언(담김만이 아니라 조작성 증명). +- **완료 정의**: verify green + e2e 전량 green + **라이브 폰 실측 = 이슈 완료정의**(실 CF Access + 터널에서 세션 개시·프로젝트 런 모니터·설정이 가로 스크롤 없이 조작 가능 + **키보드 연 상태에서 + 채팅 입력 — 필수·생략 불가**(로컬 Playwright Chromium은 iOS 키보드 동작 미커버) — C2에서 로컬 + e2e가 못 잡은 실레이아웃 버그 선례로 필수 관문. **실측한 모바일 브라우저/OS 버전을 PR 본문에 + 기록**(Codex 1R)). + +## 8. 검증 계약 (공통) + +- `npm run verify` 7게이트 green(로컬=CI). renderer CSS/tsx만 변경 시에도 brain 재생성 여부 확인 + (tsx 의존 변화 없음 예상 — brain은 src 의존 그래프 기준). +- 기존 e2e(electron + web) 무회귀. +- 데스크톱 무회귀 리뷰 규율: diff에서 §6 원칙 위반(640px 블록·열거 예외 밖 변경) 여부를 자체 적대 + 리뷰 렌즈로 명시 점검. 열거 예외는 각각 "데스크톱 no-op" 근거를 PR 본문에 기재. +- 적대 리뷰: 각 PR 전 자체 fleet-finder/fleet-refuter, PR 후 Codex 자동 리뷰 대기·반영(스레드 resolve). + +## 9. 리스크 / 완화 + +| 리스크 | 완화 | +| --- | --- | +| 데스크톱 회귀(공용 셀렉터 오염) | §6 원칙 1 — diff ⊆ 640px 블록 ∪ 열거 예외(각 예외 no-op 근거 기재) | +| e2e 가로 스크롤 단언 false-green | `.main` 스크롤박스 기준 단언(§2 아키텍처 반영) + PR1은 미수정 탭 skip 명시 | +| 로컬 e2e가 실기기 레이아웃 못 잡음 (C2 실증) | 라이브 폰 실측을 각 PR 완료 관문으로 명시 | +| 소프트 키보드가 채팅 입력창 가림 | Android: `interactive-widget=resizes-content` · iOS: CSS-only 한계 명시(§4)·라이브 실측 항목화 | +| `viewport-fit=cover`가 C2 바텀시트 홈바 겹침 | `.modal-actions` safe-area additive 패딩(§6 예외) | +| safe-area 치환 구현으로 데스크톱 패딩 붕괴 | additive 패턴(`max()`/`calc()`) 문면 구속(§6) | +| dvh 미지원 구형 브라우저 | vh 선언 병기 폴백(§6 예외 1) | +| 칩 스트립 전환으로 룸 목록 조작성 저하 | e2e 스크롤 동작 단언(룸 3개+ 시드) + 라이브 실측 | +| iOS 입력 자동 줌으로 레이아웃 점프 | 폼 필드 computed 16px 고정(세로 한정 — 랜드스케이프는 §4 한계) | +| 폰트 스왑 타이밍 flake | 측정 전 `document.fonts.ready` 대기 | + +## 10. 미해결 질문 + +없음 — 전략·PR 분할·채팅 포함은 사용자 결정 완료(§0). 세부 픽셀 값(패딩·gap·타이포 스케일)은 구현 중 +라이브 실측으로 조정(스펙은 원칙과 상한/하한만 구속). + +--- + +## 부록 — 로컬 refuter 사전검증 요약 (2026-07-13 · 4렌즈) + +| 렌즈 | verdict | 반영 | +| --- | --- | --- | +| 데스크톱 무회귀 실효성 | 생존(P1 0) | safe-area additive 문면 구속·위저드 루트 4개→래퍼 1곳·>640px 정밀화·reduced-motion 규율 | +| e2e 검증 격차 | 골격 생존·단언① P1 | `.main` 스크롤박스 단언 교체·경로 `e2e/` 정정·fonts.ready·룸 다수 시드 | +| 모바일 웹 기술 정합 | 생존(조건부) | 키보드×dvh 한계 명시+`interactive-widget`·C2 바텀시트 safe-area·min-width 44·랜드스케이프 한계 | +| 스코프·계약 공백 | 생존(조건부) | 스크롤 아키텍처 §2 등재·`pre.summary` word-break·타이포 스케일 포함·ConnectionBanner 등재·`.ask-btn`/`button.chip`·`.grid-2` 귀속 정정 | +| **Codex 체크포인트 1R** | 차단 없음·정정 3 | 위저드 모바일 스타일 PR2→PR1(세션 탭 게이트 정직성)·`.rooms` 스트립 하드 계약(scrollWidth>clientWidth+마지막 칩 클릭)·키보드 실측 필수화+브라우저 버전 기록 | diff --git a/e2e/mobile-responsive.web.e2e.ts b/e2e/mobile-responsive.web.e2e.ts new file mode 100644 index 0000000..ae796eb --- /dev/null +++ b/e2e/mobile-responsive.web.e2e.ts @@ -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) + }) + } +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4c68941..2c7d65e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -227,6 +227,11 @@ export default tseslint.config( 'node_modules/**', '.claude/**', '.dogfood/**', + // 라이브 하니스 기본 워크스페이스(gitignore·사용자 프로젝트/생성물) — CI 엔 없고 로컬 verify 만 + // 깨뜨리는 로컬↔CI 비대칭이라 lint 대상에서 제외(#221 서 실측). + // 계약: 이 경로에 git 추적 파일을 두지 말 것 — 두게 되면 blanket ignore 라 무신호 lint 사각이 + // 되므로 이 항목을 재검토한다. + 'deploy/workspace/**', '*.config.*', '*.config.mjs', ], diff --git a/src/renderer/components/ApprovalModal.test.tsx b/src/renderer/components/ApprovalModal.test.tsx index 75e665d..68e3fa5 100644 --- a/src/renderer/components/ApprovalModal.test.tsx +++ b/src/renderer/components/ApprovalModal.test.tsx @@ -671,14 +671,36 @@ describe('반응형 CSS(회귀 가드)', () => { // jsdom 은 CSS ?raw 를 빈 문자열로 주므로 원본 파일을 직접 읽는다(vitest cwd=레포 루트). const css = readFileSync(join(process.cwd(), 'src/renderer/styles.css'), 'utf8') + // #221 R2: css 전문 매치는 다른 규칙(.tool-steps 등)에 이미 매치되는 공허 핀이었고, 셸 640px + // 블록 추가로 공허성이 심화된다 — C2 승인 블록(.modal-overlay 마커)으로 스코프를 좁힌다(강화만·완화 금지). + const c2Block = (() => { + let idx = 0 + for (;;) { + const start = css.indexOf('@media (max-width: 640px)', idx) + if (start === -1) return '' + const open = css.indexOf('{', start) + let depth = 1 + let i = open + 1 + while (i < css.length && depth > 0) { + if (css[i] === '{') depth++ + else if (css[i] === '}') depth-- + i++ + } + const body = css.slice(open + 1, i - 1) + if (body.includes('.modal-overlay')) return body + idx = i + } + })() + it('폰 바텀시트 미디어쿼리·미니칩·액션 줄바꿈/바닥고정·시트 애니메이션 규칙 존재', () => { - expect(css).toMatch(/@media \(max-width: *640px\)/) // 폰 바텀시트 분기 - expect(css).toContain('.modal-nav') // 내비 컨테이너 - expect(css).toContain('.modal-chips') // 미니칩 스트립 - expect(css).toMatch(/flex-wrap: *wrap/) // 모바일 액션 줄바꿈(카운트다운↑·버튼 풀폭·적대리뷰 F4) - expect(css).toMatch(/position: *sticky/) // 액션 바닥 고정(오버플로 시 스크롤아웃 방지·적대리뷰 F5/F6) - expect(css).toMatch(/max-height: *90dvh/) // content-driven·dvh(라이브 폰 오프스크린 버그 회귀 가드) - expect(css).toMatch(/@keyframes sheetUp/) // 시트 슬라이드(reduced-motion 서 무애니) + expect(c2Block).not.toBe('') // 폰 바텀시트 분기(C2 블록 실존) + expect(css).toContain('.modal-nav {') // 내비 컨테이너(기본 규칙 — 블록 밖·셀렉터 여는 중괄호까지 핀) + expect(css).toContain('.modal-chips {') // 미니칩 스트립(기본 규칙 — 동일) + // 규칙-정밀 스코프(CodeRabbit) — 블록 내 무관 규칙이 계약을 대체 충족하지 못하게 셀렉터와 결합. + expect(c2Block).toMatch(/\.modal-actions\s*\{[^}]*flex-wrap: *wrap/) // 액션 줄바꿈(적대리뷰 F4) + expect(c2Block).toMatch(/\.modal-actions\s*\{[^}]*position: *sticky/) // 바닥 고정(적대리뷰 F5/F6) + expect(c2Block).toMatch(/\.modal-card\s*\{[^}]*max-height: *90dvh/) // content-driven·dvh(오프스크린 회귀 가드) + expect(css).toMatch(/@keyframes sheetUp/) // 시트 슬라이드(reduced-motion 서 무애니 — 블록 밖 무해 keyframes) }) }) diff --git a/src/renderer/components/SessionsPanel.test.tsx b/src/renderer/components/SessionsPanel.test.tsx index a60b050..b0cd166 100644 --- a/src/renderer/components/SessionsPanel.test.tsx +++ b/src/renderer/components/SessionsPanel.test.tsx @@ -53,6 +53,15 @@ describe('SessionsPanel', () => { expect(screen.getByText(/AI 추가/)).toBeTruthy() }) + it('AddAiWizard 를 .wizard 스타일 훅 래퍼로 감싼다 (#221 — bare 루트 4개의 폰 스타일 조준점)', async () => { + mockFleet() + const { container } = await renderSettled() + const wizard = container.querySelector('.wizard') + expect(wizard).not.toBeNull() + // 래퍼가 위저드 콘텐츠(진입 헤딩)를 실제로 감싼다 — 빈 껍데기 방지. + expect(wizard?.textContent).toMatch(/AI 추가/) + }) + it('MCP 서버 JSON 을 적용하고 상태를 표시한다', async () => { const status = [ { name: 'fs', connected: true, toolCount: 2, tools: ['mcp__fs__read', 'mcp__fs__write'] }, diff --git a/src/renderer/components/SessionsPanel.tsx b/src/renderer/components/SessionsPanel.tsx index 7f81ef6..241e78b 100644 --- a/src/renderer/components/SessionsPanel.tsx +++ b/src/renderer/components/SessionsPanel.tsx @@ -150,7 +150,11 @@ export function SessionsPanel({ sessions, onRefresh, runtime = null }: Props) {

)} - + {/* #221: .wizard = 스타일 훅 래퍼 — 위저드는 스텝별 bare 루트 4개라 루트 부착 대신 마운트 1곳을 + 감싼다(§5 마크업 변경 예산). 폰(≤640px) 스타일은 styles.css 셸 블록의 .wizard 규칙이 조준. */} +
+ +
diff --git a/src/renderer/index.html b/src/renderer/index.html index 6bdfb2b..e73cac3 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,13 @@ - + + * { + max-width: 100%; + } + + /* ConnectionBanner 공유 표면(R5) — fixed 라 scrollWidth 단언이 못 잡아 폭 캡을 핀으로 강제. */ + .update-banner { + max-width: calc(100vw - 24px); + } + + /* AddAiWizard(.wizard 래퍼 — SessionsPanel 마운트 1곳) — bare input/button/label 을 폰에서 + 풀폭·터치 타깃으로. 세션 탭 첫 인터랙티브 표면이라 PR1 귀속(Codex 스펙 1R — 게이트 정직성). */ + .wizard input:not([type='checkbox']), + .wizard select { + width: 100%; + } + .wizard button { + min-height: 44px; + padding: 8px 14px; + } + .wizard label { + display: block; + margin: 8px 0; + /* stateful 체크박스 라벨 등 비버튼 탭 표면도 44px 계약에 포함(자체 적대 리뷰 F2 — 라벨 전체가 + 클릭 표면이라 높이 확보만으로 충분). */ + min-height: 44px; + } + .wizard code { + /* 구독 스텝의 CLI 해석 경로·로그인 명령 등 긴 무공백 토큰이 .main 을 넓히는 표면 + (Codex PR 1R P2 — .summary 와 동종 결함의 위저드 국소판). */ + overflow-wrap: anywhere; + } +} diff --git a/src/renderer/styles.responsive.test.ts b/src/renderer/styles.responsive.test.ts new file mode 100644 index 0000000..92d4941 --- /dev/null +++ b/src/renderer/styles.responsive.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +// #221 모바일 반응형 계약 핀 — jsdom 은 실 레이아웃을 계산하지 않으므로 styles.css·index.html +// 문면으로 규칙 존재/패턴을 회귀 가드한다(선례: ApprovalModal.test.tsx «반응형 CSS(회귀 가드)»). +// 실 레이아웃은 e2e/mobile-responsive.web.e2e.ts(폰 뷰포트)·라이브 폰 실측이 검증. +// 주석 스트립 후 파싱 — 파서(중괄호 짝맞춤·문자열 매칭)가 주석 내 {}·리터럴 @media 문구에 깨져 +// 블록 과확장→R10 false-green 이 되는 경로 차단(자체 적대 리뷰 P3-2). +const css = readFileSync(join(process.cwd(), 'src/renderer/styles.css'), 'utf8').replace( + /\/\*[\s\S]*?\*\//g, + '', +) +const html = readFileSync(join(process.cwd(), 'src/renderer/index.html'), 'utf8') + +/** 지정 미디어쿼리의 블록 본문 전부를 중괄호 짝맞춤으로 추출한다(640px 블록은 C2 승인·신설 셸 2개 — naive first-match 는 blind). */ +function mediaBlocks(source: string, query: string): string[] { + const blocks: string[] = [] + let idx = 0 + for (;;) { + const start = source.indexOf(query, idx) + if (start === -1) break + const open = source.indexOf('{', start) + let depth = 1 + let i = open + 1 + while (i < source.length && depth > 0) { + if (source[i] === '{') depth++ + else if (source[i] === '}') depth-- + i++ + } + blocks.push(source.slice(open + 1, i - 1)) + idx = i + } + return blocks +} + +/** 셀렉터의 rule 블록 본문 전부(전 소스 — 미디어쿼리 안팎 불문). */ +function ruleBlocks(source: string, selector: string): string[] { + const blocks: string[] = [] + let idx = 0 + const needle = `${selector} {` + for (;;) { + const start = source.indexOf(needle, idx) + if (start === -1) break + const open = start + needle.length - 1 + const close = source.indexOf('}', open) + blocks.push(source.slice(open + 1, close)) + idx = close + } + return blocks +} + +const mobile = mediaBlocks(css, '@media (max-width: 640px)') +// 셸 블록 식별 마커 = .topbar(신설) · C2 블록 마커 = .modal-overlay(기존 승인 바텀시트) +const shell = mobile.find((b) => b.includes('.topbar')) ?? '' + +describe('#221 §6 열거 예외 — 전 뷰포트 diff 의 데스크톱 no-op 패턴 핀', () => { + it('G6① dvh 폴백 병기 — vh 선행·dvh 후행 순서(순서 역전=구형 브라우저 파손)', () => { + // .app 셸 높이 + expect(css).toMatch(/height: 100vh;\s*\n\s*height: 100dvh;/) + // .chat 높이(calc) + expect(css).toMatch(/height: calc\(100vh - 160px\);\s*\n\s*height: calc\(100dvh - 160px\);/) + }) + + it('G6② safe-area 는 additive 패턴만 — 모든 env() 출현 각각이 max()/calc() 내부·출현 ≥4', () => { + // env 출현 **각각**의 부모 함수를 괄호 스코프로 판정 — 선언/라인 정규식은 다중 env 중 하나만 + // additive 여도 통과한다(자체 적대 리뷰 P3-4 + CodeRabbit). + const insideAdditive = (decl: string, envIdx: number): boolean => { + let depth = 0 + for (let j = envIdx - 1; j >= 0; j--) { + const ch = decl[j] + if (ch === ')') depth++ + else if (ch === '(') { + if (depth === 0) return /(max|calc)$/.test(decl.slice(Math.max(0, j - 4), j)) + depth-- + } + } + return false + } + const decls = css.split(';').filter((d) => d.includes('env(safe-area-inset')) + expect(decls.length).toBeGreaterThanOrEqual(4) + for (const d of decls) { + for ( + let i = d.indexOf('env(safe-area-inset'); + i !== -1; + i = d.indexOf('env(safe-area-inset', i + 1) + ) { + expect(insideAdditive(d, i), `additive 밖 env 사용: ${d.trim()}`).toBe(true) + } + } + }) + + it('G6③ safe-area 대상 4셀렉터(.topbar·.footer·.update-banner·.modal-actions) 각각에 존재', () => { + for (const sel of ['.topbar', '.footer', '.update-banner', '.modal-actions']) { + const hasEnv = ruleBlocks(css, sel).some((b) => b.includes('env(safe-area-inset')) + expect(hasEnv, `${sel} 에 safe-area additive 패딩 부재`).toBe(true) + } + }) + + it('G7 viewport meta 확장 + CSP meta 불변(형제 계약)', () => { + expect(html).toContain('viewport-fit=cover') + expect(html).toContain('interactive-widget=resizes-content') + // CSP 는 이 작업의 비대상 — 문면 고정(window-hardening 형제 계약) + expect(html).toContain( + "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'", + ) + }) + + it('G8 .summary 긴 무공백 토큰 줄바꿈(전 뷰포트 결함 수정 — 실물 셀렉터는 pre.summary 아닌 .summary)', () => { + const hasWrap = ruleBlocks(css, '.summary').some((b) => b.includes('overflow-wrap: break-word')) + expect(hasWrap).toBe(true) + }) +}) + +describe('#221 ≤640px 셸 블록 — 폰 분기 규칙 존재 핀(미감 수치는 핀 금지·계약 수치만)', () => { + it('셸 640px 블록 존재(.topbar 규칙 포함 — C2 승인 블록과 별도)', () => { + expect(shell).not.toBe('') + }) + + it('터치 타깃 44px·폼 16px(iOS 자동 줌 방지) 계약 수치 — 셀렉터 결합 핀(대체 충족 차단)', () => { + // 존재-수준 핀은 다른 규칙이 대체 충족해 계약 셀렉터 삭제를 가린다(자체 적대 리뷰 P2-1/P3-4) + // — 셀렉터 그룹과 선언을 결합해 핀. + expect(ruleBlocks(shell, '.nav-btn').some((b) => b.includes('min-height: 44px'))).toBe(true) + expect(shell).toMatch( + /\.btn,\s*\.btn-sm,\s*\.room-btn,\s*\.ask-btn,\s*button\.chip,\s*\.field\s*\{[^}]*min-height: *44px/, + ) + // 위저드 bare input 커버의 존재 이유가 요소 셀렉터다 — .field 만 남기는 축소를 적발. + expect(shell).toMatch(/\.field,\s*input,\s*select,\s*textarea\s*\{[^}]*font-size: *16px/) + }) + + it('R6 인라인 고정폭 대응 — .row 랩 + .row > * max-width 100%(인라인 width 를 이기는 별개 속성)', () => { + const rowInShell = ruleBlocks(shell, '.row') + expect(rowInShell.some((b) => b.includes('flex-wrap: wrap'))).toBe(true) + expect(shell).toMatch(/\.row > \*\s*\{[^}]*max-width: *100%/) + }) + + it('R5 .update-banner 폭 캡 — fixed 라 scrollWidth 단언이 못 잡는 표면(ConnectionBanner 공유)', () => { + const bannerInShell = ruleBlocks(shell, '.update-banner') + expect(bannerInShell.some((b) => b.includes('max-width'))).toBe(true) + }) + + it('.wizard 스타일 훅 — 규칙별 결합 핀(폭 100%·버튼/라벨 44px — 대체 충족 차단)', () => { + expect(shell).toMatch( + /\.wizard input:not\(\[type='checkbox'\]\),\s*\.wizard select\s*\{[^}]*width: *100%/, + ) + expect(shell).toMatch(/\.wizard button\s*\{[^}]*min-height: *44px/) + expect(shell).toMatch(/\.wizard label\s*\{[^}]*min-height: *44px/) + // 구독 스텝 bare (CLI 경로·로그인 명령) 긴 토큰 줄바꿈(Codex PR 1R P2) + expect(shell).toMatch(/\.wizard code\s*\{[^}]*overflow-wrap: *anywhere/) + }) +}) + +describe('#221 R10 — reduced-motion 규율(§6-3: 640px 블록 밖 신규 추가 금지)', () => { + it('640px 블록 밖 prefers-reduced-motion 출현 = 기존 2곳 고정(블록 안 중첩은 자유)', () => { + // 640px 블록 전부를 제거한 나머지에서 카운트 — 전역 블록(:991)·modal-card 블록(:1149) + let rest = css + for (const b of mobile) rest = rest.replace(b, '') + const count = (rest.match(/@media \(prefers-reduced-motion/g) ?? []).length + expect(count).toBe(2) + }) +})