모바일 메뉴와 주요 화면 UX 개선 및 시각 검수

This commit is contained in:
Yun Chan 2026-09-12 17:57:59 +09:00
parent 89b5093ec7
commit e8b770e4d9
50 changed files with 3189 additions and 457 deletions

View file

@ -145,6 +145,322 @@ interface ClipReport {
}>;
}
type ScrollOwner = {
id: string;
token: string;
label: string;
clientHeight: number;
clientWidth: number;
maxTop: number;
maxLeft: number;
top: number;
left: number;
};
type CaptureManifestRecord = {
capturedAt: string;
screen: string;
viewport: { width: number; height: number; label: string };
kind: "full-page" | "vertical-segment" | "horizontal-end" | "scroll-reset" | "scroll-excluded";
owner: Pick<ScrollOwner, "id" | "label" | "clientHeight" | "clientWidth" | "maxTop" | "maxLeft">;
offset: { top: number; left: number };
path: string | null;
reason?: string;
ownerToken?: string;
ownerMatchCount?: number;
theoreticalMaxLeft?: number;
actualMaxLeft?: number;
};
type ScrollPosition = {
token: string;
matchCount: number;
top: number;
left: number;
clientHeight: number;
clientWidth: number;
scrollHeight: number;
scrollWidth: number;
maxTop: number;
maxLeft: number;
rect: { top: number; bottom: number; left: number; right: number };
};
type HorizontalLimitProbe = {
token: string;
matchCount: number;
originalLeft: number;
theoreticalMaxLeft: number;
actualMaxLeft: number;
};
const SCROLL_OWNER_ATTRIBUTE = "data-layout-gate-scroll-owner";
const MAX_SCROLL_SEGMENTS = 30;
let scrollOwnerGeneration = 0;
async function writeCaptureManifest(record: CaptureManifestRecord) {
await fs.appendFile(
path.join(SHOT_DIR, "capture-manifest.jsonl"),
`${JSON.stringify(record)}\n`,
"utf8",
);
}
async function resetScrollableOwners(page: Page): Promise<ScrollOwner[]> {
const generation = ++scrollOwnerGeneration;
return page.evaluate(async ({ ownerAttribute, ownerGeneration }) => {
const isVisible = (element: HTMLElement) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0
);
};
for (const previousOwner of Array.from(document.querySelectorAll<HTMLElement>(`[${ownerAttribute}]`))) {
previousOwner.removeAttribute(ownerAttribute);
}
const owners = new Map<HTMLElement, { id: string; token: string; label: string }>();
let nextId = 0;
const add = (element: HTMLElement | null, label: string, preferredId?: string) => {
if (!element || owners.has(element)) return;
const id = preferredId ?? `scroll-${nextId++}`;
owners.set(element, { id, token: `owner-${ownerGeneration}-${id}`, label });
};
add(document.scrollingElement as HTMLElement | null, "document", "document");
add(document.querySelector<HTMLElement>("#root"), "#root", "root");
add(document.querySelector<HTMLElement>(".vg-main"), ".vg-main", "vg-main");
add(document.querySelector<HTMLElement>("main"), "main", "main");
for (const element of Array.from(document.querySelectorAll<HTMLElement>("*"))) {
if (!isVisible(element)) continue;
const style = getComputedStyle(element);
const canScrollY =
(style.overflowY === "auto" || style.overflowY === "scroll" || style.overflowY === "overlay") &&
element.scrollHeight > element.clientHeight + 1;
const canScrollX =
(style.overflowX === "auto" || style.overflowX === "scroll" || style.overflowX === "overlay") &&
element.scrollWidth > element.clientWidth + 1;
if (!canScrollY && !canScrollX) continue;
const tag = element.tagName.toLowerCase();
const identifier = element.id ? `#${element.id}` : element.classList.length > 0 ? `.${element.classList[0]}` : tag;
add(element, identifier);
}
for (const [element, owner] of owners) {
element.setAttribute(ownerAttribute, owner.token);
element.scrollTop = 0;
element.scrollLeft = 0;
}
const ownerIds = Array.from(owners.values(), (owner) => owner.id);
const ownerTokens = Array.from(owners.values(), (owner) => owner.token);
if (new Set(ownerIds).size !== ownerIds.length || new Set(ownerTokens).size !== ownerTokens.length) {
throw new Error("Layout capture scroll-owner assignment contains duplicate IDs.");
}
for (const owner of owners.values()) {
const matches = document.querySelectorAll(`[${ownerAttribute}="${owner.token}"]`).length;
if (matches !== 1) {
throw new Error(`Layout capture scroll-owner ${owner.id} resolved to ${matches} nodes after reset.`);
}
}
window.scrollTo(0, 0);
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return Array.from(owners, ([element, owner]) => ({
...owner,
clientHeight: element.clientHeight,
clientWidth: element.clientWidth,
maxTop: Math.max(0, element.scrollHeight - element.clientHeight),
maxLeft: Math.max(0, element.scrollWidth - element.clientWidth),
top: element.scrollTop,
left: element.scrollLeft,
}));
}, { ownerAttribute: SCROLL_OWNER_ATTRIBUTE, ownerGeneration: generation });
}
async function scrollOwnerTo(page: Page, owner: ScrollOwner, top: number, left = 0) {
return page.evaluate(
async ({ ownerAttribute, ownerId: requestedOwnerId, ownerToken, top: requestedTop, left: requestedLeft }) => {
const matches = Array.from(document.querySelectorAll<HTMLElement>(`[${ownerAttribute}="${ownerToken}"]`));
if (matches.length !== 1) {
throw new Error(`Scroll owner ${requestedOwnerId} expected one node for ${ownerToken}, found ${matches.length}.`);
}
const element = matches[0];
element.scrollIntoView({ block: "nearest", inline: "nearest" });
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const maxTop = Math.max(0, element.scrollHeight - element.clientHeight);
const maxLeft = Math.max(0, element.scrollWidth - element.clientWidth);
const targetTop = Math.min(requestedTop, maxTop);
const targetLeft = Math.min(requestedLeft, maxLeft);
element.scrollTop = targetTop;
element.scrollLeft = targetLeft;
if (requestedOwnerId === "document") window.scrollTo(targetLeft, targetTop);
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const rect = element.getBoundingClientRect();
return {
token: ownerToken,
matchCount: matches.length,
top: element.scrollTop,
left: element.scrollLeft,
clientHeight: element.clientHeight,
clientWidth: element.clientWidth,
scrollHeight: element.scrollHeight,
scrollWidth: element.scrollWidth,
maxTop: Math.max(0, element.scrollHeight - element.clientHeight),
maxLeft: Math.max(0, element.scrollWidth - element.clientWidth),
rect: { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right },
};
},
{ ownerAttribute: SCROLL_OWNER_ATTRIBUTE, ownerId: owner.id, ownerToken: owner.token, top, left },
);
}
async function probeNativeMaxLeft(page: Page, owner: ScrollOwner): Promise<HorizontalLimitProbe> {
return page.evaluate(
async ({ ownerAttribute, ownerId: requestedOwnerId, ownerToken }) => {
const matches = Array.from(document.querySelectorAll<HTMLElement>(`[${ownerAttribute}="${ownerToken}"]`));
if (matches.length !== 1) {
throw new Error(`Scroll owner ${requestedOwnerId} expected one node for ${ownerToken}, found ${matches.length}.`);
}
const element = matches[0];
const originalLeft = element.scrollLeft;
const theoreticalMaxLeft = Math.max(0, element.scrollWidth - element.clientWidth);
element.scrollLeft = Number.MAX_SAFE_INTEGER;
if (requestedOwnerId === "document") window.scrollTo(Number.MAX_SAFE_INTEGER, window.scrollY);
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const actualMaxLeft = element.scrollLeft;
element.scrollLeft = originalLeft;
if (requestedOwnerId === "document") window.scrollTo(originalLeft, window.scrollY);
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return {
token: ownerToken,
matchCount: matches.length,
originalLeft,
theoreticalMaxLeft,
actualMaxLeft,
};
},
{ ownerAttribute: SCROLL_OWNER_ATTRIBUTE, ownerId: owner.id, ownerToken: owner.token },
);
}
function segmentOffsets(maxOffset: number, clientSize: number, owner: ScrollOwner) {
if (maxOffset <= 0) return [];
const step = Math.max(1, Math.floor(clientSize * 0.8));
const offsets = [0];
for (let offset = step; offset < maxOffset; offset += step) offsets.push(offset);
if (offsets[offsets.length - 1] !== maxOffset) offsets.push(maxOffset);
if (offsets.length > MAX_SCROLL_SEGMENTS) {
throw new Error(
`[${owner.label}] needs ${offsets.length} scroll captures; the ${MAX_SCROLL_SEGMENTS}-segment ceiling would omit its bottom.`,
);
}
return offsets;
}
async function captureScrollableSegments(
page: Page,
screen: string,
viewport: (typeof GATE_WIDTHS)[number],
owners: ScrollOwner[],
) {
for (const owner of owners) {
const initialPosition = await scrollOwnerTo(page, owner, 0);
expect(initialPosition.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
const liveOwner = {
...owner,
clientHeight: initialPosition.clientHeight,
clientWidth: initialPosition.clientWidth,
maxTop: initialPosition.maxTop,
maxLeft: initialPosition.maxLeft,
};
if (owner.maxTop > 0 && liveOwner.maxTop <= 0) {
await writeCaptureManifest({
capturedAt: new Date().toISOString(),
screen,
viewport,
kind: "scroll-excluded",
owner: liveOwner,
offset: { top: initialPosition.top, left: initialPosition.left },
path: null,
reason: "Owner stopped scrolling after it was brought into the capture viewport.",
ownerToken: owner.token,
ownerMatchCount: initialPosition.matchCount,
});
}
const offsets = segmentOffsets(liveOwner.maxTop, liveOwner.clientHeight, liveOwner);
for (const [segmentIndex, top] of offsets.entries()) {
const offset = await scrollOwnerTo(page, owner, top);
expect(offset.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
const reachableTarget = Math.min(top, offset.maxTop);
expect(
Math.abs(offset.top - reachableTarget),
`[${screen} @ ${viewport.label}] ${owner.label} vertical capture offset`,
).toBeLessThanOrEqual(1);
const shotPath = path.join(
SHOT_DIR,
`${screen}__${viewport.label}__${owner.id}__segment-${String(segmentIndex).padStart(2, "0")}.png`,
);
await page.screenshot({ path: shotPath, fullPage: false });
await writeCaptureManifest({
capturedAt: new Date().toISOString(),
screen,
viewport,
kind: "vertical-segment",
owner: { ...liveOwner, maxTop: offset.maxTop, maxLeft: offset.maxLeft },
offset,
path: path.relative(process.cwd(), shotPath),
ownerToken: owner.token,
ownerMatchCount: offset.matchCount,
});
}
if (liveOwner.maxLeft > 0) {
const horizontalProbe = await probeNativeMaxLeft(page, owner);
expect(horizontalProbe.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
expect(horizontalProbe.theoreticalMaxLeft, `[${screen} @ ${viewport.label}] ${owner.label} theoretical horizontal maximum`).toBe(liveOwner.maxLeft);
const offset = await scrollOwnerTo(page, owner, 0, horizontalProbe.actualMaxLeft);
expect(offset.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
expect(
Math.abs(offset.left - horizontalProbe.actualMaxLeft),
`[${screen} @ ${viewport.label}] ${owner.label} horizontal capture offset`,
).toBeLessThanOrEqual(1);
const shotPath = path.join(SHOT_DIR, `${screen}__${viewport.label}__${owner.id}__horizontal-end.png`);
await page.screenshot({ path: shotPath, fullPage: false });
await writeCaptureManifest({
capturedAt: new Date().toISOString(),
screen,
viewport,
kind: "horizontal-end",
owner: { ...liveOwner, maxTop: offset.maxTop, maxLeft: offset.maxLeft },
offset,
path: path.relative(process.cwd(), shotPath),
ownerToken: owner.token,
ownerMatchCount: offset.matchCount,
theoreticalMaxLeft: horizontalProbe.theoreticalMaxLeft,
actualMaxLeft: horizontalProbe.actualMaxLeft,
});
const afterActualMax = await scrollOwnerTo(
page,
owner,
0,
horizontalProbe.actualMaxLeft + Math.max(100, offset.clientWidth),
);
expect(afterActualMax.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
expect(
Math.abs(afterActualMax.left - horizontalProbe.actualMaxLeft),
`[${screen} @ ${viewport.label}] ${owner.label} native horizontal maximum`,
).toBeLessThanOrEqual(1);
}
}
await resetScrollableOwners(page);
}
/**
* Scans every visible interactive control and prominent text container for
* either (a) extending beyond the viewport horizontally, or (b) clipping its own
@ -261,10 +577,18 @@ async function gateScreen(
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
await prepareReady();
await page.evaluate(() => {
window.scrollTo(0, 0);
return new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
const owners = await resetScrollableOwners(page);
for (const owner of owners) {
await writeCaptureManifest({
capturedAt: new Date().toISOString(),
screen,
viewport: vp,
kind: "scroll-reset",
owner,
offset: { top: owner.top, left: owner.left },
path: null,
});
}
await expect(
page.locator("html"),
@ -306,10 +630,28 @@ async function gateScreen(
)}`,
).toEqual([]);
const topShotPath = path.join(SHOT_DIR, `${screen}__${vp.label}.png`);
await page.screenshot({
path: path.join(SHOT_DIR, `${screen}__${vp.label}.png`),
path: topShotPath,
fullPage: true,
});
await writeCaptureManifest({
capturedAt: new Date().toISOString(),
screen,
viewport: vp,
kind: "full-page",
owner: {
id: "document",
label: "document",
clientHeight: page.viewportSize()?.height ?? vp.height,
clientWidth: page.viewportSize()?.width ?? vp.width,
maxTop: 0,
maxLeft: 0,
},
offset: { top: 0, left: 0 },
path: path.relative(process.cwd(), topShotPath),
});
await captureScrollableSegments(page, screen, vp, owners);
}
}
@ -445,10 +787,11 @@ async function expectSupervisorReviewNoDeadGaps(page: Page) {
}
test.describe("layout visual gate @single-run", () => {
test.describe.configure({ mode: "serial" });
test.describe.configure({ mode: "serial", timeout: 90_000 });
test.beforeAll(async () => {
await ensureShotDir();
await fs.writeFile(path.join(SHOT_DIR, "capture-manifest.jsonl"), "", "utf8");
});
// 이 게이트는 다크 표면을 기준으로 레이아웃을 캡처하고, 라이트 검사는 각 테스트가
@ -499,24 +842,52 @@ test.describe("layout visual gate @single-run", () => {
await page.goto("/learn");
await gateScreen(page, "learner-home", async () => {
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".lh-dashboard-status .lh-metric-card").first()).toBeVisible({
timeout: 15_000,
});
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
// 대시보드 탭은 모든 폭에서 상시 노출된다 — 탭별 콘텐츠를 확인 후 기본 탭으로 복귀.
const dashTabs = page.locator(".lh-tabs");
if (await dashTabs.isVisible().catch(() => false)) {
await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click();
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
timeout: 15_000,
await expect(dashTabs).toBeVisible({ timeout: 15_000 });
await dashTabs.getByRole("tab", { name: "오늘의 회기", exact: true }).click();
await expect(page.locator("[data-learner-primary-action]")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("#lh-panel-today .lh-session-focus")).toBeVisible({ timeout: 15_000 });
await dashTabs.getByRole("tab", { name: "성장 지표", exact: true }).click();
const growthPanel = page.locator("#lh-panel-growth");
const growthStatus = growthPanel.locator(".lh-dashboard-status");
const metricCards = growthStatus.locator(".lh-metric-card");
await expect(growthStatus).toBeVisible({ timeout: 15_000 });
await expect(metricCards).toHaveCount(4);
await expect(metricCards.first()).toBeVisible();
const metricGeometry = await growthStatus.evaluate((status) => {
const panel = status.closest<HTMLElement>("#lh-panel-growth");
if (!panel) return null;
const statusRect = status.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const cards = Array.from(status.querySelectorAll<HTMLElement>(".lh-metric-card")).map((card) => {
const rect = card.getBoundingClientRect();
return { width: rect.width, height: rect.height };
});
await dashTabs.locator("button", { hasText: "오늘의 회기" }).click();
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
} else {
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
timeout: 15_000,
});
}
return {
leftGap: Math.abs(statusRect.left - panelRect.left),
rightGap: Math.abs(panelRect.right - statusRect.right),
cards,
};
});
expect(metricGeometry, "growth metrics should stay inside the growth panel").not.toBeNull();
expect(metricGeometry!.leftGap, "growth metrics should span the panel at every gate width").toBeLessThanOrEqual(1);
expect(metricGeometry!.rightGap, "growth metrics should span the panel at every gate width").toBeLessThanOrEqual(1);
expect(metricGeometry!.cards.every((card) => card.width > 0 && card.height > 0)).toBe(true);
const viewport = page.viewportSize();
const viewportLabel = GATE_WIDTHS.find(
(candidate) => candidate.width === viewport?.width && candidate.height === viewport?.height,
)?.label;
await page.screenshot({
path: path.join(SHOT_DIR, `learner-home-growth__${viewportLabel ?? "unknown"}.png`),
fullPage: true,
});
await dashTabs.getByRole("tab", { name: "기록 · 리뷰", exact: true }).click();
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({ timeout: 15_000 });
await dashTabs.getByRole("tab", { name: "오늘의 회기", exact: true }).click();
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
const shellScroll = await page.evaluate(async () => {
const nav = document.querySelector<HTMLElement>(".vg-nav");
@ -705,9 +1076,13 @@ test.describe("layout visual gate @single-run", () => {
await signInAsTeacher(page);
await page.goto("/teach");
await gateScreen(page, "professor", async () => {
await expect(page.locator(".pf-panel, .pf-shell, main").first()).toBeVisible({
await expect(page.locator(".pf-root--console")).toBeVisible({
timeout: 15_000,
});
await expect(page.locator(".pf-triage__copy > span").last()).toContainText("실제 기록", {
timeout: 15_000,
});
await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled();
await expectProfessorSummaryGap(page);
});
});
@ -778,6 +1153,8 @@ test.describe("layout visual gate @single-run", () => {
await expect(page.locator(".ps-overview")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
await expect(page.getByRole("navigation", { name: "페르소나 관리 영역" })).toBeVisible();
await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled({ timeout: 15_000 });
await expect(page.locator(".ps-error")).toHaveCount(0);
});
});
@ -788,6 +1165,7 @@ test.describe("layout visual gate @single-run", () => {
await expect(page.locator(".ps-authoring-layout")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
await expect(page.getByRole("tab", { name: "개요" })).toBeVisible();
await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled({ timeout: 15_000 });
});
});
@ -798,6 +1176,8 @@ test.describe("layout visual gate @single-run", () => {
await page.goto("/admin");
await gateScreen(page, "admin", async () => {
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled({ timeout: 15_000 });
await expect(page.locator(".vgops-status > div > span")).not.toHaveText("연결 확인 중");
});
});
@ -811,6 +1191,12 @@ test.describe("layout visual gate @single-run", () => {
timeout: 15_000,
});
await expect(page.locator(".aic-grid")).toBeVisible();
const adminAi = page.getByTestId("admin-ai-page");
await expect(adminAi).toHaveAttribute("aria-busy", "false", { timeout: 15_000 });
await expect(adminAi.getByRole("button", { name: "새로고침", exact: true })).toBeEnabled();
await expect(page.locator(".aic-source b")).not.toHaveText("계량 원천 확인 중");
await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toHaveCount(0);
await expect(page.locator('[data-testid="provider-connections-panel"] .aic-runtime')).not.toHaveText("확인 중");
});
});
@ -829,6 +1215,10 @@ test.describe("layout visual gate @single-run", () => {
]) {
await page.setViewportSize(viewport);
await expectNoHorizontalOverflow(page);
await expect.poll(() => page.locator('[data-admin-section="users"]').evaluate((element) => {
const rect = element.getBoundingClientRect();
return rect.left >= -1 && rect.right <= window.innerWidth + 1;
})).toBe(true);
const scrollReport = await page.locator(".vgops-user-table-scroll").evaluate((element) => {
element.scrollLeft = 0;
const report = {
@ -849,6 +1239,7 @@ test.describe("layout visual gate @single-run", () => {
await page.locator(".vgops-user-table-scroll").evaluate((element) => {
element.scrollLeft = 0;
});
await resetScrollableOwners(page);
await page.screenshot({
path: path.join(SHOT_DIR, `admin-users__${viewport.width}-reference-dark.png`),
fullPage: true,
@ -871,6 +1262,8 @@ test.describe("layout visual gate @single-run", () => {
await page.goto("/settings");
await gateScreen(page, "settings", async () => {
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".vg-set")).toHaveAttribute("aria-busy", "false", { timeout: 15_000 });
await expect(page.locator(".vg-set__callout--warn")).toHaveCount(0);
});
await page.setViewportSize({ width: 1280, height: 800 });
await page.getByRole("button", { name: "라이트 모드로" }).click();