diff --git a/apps/web/e2e/full-sweep-shell.spec.ts b/apps/web/e2e/full-sweep-shell.spec.ts index fbd9c51..244e714 100644 --- a/apps/web/e2e/full-sweep-shell.spec.ts +++ b/apps/web/e2e/full-sweep-shell.spec.ts @@ -119,20 +119,105 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => { // checklist: shell-appshell-role-theming // 역할별 셸 진입 시 body[data-role]이 learner/instructor/admin으로 설정된다. test("applies body[data-role] theming for each role shell", async ({ page }) => { - await signInAsLearner(page); + let currentUser = meResponse(); + await page.route("**/api/auth/me", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(currentUser), + }); + }); + await page.goto("/learn"); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); await expect(page.locator("body")).toHaveAttribute("data-role", "learner"); - await signInAsTeacher(page); + currentUser = meResponse({ + role: "teacher", + email: "shell.teacher@hs.ac.kr", + display_name: "Shell Teacher", + }); await page.goto("/teach"); await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 }); await expect(page.locator("body")).toHaveAttribute("data-role", "instructor"); - await signInAsAdmin(page); + currentUser = meResponse({ + role: "admin", + email: "shell.admin@twentyoz.kr", + display_name: "Shell Admin", + admin_access: true, + super_admin: true, + }); await page.goto("/admin"); await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 }); await expect(page.locator("body")).toHaveAttribute("data-role", "admin"); + + for (const viewport of [ + { width: 390, height: 844 }, + { width: 320, height: 568 }, + ]) { + await page.setViewportSize(viewport); + await page.evaluate(() => new Promise(requestAnimationFrame)); + const topbar = await page.locator(".vg-topbar").evaluate((element) => { + const topbarRect = element.getBoundingClientRect(); + const controls = Array.from( + element.querySelectorAll( + ".vg-topbar__brand, .vg-topbar__switch-link, .vg-iconbtn", + ), + ).map((control) => { + const rect = control.getBoundingClientRect(); + return { + label: + control.getAttribute("aria-label") ?? + control.getAttribute("title") ?? + control.textContent?.trim() ?? + control.className, + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }; + }); + return { + controls, + topbarLeft: topbarRect.left, + topbarRight: topbarRect.right, + topbarTop: topbarRect.top, + topbarBottom: topbarRect.bottom, + viewportWidth: window.innerWidth, + scrollWidth: document.documentElement.scrollWidth, + }; + }); + + const labels = topbar.controls.map((control) => control.label); + expect(labels.slice(0, 4)).toEqual([ + "Vignette", + "학습자 공간", + "교수 콘솔", + "운영 콘솔", + ]); + expect(labels[4]).toMatch(/^(라이트|다크) 모드로$/); + expect(labels[5]).toBe("로그아웃"); + for (const control of topbar.controls) { + expect(control.width, `${control.label} ${viewport.width}px width`).toBeGreaterThanOrEqual(44); + expect(control.height, `${control.label} ${viewport.width}px height`).toBeGreaterThanOrEqual(44); + expect(control.left, `${control.label} ${viewport.width}px left`).toBeGreaterThanOrEqual( + topbar.topbarLeft, + ); + expect(control.right, `${control.label} ${viewport.width}px right`).toBeLessThanOrEqual( + topbar.topbarRight, + ); + expect(control.top, `${control.label} ${viewport.width}px top`).toBeGreaterThanOrEqual( + topbar.topbarTop, + ); + expect(control.bottom, `${control.label} ${viewport.width}px bottom`).toBeLessThanOrEqual( + topbar.topbarBottom, + ); + } + expect(topbar.scrollWidth).toBe(topbar.viewportWidth); + } }); // checklist: shell-auth-bootstrap-loading diff --git a/apps/web/src/components/shell/shell.css b/apps/web/src/components/shell/shell.css index 7484a48..35791c3 100644 --- a/apps/web/src/components/shell/shell.css +++ b/apps/web/src/components/shell/shell.css @@ -369,20 +369,48 @@ display: none; } .vg-topbar { - padding: 0 var(--sp-4); - gap: var(--sp-3); + padding: 0 4px; + gap: 4px; } + .vg-topbar__brand { + width: 44px; + height: 44px; + flex: 0 0 44px; + justify-content: center; + } + .vg-topbar__wm, .vg-topbar__user { - max-width: 42px; - padding-right: 6px; + display: none; + } + .vg-topbar__actions { + flex: 1 1 auto; + gap: 4px; + } + .vg-topbar__switch { + flex: 1 1 auto; + height: 44px; + padding: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + } + .vg-topbar__switch::-webkit-scrollbar { + display: none; } .vg-topbar__switch-link span { display: none; } .vg-topbar__switch-link { - width: 28px; + width: 44px; + height: 44px; + flex: 0 0 44px; padding: 0; } + .vg-iconbtn { + width: 44px; + height: 44px; + flex: 0 0 44px; + } .vg-main { padding: var(--sp-5) var(--sp-4) var(--sp-7); } diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 581f731..07d8bd4 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -675,6 +675,7 @@

196차 적용(2026-08-10): clean HEAD 9a7dd98c…·tree 1dce20c5…의 5차 단일 full run은 실제 동일 학습자 브라우저 단계에서 SSE·review ready·G4 0→1→1·G5 0→1→1을 78초에 모두 통과해 핵심 폐루프 첫 GREEN을 만들었다. 다만 같은 fixture를 이어서 사용한 returned-practice desktop/mobile 회귀가 이미 적재된 G4/G5를 초기 0으로 오인해 2/4 실패했고 전체 receipt periodic-learner-e2e-20260810-010038.json은 정직하게 FAILED다. 공개·NAS·active DB 접촉 0, container/volume/network/listener/temp 잔여 0은 유지했다. runner는 핵심 폐루프와 후속 회귀 전에 각각 새 학습자·회기 fixture를 준비하도록 분리했고 unit 12/12·Ruff·compile을 통과했다. 실패 화면에서 리뷰 요약 literal [NAME]/[ORG]익명 내담자가고 조사 결함도 발견해 표시 전용 자연어 치환과 복합 조사 우선순위를 보강했고 session-review desktop/mobile 2/2·typecheck·build를 통과했다. 당시에는 전체 GREEN receipt가 없어 자동 full runner를 등록하지 않았다.

197차 적용(2026-08-10): clean HEAD 4cd35488…·tree 001a7c74…의 6차 full run은 핵심 폐루프를 두 번째로 통과했고, 별도 returned-practice fixture도 초기 G4/G5 0을 증명했다. desktop G4/G5 2건은 GREEN이었지만 desktop이 만든 성공 상태를 mobile G4가 다시 읽을 때 테스트가 반영 상태 다시 확인 대신 최초 버튼을 찾아 5분 timeout, mobile G5는 serial 중단됐다. receipt periodic-learner-e2e-20260810-012909.json은 FAILED이며 public/NAS/active DB 접촉 0·exact cleanup GREEN이다. mobile 성공-state locator를 교정했고 실패 화면에서 추가로 확인한 강점·개선점·다음 문장·내담자 반응·워크시트 근거의 literal [NAME]/[ORG]도 저장/API는 유지한 채 표시층에서 자연어화했다. focused session-review desktop/mobile 2/2·typecheck·live spec 4건 collection을 통과했지만 full runner는 재실행하지 않았다.

198차 적용(2026-08-10): clean HEAD aa81af29…·tree 3901d71c…의 7차 full run이 499.8초에 첫 전체 GREEN receipt periodic-learner-e2e-20260810-020117.json·SHA-256 bae963de…f32e1a를 만들었다. 실제 브라우저 same-learner session 생성·SSE·review ready·G4/G5 0→1→1, 별도 zero-state returned-practice desktop/mobile 4/4, route mock 0을 통과했다. public 8001·active DB 55432·engine 9099·NAS 접촉은 0이며 전용 container 4·volume 3·network 1·engine PID와 6578-6581 listener는 cleanup 뒤 모두 0이다. 기존 vignette-e2e hourly heartbeat는 중복 생성 없이 갱신해 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀐 때만 preflight 뒤 full runner를 최대 1회 실행하고, 실패 시 같은 heartbeat에서 재시도하지 않는다. G7 human pack evaluator·runner/checker와 후속 intake compiler 계약도 103/103으로 재검증했으며 물리 마이크는 열지 않았다.

+

199차 적용(2026-08-12): 실제 공개 학생 홈을 390×844·320×568에서 읽기 전용으로 확인해 부트 진단·console error·가로 overflow·literal PII placeholder는 0이고 핵심 학습 카드·탭·리뷰 CTA는 44px 이상임을 재확인했다. 반면 공통 톱바의 공간 전환 링크는 28px, 테마·로그아웃은 34px에 머물러 있던 P1을 발견했다. 소스는 720px 이하에서 워드마크 글자·사용자 칩을 접고 브랜드·공간 전환·테마·로그아웃 6개를 모두 44×44px로 고정했으며, 관리자 3역할 최악 조건 390/320 desktop/mobile 2/2, 학생 자기주도 홈→회기→리뷰→재연습 desktop/mobile 6/6, typecheck와 diff-check를 통과했다. 공개 앱에는 아직 이 변경을 배포하지 않았으므로 실제 공개 톱바는 별도 승인 배포 전까지 기존 크기이며, G7은 명시 동의 물리 마이크·독립 human pack·canonical checker exit 0 전까지 external GATE다.

래스터만 사용이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.
기능 우선메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.