모바일 메뉴와 주요 화면 UX 개선 및 시각 검수
This commit is contained in:
parent
89b5093ec7
commit
e8b770e4d9
50 changed files with 3189 additions and 457 deletions
|
|
@ -24,3 +24,4 @@
|
||||||
- 2026-08-30 · 목표 선택은 `핵심 1개 · 보조 0~3개`를 명시하고, 보조 목표를 제거하지 않고 핵심 순서만 바꿀 수 있게 한다. 짧은 데스크톱 높이에서는 같은 선택을 native select로 압축하고 시작 행동을 안전한 sticky bar에 둬 4개 목표와 CTA가 겹치지 않게 한다. 모든 조작은 44px 이상이며 토큰·기존 payload 계약은 유지한다.
|
- 2026-08-30 · 목표 선택은 `핵심 1개 · 보조 0~3개`를 명시하고, 보조 목표를 제거하지 않고 핵심 순서만 바꿀 수 있게 한다. 짧은 데스크톱 높이에서는 같은 선택을 native select로 압축하고 시작 행동을 안전한 sticky bar에 둬 4개 목표와 CTA가 겹치지 않게 한다. 모든 조작은 44px 이상이며 토큰·기존 payload 계약은 유지한다.
|
||||||
- 2026-08-30 · 모바일은 DOM과 같은 사례 → 학습 → 재연습 순서로 읽고, 시작 CTA는 bottom bar와 safe area 위에 고정한다. 키보드 단축키 도움말과 코치 근거/이력은 열 때 포커스를 받고 Tab 순환·닫은 뒤 호출자 복원을 보장한다.
|
- 2026-08-30 · 모바일은 DOM과 같은 사례 → 학습 → 재연습 순서로 읽고, 시작 CTA는 bottom bar와 safe area 위에 고정한다. 키보드 단축키 도움말과 코치 근거/이력은 열 때 포커스를 받고 Tab 순환·닫은 뒤 호출자 복원을 보장한다.
|
||||||
- 2026-08-30 · 공통 셸의 첫 키보드 정지는 `본문으로 건너뛰기`다. 링크는 포커스될 때만 보이고, 어느 역할·화면에서도 식별 가능한 main으로 포커스를 옮긴다.
|
- 2026-08-30 · 공통 셸의 첫 키보드 정지는 `본문으로 건너뛰기`다. 링크는 포커스될 때만 보이고, 어느 역할·화면에서도 식별 가능한 main으로 포커스를 옮긴다.
|
||||||
|
- 2026-09-12 · 공통 셸은 역할별로 가장 자주 쓰는 3개 목적지를 모바일 하단 내비에 먼저 두고, 나머지 허용 목적지는 명시적 `전체 메뉴` native dialog로 제공한다. 721–1024px에서는 168px 레일로 메뉴 텍스트를 보존하며, 모바일 역할 전환은 현재 역할명이 보이는 native select(접근성 이름: `공간 선택`)로 한다. `Ctrl+W`·`Ctrl+N`·`Ctrl+T`의 브라우저 예약 단축키 가로채기를 제거하고, 기존 Alt 단축키는 보존하며 `Alt+H`는 역할 홈으로 이동한다. 로그인 카피는 계정·저장소 설명 대신 상담 연습과 회고라는 역할 목적을 먼저 말한다. 기존 토큰·권한별 메뉴 필터·라우트 계약은 유지한다.
|
||||||
|
|
|
||||||
|
|
@ -824,7 +824,7 @@ test.describe("admin route guards", () => {
|
||||||
{ path: "/admin/ai", heading: "AI 운영과 DB 계량" },
|
{ path: "/admin/ai", heading: "AI 운영과 DB 계량" },
|
||||||
{ path: "/admin/users", heading: "가입 승인과 권한 관리" },
|
{ path: "/admin/users", heading: "가입 승인과 권한 관리" },
|
||||||
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위" },
|
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위" },
|
||||||
{ path: "/admin/tickets", heading: "사용자 문제 큐" },
|
{ path: "/admin/tickets", heading: "지원 요청" },
|
||||||
] as const;
|
] as const;
|
||||||
const sessions = [
|
const sessions = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ test.describe("G1 치료 동맹 펄스", () => {
|
||||||
test("AI 관점을 공개하기 전에 학습자 자기평가를 잠근다", async ({
|
test("AI 관점을 공개하기 전에 학습자 자기평가를 잠근다", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await routeReviewUser(page, "learner");
|
await routeReviewUser(page, "learner");
|
||||||
let pulse: ReturnType<typeof pulseFixture> | null = null;
|
let pulse: ReturnType<typeof pulseFixture> | null = null;
|
||||||
let submittedBody: Record<string, unknown> | null = null;
|
let submittedBody: Record<string, unknown> | null = null;
|
||||||
|
|
@ -156,13 +157,20 @@ test.describe("G1 치료 동맹 펄스", () => {
|
||||||
await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByText("관찰자 관점", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("관찰자 관점", { exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
const submit = page.getByRole("button", { name: "내 판단 잠그고 관점 비교" });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
const axes = page.locator(".ap-axis");
|
const axes = page.locator(".ap-axis");
|
||||||
await axes.nth(0).locator(".ap-scale__option").nth(3).click();
|
await axes.nth(0).locator(".ap-scale__option").nth(3).click();
|
||||||
await axes.nth(1).locator(".ap-scale__option").nth(2).click();
|
await axes.nth(1).locator(".ap-scale__option").nth(2).click();
|
||||||
await axes.nth(2).locator(".ap-scale__option").nth(4).click();
|
await axes.nth(2).locator(".ap-scale__option").nth(4).click();
|
||||||
await page.getByText("내 판단의 근거 장면 선택").click();
|
await page.getByText("내 판단의 근거 장면 선택").click();
|
||||||
await page.getByRole("checkbox").first().check();
|
await page.getByRole("checkbox").first().check();
|
||||||
await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click();
|
await expect(submit).toBeEnabled();
|
||||||
|
const submitBox = await submit.boundingBox();
|
||||||
|
expect(submitBox).not.toBeNull();
|
||||||
|
expect(submitBox!.height).toBeGreaterThanOrEqual(44);
|
||||||
|
await submit.click();
|
||||||
|
|
||||||
await expect(page.getByText("내 판단이 잠겼습니다")).toBeVisible();
|
await expect(page.getByText("내 판단이 잠겼습니다")).toBeVisible();
|
||||||
await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0);
|
||||||
|
|
|
||||||
|
|
@ -293,14 +293,14 @@ test.describe("continuous improvement admin cockpit", () => {
|
||||||
|
|
||||||
await page.goto("/admin/continuous-improvement");
|
await page.goto("/admin/continuous-improvement");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "개선안 검토와 승인" })).toBeVisible();
|
||||||
await expect(page.getByText("합성 콘텐츠 검증 파이프라인")).toBeVisible();
|
await expect(page.getByText("생성 콘텐츠 검토")).toBeVisible();
|
||||||
await expect(page.getByText("모델 변경 · 릴리스 게이트")).toBeVisible();
|
await expect(page.getByText("모델 변경과 릴리스 검토")).toBeVisible();
|
||||||
await expect(page.getByText("사건 회귀 DAG")).toBeVisible();
|
await expect(page.getByText("문제 재발 방지 기록")).toBeVisible();
|
||||||
await expect(page.getByText("모니터 · 롤백 원장")).toBeVisible();
|
await expect(page.getByText("적용과 복구 이력")).toBeVisible();
|
||||||
await expect(page.getByText("롤백 실행 대기 · 실행기 미구성", { exact: true })).toBeVisible();
|
await expect(page.getByText("롤백 실행 대기 · 실행기 미구성", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("롤백 실행 실패", { exact: true })).toBeVisible();
|
await expect(page.getByText("롤백 실행 실패", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("롤백은 실행됐지만 verification 이벤트가 아직 없어")).toBeVisible();
|
await expect(page.getByText("복구는 실행됐지만 검증 기록이 아직 없습니다")).toBeVisible();
|
||||||
const contentCandidate = page.locator(
|
const contentCandidate = page.locator(
|
||||||
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
||||||
);
|
);
|
||||||
|
|
@ -314,7 +314,7 @@ test.describe("continuous improvement admin cockpit", () => {
|
||||||
await expect(contentCandidate.getByRole("button", { name: "승인 기록" })).toBeDisabled();
|
await expect(contentCandidate.getByRole("button", { name: "승인 기록" })).toBeDisabled();
|
||||||
await contentReason.fill("합성 경계와 수련 목표를 직접 검수함");
|
await contentReason.fill("합성 경계와 수련 목표를 직접 검수함");
|
||||||
await contentReason.press("Enter");
|
await contentReason.press("Enter");
|
||||||
await expect(contentCandidate.getByText("콘텐츠 승인 원장 기록됨")).toBeVisible();
|
await expect(contentCandidate.getByText("콘텐츠 승인 기록됨")).toBeVisible();
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||||
await page.getByTestId("continuous-improvement-cockpit").screenshot({
|
await page.getByTestId("continuous-improvement-cockpit").screenshot({
|
||||||
path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"),
|
path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"),
|
||||||
|
|
@ -325,7 +325,7 @@ test.describe("continuous improvement admin cockpit", () => {
|
||||||
await expect(missingGate.getByRole("radio", { name: "승인", exact: true })).toBeDisabled();
|
await expect(missingGate.getByRole("radio", { name: "승인", exact: true })).toBeDisabled();
|
||||||
await expect(missingGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
await expect(missingGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
||||||
await expect(missingGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
await expect(missingGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
||||||
await expect(missingGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
|
await expect(missingGate.getByText("필수 증거 4종이 갖춰지지 않아 승인할 수 없습니다")).toBeVisible();
|
||||||
await missingGate.screenshot({
|
await missingGate.screenshot({
|
||||||
path: testInfo.outputPath("g8-human-decision-approval-blocked.png"),
|
path: testInfo.outputPath("g8-human-decision-approval-blocked.png"),
|
||||||
animations: "disabled",
|
animations: "disabled",
|
||||||
|
|
@ -338,10 +338,10 @@ test.describe("continuous improvement admin cockpit", () => {
|
||||||
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||||
const approvalButton = modelGate.getByRole("button", { name: "승인 기록" });
|
const approvalButton = modelGate.getByRole("button", { name: "승인 기록" });
|
||||||
await expect(approvalButton).toBeDisabled();
|
await expect(approvalButton).toBeDisabled();
|
||||||
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible();
|
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 합니다")).toBeVisible();
|
||||||
await approvalReason.fill("기준선과 rollback runbook을 독립 검토함");
|
await approvalReason.fill("기준선과 rollback runbook을 독립 검토함");
|
||||||
await expect(approvalButton).toBeEnabled();
|
await expect(approvalButton).toBeEnabled();
|
||||||
await expect(modelGate.getByText("승인 결정과 사유를 append-only 원장에 기록할 준비됨")).toBeVisible();
|
await expect(modelGate.getByText("승인 결정과 사유를 변경 이력에 기록할 준비가 되었습니다")).toBeVisible();
|
||||||
await modelGate.screenshot({
|
await modelGate.screenshot({
|
||||||
path: testInfo.outputPath("g8-human-decision-approval-ready.png"),
|
path: testInfo.outputPath("g8-human-decision-approval-ready.png"),
|
||||||
animations: "disabled",
|
animations: "disabled",
|
||||||
|
|
@ -351,7 +351,7 @@ test.describe("continuous improvement admin cockpit", () => {
|
||||||
await approvalReason.focus();
|
await approvalReason.focus();
|
||||||
await approvalReason.press("Enter");
|
await approvalReason.press("Enter");
|
||||||
|
|
||||||
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
|
await expect(modelGate.getByText("변경 승인 기록됨")).toBeVisible();
|
||||||
expect(mock.approvalBodies).toHaveLength(2);
|
expect(mock.approvalBodies).toHaveLength(2);
|
||||||
expect(mock.approvalBodies[0]).toMatchObject({
|
expect(mock.approvalBodies[0]).toMatchObject({
|
||||||
target_kind: "content_qualification",
|
target_kind: "content_qualification",
|
||||||
|
|
@ -388,14 +388,14 @@ test.describe("continuous improvement admin cockpit", () => {
|
||||||
const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
|
const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
|
||||||
const releaseApprove = releaseGate.getByRole("radio", { name: "승인", exact: true });
|
const releaseApprove = releaseGate.getByRole("radio", { name: "승인", exact: true });
|
||||||
await expect(releaseApprove).toBeDisabled();
|
await expect(releaseApprove).toBeDisabled();
|
||||||
await expect(releaseGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
|
await expect(releaseGate.getByText("필수 증거 4종이 갖춰지지 않아 승인할 수 없습니다")).toBeVisible();
|
||||||
await expect(releaseGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
await expect(releaseGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
||||||
await expect(releaseGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
await expect(releaseGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
||||||
|
|
||||||
await releaseGate.getByRole("radio", { name: "보류", exact: true }).check();
|
await releaseGate.getByRole("radio", { name: "보류", exact: true }).check();
|
||||||
await releaseGate.getByLabel("사람 결정 사유").fill("rollback 증거 보강 뒤 다시 검토");
|
await releaseGate.getByLabel("사람 결정 사유").fill("rollback 증거 보강 뒤 다시 검토");
|
||||||
await releaseGate.getByRole("button", { name: "보류 기록" }).click();
|
await releaseGate.getByRole("button", { name: "보류 기록" }).click();
|
||||||
await expect(releaseGate.getByText("보류 원장 기록됨")).toBeVisible();
|
await expect(releaseGate.getByText("보류 기록됨")).toBeVisible();
|
||||||
|
|
||||||
const contentCandidate = page.locator(
|
const contentCandidate = page.locator(
|
||||||
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
||||||
|
|
@ -403,13 +403,13 @@ test.describe("continuous improvement admin cockpit", () => {
|
||||||
await contentCandidate.getByRole("radio", { name: "반려", exact: true }).check();
|
await contentCandidate.getByRole("radio", { name: "반려", exact: true }).check();
|
||||||
await contentCandidate.getByLabel("콘텐츠 결정 사유").fill("검수 기준을 충족하지 못한 후보");
|
await contentCandidate.getByLabel("콘텐츠 결정 사유").fill("검수 기준을 충족하지 못한 후보");
|
||||||
await contentCandidate.getByRole("button", { name: "반려 기록" }).click();
|
await contentCandidate.getByRole("button", { name: "반려 기록" }).click();
|
||||||
await expect(contentCandidate.getByText("반려 원장 기록됨")).toBeVisible();
|
await expect(contentCandidate.getByText("반려 기록됨")).toBeVisible();
|
||||||
|
|
||||||
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
|
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
|
||||||
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||||
await modelGate.getByLabel("사람 결정 사유").fill("독립 증거 4종과 승격 조건을 확인함");
|
await modelGate.getByLabel("사람 결정 사유").fill("독립 증거 4종과 승격 조건을 확인함");
|
||||||
await modelGate.getByRole("button", { name: "승인 기록" }).click();
|
await modelGate.getByRole("button", { name: "승인 기록" }).click();
|
||||||
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
|
await expect(modelGate.getByText("변경 승인 기록됨")).toBeVisible();
|
||||||
|
|
||||||
expect(mock.approvalBodies).toHaveLength(3);
|
expect(mock.approvalBodies).toHaveLength(3);
|
||||||
expect(mock.approvalBodies[0]).toMatchObject({
|
expect(mock.approvalBodies[0]).toMatchObject({
|
||||||
|
|
@ -436,16 +436,16 @@ test.describe("continuous improvement admin cockpit", () => {
|
||||||
test("shows empty and degraded states without opening an approval action", async ({ page }) => {
|
test("shows empty and degraded states without opening an approval action", async ({ page }) => {
|
||||||
await installMock(page, { empty: true });
|
await installMock(page, { empty: true });
|
||||||
await page.goto("/admin/continuous-improvement");
|
await page.goto("/admin/continuous-improvement");
|
||||||
await expect(page.getByText("검토할 콘텐츠 후보가 없어")).toBeVisible();
|
await expect(page.getByText("검토할 콘텐츠 후보가 없습니다")).toBeVisible();
|
||||||
await expect(page.getByText("대기 중인 게이트가 없어")).toBeVisible();
|
await expect(page.getByText("대기 중인 변경 검토가 없습니다")).toBeVisible();
|
||||||
await expect(page.getByText("등록된 운영 사건이 없어")).toBeVisible();
|
await expect(page.getByText("등록된 운영 문제가 없습니다")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /승인 기록|결정 기록/ })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: /승인 기록|결정 기록/ })).toHaveCount(0);
|
||||||
|
|
||||||
await page.unroute("**/api/**");
|
await page.unroute("**/api/**");
|
||||||
await installMock(page, { status: 503 });
|
await installMock(page, { status: 503 });
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByRole("heading", { name: "개선 원장을 확인할 수 없어" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "개선 기록을 확인할 수 없습니다" })).toBeVisible();
|
||||||
await expect(page.getByText(/승인 동작은 닫힌 상태/)).toBeVisible();
|
await expect(page.getByText(/승인 동작은 사용할 수 없습니다/)).toBeVisible();
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,31 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("full sweep: admin ai operations", () => {
|
test.describe("full sweep: admin ai operations", () => {
|
||||||
|
test("제공자 조회 실패를 표시하고 다시 확인할 수 있다", async ({ page }) => {
|
||||||
|
await mockAdminAiSession(page, createMockState());
|
||||||
|
let attempts = 0;
|
||||||
|
let recovered = false;
|
||||||
|
await page.route("**/api/admin/providers", async (route) => {
|
||||||
|
attempts += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: recovered ? 200 : 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(!recovered
|
||||||
|
? { detail: "제공자 상태 조회 실패" }
|
||||||
|
: { providers: [], gateway_sync: { synced: false, detail: "연결 설정 대기" } }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/admin/ai");
|
||||||
|
const panel = page.getByTestId("provider-connections-panel");
|
||||||
|
await expect(panel.locator(".aic-runtime")).toHaveText("연결 상태 확인 실패");
|
||||||
|
const failedAttempts = attempts;
|
||||||
|
recovered = true;
|
||||||
|
await panel.getByRole("button", { name: "다시 확인" }).click();
|
||||||
|
await expect(panel.locator(".aic-runtime")).toHaveText("적용 대기");
|
||||||
|
await expect(panel.getByRole("alert")).toHaveCount(0);
|
||||||
|
expect(attempts).toBeGreaterThan(failedAttempts);
|
||||||
|
});
|
||||||
|
|
||||||
// checklist: admin-ai-guard-pending-approval
|
// checklist: admin-ai-guard-pending-approval
|
||||||
test("redirects a pending-approval admin from /admin/ai to /pending", async ({ page }) => {
|
test("redirects a pending-approval admin from /admin/ai to /pending", async ({ page }) => {
|
||||||
const state = createMockState({
|
const state = createMockState({
|
||||||
|
|
|
||||||
|
|
@ -690,14 +690,14 @@ test.describe("admin console full-sweep (fixtures)", () => {
|
||||||
mock.tickets = [makeMockTicket({ ticket_id: "filter-1", subject: "필터 대상 티켓" })];
|
mock.tickets = [makeMockTicket({ ticket_id: "filter-1", subject: "필터 대상 티켓" })];
|
||||||
|
|
||||||
await page.goto("/admin/tickets");
|
await page.goto("/admin/tickets");
|
||||||
await expect(page.getByRole("heading", { name: "사용자 문제 큐" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "지원 요청" })).toBeVisible();
|
||||||
const clearButton = page.getByRole("button", { name: "초기화" });
|
const clearButton = page.getByRole("button", { name: "초기화" });
|
||||||
await expect(clearButton).toBeDisabled();
|
await expect(clearButton).toBeDisabled();
|
||||||
|
|
||||||
const searchRequest = page.waitForRequest(
|
const searchRequest = page.waitForRequest(
|
||||||
isTicketsRequest((params) => params.get("search") === "로그인 오류"),
|
isTicketsRequest((params) => params.get("search") === "로그인 오류"),
|
||||||
);
|
);
|
||||||
await page.getByLabel("티켓 검색").fill("로그인 오류");
|
await page.getByLabel("지원 요청 검색").fill("로그인 오류");
|
||||||
await searchRequest;
|
await searchRequest;
|
||||||
|
|
||||||
const statusRequest = page.waitForRequest(
|
const statusRequest = page.waitForRequest(
|
||||||
|
|
@ -755,7 +755,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
|
||||||
);
|
);
|
||||||
await clearButton.click();
|
await clearButton.click();
|
||||||
await resetRequest;
|
await resetRequest;
|
||||||
await expect(page.getByLabel("티켓 검색")).toHaveValue("");
|
await expect(page.getByLabel("지원 요청 검색")).toHaveValue("");
|
||||||
await expect(page.getByLabel("상태 필터")).toHaveValue("all");
|
await expect(page.getByLabel("상태 필터")).toHaveValue("all");
|
||||||
await expect(page.getByLabel("카테고리 필터")).toHaveValue("all");
|
await expect(page.getByLabel("카테고리 필터")).toHaveValue("all");
|
||||||
await expect(page.getByLabel("우선순위 필터")).toHaveValue("all");
|
await expect(page.getByLabel("우선순위 필터")).toHaveValue("all");
|
||||||
|
|
@ -1220,13 +1220,13 @@ test.describe("admin console full-sweep (real API)", () => {
|
||||||
await signInAsAdmin(page);
|
await signInAsAdmin(page);
|
||||||
|
|
||||||
await page.goto("/admin/tickets");
|
await page.goto("/admin/tickets");
|
||||||
await expect(page.getByRole("heading", { name: "사용자 문제 큐" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "지원 요청" })).toBeVisible();
|
||||||
|
|
||||||
const searchRequest = page.waitForRequest(
|
const searchRequest = page.waitForRequest(
|
||||||
isTicketsRequest((params) => params.get("search") === "freeze-probe"),
|
isTicketsRequest((params) => params.get("search") === "freeze-probe"),
|
||||||
);
|
);
|
||||||
// 실 DB 스케일에서는 이 fill에서 렌더러가 프리즈되어 어떤 요청도 나가지 않는다.
|
// 실 DB 스케일에서는 이 fill에서 렌더러가 프리즈되어 어떤 요청도 나가지 않는다.
|
||||||
await page.getByLabel("티켓 검색").fill("freeze-probe");
|
await page.getByLabel("지원 요청 검색").fill("freeze-probe");
|
||||||
await searchRequest;
|
await searchRequest;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -329,9 +329,12 @@ function jsonRoute(body: unknown, status = 200) {
|
||||||
/** 페르소나·회기 목록·대시보드·스포트라이트 상세를 fixture로 고정한다. */
|
/** 페르소나·회기 목록·대시보드·스포트라이트 상세를 fixture로 고정한다. */
|
||||||
async function installLearnerHomeFixtures(
|
async function installLearnerHomeFixtures(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { detail?: boolean } = {},
|
options: {
|
||||||
|
detail?: boolean;
|
||||||
|
dashboard?: typeof DASHBOARD_BODY;
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const { detail = true } = options;
|
const { detail = true, dashboard = DASHBOARD_BODY } = options;
|
||||||
await page.route(/\/api\/personas$/, (route) =>
|
await page.route(/\/api\/personas$/, (route) =>
|
||||||
route.fulfill(jsonRoute(PERSONAS_BODY)),
|
route.fulfill(jsonRoute(PERSONAS_BODY)),
|
||||||
);
|
);
|
||||||
|
|
@ -339,7 +342,7 @@ async function installLearnerHomeFixtures(
|
||||||
route.fulfill(jsonRoute(SESSIONS_BODY)),
|
route.fulfill(jsonRoute(SESSIONS_BODY)),
|
||||||
);
|
);
|
||||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||||
route.fulfill(jsonRoute(DASHBOARD_BODY)),
|
route.fulfill(jsonRoute(dashboard)),
|
||||||
);
|
);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) =>
|
await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) =>
|
||||||
|
|
@ -383,6 +386,31 @@ async function installStarterFixtures(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function installLongPersonaFixtures(page: Page) {
|
||||||
|
const personas = Array.from({ length: 16 }, (_, index) => {
|
||||||
|
const number = index + 1;
|
||||||
|
return {
|
||||||
|
code: `P${number}`,
|
||||||
|
display_name: number === 4 ? "박새봄" : `긴 호소 내담자 ${number}`,
|
||||||
|
difficulty: number === 4 ? "easy" : "hard",
|
||||||
|
theory_target: ["cbt"],
|
||||||
|
demographics: { age_band: "30대" },
|
||||||
|
presenting_summary:
|
||||||
|
"반복되는 긴 호소 내용으로 인해 실제 목록 카드가 여러 줄로 늘어나는 상황을 검증합니다. 일상과 관계의 어려움을 자세히 설명합니다.",
|
||||||
|
voice_preset: null,
|
||||||
|
source: "database",
|
||||||
|
degraded: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(personas)));
|
||||||
|
await page.route(/\/api\/sessions$/, (route) =>
|
||||||
|
route.fulfill(jsonRoute({ sessions: [], source: "runtime" })),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||||
|
route.fulfill(jsonRoute(EMPTY_DASHBOARD_BODY)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("full-sweep learner home", () => {
|
test.describe("full-sweep learner home", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await useRealApi(page);
|
await useRealApi(page);
|
||||||
|
|
@ -421,17 +449,18 @@ test.describe("full-sweep learner home", () => {
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// checklist: learner-home-head-status-live
|
// checklist: learner-home-head-action
|
||||||
test("announces persona and history counts in the aria-live head status", async ({
|
test("shows the action-focused dashboard header without a global count badge", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await signInAsLearner(page);
|
await signInAsLearner(page);
|
||||||
await page.goto("/learn");
|
await page.goto("/learn");
|
||||||
|
|
||||||
const status = page.locator(".lh-head__status");
|
await expect(
|
||||||
await expect(status).toHaveAttribute("aria-live", "polite");
|
page.getByRole("heading", { name: "오늘의 상담 연습" }),
|
||||||
// 실 API: 사용 가능 페르소나 수 + 기록 수(신규 학습자 → 0회)가 상태 문구로 뜬다.
|
).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(status).toHaveText(/\d+명 · \d+회/, { timeout: 15_000 });
|
await expect(page.locator(".lh-head__status")).toHaveCount(0);
|
||||||
|
await expect(page.locator("main")).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// checklist: learner-home-dash-metric-cards
|
// checklist: learner-home-dash-metric-cards
|
||||||
|
|
@ -441,14 +470,15 @@ test.describe("full-sweep learner home", () => {
|
||||||
await signInAsLearner(page);
|
await signInAsLearner(page);
|
||||||
await installLearnerHomeFixtures(page);
|
await installLearnerHomeFixtures(page);
|
||||||
await page.goto("/learn");
|
await page.goto("/learn");
|
||||||
|
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||||
|
|
||||||
const cards = page.locator(".lh-dashboard-status");
|
const cards = page.locator("#lh-panel-growth .lh-dashboard-status");
|
||||||
await expect(cards.getByRole("button")).toHaveCount(4);
|
await expect(cards.getByRole("button")).toHaveCount(4);
|
||||||
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
|
await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText(
|
||||||
"4회",
|
"4회",
|
||||||
);
|
);
|
||||||
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
|
await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText(
|
||||||
"3회 종료 · 1회 진행",
|
"상담 완료 3회 · 진행 중 1회",
|
||||||
);
|
);
|
||||||
await expect(cards.getByRole("button", { name: /리뷰 대기/ })).toContainText(
|
await expect(cards.getByRole("button", { name: /리뷰 대기/ })).toContainText(
|
||||||
"1건",
|
"1건",
|
||||||
|
|
@ -466,17 +496,51 @@ test.describe("full-sweep learner home", () => {
|
||||||
"성하늘 · 3회",
|
"성하늘 · 3회",
|
||||||
);
|
);
|
||||||
|
|
||||||
await cards.getByRole("button", { name: /진행 회기/ }).click();
|
await cards.getByRole("button", { name: /전체 회기/ }).click();
|
||||||
await expect(page).toHaveURL(/\/learn\/history$/);
|
await expect(page).toHaveURL(/\/learn\/history$/);
|
||||||
|
|
||||||
await page.goto("/learn");
|
await page.goto("/learn");
|
||||||
|
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||||
await page
|
await page
|
||||||
.locator(".lh-dashboard-status")
|
.locator("#lh-panel-growth .lh-dashboard-status")
|
||||||
.getByRole("button", { name: /라포 흐름/ })
|
.getByRole("button", { name: /라포 흐름/ })
|
||||||
.click();
|
.click();
|
||||||
await expect(page).toHaveURL(/\/learn\/practice$/);
|
await expect(page).toHaveURL(/\/learn\/practice$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("labels all sessions separately from completed counseling and empty ended sessions", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page, {
|
||||||
|
dashboard: {
|
||||||
|
...DASHBOARD_BODY,
|
||||||
|
overview: {
|
||||||
|
...DASHBOARD_BODY.overview,
|
||||||
|
total_sessions: 5,
|
||||||
|
completed_sessions: 2,
|
||||||
|
active_sessions: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.goto("/learn");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator("#lh-panel-today .lh-dashboard-status"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||||
|
const allSessions = page
|
||||||
|
.locator("#lh-panel-growth .lh-dashboard-status")
|
||||||
|
.getByRole("button", { name: "전체 회기" });
|
||||||
|
await expect(allSessions).toContainText("5회");
|
||||||
|
await expect(allSessions).toContainText("상담 완료 2회 · 진행 중 0회");
|
||||||
|
await expect(allSessions).toContainText("대화 없이 종료한 회기 포함");
|
||||||
|
await expect(allSessions).toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
"전체 회기에는 대화 없이 종료한 회기도 포함합니다.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// checklist: learner-home-dash-recap-card, learner-home-dash-coach-cta
|
// checklist: learner-home-dash-recap-card, learner-home-dash-coach-cta
|
||||||
test("shows the spotlight recap card and coach CTA for an active session", async ({
|
test("shows the spotlight recap card and coach CTA for an active session", async ({
|
||||||
page,
|
page,
|
||||||
|
|
@ -547,7 +611,7 @@ test.describe("full-sweep learner home", () => {
|
||||||
const recap = page.getByLabel("마지막 세션 리캡");
|
const recap = page.getByLabel("마지막 세션 리캡");
|
||||||
await expect(recap).toContainText("할 수 있게 되는 건지 잘 모르겠는데요");
|
await expect(recap).toContainText("할 수 있게 되는 건지 잘 모르겠는데요");
|
||||||
await expect(recap).toContainText("소속 기관에서 오라고 했어요");
|
await expect(recap).toContainText("소속 기관에서 오라고 했어요");
|
||||||
await expect(recap).toContainText("익명 내담자에게 연락처로 연락해 볼까요");
|
await expect(recap).toContainText("익명 인물에게 연락처로 연락해 볼까요");
|
||||||
for (const token of ["[NAME]", "[ORG]", "[PHONE]"]) {
|
for (const token of ["[NAME]", "[ORG]", "[PHONE]"]) {
|
||||||
await expect(recap).not.toContainText(token);
|
await expect(recap).not.toContainText(token);
|
||||||
}
|
}
|
||||||
|
|
@ -946,4 +1010,210 @@ test.describe("full-sweep learner home", () => {
|
||||||
listbox.getByRole("option", { name: /P4/ }),
|
listbox.getByRole("option", { name: /P4/ }),
|
||||||
).toHaveAttribute("aria-selected", "false");
|
).toHaveAttribute("aria-selected", "false");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps the initially selected P4 visible inside the mobile persona list", async ({ page }, testInfo) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installStarterFixtures(page);
|
||||||
|
await page.setViewportSize({ width: 320, height: 844 });
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||||
|
const selected = listbox.getByRole("option", { name: /P4/ });
|
||||||
|
await expect(selected).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect.poll(() => page.evaluate(() => {
|
||||||
|
const list = document.querySelector<HTMLElement>(".lh-personas");
|
||||||
|
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
|
||||||
|
if (!list || !option) return false;
|
||||||
|
const listRect = list.getBoundingClientRect();
|
||||||
|
const optionRect = option.getBoundingClientRect();
|
||||||
|
const subpixelTolerance = 1;
|
||||||
|
return (
|
||||||
|
optionRect.left >= listRect.left - subpixelTolerance &&
|
||||||
|
optionRect.right <= listRect.right + subpixelTolerance &&
|
||||||
|
optionRect.top >= listRect.top - subpixelTolerance &&
|
||||||
|
optionRect.bottom <= listRect.bottom + subpixelTolerance
|
||||||
|
);
|
||||||
|
})).toBe(true);
|
||||||
|
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath("p4-initial-mobile-320.png"),
|
||||||
|
fullPage: false,
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the selected persona visible after a mobile viewport resize", async ({ page }) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installStarterFixtures(page);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||||
|
const selected = listbox.getByRole("option", { name: /P4/ });
|
||||||
|
await expect(selected).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 320, height: 844 });
|
||||||
|
await expect.poll(() => page.evaluate(() => {
|
||||||
|
const list = document.querySelector<HTMLElement>(".lh-personas");
|
||||||
|
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
|
||||||
|
if (!list || !option) return false;
|
||||||
|
const listRect = list.getBoundingClientRect();
|
||||||
|
const optionRect = option.getBoundingClientRect();
|
||||||
|
const subpixelTolerance = 1;
|
||||||
|
return (
|
||||||
|
optionRect.left >= listRect.left - subpixelTolerance &&
|
||||||
|
optionRect.right <= listRect.right + subpixelTolerance &&
|
||||||
|
optionRect.top >= listRect.top - subpixelTolerance &&
|
||||||
|
optionRect.bottom <= listRect.bottom + subpixelTolerance
|
||||||
|
);
|
||||||
|
})).toBe(true);
|
||||||
|
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains long persona lists without pushing the selected practice CTA below the list", async ({ page }, testInfo) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLongPersonaFixtures(page);
|
||||||
|
|
||||||
|
for (const width of [768, 1024, 1440]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||||
|
const selected = listbox.getByRole("option", { name: /P4/ });
|
||||||
|
const longPersona = listbox.getByRole("option", { name: /P12/ });
|
||||||
|
const lastPersona = listbox.getByRole("option", { name: /P16/ });
|
||||||
|
const startCta = page.getByRole("button", { name: "완전히 새로 시작" });
|
||||||
|
await expect(selected).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(longPersona).toBeVisible();
|
||||||
|
await expect.poll(() => page.evaluate(() => {
|
||||||
|
const list = document.querySelector<HTMLElement>(".lh-personas");
|
||||||
|
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
|
||||||
|
if (!list || !option) return false;
|
||||||
|
const listRect = list.getBoundingClientRect();
|
||||||
|
const optionRect = option.getBoundingClientRect();
|
||||||
|
const options = [...list.querySelectorAll<HTMLElement>("[role='option']")];
|
||||||
|
const rowsDoNotOverlap = options.every((current, index) => {
|
||||||
|
const currentRect = current.getBoundingClientRect();
|
||||||
|
return options.slice(index + 1).every((other) => {
|
||||||
|
const otherRect = other.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
currentRect.right <= otherRect.left ||
|
||||||
|
otherRect.right <= currentRect.left ||
|
||||||
|
currentRect.bottom <= otherRect.top ||
|
||||||
|
otherRect.bottom <= currentRect.top
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const contentFits = options.every((current) => {
|
||||||
|
const content = current.querySelector<HTMLElement>(".lh-persona__body");
|
||||||
|
return !content || content.getBoundingClientRect().bottom <= current.getBoundingClientRect().bottom;
|
||||||
|
});
|
||||||
|
const hit = document.elementFromPoint(
|
||||||
|
optionRect.left + optionRect.width / 2,
|
||||||
|
optionRect.top + optionRect.height / 2,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
list.scrollHeight > list.clientHeight &&
|
||||||
|
getComputedStyle(list).overflowY === "auto" &&
|
||||||
|
optionRect.top >= listRect.top &&
|
||||||
|
optionRect.bottom <= listRect.bottom &&
|
||||||
|
rowsDoNotOverlap &&
|
||||||
|
contentFits &&
|
||||||
|
!!hit && option.contains(hit)
|
||||||
|
);
|
||||||
|
})).toBe(true);
|
||||||
|
await expect(startCta).toBeInViewport();
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath(`long-personas-w${width}-top.png`),
|
||||||
|
fullPage: false,
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
await longPersona.scrollIntoViewIfNeeded();
|
||||||
|
await expect(longPersona).toBeInViewport();
|
||||||
|
await listbox.evaluate((element) => {
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
});
|
||||||
|
await expect(lastPersona).toBeInViewport();
|
||||||
|
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath(`long-personas-w${width}-bottom.png`),
|
||||||
|
fullPage: false,
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens zero-record persona rows and keeps the compact history overview two columns", async ({ page }, testInfo) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installStarterFixtures(page);
|
||||||
|
for (const width of [320, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 844 });
|
||||||
|
await page.goto("/learn/history");
|
||||||
|
const overview = page.locator(".lh-history-overview");
|
||||||
|
const practiceCta = page.getByRole("button", { name: "상담 연습으로" });
|
||||||
|
const toggle = page.getByRole("button", { name: /미진행 내담자 2명 보기/ });
|
||||||
|
await expect(page.getByText("아직 연습 기록이 없습니다.")).toBeVisible();
|
||||||
|
await expect(practiceCta).toBeVisible();
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
const columns = await overview.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean).length);
|
||||||
|
expect(columns).toBe(2);
|
||||||
|
for (const target of [practiceCta, toggle]) {
|
||||||
|
const height = await target.evaluate((element) => element.getBoundingClientRect().height);
|
||||||
|
expect(height).toBeGreaterThanOrEqual(44);
|
||||||
|
}
|
||||||
|
await toggle.scrollIntoViewIfNeeded();
|
||||||
|
await page.locator(".lh-persona-progress").screenshot({
|
||||||
|
path: testInfo.outputPath(`history-empty-bottom-mobile-${width}.png`),
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
await toggle.click();
|
||||||
|
await expect(page.locator(".lh-persona-progress__row")).toHaveCount(2);
|
||||||
|
await page.getByRole("button", { name: "미진행 내담자 숨기기" }).click();
|
||||||
|
await expect(page.locator(".lh-persona-progress__row")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps history filters and session actions usable at mobile widths", async ({ page }, testInfo) => {
|
||||||
|
await signInAsLearner(page);
|
||||||
|
await installLearnerHomeFixtures(page);
|
||||||
|
|
||||||
|
for (const width of [320, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 844 });
|
||||||
|
await page.goto("/learn/history");
|
||||||
|
const filters = page.locator(".lh-history-filter button");
|
||||||
|
const historyActions = page.locator(".lh-session-actions button");
|
||||||
|
await expect(filters).toHaveCount(4);
|
||||||
|
await expect(historyActions).not.toHaveCount(0);
|
||||||
|
for (const target of [filters, historyActions]) {
|
||||||
|
const boxes = await target.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { height: rect.height, width: rect.width };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(boxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
if (width === 320) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath("history-top-mobile-320.png"),
|
||||||
|
fullPage: false,
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
await historyActions.first().locator("..").locator("..").screenshot({
|
||||||
|
path: testInfo.outputPath("history-action-row-mobile-320.png"),
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
const practiceActions = page.locator(".lh-session-actions button");
|
||||||
|
await expect(practiceActions).not.toHaveCount(0);
|
||||||
|
const practiceBoxes = await practiceActions.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { height: rect.height, width: rect.width };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(practiceBoxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,81 @@ test.describe("persona studio full sweep", () => {
|
||||||
await expect(page.locator(".ps-authoring-layout > .ps-rail")).toHaveCount(0);
|
await expect(page.locator(".ps-authoring-layout > .ps-rail")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps authoring steps in one row and exposes the editor at every target width", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await mockStudio(page, { catalog: [approvedP1], reviews: [] });
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{ width: 320, height: 844 },
|
||||||
|
{ width: 390, height: 844 },
|
||||||
|
{ width: 1440, height: 900 },
|
||||||
|
];
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
|
||||||
|
|
||||||
|
const stepper = page.getByRole("navigation", { name: "페르소나 작성 단계" });
|
||||||
|
const steps = stepper.getByRole("button");
|
||||||
|
await expect(steps).toHaveCount(4);
|
||||||
|
const boxes = await steps.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(Math.max(...boxes.map((box) => box.y)) - Math.min(...boxes.map((box) => box.y))).toBeLessThanOrEqual(1);
|
||||||
|
for (const box of boxes) {
|
||||||
|
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
|
||||||
|
}
|
||||||
|
await expect(page.locator(".ps-editor__top")).toBeInViewport();
|
||||||
|
|
||||||
|
if (viewport.width <= 720) {
|
||||||
|
const undersizedActions = await page
|
||||||
|
.locator(".ps-root .vg-btn:not(:disabled), .ps-root .ps-tabs button:not(:disabled)")
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements.flatMap((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 || rect.height === 0) return [];
|
||||||
|
return rect.width >= 44 && rect.height >= 44
|
||||||
|
? []
|
||||||
|
: [{
|
||||||
|
selector: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`,
|
||||||
|
text: element.textContent?.trim(),
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
}];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(undersizedActions).toEqual([]);
|
||||||
|
}
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
|
||||||
|
if (process.env.PERSONA_AUTHORING_CAPTURE_DIR) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${process.env.PERSONA_AUTHORING_CAPTURE_DIR}/persona-authoring-${viewport.width}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto("/teach/personas?view=catalog");
|
||||||
|
await expect(page.getByRole("button", { name: "전체 페르소나 보기" })).toBeVisible();
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
if (process.env.PERSONA_AUTHORING_CAPTURE_DIR) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${process.env.PERSONA_AUTHORING_CAPTURE_DIR}/persona-catalog-${viewport.width}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// checklist: persona-studio-inspector-review-actions
|
// checklist: persona-studio-inspector-review-actions
|
||||||
test("edits and approves review drafts from the inspector queue", async ({ page }) => {
|
test("edits and approves review drafts from the inspector queue", async ({ page }) => {
|
||||||
await signInAsTeacher(page);
|
await signInAsTeacher(page);
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Scans every visible interactive control and prominent text container for
|
||||||
* either (a) extending beyond the viewport horizontally, or (b) clipping its own
|
* 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.setViewportSize({ width: vp.width, height: vp.height });
|
||||||
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
|
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
|
||||||
await prepareReady();
|
await prepareReady();
|
||||||
await page.evaluate(() => {
|
const owners = await resetScrollableOwners(page);
|
||||||
window.scrollTo(0, 0);
|
for (const owner of owners) {
|
||||||
return new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
await writeCaptureManifest({
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
screen,
|
||||||
|
viewport: vp,
|
||||||
|
kind: "scroll-reset",
|
||||||
|
owner,
|
||||||
|
offset: { top: owner.top, left: owner.left },
|
||||||
|
path: null,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("html"),
|
page.locator("html"),
|
||||||
|
|
@ -306,10 +630,28 @@ async function gateScreen(
|
||||||
)}`,
|
)}`,
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|
||||||
|
const topShotPath = path.join(SHOT_DIR, `${screen}__${vp.label}.png`);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: path.join(SHOT_DIR, `${screen}__${vp.label}.png`),
|
path: topShotPath,
|
||||||
fullPage: true,
|
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("layout visual gate @single-run", () => {
|
||||||
test.describe.configure({ mode: "serial" });
|
test.describe.configure({ mode: "serial", timeout: 90_000 });
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
await ensureShotDir();
|
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 page.goto("/learn");
|
||||||
await gateScreen(page, "learner-home", async () => {
|
await gateScreen(page, "learner-home", async () => {
|
||||||
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
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");
|
const dashTabs = page.locator(".lh-tabs");
|
||||||
if (await dashTabs.isVisible().catch(() => false)) {
|
await expect(dashTabs).toBeVisible({ timeout: 15_000 });
|
||||||
await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click();
|
|
||||||
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
|
await dashTabs.getByRole("tab", { name: "오늘의 회기", exact: true }).click();
|
||||||
timeout: 15_000,
|
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();
|
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 });
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const shellScroll = await page.evaluate(async () => {
|
const shellScroll = await page.evaluate(async () => {
|
||||||
const nav = document.querySelector<HTMLElement>(".vg-nav");
|
const nav = document.querySelector<HTMLElement>(".vg-nav");
|
||||||
|
|
@ -705,9 +1076,13 @@ test.describe("layout visual gate @single-run", () => {
|
||||||
await signInAsTeacher(page);
|
await signInAsTeacher(page);
|
||||||
await page.goto("/teach");
|
await page.goto("/teach");
|
||||||
await gateScreen(page, "professor", async () => {
|
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,
|
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);
|
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.locator(".ps-overview")).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
|
||||||
await expect(page.getByRole("navigation", { 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.locator(".ps-authoring-layout")).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
|
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
|
||||||
await expect(page.getByRole("tab", { 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 page.goto("/admin");
|
||||||
await gateScreen(page, "admin", async () => {
|
await gateScreen(page, "admin", async () => {
|
||||||
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
|
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,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
await expect(page.locator(".aic-grid")).toBeVisible();
|
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 page.setViewportSize(viewport);
|
||||||
await expectNoHorizontalOverflow(page);
|
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) => {
|
const scrollReport = await page.locator(".vgops-user-table-scroll").evaluate((element) => {
|
||||||
element.scrollLeft = 0;
|
element.scrollLeft = 0;
|
||||||
const report = {
|
const report = {
|
||||||
|
|
@ -849,6 +1239,7 @@ test.describe("layout visual gate @single-run", () => {
|
||||||
await page.locator(".vgops-user-table-scroll").evaluate((element) => {
|
await page.locator(".vgops-user-table-scroll").evaluate((element) => {
|
||||||
element.scrollLeft = 0;
|
element.scrollLeft = 0;
|
||||||
});
|
});
|
||||||
|
await resetScrollableOwners(page);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: path.join(SHOT_DIR, `admin-users__${viewport.width}-reference-dark.png`),
|
path: path.join(SHOT_DIR, `admin-users__${viewport.width}-reference-dark.png`),
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
|
|
@ -871,6 +1262,8 @@ test.describe("layout visual gate @single-run", () => {
|
||||||
await page.goto("/settings");
|
await page.goto("/settings");
|
||||||
await gateScreen(page, "settings", async () => {
|
await gateScreen(page, "settings", async () => {
|
||||||
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
|
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.setViewportSize({ width: 1280, height: 800 });
|
||||||
await page.getByRole("button", { name: "라이트 모드로" }).click();
|
await page.getByRole("button", { name: "라이트 모드로" }).click();
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ test.describe("public admin visual @public-auth", () => {
|
||||||
{ path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" },
|
{ path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" },
|
||||||
{ path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" },
|
{ path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" },
|
||||||
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" },
|
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" },
|
||||||
{ path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" },
|
{ path: "/admin/tickets", heading: "지원 요청", root: "[data-vignette-admin-root]" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
|
|
@ -268,7 +268,7 @@ test.describe("public admin visual @public-auth", () => {
|
||||||
{ path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" },
|
{ path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" },
|
||||||
{ path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" },
|
{ path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" },
|
||||||
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" },
|
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" },
|
||||||
{ path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" },
|
{ path: "/admin/tickets", heading: "지원 요청", root: "[data-vignette-admin-root]" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,111 @@ async function expectNoLocalStageDemoControl(page: Page) {
|
||||||
await expect(page.locator(".sx-track__advance")).toHaveCount(0);
|
await expect(page.locator(".sx-track__advance")).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectPrestartSummaryReadableOnMobile(page: Page) {
|
||||||
|
if (!(await page.evaluate(() => window.matchMedia("(max-width: 720px)").matches))) return;
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const section = document.querySelector(".sx-page--prestart");
|
||||||
|
let owner = document.scrollingElement;
|
||||||
|
for (let node = section?.parentElement; node; node = node.parentElement) {
|
||||||
|
const style = window.getComputedStyle(node);
|
||||||
|
if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) {
|
||||||
|
owner = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!owner) throw new Error("Expected prestart scroll owner");
|
||||||
|
owner.scrollTop = Math.round((owner.scrollHeight - owner.clientHeight) / 2);
|
||||||
|
});
|
||||||
|
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve)));
|
||||||
|
|
||||||
|
const metrics = await page.evaluate(() => {
|
||||||
|
const facts = document.querySelector<HTMLElement>(".sx-page--prestart .sx-prestart__facts");
|
||||||
|
const complaint = facts?.querySelector<HTMLElement>(":scope > div:first-child");
|
||||||
|
const complaintText = complaint?.querySelector<HTMLElement>("dd");
|
||||||
|
const actions = document.querySelector<HTMLElement>(".sx-page--prestart .sx-prestart__actions");
|
||||||
|
if (!facts || !complaint || !complaintText || !actions) return null;
|
||||||
|
|
||||||
|
const complaintRect = complaint.getBoundingClientRect();
|
||||||
|
const factsRect = facts.getBoundingClientRect();
|
||||||
|
const complaintStyle = window.getComputedStyle(complaintText);
|
||||||
|
const actionsStyle = window.getComputedStyle(actions);
|
||||||
|
const actionsRect = actions.getBoundingClientRect();
|
||||||
|
const navigation = document.querySelector<HTMLElement>(".vg-nav");
|
||||||
|
const navigationRect = navigation?.getBoundingClientRect();
|
||||||
|
const startButton = actions.querySelector<HTMLElement>(".vg-btn");
|
||||||
|
const actionDescription = actions.querySelector<HTMLElement>("span");
|
||||||
|
const alphaMatch = actionsStyle.backgroundColor.match(/^rgba\([^,]+,\s*[^,]+,\s*[^,]+,\s*([0-9.]+)\)$/);
|
||||||
|
const actionsAlpha = actionsStyle.backgroundColor === "transparent"
|
||||||
|
? 0
|
||||||
|
: alphaMatch
|
||||||
|
? Number.parseFloat(alphaMatch[1])
|
||||||
|
: 1;
|
||||||
|
const hitPoints = [startButton, actionDescription, actions]
|
||||||
|
.filter((element): element is HTMLElement => Boolean(element))
|
||||||
|
.map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const x = Math.round(rect.left + rect.width / 2);
|
||||||
|
const y = Math.round(rect.top + rect.height / 2);
|
||||||
|
const hit = document.elementFromPoint(x, y);
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
hitClass: hit instanceof HTMLElement ? hit.className : hit?.nodeName ?? null,
|
||||||
|
belongsToAction: Boolean(hit && actions.contains(hit)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
complaintWidth: Math.round(complaintRect.width),
|
||||||
|
factsWidth: Math.round(factsRect.width),
|
||||||
|
fontSize: Number.parseFloat(complaintStyle.fontSize),
|
||||||
|
lineHeight: Number.parseFloat(complaintStyle.lineHeight),
|
||||||
|
actionsBackground: actionsStyle.backgroundColor,
|
||||||
|
actionsAlpha,
|
||||||
|
actionsPosition: actionsStyle.position,
|
||||||
|
actionsBottom: Math.round(actionsRect.bottom),
|
||||||
|
navigationTop: navigationRect ? Math.round(navigationRect.top) : null,
|
||||||
|
hitPoints,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metrics, "Expected mobile prestart facts and start action").not.toBeNull();
|
||||||
|
expect(metrics!.complaintWidth, `Complaint should span the facts row: ${JSON.stringify(metrics)}`).toBeGreaterThanOrEqual(
|
||||||
|
metrics!.factsWidth - 2,
|
||||||
|
);
|
||||||
|
expect(metrics!.fontSize, `Complaint text is too small: ${JSON.stringify(metrics)}`).toBeGreaterThanOrEqual(14);
|
||||||
|
expect(metrics!.lineHeight, `Complaint line height is too tight: ${JSON.stringify(metrics)}`).toBeGreaterThanOrEqual(21);
|
||||||
|
expect(metrics!.actionsAlpha, `Start action must be opaque: ${JSON.stringify(metrics)}`).toBe(1);
|
||||||
|
expect(metrics!.actionsPosition, `Start action must stay at the mobile bottom: ${JSON.stringify(metrics)}`).toBe("fixed");
|
||||||
|
expect(metrics!.navigationTop, `Expected mobile navigation: ${JSON.stringify(metrics)}`).not.toBeNull();
|
||||||
|
expect(metrics!.actionsBottom, `Start action must stay above mobile navigation: ${JSON.stringify(metrics)}`).toBeLessThanOrEqual(
|
||||||
|
metrics!.navigationTop! + 1,
|
||||||
|
);
|
||||||
|
expect(metrics!.hitPoints, `Expected three start action hit points: ${JSON.stringify(metrics)}`).toHaveLength(3);
|
||||||
|
expect(metrics!.hitPoints.every((point) => point.belongsToAction), `Start action is covered at its controls: ${JSON.stringify(metrics)}`).toBe(true);
|
||||||
|
|
||||||
|
const goals = page.locator(".sx-page--prestart .sx-goals__grid button");
|
||||||
|
for (const goal of await goals.all()) {
|
||||||
|
await goal.scrollIntoViewIfNeeded();
|
||||||
|
const geometry = await goal.evaluate((button) => {
|
||||||
|
const action = document.querySelector<HTMLElement>(".sx-page--prestart .sx-prestart__actions");
|
||||||
|
const buttonRect = button.getBoundingClientRect();
|
||||||
|
const actionRect = action?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
top: Math.round(buttonRect.top),
|
||||||
|
bottom: Math.round(buttonRect.bottom),
|
||||||
|
actionTop: actionRect ? Math.round(actionRect.top) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.top, `Goal is above the viewport: ${JSON.stringify(geometry)}`).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.actionTop, `Expected fixed start action: ${JSON.stringify(geometry)}`).not.toBeNull();
|
||||||
|
expect(geometry.bottom, `Goal is hidden behind the start action: ${JSON.stringify(geometry)}`).toBeLessThanOrEqual(
|
||||||
|
geometry.actionTop! - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function expectMobileContextIfNarrow(page: Page) {
|
async function expectMobileContextIfNarrow(page: Page) {
|
||||||
const isNarrow = await page.evaluate(() =>
|
const isNarrow = await page.evaluate(() =>
|
||||||
window.matchMedia("(max-width: 1180px)").matches,
|
window.matchMedia("(max-width: 1180px)").matches,
|
||||||
|
|
@ -473,6 +578,7 @@ test.describe("learner session full-screen layout", () => {
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
|
await expectPrestartSummaryReadableOnMobile(page);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||||
await completeAlliancePreCheckpoint(page);
|
await completeAlliancePreCheckpoint(page);
|
||||||
|
|
|
||||||
|
|
@ -478,4 +478,35 @@ export async function routeEmptySessionReview(
|
||||||
body: JSON.stringify(emptyReviewResponse(sessionId)),
|
body: JSON.stringify(emptyReviewResponse(sessionId)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ items: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(
|
||||||
|
new RegExp(`/api/sessions/${sessionId}/multimodal-alliance\\?include_derived=true$`),
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "multimodal alliance ledger not found" }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/outcome-trajectory`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "outcome trajectory not found" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/ruptures`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "rupture ledger not found" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
241
apps/web/e2e/settings-ui.spec.ts
Normal file
241
apps/web/e2e/settings-ui.spec.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
function jsonRoute(body: unknown, status = 200) {
|
||||||
|
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE = {
|
||||||
|
user_id: "e2e-settings-ui",
|
||||||
|
email: "settings.ui@hs.ac.kr",
|
||||||
|
display_name: "설정 화면 검증 학습자",
|
||||||
|
legal_name: "설정 화면 검증 학습자",
|
||||||
|
nickname: "설정검증",
|
||||||
|
self_introduction: "",
|
||||||
|
role: "learner",
|
||||||
|
cohort_ids: [],
|
||||||
|
affiliation: "한신대학교",
|
||||||
|
department: "상담심리학과",
|
||||||
|
grade_level: "3학년",
|
||||||
|
phone: "010-0000-0000",
|
||||||
|
contact_address: "경기도 오산시",
|
||||||
|
avatar_url: "",
|
||||||
|
onboarding_required: false,
|
||||||
|
onboarding_completed_at: 1780000000,
|
||||||
|
terms_version: "terms-draft-2026-06-27",
|
||||||
|
terms_agreed_at: 1780000000,
|
||||||
|
privacy_version: "privacy-draft-2026-06-27",
|
||||||
|
privacy_agreed_at: 1780000000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFERENCES = {
|
||||||
|
theme: "light",
|
||||||
|
voice_preset_id: "soft-young-fem",
|
||||||
|
voice_rate: 1,
|
||||||
|
notifications: {
|
||||||
|
session_done: true,
|
||||||
|
safety_signal: true,
|
||||||
|
learner_progress: true,
|
||||||
|
account_approval: true,
|
||||||
|
product_news: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const VOICE_PRESETS = [
|
||||||
|
{
|
||||||
|
id: "soft-young-fem",
|
||||||
|
name: "차분한 목소리",
|
||||||
|
voice_id: "ko-soft-01",
|
||||||
|
desc: "낮은 톤의 안정적인 발화",
|
||||||
|
persona_hint: "차분한 20대 내담자",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bright-mid-fem",
|
||||||
|
name: "밝은 목소리",
|
||||||
|
voice_id: "ko-bright-02",
|
||||||
|
desc: "밝고 빠른 발화",
|
||||||
|
persona_hint: "활발한 30대 내담자",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function mountSettingsFixture(page: Page) {
|
||||||
|
await page.route("**/api/**", (route) =>
|
||||||
|
route.fulfill(jsonRoute({ detail: "not part of settings UI fixture" }, 404)),
|
||||||
|
);
|
||||||
|
await page.route("**/api/auth/me", (route) =>
|
||||||
|
route.fulfill(jsonRoute({
|
||||||
|
user_id: PROFILE.user_id,
|
||||||
|
email: PROFILE.email,
|
||||||
|
role: "learner",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: 1780000000,
|
||||||
|
onboarding_completed_at: PROFILE.onboarding_completed_at,
|
||||||
|
nickname: PROFILE.nickname,
|
||||||
|
display_name: PROFILE.display_name,
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
await page.route("**/api/users/me", (route: Route) => {
|
||||||
|
if (route.request().method() === "GET") return route.fulfill(jsonRoute(PROFILE));
|
||||||
|
return route.fulfill(jsonRoute(PROFILE));
|
||||||
|
});
|
||||||
|
await page.route("**/api/users/me/preferences", (route: Route) => {
|
||||||
|
if (route.request().method() === "GET") return route.fulfill(jsonRoute(PREFERENCES));
|
||||||
|
return route.fulfill(jsonRoute(PREFERENCES));
|
||||||
|
});
|
||||||
|
await page.route("**/api/users/me/voice-presets", (route) => route.fulfill(jsonRoute(VOICE_PRESETS)));
|
||||||
|
await page.route("**/api/users/support-tickets", (route) =>
|
||||||
|
route.fulfill(jsonRoute({ source: "fixture", durable: false, generated_at: 1780000000, tickets: [] })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNoHorizontalClipping(page: Page, width: number) {
|
||||||
|
const offenders = await page.locator(".vg-set, .vg-set *").evaluateAll((elements, viewportWidth) =>
|
||||||
|
elements.flatMap((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 || rect.height === 0 || rect.bottom <= 0) return [];
|
||||||
|
return rect.left < -0.5 || rect.right > Number(viewportWidth) + 0.5
|
||||||
|
? [{ tag: element.tagName, className: element.className, left: rect.left, right: rect.right }]
|
||||||
|
: [];
|
||||||
|
}), width,
|
||||||
|
);
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForScrollSettled(page: Page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const scroller = document.querySelector(".vg-main");
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let frame = 0;
|
||||||
|
let stableFrames = 0;
|
||||||
|
let previous = scroller?.scrollTop ?? window.scrollY;
|
||||||
|
const check = () => {
|
||||||
|
const current = scroller?.scrollTop ?? window.scrollY;
|
||||||
|
stableFrames = Math.abs(current - previous) < 0.5 ? stableFrames + 1 : 0;
|
||||||
|
previous = current;
|
||||||
|
frame += 1;
|
||||||
|
if (stableFrames >= 8 || frame >= 120) return resolve();
|
||||||
|
requestAnimationFrame(check);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(check);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectMobileInteractiveTargets(page: Page) {
|
||||||
|
const undersized = await page
|
||||||
|
.locator(".vg-set button:not(:disabled), .vg-set [role='button']:not([aria-disabled='true'])")
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements.flatMap((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const selector = element.id
|
||||||
|
? `#${element.id}`
|
||||||
|
: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`;
|
||||||
|
return rect.width >= 44 && rect.height >= 44
|
||||||
|
? []
|
||||||
|
: [{ selector, text: element.textContent?.trim(), width: rect.width, height: rect.height }];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(undersized).toEqual([]);
|
||||||
|
|
||||||
|
const voicePreview = await page.locator(".vg-set__voice-play").first().boundingBox();
|
||||||
|
expect(voicePreview?.width).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(voicePreview?.height).toBeGreaterThanOrEqual(44);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("설정 섹션 내비는 모든 화면 폭에서 라벨·터치 영역·탭 이동을 보장한다", async ({ page }) => {
|
||||||
|
await mountSettingsFixture(page);
|
||||||
|
const viewports = [
|
||||||
|
{ width: 320, height: 844 },
|
||||||
|
{ width: 390, height: 844 },
|
||||||
|
{ width: 768, height: 900 },
|
||||||
|
{ width: 1440, height: 900 },
|
||||||
|
];
|
||||||
|
const sections = [
|
||||||
|
["계정", "set-account"],
|
||||||
|
["지원 요청", "set-support"],
|
||||||
|
["동의", "set-consent"],
|
||||||
|
["테마", "set-appearance"],
|
||||||
|
["알림", "set-notify"],
|
||||||
|
["음성", "set-voice"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible();
|
||||||
|
|
||||||
|
const nav = page.getByRole("navigation", { name: "설정 섹션" });
|
||||||
|
const tabs = nav.getByRole("button");
|
||||||
|
await expect(tabs).toHaveCount(6);
|
||||||
|
await expectNoHorizontalClipping(page, viewport.width);
|
||||||
|
|
||||||
|
for (const [label, sectionId] of sections) {
|
||||||
|
const tab = nav.getByRole("button", { name: label });
|
||||||
|
await expect(tab).toBeVisible();
|
||||||
|
const box = await tab.boundingBox();
|
||||||
|
expect(box?.height).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(box?.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(viewport.width);
|
||||||
|
|
||||||
|
await tab.click();
|
||||||
|
await waitForScrollSettled(page);
|
||||||
|
await expect(tab).toHaveAttribute("aria-current", "location");
|
||||||
|
await expect(page.locator(`#${sectionId}`)).toBeInViewport();
|
||||||
|
if (viewport.width <= 1080) {
|
||||||
|
const [navBox, sectionBox] = await Promise.all([
|
||||||
|
nav.boundingBox(),
|
||||||
|
page.locator(`#${sectionId}`).boundingBox(),
|
||||||
|
]);
|
||||||
|
expect(sectionBox?.y).toBeGreaterThanOrEqual((navBox?.y ?? 0) + (navBox?.height ?? 0));
|
||||||
|
}
|
||||||
|
if (viewport.width === 320 && label === "계정") {
|
||||||
|
const [railBox, accountBox] = await Promise.all([
|
||||||
|
page.locator(".vg-set__rail").boundingBox(),
|
||||||
|
page.locator("#set-account").boundingBox(),
|
||||||
|
]);
|
||||||
|
expect(railBox?.x).toBeCloseTo(accountBox?.x ?? 0, 0);
|
||||||
|
expect(railBox?.width).toBeCloseTo(accountBox?.width ?? 0, 0);
|
||||||
|
const railBackground = await page.locator(".vg-set__rail").evaluate(
|
||||||
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
expect(railBackground).not.toBe("rgba(0, 0, 0, 0)");
|
||||||
|
}
|
||||||
|
if (viewport.width === 320 && label === "지원 요청") {
|
||||||
|
const description = await page.locator("#set-support .vg-set__group-desc").boundingBox();
|
||||||
|
expect(description?.width).toBeGreaterThanOrEqual(150);
|
||||||
|
}
|
||||||
|
if (viewport.width === 320 && label === "동의") {
|
||||||
|
const [description, action] = await Promise.all([
|
||||||
|
page.locator("#set-consent .vg-set__opt-text").first().boundingBox(),
|
||||||
|
page.getByRole("button", { name: "실습 동의 철회" }).boundingBox(),
|
||||||
|
]);
|
||||||
|
expect(description?.width).toBeGreaterThanOrEqual(150);
|
||||||
|
expect(action?.height).toBeGreaterThanOrEqual(44);
|
||||||
|
}
|
||||||
|
await expectNoHorizontalClipping(page, viewport.width);
|
||||||
|
if (viewport.width === 320 && process.env.SETTINGS_UI_CAPTURE_DIR) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${process.env.SETTINGS_UI_CAPTURE_DIR}/settings-320-${sectionId}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = nav.getByRole("button", { name: "계정" });
|
||||||
|
await account.focus();
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await expect(nav.getByRole("button", { name: "지원 요청" })).toBeFocused();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(nav.getByRole("button", { name: "지원 요청" })).toHaveAttribute("aria-current", "location");
|
||||||
|
await expect(page.locator("#set-support")).toBeInViewport();
|
||||||
|
|
||||||
|
if (viewport.width === 320) {
|
||||||
|
await expectMobileInteractiveTargets(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
137
apps/web/e2e/shell-navigation.spec.ts
Normal file
137
apps/web/e2e/shell-navigation.spec.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
expectNoHorizontalOverflow,
|
||||||
|
signInAsAdmin,
|
||||||
|
signInAsLearner,
|
||||||
|
signInAsTeacher,
|
||||||
|
} from "./support";
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{ width: 320, height: 568 },
|
||||||
|
{ width: 390, height: 844 },
|
||||||
|
{ width: 768, height: 900 },
|
||||||
|
{ width: 1024, height: 900 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function expectMinimumTargets(page: Page, selector: string) {
|
||||||
|
const targets = await page.locator(selector).evaluateAll((elements) =>
|
||||||
|
elements
|
||||||
|
.filter((element) => window.getComputedStyle(element).display !== "none")
|
||||||
|
.map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { text: element.textContent?.trim(), width: rect.width, height: rect.height };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const target of targets) {
|
||||||
|
expect(target.width, `${target.text} width`).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(target.height, `${target.text} height`).toBeGreaterThanOrEqual(44);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("공통 셸 내비게이션", () => {
|
||||||
|
test("역할별 하단 목적지와 태블릿 레일은 레이블·조작면·오버플로 계약을 지킨다", async ({ page }) => {
|
||||||
|
const roles = [
|
||||||
|
{
|
||||||
|
signIn: signInAsLearner,
|
||||||
|
path: "/learn",
|
||||||
|
mobile: ["학습자 홈", "상담 연습", "학습 기록"],
|
||||||
|
desktop: ["학습자 홈", "상담 연습", "학습 기록", "설정"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signIn: signInAsTeacher,
|
||||||
|
path: "/teach",
|
||||||
|
mobile: ["교수자 홈", "학생 분석", "감독·연구"],
|
||||||
|
desktop: ["교수자 홈", "학생 분석", "감독·연구", "내담자 설정", "설정"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signIn: signInAsAdmin,
|
||||||
|
path: "/admin",
|
||||||
|
mobile: ["운영 홈", "AI 운영", "사용자 관리"],
|
||||||
|
desktop: ["운영 홈", "AI 운영", "사용자 관리", "접근 관리", "지원 요청"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
await role.signIn(page);
|
||||||
|
await page.goto(role.path);
|
||||||
|
await expect(page.locator(".vg-shell")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
|
||||||
|
if (viewport.width <= 720) {
|
||||||
|
const labels = await page.locator(".vg-nav__item--mobile-primary:visible .vg-nav__txt").allTextContents();
|
||||||
|
expect(labels).toEqual(role.mobile);
|
||||||
|
await expect(page.getByRole("button", { name: "전체 메뉴" })).toBeVisible();
|
||||||
|
await expectMinimumTargets(page, ".vg-nav__item--mobile-primary:visible, .vg-nav__more:visible");
|
||||||
|
} else {
|
||||||
|
for (const label of role.desktop) {
|
||||||
|
await expect(page.getByRole("link", { name: label, exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
if (viewport.width === 1024 && role.path === "/admin") {
|
||||||
|
for (const group of ["운영", "교수", "학습", "설정"]) {
|
||||||
|
await expect(page.locator(".vg-nav__label", { hasText: group })).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("전체 메뉴는 키보드로 닫히고 현재 위치를 보존하며 교수자 홈 단축키를 정확히 보낸다", async ({ page }) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/teach/personas");
|
||||||
|
await expect(page.locator(".vg-shell")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const menuButton = page.getByRole("button", { name: "전체 메뉴" });
|
||||||
|
await expect(menuButton).toHaveClass(/is-active/);
|
||||||
|
await menuButton.click();
|
||||||
|
|
||||||
|
const dialog = page.locator("#vg-all-menu-dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.getByRole("heading", { name: "전체 메뉴" })).toBeVisible();
|
||||||
|
await expect(dialog.getByRole("link", { name: "학생 분석" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "전체 메뉴 닫기" })).toBeFocused();
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
await expect(menuButton).toBeFocused();
|
||||||
|
|
||||||
|
await menuButton.click();
|
||||||
|
await dialog.getByRole("link", { name: "학생 분석" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/teach/analysis", { waitUntil: "networkidle" });
|
||||||
|
await page.keyboard.press("Alt+h");
|
||||||
|
await expect(page).toHaveURL(/\/teach$/);
|
||||||
|
|
||||||
|
const browserShortcutCancellation = await page.evaluate(() => {
|
||||||
|
const results = ["t", "w"].map((key) => {
|
||||||
|
const event = new KeyboardEvent("keydown", { key, ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
return event.defaultPrevented;
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
expect(browserShortcutCancellation).toEqual([false, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("좁은 화면의 공간 선택은 레이블을 유지하며 44px 조작면을 지킨다", async ({ page }) => {
|
||||||
|
await signInAsAdmin(page);
|
||||||
|
await page.setViewportSize({ width: 320, height: 568 });
|
||||||
|
await page.goto("/admin");
|
||||||
|
await expect(page.locator(".vg-shell")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const select = page.getByRole("combobox", { name: "공간 선택" });
|
||||||
|
await expect(select).toBeVisible();
|
||||||
|
await expect(select).toHaveValue("admin");
|
||||||
|
await expect(select.locator("option")).toHaveText(["학습자", "교수자", "관리자"]);
|
||||||
|
await expectMinimumTargets(page, ".vg-topbar__switch-select:visible, .vg-iconbtn:visible");
|
||||||
|
|
||||||
|
await select.selectOption("teacher");
|
||||||
|
await expect(page).toHaveURL(/\/teach$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -273,9 +273,13 @@ test.describe("G6 supervision and research OS", () => {
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", {
|
page.getByRole("heading", {
|
||||||
name: "먼저 볼 경로와, 믿을 수 있는 출처를 분리해 본다.",
|
name: "감독·연구",
|
||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("학습 검토", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator(".g6-hero p")).toHaveText(
|
||||||
|
"검토가 필요한 학습 회기와 평가 근거를 확인합니다.",
|
||||||
|
);
|
||||||
await expect(page.getByRole("tab", { name: "교수 감독" })).toHaveAttribute(
|
await expect(page.getByRole("tab", { name: "교수 감독" })).toHaveAttribute(
|
||||||
"aria-selected",
|
"aria-selected",
|
||||||
"true",
|
"true",
|
||||||
|
|
@ -292,11 +296,11 @@ test.describe("G6 supervision and research OS", () => {
|
||||||
await page.getByRole("button", { name: /learner-risk/ }).click();
|
await page.getByRole("button", { name: /learner-risk/ }).click();
|
||||||
await expect(page.locator(".g6-ledger li")).toHaveCount(3);
|
await expect(page.locator(".g6-ledger li")).toHaveCount(3);
|
||||||
await expect(page.locator(".g6-drilldown a")).toHaveCount(3);
|
await expect(page.locator(".g6-drilldown a")).toHaveCount(3);
|
||||||
await expect(page.getByTestId("drilldown-depth")).toContainText("계약 상한 3번");
|
await expect(page.getByTestId("drilldown-depth")).toHaveText("선택한 항목의 근거를 확인합니다.");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "개인의 순위를 만들지 않는 교육과정 공백" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "반복되는 학습 보완점" })).toBeVisible();
|
||||||
await expect(page.getByText("competency.rupture-repair").first()).toBeVisible();
|
await expect(page.getByText("competency.rupture-repair").first()).toBeVisible();
|
||||||
await expect(page.getByText("근거 없는 상태를 유지하고 해석하지 않아.")).toBeVisible();
|
await expect(page.getByText("근거가 부족한 상태를 유지하고 해석하지 않습니다.")).toBeVisible();
|
||||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("partial", { exact: true })).toBeVisible();
|
await expect(page.getByText("partial", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("원문 축어록 제외")).toBeVisible();
|
await expect(page.getByText("원문 축어록 제외")).toBeVisible();
|
||||||
|
|
@ -306,12 +310,36 @@ test.describe("G6 supervision and research OS", () => {
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("wraps a long learner reference without clipping at 320px", async ({ page }) => {
|
||||||
|
const longLearnerRef = "learner-bc2e3fe5b35e46a8b1c9d2e4f6a7b8c9d0e1f2a3b4c5d6e7f8";
|
||||||
|
const supervision = supervisionFixture();
|
||||||
|
supervision.attention_items[0] = {
|
||||||
|
...supervision.attention_items[0],
|
||||||
|
learner_ref: longLearnerRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
await routeG6(page, "teacher", supervision);
|
||||||
|
await page.setViewportSize({ width: 320, height: 900 });
|
||||||
|
await page.goto("/teach/supervision");
|
||||||
|
|
||||||
|
const detailReference = page.locator(".g6-case h3");
|
||||||
|
await expect(detailReference).toHaveText(longLearnerRef);
|
||||||
|
await expect(detailReference).toHaveCSS("overflow-wrap", "anywhere");
|
||||||
|
expect(
|
||||||
|
await detailReference.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
|
||||||
|
).toBeTruthy();
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
test("research role traces model, prompt, instrument, subgroup, and four-domain provenance", async ({
|
test("research role traces model, prompt, instrument, subgroup, and four-domain provenance", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
await routeG6(page, "admin");
|
await routeG6(page, "admin");
|
||||||
await page.goto("/teach/supervision");
|
await page.goto("/teach/supervision");
|
||||||
|
await expect(page.locator(".g6-hero p")).toHaveText(
|
||||||
|
"검토가 필요한 학습 회기와 평가 근거를 확인합니다. 연구 품질 탭에서는 평가 버전과 출처를 확인할 수 있습니다.",
|
||||||
|
);
|
||||||
await page.getByRole("tab", { name: "연구 품질" }).click();
|
await page.getByRole("tab", { name: "연구 품질" }).click();
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "모델·프롬프트·도구·하위집단 드리프트" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "모델·프롬프트·도구·하위집단 드리프트" })).toBeVisible();
|
||||||
|
|
@ -325,7 +353,7 @@ test.describe("G6 supervision and research OS", () => {
|
||||||
await expect(page.getByText("synthetic-low-disclosure", { exact: true })).toBeVisible();
|
await expect(page.getByText("synthetic-low-disclosure", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("-25%p", { exact: true })).toBeVisible();
|
await expect(page.getByText("-25%p", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByText("4/4 provenance 연결", { exact: true })).toBeVisible();
|
await expect(page.getByText("4/4 출처 연결", { exact: true })).toBeVisible();
|
||||||
for (const label of ["동맹", "파열·수선", "전이", "보정"]) {
|
for (const label of ["동맹", "파열·수선", "전이", "보정"]) {
|
||||||
await expect(page.getByText(label, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText(label, { exact: true }).first()).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
@ -373,14 +401,14 @@ test.describe("G6 supervision and research OS", () => {
|
||||||
await routeG6(page, "admin", emptySupervision, summaryOnlyResearch);
|
await routeG6(page, "admin", emptySupervision, summaryOnlyResearch);
|
||||||
await page.goto("/teach/supervision");
|
await page.goto("/teach/supervision");
|
||||||
|
|
||||||
await expect(page.getByText("현재 우선 검토 항목이 없어")).toBeVisible();
|
await expect(page.getByText("현재 우선 검토 항목이 없습니다")).toBeVisible();
|
||||||
await expect(page.getByText("현재 교육과정 공백이 없어")).toBeVisible();
|
await expect(page.getByText("현재 교육과정 공백이 없습니다")).toBeVisible();
|
||||||
await expect(page.getByText("교수자–AI 불일치 메타데이터가 없어")).toBeVisible();
|
await expect(page.getByText("교수자–AI 불일치 메타데이터가 없습니다")).toBeVisible();
|
||||||
await page.getByRole("tab", { name: "연구 품질" }).click();
|
await page.getByRole("tab", { name: "연구 품질" }).click();
|
||||||
await expect(page.getByText("버전 드리프트 비교가 없어")).toBeVisible();
|
await expect(page.getByText("버전 드리프트 비교가 없습니다")).toBeVisible();
|
||||||
await expect(page.getByText("provenance 저하", { exact: true })).toBeVisible();
|
await expect(page.getByText("출처 연결 저하", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("매니페스트 요약만 도착했어")).toBeVisible();
|
await expect(page.getByText("매니페스트 요약만 있습니다")).toBeVisible();
|
||||||
await expect(page.getByText("원본 provenance 미제공").first()).toBeVisible();
|
await expect(page.getByText("원본 출처 미제공").first()).toBeVisible();
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
229
apps/web/e2e/teacher-mobile-targets.spec.ts
Normal file
229
apps/web/e2e/teacher-mobile-targets.spec.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expectNoHorizontalOverflow, signInAsTeacher } from "./support";
|
||||||
|
|
||||||
|
const OUTPUT_DIR = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"../../outputs/ux-audit-2026-09-12/teacher-targets",
|
||||||
|
);
|
||||||
|
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ width: 320, height: 844, label: "320" },
|
||||||
|
{ width: 390, height: 844, label: "390" },
|
||||||
|
{ width: 768, height: 900, label: "768" },
|
||||||
|
{ width: 1440, height: 900, label: "1440" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TargetMeasurement = {
|
||||||
|
label: string;
|
||||||
|
selector: string;
|
||||||
|
viewport: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
right: number;
|
||||||
|
scrollWidth: number;
|
||||||
|
clientWidth: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientHeight: number;
|
||||||
|
clipped: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnalysisMeasurement = {
|
||||||
|
selector: string;
|
||||||
|
x: number;
|
||||||
|
width: number;
|
||||||
|
right: number;
|
||||||
|
clipped: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function measureTargets(page: Page, viewport: number): Promise<TargetMeasurement[]> {
|
||||||
|
return page.locator(".pf-head__actions .vg-btn, .pf-studio-card__actions .vg-btn, .pf-sort-segment button, .pf-learner-row__action .vg-btn, .pf-session--action, .pf-recent-row--action").evaluateAll(
|
||||||
|
(elements, currentViewport) =>
|
||||||
|
elements
|
||||||
|
.map((element) => {
|
||||||
|
const target = element as HTMLElement;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const style = window.getComputedStyle(target);
|
||||||
|
const visible =
|
||||||
|
style.display !== "none" &&
|
||||||
|
style.visibility !== "hidden" &&
|
||||||
|
Number(style.opacity) !== 0 &&
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0;
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: target.textContent?.replace(/\s+/g, " ").trim() ?? "",
|
||||||
|
selector: target.className,
|
||||||
|
viewport: currentViewport,
|
||||||
|
x: Math.round(rect.x),
|
||||||
|
y: Math.round(rect.y),
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
height: Math.round(rect.height),
|
||||||
|
right: Math.round(rect.right),
|
||||||
|
scrollWidth: target.scrollWidth,
|
||||||
|
clientWidth: target.clientWidth,
|
||||||
|
scrollHeight: target.scrollHeight,
|
||||||
|
clientHeight: target.clientHeight,
|
||||||
|
clipped:
|
||||||
|
rect.left < -1 ||
|
||||||
|
rect.right > window.innerWidth + 1 ||
|
||||||
|
target.scrollWidth > target.clientWidth + 1 ||
|
||||||
|
target.scrollHeight > target.clientHeight + 1,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((measurement): measurement is NonNullable<typeof measurement> => measurement !== null),
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureConsole(page: Page, label: string) {
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, `console-top-${label}.png`) });
|
||||||
|
|
||||||
|
const pending = page.locator('[data-pending-review-row="true"]').first();
|
||||||
|
if (await pending.count()) {
|
||||||
|
await pending.scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, `console-pending-${label}.png`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent = page.locator(".pf-recent-row--action").first();
|
||||||
|
if (await recent.count()) {
|
||||||
|
await recent.scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, `console-recent-${label}.png`) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureAnalysisSurface(page: Page): Promise<AnalysisMeasurement[]> {
|
||||||
|
return page.locator(
|
||||||
|
".pf-root--analysis, .pf-head h1, .pf-head p, .pf-head__actions, .pf-section--learner-overview, .pf-learner-overview, .pf-learner-toolbar, .pf-search-field, .pf-sort-segment, .pf-learner-table",
|
||||||
|
).evaluateAll((elements) =>
|
||||||
|
elements.map((element) => {
|
||||||
|
const target = element as HTMLElement;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
selector: target.className || target.tagName.toLowerCase(),
|
||||||
|
x: Math.round(rect.x),
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
right: Math.round(rect.right),
|
||||||
|
clipped: rect.left < -1 || rect.right > window.innerWidth + 1,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("교수자 콘솔의 모바일 터치 대상은 44px이며 잘리지 않는다 @single-run", async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
|
||||||
|
const evidence: TargetMeasurement[] = [];
|
||||||
|
const analysisEvidence: Record<string, unknown>[] = [];
|
||||||
|
for (const viewport of VIEWPORTS) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto("/teach");
|
||||||
|
await expect(page.locator(".pf-root--console")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByRole("button", { name: "학생 분석" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "새로고침" })).toBeVisible();
|
||||||
|
await expect(page.locator(".pf-studio-card__actions .vg-btn")).toBeVisible();
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
await captureConsole(page, viewport.label);
|
||||||
|
|
||||||
|
const consoleTargets = await measureTargets(page, viewport.width);
|
||||||
|
evidence.push(...consoleTargets);
|
||||||
|
const clipped = consoleTargets.filter((target) => target.clipped);
|
||||||
|
expect(clipped, `교수 콘솔 ${viewport.width}px 잘린 대상`).toEqual([]);
|
||||||
|
if (viewport.width <= 720) {
|
||||||
|
const undersized = consoleTargets.filter(
|
||||||
|
(target) => target.width < 44 || target.height < 44,
|
||||||
|
);
|
||||||
|
expect(undersized, `교수 콘솔 ${viewport.width}px 44px 미만 대상`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto("/teach/analysis");
|
||||||
|
await expect(page.locator(".pf-root--analysis")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByRole("heading", { name: "학생별 학습 현황" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "새로고침" })).toBeVisible();
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-top-${viewport.label}.png`) });
|
||||||
|
|
||||||
|
const analysisSurface = await measureAnalysisSurface(page);
|
||||||
|
const outsideViewport = analysisSurface.filter((measurement) => measurement.clipped);
|
||||||
|
expect(outsideViewport, `학생 분석 ${viewport.width}px 표면 범위`).toEqual([]);
|
||||||
|
|
||||||
|
const table = page.locator(".pf-learner-table");
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
await table.evaluate((element) => {
|
||||||
|
element.scrollLeft = 0;
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-table-left-${viewport.label}.png`) });
|
||||||
|
const firstColumn = table.locator(".pf-learner-table__head > span").first();
|
||||||
|
const tableBox = await table.boundingBox();
|
||||||
|
const firstColumnBox = await firstColumn.boundingBox();
|
||||||
|
expect(firstColumnBox, `학생 분석 ${viewport.width}px 표 왼쪽 열`).not.toBeNull();
|
||||||
|
expect(tableBox, `학생 분석 ${viewport.width}px 표 컨테이너`).not.toBeNull();
|
||||||
|
expect(firstColumnBox!.x).toBeGreaterThanOrEqual(tableBox!.x - 1);
|
||||||
|
const tableBounds = await table.evaluate((element) => ({
|
||||||
|
clientWidth: element.clientWidth,
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
scrollLeft: element.scrollLeft,
|
||||||
|
}));
|
||||||
|
await table.evaluate((element) => {
|
||||||
|
element.scrollLeft = element.scrollWidth - element.clientWidth;
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-table-right-${viewport.label}.png`) });
|
||||||
|
const tableEnd = await table.evaluate((element) => element.scrollLeft);
|
||||||
|
const lastColumn = table.locator(".pf-learner-table__head > span").last();
|
||||||
|
const lastColumnBox = await lastColumn.boundingBox();
|
||||||
|
expect(lastColumnBox, `학생 분석 ${viewport.width}px 표 오른쪽 열`).not.toBeNull();
|
||||||
|
expect(lastColumnBox!.x + lastColumnBox!.width).toBeLessThanOrEqual(
|
||||||
|
tableBox!.x + tableBox!.width + 1,
|
||||||
|
);
|
||||||
|
if (tableBounds.scrollWidth > tableBounds.clientWidth) {
|
||||||
|
expect(tableEnd, `학생 분석 ${viewport.width}px 표 오른쪽 끝`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
analysisEvidence.push({
|
||||||
|
viewport: viewport.width,
|
||||||
|
elements: analysisSurface,
|
||||||
|
table: { ...tableBounds, endScrollLeft: tableEnd },
|
||||||
|
});
|
||||||
|
|
||||||
|
const analysisTargets = await measureTargets(page, viewport.width);
|
||||||
|
evidence.push(...analysisTargets);
|
||||||
|
const analysisClipped = analysisTargets.filter((target) => target.clipped);
|
||||||
|
expect(analysisClipped, `학생 분석 ${viewport.width}px 잘린 대상`).toEqual([]);
|
||||||
|
if (viewport.width <= 720) {
|
||||||
|
const undersized = analysisTargets.filter(
|
||||||
|
(target) => target.width < 44 || target.height < 44,
|
||||||
|
);
|
||||||
|
expect(undersized, `학생 분석 ${viewport.width}px 44px 미만 대상`).toEqual([]);
|
||||||
|
|
||||||
|
const openDetail = page.locator(".pf-learner-row__action .vg-btn").first();
|
||||||
|
await expect(openDetail).toBeVisible();
|
||||||
|
await openDetail.click();
|
||||||
|
const backToList = page.getByRole("button", { name: "전체 목록" });
|
||||||
|
await expect(backToList).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
const backToListBox = await backToList.boundingBox();
|
||||||
|
expect(backToListBox, `학생 분석 상세 ${viewport.width}px 전체 목록`).not.toBeNull();
|
||||||
|
expect(backToListBox!.width).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(backToListBox!.height).toBeGreaterThanOrEqual(44);
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(OUTPUT_DIR, `analysis-detail-${viewport.label}.png`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
path.join(OUTPUT_DIR, "target-measurements.json"),
|
||||||
|
`${JSON.stringify(evidence, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(OUTPUT_DIR, "analysis-measurements.json"),
|
||||||
|
`${JSON.stringify(analysisEvidence, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -913,7 +913,7 @@ async function openLearnerHome(page: Page) {
|
||||||
await page.goto("/learn");
|
await page.goto("/learn");
|
||||||
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
|
page.getByRole("heading", { name: "오늘의 상담 연습" }),
|
||||||
).toBeVisible({ timeout: 15_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@
|
||||||
* 다룬 시나리오(역할별 라우트 가드, 검색 by 이메일·역할·코호트, 티켓 필터 쿼리,
|
* 다룬 시나리오(역할별 라우트 가드, 검색 by 이메일·역할·코호트, 티켓 필터 쿼리,
|
||||||
* usage 윈도 전환, 콘텐츠·모델 게이트 승인 플로우)는 반복하지 않는다.
|
* usage 윈도 전환, 콘텐츠·모델 게이트 승인 플로우)는 반복하지 않는다.
|
||||||
*/
|
*/
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
const NOW = 1_755_400_000; // 2026-08-17 근처 고정 기준
|
const NOW = 1_755_400_000; // 2026-08-17 근처 고정 기준
|
||||||
|
|
@ -1442,4 +1444,78 @@ test.describe("관리자 콘솔 유스케이스 여정", () => {
|
||||||
}
|
}
|
||||||
expect((await protocolTab.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
expect((await protocolTab.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("모바일 티켓 새로고침과 해결 처리가 동작한다 @single-run", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const outputDir = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"../../outputs/ux-audit-2026-09-12/admin-ticket-targets",
|
||||||
|
);
|
||||||
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
await page.setViewportSize({ width: 320, height: 844 });
|
||||||
|
await installAdminMock(page, {
|
||||||
|
tickets: [
|
||||||
|
makeTicket({
|
||||||
|
ticket_id: "ticket-mobile-active",
|
||||||
|
subject: "모바일 티켓 처리",
|
||||||
|
status: "open",
|
||||||
|
}),
|
||||||
|
makeTicket({
|
||||||
|
ticket_id: "ticket-mobile-history",
|
||||||
|
subject: "해결된 모바일 티켓",
|
||||||
|
status: "resolved",
|
||||||
|
resolved_at: NOW - 600,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/admin/tickets");
|
||||||
|
const refresh = page
|
||||||
|
.locator('[data-admin-section="tickets"] .vgops-head')
|
||||||
|
.getByRole("button", { name: "새로고침" });
|
||||||
|
const activeCard = page.locator(".vgops-ticket").filter({
|
||||||
|
hasText: "모바일 티켓 처리",
|
||||||
|
});
|
||||||
|
await expect(refresh).toBeVisible();
|
||||||
|
await expect(activeCard).toBeVisible();
|
||||||
|
await page.screenshot({ path: path.join(outputDir, "tickets-top-320.png") });
|
||||||
|
|
||||||
|
const refreshBox = await refresh.boundingBox();
|
||||||
|
expect(refreshBox?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(refreshBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||||
|
await page.setViewportSize({ width: 710, height: 844 });
|
||||||
|
const refreshAt720 = await refresh.boundingBox();
|
||||||
|
expect(refreshAt720?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(refreshAt720?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||||
|
await page.setViewportSize({ width: 320, height: 844 });
|
||||||
|
const reload = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.request().method() === "GET" &&
|
||||||
|
new URL(response.url()).pathname.endsWith("/admin/tickets"),
|
||||||
|
);
|
||||||
|
await refresh.click();
|
||||||
|
await reload;
|
||||||
|
await expect(activeCard).toBeVisible();
|
||||||
|
|
||||||
|
const patch = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "PATCH" &&
|
||||||
|
request.url().includes("/admin/tickets/ticket-mobile-active"),
|
||||||
|
);
|
||||||
|
await activeCard.getByRole("button", { name: "해결" }).click();
|
||||||
|
await patch;
|
||||||
|
|
||||||
|
const history = page.locator(".vgops-ticket--history").filter({
|
||||||
|
hasText: "모바일 티켓 처리",
|
||||||
|
});
|
||||||
|
await expect(history).toBeVisible();
|
||||||
|
await history.scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({ path: path.join(outputDir, "tickets-history-320.png") });
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -126,13 +126,13 @@ const LEGAL_DOCS = {
|
||||||
source_note: "운영 전 초안 문서입니다.",
|
source_note: "운영 전 초안 문서입니다.",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function routeOnboardingData(page: Page) {
|
async function routeOnboardingData(page: Page, legalDocs = LEGAL_DOCS) {
|
||||||
await page.route("**/api/users/me", (route) => {
|
await page.route("**/api/users/me", (route) => {
|
||||||
if (route.request().method() !== "GET") return route.fallback();
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
return route.fulfill(jsonRoute(FRESH_PROFILE));
|
return route.fulfill(jsonRoute(FRESH_PROFILE));
|
||||||
});
|
});
|
||||||
await page.route("**/api/users/legal-docs", (route) =>
|
await page.route("**/api/users/legal-docs", (route) =>
|
||||||
route.fulfill(jsonRoute(LEGAL_DOCS)),
|
route.fulfill(jsonRoute(legalDocs)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,6 +143,20 @@ async function setupFreshLearnerOnOnboarding(page: Page) {
|
||||||
await routeOnboardingData(page);
|
await routeOnboardingData(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LONG_LEGAL_DOCS = {
|
||||||
|
...LEGAL_DOCS,
|
||||||
|
terms: {
|
||||||
|
...LEGAL_DOCS.terms,
|
||||||
|
title: "Vignette 서비스 이용약관 초안",
|
||||||
|
version: "terms-draft-2026-06-27",
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
...LEGAL_DOCS.privacy,
|
||||||
|
title: "Vignette 개인정보 처리방침 초안",
|
||||||
|
version: "privacy-draft-2026-06-27",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** 온보딩 폼의 텍스트 필수 입력을 전부 채운다(동의 체크는 호출부 책임). */
|
/** 온보딩 폼의 텍스트 필수 입력을 전부 채운다(동의 체크는 호출부 책임). */
|
||||||
async function fillOnboardingTextFields(page: Page, phone = "010-1234-5678") {
|
async function fillOnboardingTextFields(page: Page, phone = "010-1234-5678") {
|
||||||
await page.getByLabel("닉네임").fill("비넷 유스케이스");
|
await page.getByLabel("닉네임").fill("비넷 유스케이스");
|
||||||
|
|
@ -289,6 +303,54 @@ test.describe("uc auth-onboarding", () => {
|
||||||
await expect(page.locator(".ob-legal .ob-note")).toHaveText("운영 전 초안 문서입니다.");
|
await expect(page.locator(".ob-legal .ob-note")).toHaveText("운영 전 초안 문서입니다.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("좁은 화면에서도 약관 제목·버전을 모두 읽고 문서를 펼친다", async ({ page }) => {
|
||||||
|
for (const viewport of [
|
||||||
|
{ width: 320, height: 844 },
|
||||||
|
{ width: 390, height: 844 },
|
||||||
|
{ width: 1440, height: 900 },
|
||||||
|
]) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await routeUnmockedApi(page);
|
||||||
|
await routeMe(page, () => makeMe({ onboarding_completed_at: null, nickname: "" }));
|
||||||
|
await routeOnboardingData(page, LONG_LEGAL_DOCS);
|
||||||
|
|
||||||
|
await page.goto("/onboarding");
|
||||||
|
const details = page.locator(".ob-legal details");
|
||||||
|
const summaries = details.locator("summary");
|
||||||
|
await expect(summaries).toHaveCount(2);
|
||||||
|
|
||||||
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
const summary = summaries.nth(index);
|
||||||
|
const title = summary.locator(".ob-legal__title");
|
||||||
|
const version = summary.locator(".ob-legal__version");
|
||||||
|
await expect(title).toHaveText(index === 0 ? LONG_LEGAL_DOCS.terms.title : LONG_LEGAL_DOCS.privacy.title);
|
||||||
|
await expect(version).toHaveText(index === 0 ? LONG_LEGAL_DOCS.terms.version : LONG_LEGAL_DOCS.privacy.version);
|
||||||
|
|
||||||
|
const geometry = await summary.evaluate((element) => {
|
||||||
|
const title = element.querySelector<HTMLElement>(".ob-legal__title");
|
||||||
|
const version = element.querySelector<HTMLElement>(".ob-legal__version");
|
||||||
|
if (!title || !version) throw new Error("약관 제목 또는 버전 요소가 없습니다.");
|
||||||
|
const summaryRect = element.getBoundingClientRect();
|
||||||
|
const titleRect = title.getBoundingClientRect();
|
||||||
|
const versionRect = version.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
summaryHeight: summaryRect.height,
|
||||||
|
titleScrollWidth: title.scrollWidth,
|
||||||
|
titleWidth: title.clientWidth,
|
||||||
|
versionTop: versionRect.top,
|
||||||
|
titleBottom: titleRect.bottom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.summaryHeight).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(geometry.titleScrollWidth).toBeLessThanOrEqual(geometry.titleWidth);
|
||||||
|
expect(geometry.versionTop).toBeGreaterThanOrEqual(geometry.titleBottom);
|
||||||
|
|
||||||
|
await summary.click();
|
||||||
|
await expect(details.nth(index)).toHaveAttribute("open", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// usecase: 신규 사용자가 연락처를 전화번호가 아닌 값으로 넣고 저장하려 한다
|
// usecase: 신규 사용자가 연락처를 전화번호가 아닌 값으로 넣고 저장하려 한다
|
||||||
test("온보딩 연락처 형식이 틀리면 저장하지 않고 형식 오류를 안내한다", async ({ page }) => {
|
test("온보딩 연락처 형식이 틀리면 저장하지 않고 형식 오류를 안내한다", async ({ page }) => {
|
||||||
await setupFreshLearnerOnOnboarding(page);
|
await setupFreshLearnerOnOnboarding(page);
|
||||||
|
|
|
||||||
|
|
@ -400,18 +400,19 @@ async function installFixtures(page: Page, options: FixtureOptions = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("uc learner home dashboard", () => {
|
test.describe("uc learner home dashboard", () => {
|
||||||
// usecase: 평가가 아직 없는 학습자가 메트릭 카드에서 현황 수치와 빈-평가 문구를 읽는다
|
// usecase: 평가가 아직 없는 학습자가 성장 지표에서 현황 수치와 빈-평가 문구를 읽는다
|
||||||
test("평가 전 학습자가 메트릭 카드에서 진행·리뷰·평가·라포 수치를 확인한다", async ({
|
test("평가 전 학습자가 성장 지표에서 전체 회기·리뷰·평가·라포 수치를 확인한다", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installFixtures(page);
|
await installFixtures(page);
|
||||||
await page.goto("/learn");
|
await page.goto("/learn");
|
||||||
|
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||||
|
|
||||||
const cards = page.locator(".lh-dashboard-status");
|
const cards = page.locator("#lh-panel-growth .lh-dashboard-status");
|
||||||
await expect(cards.getByRole("button")).toHaveCount(4);
|
await expect(cards.getByRole("button")).toHaveCount(4);
|
||||||
const progressCard = cards.getByRole("button", { name: /진행 회기/ });
|
const progressCard = cards.getByRole("button", { name: /전체 회기/ });
|
||||||
await expect(progressCard).toContainText("2회");
|
await expect(progressCard).toContainText("2회");
|
||||||
await expect(progressCard).toContainText("1회 종료 · 1회 진행");
|
await expect(progressCard).toContainText("상담 완료 1회 · 진행 중 1회");
|
||||||
const reviewCard = cards.getByRole("button", { name: /리뷰 대기/ });
|
const reviewCard = cards.getByRole("button", { name: /리뷰 대기/ });
|
||||||
await expect(reviewCard).toContainText("0건");
|
await expect(reviewCard).toContainText("0건");
|
||||||
await expect(reviewCard).toContainText("대기 중인 리뷰 없음");
|
await expect(reviewCard).toContainText("대기 중인 리뷰 없음");
|
||||||
|
|
@ -490,9 +491,10 @@ test.describe("uc learner home dashboard", () => {
|
||||||
test("리뷰 대기 메트릭 카드를 누르면 기록 화면으로 이동한다", async ({ page }) => {
|
test("리뷰 대기 메트릭 카드를 누르면 기록 화면으로 이동한다", async ({ page }) => {
|
||||||
await installFixtures(page);
|
await installFixtures(page);
|
||||||
await page.goto("/learn");
|
await page.goto("/learn");
|
||||||
|
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.locator(".lh-dashboard-status")
|
.locator("#lh-panel-growth .lh-dashboard-status")
|
||||||
.getByRole("button", { name: /리뷰 대기/ })
|
.getByRole("button", { name: /리뷰 대기/ })
|
||||||
.click();
|
.click();
|
||||||
await expect(page).toHaveURL(/\/learn\/history$/);
|
await expect(page).toHaveURL(/\/learn\/history$/);
|
||||||
|
|
@ -584,8 +586,8 @@ test.describe("uc learner home dashboard", () => {
|
||||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`));
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`));
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 내담자를 바꿔 선택한 뒤 '새 회기 시작'으로 새 세션 경로에 진입한다
|
// usecase: 학습자가 내담자를 바꿔 선택한 뒤 새 사례로 세션 시작 화면에 진입한다
|
||||||
test("다른 내담자를 선택하고 '새 회기 시작'을 누르면 해당 페르소나 세션 경로로 이동한다", async ({
|
test("다른 내담자를 선택하고 '완전히 새로 시작'을 누르면 새 사례 세션 경로로 이동한다", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installFixtures(page);
|
await installFixtures(page);
|
||||||
|
|
@ -597,8 +599,8 @@ test.describe("uc learner home dashboard", () => {
|
||||||
"aria-selected",
|
"aria-selected",
|
||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
await page.getByRole("button", { name: "완전히 새로 시작" }).click();
|
||||||
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
|
await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=fresh$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 페르소나 카드에서 난이도 라벨과 주호소 설명을 읽고 비교한다
|
// usecase: 학습자가 페르소나 카드에서 난이도 라벨과 주호소 설명을 읽고 비교한다
|
||||||
|
|
@ -689,9 +691,10 @@ test.describe("uc learner home dashboard", () => {
|
||||||
await expect(focus).toContainText("첫 회기 준비");
|
await expect(focus).toContainText("첫 회기 준비");
|
||||||
await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible();
|
await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible();
|
||||||
await expect(focus.locator(".lh-session-focus__status")).toHaveText("대기");
|
await expect(focus.locator(".lh-session-focus__status")).toHaveText("대기");
|
||||||
// 진행 회기 메트릭도 0회.
|
// 전체 회기 메트릭도 0회.
|
||||||
|
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(".lh-dashboard-status").getByRole("button", { name: /진행 회기/ }),
|
page.locator("#lh-panel-growth .lh-dashboard-status").getByRole("button", { name: /전체 회기/ }),
|
||||||
).toContainText("0회");
|
).toContainText("0회");
|
||||||
// 주 CTA가 '학습 대상 선택'으로 연습 화면에 진입한다.
|
// 주 CTA가 '학습 대상 선택'으로 연습 화면에 진입한다.
|
||||||
const primaryAction = page.locator("[data-learner-primary-action]");
|
const primaryAction = page.locator("[data-learner-primary-action]");
|
||||||
|
|
@ -750,11 +753,11 @@ test.describe("uc learner home dashboard", () => {
|
||||||
await expect(growthPanel).toContainText(
|
await expect(growthPanel).toContainText(
|
||||||
"첫 회기를 끝내면 라포 누적이 표시됩니다.",
|
"첫 회기를 끝내면 라포 누적이 표시됩니다.",
|
||||||
);
|
);
|
||||||
await expect(growthPanel).toContainText("첫 회기 이후 표시");
|
await expect(growthPanel).toContainText("첫 회기 후 표시");
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 키보드만으로(Tab 이동 → Enter) 새 회기를 시작한다
|
// usecase: 학습자가 키보드만으로(Tab 이동 → Enter) 새 사례 회기 시작 화면으로 이동한다
|
||||||
test("키보드 Tab 이동과 Enter만으로 새 회기를 시작할 수 있다", async ({
|
test("키보드 Tab 이동과 Enter만으로 새 사례 회기를 시작할 수 있다", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installFixtures(page);
|
await installFixtures(page);
|
||||||
|
|
@ -763,9 +766,9 @@ test.describe("uc learner home dashboard", () => {
|
||||||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||||
const p1 = listbox.getByRole("option", { name: /P1/ });
|
const p1 = listbox.getByRole("option", { name: /P1/ });
|
||||||
await expect(p1).toHaveAttribute("aria-selected", "true");
|
await expect(p1).toHaveAttribute("aria-selected", "true");
|
||||||
// 선택된 페르소나 카드에서 시작해 Tab으로 '새 회기 시작' 버튼까지 이동한다.
|
// 선택된 페르소나 카드에서 시작해 Tab으로 '완전히 새로 시작' 버튼까지 이동한다.
|
||||||
await p1.focus();
|
await p1.focus();
|
||||||
const startButton = page.getByRole("button", { name: "새 회기 시작" });
|
const startButton = page.getByRole("button", { name: "완전히 새로 시작" });
|
||||||
let reached = false;
|
let reached = false;
|
||||||
for (let i = 0; i < 25; i += 1) {
|
for (let i = 0; i < 25; i += 1) {
|
||||||
await page.keyboard.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
|
|
@ -774,9 +777,9 @@ test.describe("uc learner home dashboard", () => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(reached, "Tab 이동으로 '새 회기 시작' 버튼에 도달해야 한다").toBe(true);
|
expect(reached, "Tab 이동으로 '완전히 새로 시작' 버튼에 도달해야 한다").toBe(true);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await expect(page).toHaveURL(/\/learn\/session\/P1$/);
|
await expect(page).toHaveURL(/\/learn\/session\/P1\?continuity=fresh$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 대시보드 영역 탭을 화살표 키로 오간다
|
// usecase: 학습자가 대시보드 영역 탭을 화살표 키로 오간다
|
||||||
|
|
|
||||||
312
apps/web/e2e/ux-audit-surfaces.spec.ts
Normal file
312
apps/web/e2e/ux-audit-surfaces.spec.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { completeOnboarding, signInAsAdmin, signInAsLearner, signInAsTeacher, useRealApi } from "./support";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173";
|
||||||
|
const VIEWPORT_HEIGHT = 900;
|
||||||
|
const VIEWPORT_WIDTHS = [320, 390, 768, 1024, 1440] as const;
|
||||||
|
const SEGMENT_STEP_RATIO = 0.8;
|
||||||
|
const MAX_SEGMENTS = 20;
|
||||||
|
const REPO_ROOT = path.resolve(process.cwd(), "..", "..");
|
||||||
|
const OUTPUT_DIR = process.env.UX_AUDIT_OUTPUT_DIR ?? path.join(REPO_ROOT, "outputs", "ux-audit-2026-09-12", "surfaces");
|
||||||
|
const MANIFEST_PATH = path.join(OUTPUT_DIR, "manifest.json");
|
||||||
|
|
||||||
|
type Role = "learner" | "teacher" | "admin" | "fixture";
|
||||||
|
type ScrollOwnerKind = "main" | "root" | "document";
|
||||||
|
|
||||||
|
interface Surface {
|
||||||
|
route: string;
|
||||||
|
role: Role;
|
||||||
|
source: "real-api" | "fixture";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrollPlan {
|
||||||
|
owner: ScrollOwnerKind;
|
||||||
|
selector: string | null;
|
||||||
|
offsets: number[];
|
||||||
|
incomplete: boolean;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManifestEntry {
|
||||||
|
route: string;
|
||||||
|
role: Role;
|
||||||
|
source: Surface["source"];
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
scrollowner: ScrollOwnerKind;
|
||||||
|
offset: number;
|
||||||
|
file: string;
|
||||||
|
timestamp: string;
|
||||||
|
screenheading: string;
|
||||||
|
consoleerror: string[];
|
||||||
|
httperror: string[];
|
||||||
|
status: "captured" | "http-error";
|
||||||
|
incomplete: boolean;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: ManifestEntry[] = [];
|
||||||
|
|
||||||
|
function fileStem(route: string) {
|
||||||
|
return route.replace(/^\//, "").replaceAll("/", "-") || "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
function routePattern(route: string) {
|
||||||
|
return new RegExp(`${route.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:[?#]|$)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLocalAuditBaseUrl() {
|
||||||
|
const parsed = new URL(BASE_URL);
|
||||||
|
expect(parsed.protocol, `UI 감사는 HTTPS 운영 서버를 대상으로 실행할 수 없다: ${BASE_URL}`).toBe("http:");
|
||||||
|
expect(
|
||||||
|
["localhost", "127.0.0.1", "::1"],
|
||||||
|
`UI 감사는 로컬 호스트에서만 dev 계정을 생성할 수 있다: ${BASE_URL}`,
|
||||||
|
).toContain(parsed.hostname);
|
||||||
|
return parsed.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeManifest(baseURL: string) {
|
||||||
|
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
MANIFEST_PATH,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
baseURL,
|
||||||
|
viewportWidths: VIEWPORT_WIDTHS,
|
||||||
|
viewportHeight: VIEWPORT_HEIGHT,
|
||||||
|
entries: manifest,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginFreshLearner(page: Page, prefix: string) {
|
||||||
|
const response = await page.request.post("/api/auth/dev-login", {
|
||||||
|
data: {
|
||||||
|
email: `${prefix}.${Date.now()}@hs.ac.kr`,
|
||||||
|
role: "learner",
|
||||||
|
display_name: "UX Audit Fixture Learner",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginOnboardedLearner(page: Page, prefix: string) {
|
||||||
|
await loginFreshLearner(page, prefix);
|
||||||
|
await completeOnboarding(page, {
|
||||||
|
legal_name: "UX 감사 학습자",
|
||||||
|
affiliation: "한신대학교",
|
||||||
|
department: "상담심리학과",
|
||||||
|
grade_level: "3학년",
|
||||||
|
phone: "010-0000-0000",
|
||||||
|
contact_address: "경기도 오산시 한신대학교",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPendingFixture(page: Page) {
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await route.fetch();
|
||||||
|
const body = (await response.json()) as Record<string, unknown>;
|
||||||
|
await route.fulfill({
|
||||||
|
response,
|
||||||
|
json: {
|
||||||
|
...body,
|
||||||
|
email: "pending@hs.ac.kr",
|
||||||
|
account_status: "pending",
|
||||||
|
approval_required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSurfaceReady(page: Page, route: string) {
|
||||||
|
await expect(page).toHaveURL(routePattern(route), { timeout: 20_000 });
|
||||||
|
await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 20_000 });
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
page.locator(
|
||||||
|
".g6-loading, .cic-loading, .lh-loading, .vg-loading, [data-loading='true']",
|
||||||
|
).count(),
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.toBe(0);
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readScrollPlan(page: Page): Promise<ScrollPlan> {
|
||||||
|
return page.evaluate(({ maxSegments, stepRatio }) => {
|
||||||
|
const candidates: Array<{ owner: ScrollOwnerKind; selector: string | null; element: HTMLElement }> = [];
|
||||||
|
const main = document.querySelector<HTMLElement>("#vg-main-content");
|
||||||
|
const root = document.querySelector<HTMLElement>("#root");
|
||||||
|
if (main) candidates.push({ owner: "main", selector: "#vg-main-content", element: main });
|
||||||
|
if (root) candidates.push({ owner: "root", selector: "#root", element: root });
|
||||||
|
const documentScroller = document.scrollingElement as HTMLElement | null;
|
||||||
|
if (documentScroller) candidates.push({ owner: "document", selector: null, element: documentScroller });
|
||||||
|
|
||||||
|
const selected =
|
||||||
|
candidates.find((candidate) => candidate.element.scrollHeight > candidate.element.clientHeight + 1) ??
|
||||||
|
candidates.at(-1);
|
||||||
|
if (!selected) throw new Error("No scroll owner found for UX audit capture");
|
||||||
|
|
||||||
|
const maxOffset = Math.max(0, selected.element.scrollHeight - selected.element.clientHeight);
|
||||||
|
const step = Math.max(1, Math.floor(selected.element.clientHeight * stepRatio));
|
||||||
|
const allOffsets = [0];
|
||||||
|
for (let offset = step; offset < maxOffset; offset += step) allOffsets.push(offset);
|
||||||
|
if (allOffsets.at(-1) !== maxOffset) allOffsets.push(maxOffset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
owner: selected.owner,
|
||||||
|
selector: selected.selector,
|
||||||
|
offsets: allOffsets.slice(0, maxSegments),
|
||||||
|
incomplete: allOffsets.length > maxSegments,
|
||||||
|
scrollHeight: selected.element.scrollHeight,
|
||||||
|
clientHeight: selected.element.clientHeight,
|
||||||
|
};
|
||||||
|
}, { maxSegments: MAX_SEGMENTS, stepRatio: SEGMENT_STEP_RATIO });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToOffset(page: Page, plan: ScrollPlan, offset: number) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ selector, offset: nextOffset }) => {
|
||||||
|
const target = selector
|
||||||
|
? document.querySelector<HTMLElement>(selector)
|
||||||
|
: (document.scrollingElement as HTMLElement | null);
|
||||||
|
if (!target) throw new Error(`Scroll owner missing: ${selector ?? "document"}`);
|
||||||
|
target.scrollTop = nextOffset;
|
||||||
|
target.dispatchEvent(new Event("scroll"));
|
||||||
|
},
|
||||||
|
{ selector: plan.selector, offset },
|
||||||
|
);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureSurface(
|
||||||
|
page: Page,
|
||||||
|
surface: Surface,
|
||||||
|
baseURL: string,
|
||||||
|
consoleErrors: string[],
|
||||||
|
httpErrors: string[],
|
||||||
|
) {
|
||||||
|
for (const width of VIEWPORT_WIDTHS) {
|
||||||
|
const consoleStart = consoleErrors.length;
|
||||||
|
const httpStart = httpErrors.length;
|
||||||
|
await page.setViewportSize({ width, height: VIEWPORT_HEIGHT });
|
||||||
|
await page.goto(`${baseURL}${surface.route}`, { waitUntil: "domcontentloaded" });
|
||||||
|
await waitForSurfaceReady(page, surface.route);
|
||||||
|
|
||||||
|
const horizontalOverflow = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
|
);
|
||||||
|
expect(horizontalOverflow, `${surface.route} ${width}px 문서 가로 오버플로`).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
const plan = await readScrollPlan(page);
|
||||||
|
const heading = (await page.getByRole("heading").first().innerText()).replace(/\s+/g, " ").trim();
|
||||||
|
for (const [segment, offset] of plan.offsets.entries()) {
|
||||||
|
await scrollToOffset(page, plan, offset);
|
||||||
|
const filename = `${fileStem(surface.route)}-${surface.role}-w${width}-s${segment}-y${offset}.png`;
|
||||||
|
const outputPath = path.join(OUTPUT_DIR, filename);
|
||||||
|
await page.screenshot({ path: outputPath, fullPage: false, animations: "disabled" });
|
||||||
|
manifest.push({
|
||||||
|
route: surface.route,
|
||||||
|
role: surface.role,
|
||||||
|
source: surface.source,
|
||||||
|
width,
|
||||||
|
height: VIEWPORT_HEIGHT,
|
||||||
|
scrollowner: plan.owner,
|
||||||
|
offset,
|
||||||
|
file: path.relative(REPO_ROOT, outputPath).split(path.sep).join("/"),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
screenheading: heading,
|
||||||
|
consoleerror: consoleErrors.slice(consoleStart),
|
||||||
|
httperror: httpErrors.slice(httpStart),
|
||||||
|
status: httpErrors.length > httpStart ? "http-error" : "captured",
|
||||||
|
incomplete: plan.incomplete,
|
||||||
|
scrollHeight: plan.scrollHeight,
|
||||||
|
clientHeight: plan.clientHeight,
|
||||||
|
});
|
||||||
|
await writeManifest(baseURL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
test.use({ baseURL: BASE_URL });
|
||||||
|
|
||||||
|
test("@single-run UI 감사 누락 표면을 실제 상태와 전체 세로 구간으로 보존한다", async ({ page }) => {
|
||||||
|
test.setTimeout(1_800_000);
|
||||||
|
await useRealApi(page);
|
||||||
|
const baseURL = assertLocalAuditBaseUrl();
|
||||||
|
await writeManifest(baseURL);
|
||||||
|
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
const httpErrors: string[] = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") consoleErrors.push(message.text());
|
||||||
|
});
|
||||||
|
page.on("response", (response) => {
|
||||||
|
if (response.status() >= 400) httpErrors.push(`${response.status()} ${response.url()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await signInAsLearner(page);
|
||||||
|
for (const surface of [
|
||||||
|
{ route: "/learn/practice", role: "learner", source: "real-api" },
|
||||||
|
{ route: "/learn/history", role: "learner", source: "real-api" },
|
||||||
|
] satisfies Surface[]) {
|
||||||
|
await captureSurface(page, surface, baseURL, consoleErrors, httpErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await captureSurface(
|
||||||
|
page,
|
||||||
|
{ route: "/teach/supervision", role: "teacher", source: "real-api" },
|
||||||
|
baseURL,
|
||||||
|
consoleErrors,
|
||||||
|
httpErrors,
|
||||||
|
);
|
||||||
|
|
||||||
|
await signInAsAdmin(page);
|
||||||
|
for (const surface of [
|
||||||
|
{ route: "/admin/continuous-improvement", role: "admin", source: "real-api" },
|
||||||
|
{ route: "/admin/access", role: "admin", source: "real-api" },
|
||||||
|
{ route: "/admin/tickets", role: "admin", source: "real-api" },
|
||||||
|
] satisfies Surface[]) {
|
||||||
|
await captureSurface(page, surface, baseURL, consoleErrors, httpErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginFreshLearner(page, "ux-audit-onboarding");
|
||||||
|
await captureSurface(
|
||||||
|
page,
|
||||||
|
{ route: "/onboarding", role: "fixture", source: "fixture" },
|
||||||
|
baseURL,
|
||||||
|
consoleErrors,
|
||||||
|
httpErrors,
|
||||||
|
);
|
||||||
|
|
||||||
|
await loginOnboardedLearner(page, "ux-audit-pending");
|
||||||
|
await installPendingFixture(page);
|
||||||
|
await captureSurface(
|
||||||
|
page,
|
||||||
|
{ route: "/pending", role: "fixture", source: "fixture" },
|
||||||
|
baseURL,
|
||||||
|
consoleErrors,
|
||||||
|
httpErrors,
|
||||||
|
);
|
||||||
|
await page.unroute("**/api/auth/me");
|
||||||
|
|
||||||
|
const incomplete = manifest.filter((entry) => entry.incomplete);
|
||||||
|
expect(incomplete, `20개를 넘는 스크롤 구간은 미완료로 기록한다: ${JSON.stringify(incomplete)}`).toEqual([]);
|
||||||
|
});
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Topbar } from "./Topbar";
|
import { Topbar } from "./Topbar";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { canAccessRole, designRoleOf, useAuth, type Role } from "../../lib/auth";
|
import { canAccessRole, designRoleOf, roleHomePath, useAuth, type Role } from "../../lib/auth";
|
||||||
|
|
||||||
export interface AppShellProps {
|
export interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
@ -84,14 +84,9 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.repeat) return;
|
if (e.repeat) return;
|
||||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
||||||
const isInput = target?.closest("input, textarea, select, [contenteditable='true']");
|
|
||||||
|
|
||||||
const isAlt = e.altKey && !e.ctrlKey && !e.metaKey;
|
const isAlt = e.altKey && !e.ctrlKey && !e.metaKey;
|
||||||
const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey;
|
// native dialog는 Escape와 포커스 복귀를 스스로 처리한다.
|
||||||
|
if (e.key === "Escape" && !document.querySelector("dialog[open]")) {
|
||||||
// Ctrl+W 또는 Escape: 활성 모달 닫기
|
|
||||||
if ((isCtrl && e.key.toLowerCase() === "w") || e.key === "Escape") {
|
|
||||||
const openModalCloseBtn = document.querySelector<HTMLButtonElement>(
|
const openModalCloseBtn = document.querySelector<HTMLButtonElement>(
|
||||||
"[role='dialog'] button:has(svg), [role='dialog'] .sx-panel-close-btn, .vg-dialog__close, [role='dialog'] button[aria-label*='닫기']"
|
"[role='dialog'] button:has(svg), [role='dialog'] .sx-panel-close-btn, .vg-dialog__close, [role='dialog'] button[aria-label*='닫기']"
|
||||||
);
|
);
|
||||||
|
|
@ -104,14 +99,14 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
|
||||||
|
|
||||||
// Alt+H: 홈으로 이동
|
// Alt+H: 홈으로 이동
|
||||||
if (isAlt && e.key.toLowerCase() === "h") {
|
if (isAlt && e.key.toLowerCase() === "h") {
|
||||||
|
if (!shellRole) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const homePath = user?.role === "admin" ? "/admin" : user?.role === "teacher" ? "/professor" : "/learn";
|
navigate(roleHomePath(shellRole));
|
||||||
navigate(homePath);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+N 또는 Ctrl+N 또는 Ctrl+T (입력창 밖): 새 회기 시작 / 페르소나 선택
|
// Alt+N: 새 회기 시작 / 페르소나 선택
|
||||||
if ((isAlt && e.key.toLowerCase() === "n") || (!isInput && isCtrl && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "t"))) {
|
if (isAlt && e.key.toLowerCase() === "n") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate("/learn");
|
navigate("/learn");
|
||||||
return;
|
return;
|
||||||
|
|
@ -149,7 +144,7 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
|
||||||
|
|
||||||
window.addEventListener("keydown", handleGlobalKeyDown);
|
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
|
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||||
}, [navigate, user]);
|
}, [navigate, shellRole]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NavLink } from "react-router-dom";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import { Icon, type IconName } from "../ui/Icon";
|
import { Icon, type IconName } from "../ui/Icon";
|
||||||
import type { Role } from "../../lib/auth";
|
import type { Role } from "../../lib/auth";
|
||||||
|
|
||||||
|
|
@ -8,6 +9,8 @@ export interface NavItem {
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
/** NavLink end (정확 매칭) */
|
/** NavLink end (정확 매칭) */
|
||||||
end?: boolean;
|
end?: boolean;
|
||||||
|
/** 데스크톱과 전체 메뉴에서 항목의 역할을 설명하는 그룹 */
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -16,41 +19,48 @@ export interface NavItem {
|
||||||
*/
|
*/
|
||||||
const NAV_BY_ROLE: Record<Role, NavItem[]> = {
|
const NAV_BY_ROLE: Record<Role, NavItem[]> = {
|
||||||
learner: [
|
learner: [
|
||||||
{ to: "/learn", label: "대시보드", icon: "home", end: true },
|
{ to: "/learn", label: "학습자 홈", icon: "home", end: true, group: "학습" },
|
||||||
{ to: "/learn/practice", label: "학습", icon: "session" },
|
{ to: "/learn/practice", label: "상담 연습", icon: "session", group: "학습" },
|
||||||
{ to: "/learn/history", label: "기록", icon: "review" },
|
{ to: "/learn/history", label: "학습 기록", icon: "review", group: "학습" },
|
||||||
{ to: "/settings", label: "설정", icon: "settings" },
|
{ to: "/settings", label: "설정", icon: "settings", group: "설정" },
|
||||||
],
|
],
|
||||||
teacher: [
|
teacher: [
|
||||||
{ to: "/teach", label: "콘솔", icon: "users", end: true },
|
{ to: "/teach", label: "교수자 홈", icon: "users", end: true, group: "교수" },
|
||||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
|
{ to: "/teach/analysis", label: "학생 분석", icon: "review", group: "교수" },
|
||||||
{ to: "/teach/supervision", label: "감독·연구", icon: "shield" },
|
{ to: "/teach/supervision", label: "감독·연구", icon: "shield", group: "교수" },
|
||||||
{ to: "/teach/personas", label: "페르소나", icon: "review" },
|
{ to: "/teach/personas", label: "내담자 설정", icon: "review", group: "교수" },
|
||||||
{ to: "/settings", label: "설정", icon: "settings" },
|
{ to: "/settings", label: "설정", icon: "settings", group: "설정" },
|
||||||
],
|
],
|
||||||
admin: [
|
admin: [
|
||||||
{ to: "/admin", label: "운영 홈", icon: "shield", end: true },
|
{ to: "/admin", label: "운영 홈", icon: "shield", end: true, group: "운영" },
|
||||||
{ to: "/admin/ai", label: "AI 운영", icon: "session" },
|
{ to: "/admin/ai", label: "AI 운영", icon: "session", group: "운영" },
|
||||||
{ to: "/admin/continuous-improvement", label: "지속 개선", icon: "refresh" },
|
{ to: "/admin/continuous-improvement", label: "지속 개선", icon: "refresh", group: "운영" },
|
||||||
{ to: "/admin/users", label: "사용자", icon: "users" },
|
{ to: "/admin/users", label: "사용자 관리", icon: "users", group: "운영" },
|
||||||
{ to: "/admin/access", label: "권한", icon: "settings" },
|
{ to: "/admin/access", label: "접근 관리", icon: "settings", group: "운영" },
|
||||||
{ to: "/admin/tickets", label: "티켓", icon: "review" },
|
{ to: "/admin/tickets", label: "지원 요청", icon: "review", group: "운영" },
|
||||||
{ to: "/teach", label: "교수 콘솔", icon: "users", end: true },
|
{ to: "/teach", label: "교수자 홈", icon: "users", end: true, group: "교수" },
|
||||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
|
{ to: "/teach/analysis", label: "학생 분석", icon: "review", group: "교수" },
|
||||||
{ to: "/teach/supervision", label: "감독·연구", icon: "shield" },
|
{ to: "/teach/supervision", label: "감독·연구", icon: "shield", group: "교수" },
|
||||||
{ to: "/teach/personas", label: "페르소나", icon: "review" },
|
{ to: "/teach/personas", label: "내담자 설정", icon: "review", group: "교수" },
|
||||||
{ to: "/learn", label: "학습자 홈", icon: "home", end: true },
|
{ to: "/learn", label: "학습자 홈", icon: "home", end: true, group: "학습" },
|
||||||
{ to: "/learn/practice", label: "학습", icon: "session" },
|
{ to: "/learn/practice", label: "상담 연습", icon: "session", group: "학습" },
|
||||||
{ to: "/learn/history", label: "기록", icon: "review" },
|
{ to: "/learn/history", label: "학습 기록", icon: "review", group: "학습" },
|
||||||
{ to: "/settings", label: "설정", icon: "settings" },
|
{ to: "/settings", label: "설정", icon: "settings", group: "설정" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MOBILE_PRIMARY_PATHS: Record<Role, string[]> = {
|
||||||
|
learner: ["/learn", "/learn/practice", "/learn/history"],
|
||||||
|
teacher: ["/teach", "/teach/analysis", "/teach/supervision"],
|
||||||
|
admin: ["/admin", "/admin/ai", "/admin/users"],
|
||||||
|
};
|
||||||
|
|
||||||
const ADMIN_ENTRY: NavItem = {
|
const ADMIN_ENTRY: NavItem = {
|
||||||
to: "/admin",
|
to: "/admin",
|
||||||
label: "운영 콘솔",
|
label: "운영 홈",
|
||||||
icon: "shield",
|
icon: "shield",
|
||||||
end: true,
|
end: true,
|
||||||
|
group: "운영",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function navItemsFor(role: Role, showAdminEntry = false): NavItem[] {
|
export function navItemsFor(role: Role, showAdminEntry = false): NavItem[] {
|
||||||
|
|
@ -63,23 +73,81 @@ export interface SidebarProps {
|
||||||
role: Role;
|
role: Role;
|
||||||
/** 기본 역할 화면에서도 관리자 권한 사용자가 운영 콘솔로 돌아갈 수 있게 한다. */
|
/** 기본 역할 화면에서도 관리자 권한 사용자가 운영 콘솔로 돌아갈 수 있게 한다. */
|
||||||
showAdminEntry?: boolean;
|
showAdminEntry?: boolean;
|
||||||
/** 상단 그룹 라벨 (기본 "메뉴") */
|
/** 이전 호출부 호환용 기본 그룹 라벨 */
|
||||||
groupLabel?: string;
|
groupLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 좌측 네비 (240px). 활성 = accent-tint 알약. 강조바 금지. §6.3 */
|
function isPathActive(pathname: string, item: NavItem): boolean {
|
||||||
|
if (item.end) return pathname === item.to;
|
||||||
|
return pathname === item.to || pathname.startsWith(`${item.to}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupsFor(items: NavItem[], fallbackLabel: string): Array<[string, NavItem[]]> {
|
||||||
|
const groups = new Map<string, NavItem[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const group = item.group ?? fallbackLabel;
|
||||||
|
groups.set(group, [...(groups.get(group) ?? []), item]);
|
||||||
|
}
|
||||||
|
return [...groups.entries()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 좌측 네비와 좁은 화면의 하단 내비. */
|
||||||
export function Sidebar({ role, showAdminEntry = false, groupLabel = "메뉴" }: SidebarProps) {
|
export function Sidebar({ role, showAdminEntry = false, groupLabel = "메뉴" }: SidebarProps) {
|
||||||
const items = navItemsFor(role, showAdminEntry);
|
const items = navItemsFor(role, showAdminEntry);
|
||||||
|
const groups = groupsFor(items, groupLabel);
|
||||||
|
const location = useLocation();
|
||||||
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
|
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const primaryPaths = MOBILE_PRIMARY_PATHS[role];
|
||||||
|
const isMoreActive = !items.some(
|
||||||
|
(item) => primaryPaths.includes(item.to) && isPathActive(location.pathname, item),
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (dialog?.open) dialog.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog || dialog.open) return;
|
||||||
|
dialog.showModal();
|
||||||
|
setMenuOpen(true);
|
||||||
|
window.requestAnimationFrame(() => closeButtonRef.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
closeMenu();
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mobileViewport = window.matchMedia("(max-width: 720px)");
|
||||||
|
const closeOutsideMobile = () => {
|
||||||
|
if (!mobileViewport.matches && dialogRef.current?.open) dialogRef.current.close();
|
||||||
|
};
|
||||||
|
mobileViewport.addEventListener("change", closeOutsideMobile);
|
||||||
|
return () => mobileViewport.removeEventListener("change", closeOutsideMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="vg-nav" aria-label="주 메뉴">
|
<nav className="vg-nav" aria-label="주 메뉴">
|
||||||
<span className="vg-nav__label">{groupLabel}</span>
|
{groups.map(([group, groupItems]) => (
|
||||||
{items.map((item) => (
|
<section className="vg-nav__group" key={group} aria-label={`${group} 메뉴`}>
|
||||||
|
<span className="vg-nav__label">{group}</span>
|
||||||
|
{groupItems.map((item) => {
|
||||||
|
const isPrimary = primaryPaths.includes(item.to);
|
||||||
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.end}
|
end={item.end}
|
||||||
|
onClick={closeMenu}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
"vg-nav__item" + (isActive ? " is-active" : "")
|
"vg-nav__item" +
|
||||||
|
(isActive ? " is-active" : "") +
|
||||||
|
(isPrimary ? " vg-nav__item--mobile-primary" : "")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="vg-nav__ic">
|
<span className="vg-nav__ic">
|
||||||
|
|
@ -87,8 +155,79 @@ export function Sidebar({ role, showAdminEntry = false, groupLabel = "메뉴" }:
|
||||||
</span>
|
</span>
|
||||||
<span className="vg-nav__txt">{item.label}</span>
|
<span className="vg-nav__txt">{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={menuButtonRef}
|
||||||
|
type="button"
|
||||||
|
className={"vg-nav__more" + (isMoreActive ? " is-active" : "")}
|
||||||
|
aria-label="전체 메뉴"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-controls="vg-all-menu-dialog"
|
||||||
|
onClick={openMenu}
|
||||||
|
>
|
||||||
|
<span className="vg-nav__ic">
|
||||||
|
<Icon name="menu" size={20} strokeWidth={1.75} />
|
||||||
|
</span>
|
||||||
|
<span className="vg-nav__txt">전체 메뉴</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
id="vg-all-menu-dialog"
|
||||||
|
ref={dialogRef}
|
||||||
|
className="vg-nav__dialog"
|
||||||
|
aria-labelledby="vg-all-menu-title"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
const trigger = menuButtonRef.current;
|
||||||
|
if (trigger && window.getComputedStyle(trigger).display !== "none") trigger.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="vg-nav__dialog-head">
|
||||||
|
<div>
|
||||||
|
<p className="vg-nav__dialog-kicker">현재 역할에서 이동</p>
|
||||||
|
<h2 id="vg-all-menu-title">전체 메뉴</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
ref={closeButtonRef}
|
||||||
|
type="button"
|
||||||
|
className="vg-nav__dialog-close"
|
||||||
|
aria-label="전체 메뉴 닫기"
|
||||||
|
onClick={closeMenu}
|
||||||
|
>
|
||||||
|
<Icon name="x" size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="vg-nav__dialog-body">
|
||||||
|
{groups.map(([group, groupItems]) => (
|
||||||
|
<section className="vg-nav__dialog-group" key={group} aria-label={`${group} 메뉴`}>
|
||||||
|
<h3>{group}</h3>
|
||||||
|
{groupItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
onClick={closeMenu}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
"vg-nav__dialog-item" + (isActive ? " is-active" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} size={18} strokeWidth={1.75} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<span className="vg-nav__spacer" />
|
<span className="vg-nav__spacer" />
|
||||||
|
|
||||||
<div className="vg-nav__foot">
|
<div className="vg-nav__foot">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { type ChangeEvent } from "react";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import { ResilientImage } from "../avatar/ResilientImage";
|
import { ResilientImage } from "../avatar/ResilientImage";
|
||||||
import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
|
import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
|
||||||
|
|
@ -27,6 +28,17 @@ function initials(name?: string | null): string {
|
||||||
return trimmed.slice(0, 1);
|
return trimmed.slice(0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mobileRoleLabel(role: Role): string {
|
||||||
|
switch (role) {
|
||||||
|
case "learner":
|
||||||
|
return "학습자";
|
||||||
|
case "teacher":
|
||||||
|
return "교수자";
|
||||||
|
case "admin":
|
||||||
|
return "관리자";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface TopbarProps {
|
export interface TopbarProps {
|
||||||
/** 역할 컨텍스트 라벨 오버라이드 (없으면 user.role 라벨) */
|
/** 역할 컨텍스트 라벨 오버라이드 (없으면 user.role 라벨) */
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
|
|
@ -41,12 +53,22 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
||||||
|
|
||||||
const label = contextLabel ?? (user ? roleLabel(user.role) : null);
|
const label = contextLabel ?? (user ? roleLabel(user.role) : null);
|
||||||
const switchRoles: Role[] = user ? accessibleRolesFor(user) : [];
|
const switchRoles: Role[] = user ? accessibleRolesFor(user) : [];
|
||||||
|
const availableSwitchRoles = switchRoles.filter((role) => user && canAccessRole(user, role));
|
||||||
|
const activeRole =
|
||||||
|
availableSwitchRoles.find((role) => {
|
||||||
|
const home = roleHomePath(role);
|
||||||
|
return location.pathname === home || location.pathname.startsWith(`${home}/`);
|
||||||
|
}) ?? user?.role;
|
||||||
|
|
||||||
const onLogout = async () => {
|
const onLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onRoleSelect = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
navigate(roleHomePath(event.target.value as Role));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="vg-topbar">
|
<header className="vg-topbar">
|
||||||
<Link className="vg-topbar__brand" to={user ? "/" : "/login"}>
|
<Link className="vg-topbar__brand" to={user ? "/" : "/login"}>
|
||||||
|
|
@ -62,11 +84,10 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
||||||
<span className="vg-topbar__spacer" />
|
<span className="vg-topbar__spacer" />
|
||||||
|
|
||||||
<div className="vg-topbar__actions">
|
<div className="vg-topbar__actions">
|
||||||
{user && switchRoles.length > 0 ? (
|
{user && availableSwitchRoles.length > 0 ? (
|
||||||
|
<>
|
||||||
<div className="vg-topbar__switch" aria-label="공간 전환">
|
<div className="vg-topbar__switch" aria-label="공간 전환">
|
||||||
{switchRoles
|
{availableSwitchRoles.map((role) => {
|
||||||
.filter((role) => canAccessRole(user, role))
|
|
||||||
.map((role) => {
|
|
||||||
const to = roleHomePath(role);
|
const to = roleHomePath(role);
|
||||||
const active = location.pathname === to || location.pathname.startsWith(`${to}/`);
|
const active = location.pathname === to || location.pathname.startsWith(`${to}/`);
|
||||||
return (
|
return (
|
||||||
|
|
@ -86,6 +107,22 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="vg-topbar__switch-select-wrap">
|
||||||
|
<span className="vg-topbar__switch-select-label">공간 선택</span>
|
||||||
|
<select
|
||||||
|
className="vg-topbar__switch-select"
|
||||||
|
aria-label="공간 선택"
|
||||||
|
value={activeRole}
|
||||||
|
onChange={onRoleSelect}
|
||||||
|
>
|
||||||
|
{availableSwitchRoles.map((role) => (
|
||||||
|
<option key={role} value={role}>
|
||||||
|
{mobileRoleLabel(role)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,32 @@
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.vg-topbar__switch-select-wrap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.vg-topbar__switch-select-label {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.vg-topbar__switch-select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--hair);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface-2);
|
||||||
|
color: var(--text-strong);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
padding: 0 28px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 톱바 아이콘 버튼 (테마/로그아웃) */
|
/* 톱바 아이콘 버튼 (테마/로그아웃) */
|
||||||
.vg-iconbtn {
|
.vg-iconbtn {
|
||||||
|
|
@ -243,6 +269,11 @@
|
||||||
gap: var(--sp-1);
|
gap: var(--sp-1);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.vg-nav__group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
.vg-nav__label {
|
.vg-nav__label {
|
||||||
font-family: var(--font-num);
|
font-family: var(--font-num);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -291,6 +322,10 @@
|
||||||
.vg-nav__item.is-active .vg-nav__ic {
|
.vg-nav__item.is-active .vg-nav__ic {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.vg-nav__more,
|
||||||
|
.vg-nav__dialog {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.vg-nav__spacer {
|
.vg-nav__spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
@ -353,6 +388,23 @@
|
||||||
padding: 11px;
|
padding: 11px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (min-width: 721px) and (max-width: 1024px) {
|
||||||
|
.vg-shell__body {
|
||||||
|
--nav-cur: 168px;
|
||||||
|
}
|
||||||
|
.vg-nav {
|
||||||
|
padding: var(--sp-4) var(--sp-2);
|
||||||
|
}
|
||||||
|
.vg-nav__label,
|
||||||
|
.vg-nav__item span:not(.vg-nav__ic),
|
||||||
|
.vg-nav__ethic {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.vg-nav__item {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
html {
|
html {
|
||||||
scroll-padding-top: calc(var(--topbar-h) + var(--sp-2));
|
scroll-padding-top: calc(var(--topbar-h) + var(--sp-2));
|
||||||
|
|
@ -360,6 +412,7 @@
|
||||||
}
|
}
|
||||||
.vg-shell {
|
.vg-shell {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
|
--bottom-bar-h: 68px;
|
||||||
}
|
}
|
||||||
.vg-shell__body {
|
.vg-shell__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -384,11 +437,10 @@
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-top: 1px solid var(--hair);
|
border-top: 1px solid var(--hair);
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: row;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
justify-content: space-around;
|
gap: 2px;
|
||||||
gap: 4px;
|
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
@ -401,22 +453,35 @@
|
||||||
.vg-nav__foot {
|
.vg-nav__foot {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.vg-nav__group {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
.vg-nav__item {
|
.vg-nav__item {
|
||||||
flex: 1 1 0;
|
display: none;
|
||||||
|
}
|
||||||
|
.vg-nav__item--mobile-primary,
|
||||||
|
.vg-nav__more {
|
||||||
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 96px;
|
height: 52px;
|
||||||
height: 48px;
|
min-height: 52px;
|
||||||
min-height: 48px;
|
|
||||||
padding: 3px 2px;
|
padding: 3px 2px;
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-body);
|
||||||
|
font: inherit;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out);
|
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.vg-nav__item:active {
|
.vg-nav__item--mobile-primary:active,
|
||||||
|
.vg-nav__more:active {
|
||||||
transform: scale(0.93);
|
transform: scale(0.93);
|
||||||
}
|
}
|
||||||
.vg-nav__item .vg-nav__ic {
|
.vg-nav__item .vg-nav__ic {
|
||||||
|
|
@ -429,16 +494,17 @@
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
.vg-nav__item .vg-nav__txt,
|
.vg-nav__item .vg-nav__txt,
|
||||||
.vg-nav__item span:not(.vg-nav__ic) {
|
.vg-nav__more .vg-nav__txt {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
text-overflow: ellipsis;
|
text-overflow: clip;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
.vg-topbar__role {
|
.vg-topbar__role {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -471,20 +537,20 @@
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.vg-topbar__switch {
|
.vg-topbar__switch {
|
||||||
height: 38px;
|
display: none;
|
||||||
padding: 2px;
|
|
||||||
}
|
}
|
||||||
.vg-topbar__switch-link {
|
.vg-topbar__switch-select-wrap {
|
||||||
min-height: 34px;
|
display: block;
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
}
|
||||||
.vg-topbar__switch-link span {
|
.vg-topbar__switch-select {
|
||||||
display: inline;
|
box-sizing: border-box;
|
||||||
|
width: min(104px, calc(100vw - 224px));
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.vg-iconbtn {
|
.vg-iconbtn {
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
.vg-main {
|
.vg-main {
|
||||||
padding: var(--sp-4) var(--sp-4) calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-6));
|
padding: var(--sp-4) var(--sp-4) calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-6));
|
||||||
|
|
@ -495,14 +561,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
/* 관리자처럼 세 역할을 모두 전환할 수 있는 계정도 테마·로그아웃 버튼을
|
.vg-topbar {
|
||||||
밀어내지 않게 한다. 링크 이름은 aria-label/title에 그대로 남는다. */
|
min-height: 56px;
|
||||||
.vg-topbar__switch-link {
|
padding: 0 var(--sp-2);
|
||||||
flex: 0 0 34px;
|
gap: 4px;
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.vg-topbar__switch-link span {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -758,3 +820,173 @@ body[data-role] .vg-shell--botanical .vg-nav__foot {
|
||||||
padding: 3px 2px;
|
padding: 3px 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__group {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item,
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item--mobile-primary,
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item--mobile-primary.is-active,
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__more {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
min-width: 0;
|
||||||
|
height: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 3px 2px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-body);
|
||||||
|
font: inherit;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item--mobile-primary.is-active,
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__more.is-active {
|
||||||
|
background: linear-gradient(
|
||||||
|
112deg,
|
||||||
|
color-mix(in srgb, var(--bg-surface) 92%, var(--botanical-gold)),
|
||||||
|
color-mix(in srgb, var(--bg-surface-2) 80%, transparent)
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, var(--botanical-gold) 34%, var(--border-subtle));
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 84%, transparent);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item--mobile-primary .vg-nav__txt,
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__more .vg-nav__txt {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-nav__dialog[open] {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
inset: auto 0 0;
|
||||||
|
z-index: 60;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
max-height: min(92dvh, 680px);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--hair);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
background: var(--bg-app);
|
||||||
|
color: var(--text-strong);
|
||||||
|
box-shadow: var(--shadow-float);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.vg-nav__dialog::backdrop {
|
||||||
|
background: color-mix(in srgb, var(--text-strong) 28%, transparent);
|
||||||
|
}
|
||||||
|
.vg-nav__dialog-head {
|
||||||
|
min-height: 72px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--hair);
|
||||||
|
}
|
||||||
|
.vg-nav__dialog-kicker {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.vg-nav__dialog h2,
|
||||||
|
.vg-nav__dialog h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
.vg-nav__dialog h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.vg-nav__dialog h3 {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.vg-nav__dialog-close {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-body);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.vg-nav__dialog-close:hover {
|
||||||
|
background: var(--bg-surface-2);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
.vg-nav__dialog-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: var(--sp-3) var(--sp-4) calc(var(--sp-5) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.vg-nav__dialog-group + .vg-nav__dialog-group {
|
||||||
|
margin-top: var(--sp-4);
|
||||||
|
}
|
||||||
|
.vg-nav__dialog-group h3 {
|
||||||
|
padding: 0 var(--sp-3) var(--sp-2);
|
||||||
|
}
|
||||||
|
.vg-nav__dialog-item {
|
||||||
|
min-height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 10px var(--sp-3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-body);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.vg-nav__dialog-item:hover,
|
||||||
|
.vg-nav__dialog-item.is-active {
|
||||||
|
background: var(--accent-tint);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 721px) and (max-width: 1024px) {
|
||||||
|
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||||
|
--nav-cur: 168px;
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item,
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,8 @@ const TICKET_STATUS_OPTIONS: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ value: "all", label: "전체 상태" },
|
{ value: "all", label: "전체 상태" },
|
||||||
{ value: "open", label: "미해결" },
|
{ value: "open", label: "접수" },
|
||||||
{ value: "triaged", label: "분류됨" },
|
{ value: "triaged", label: "분류" },
|
||||||
{ value: "in_progress", label: "처리 중" },
|
{ value: "in_progress", label: "처리 중" },
|
||||||
{ value: "resolved", label: "해결" },
|
{ value: "resolved", label: "해결" },
|
||||||
{ value: "closed", label: "종결" },
|
{ value: "closed", label: "종결" },
|
||||||
|
|
@ -407,8 +407,8 @@ function lastDownLabel(data: AdminUptimeResponse | null): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ticketStatusLabel(status: AdminSupportTicket["status"]): string {
|
function ticketStatusLabel(status: AdminSupportTicket["status"]): string {
|
||||||
if (status === "open") return "미해결";
|
if (status === "open") return "접수";
|
||||||
if (status === "triaged") return "분류됨";
|
if (status === "triaged") return "분류";
|
||||||
if (status === "in_progress") return "처리 중";
|
if (status === "in_progress") return "처리 중";
|
||||||
if (status === "resolved") return "해결";
|
if (status === "resolved") return "해결";
|
||||||
return "종결";
|
return "종결";
|
||||||
|
|
@ -2862,12 +2862,11 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
{/* eyebrow "접근 권한"은 아래 "역할, 그룹, 접근 범위"와 같은 말이라 제거 */}
|
{/* eyebrow "접근 권한"은 아래 "역할, 그룹, 접근 범위"와 같은 말이라 제거 */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="역할, 그룹, 접근 범위"
|
title="역할, 그룹, 접근 범위"
|
||||||
description="운영 권한 정책과 평가용 상담 프로토콜의 수명주기를 관리합니다."
|
description="역할별 접근 범위와 상담 프로토콜을 확인합니다."
|
||||||
/>
|
/>
|
||||||
{accessTab !== "protocols" ? (
|
{accessTab !== "protocols" ? (
|
||||||
<div className="vgops-users-note">
|
<div className="vgops-users-note">
|
||||||
역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책
|
역할과 그룹의 접근 범위를 확인할 수 있습니다. 이 화면에서는 역할·그룹을 변경할 수 없습니다.
|
||||||
기준을 먼저 고정합니다.
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Tabs<AccessTab>
|
<Tabs<AccessTab>
|
||||||
|
|
@ -2963,10 +2962,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
|
|
||||||
const renderTickets = () => (
|
const renderTickets = () => (
|
||||||
<>
|
<>
|
||||||
{/* eyebrow "운영 티켓"은 아래 "사용자 문제 큐"와 같은 말이라 제거 */}
|
{/* eyebrow "운영 티켓"은 아래 "지원 요청"과 같은 말이라 제거 */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="사용자 문제 큐"
|
title="지원 요청"
|
||||||
description="미해결 접수를 우선 확인하고 처리 상태를 갱신합니다."
|
description="접수된 요청과 처리 상태를 확인합니다."
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -2978,15 +2977,14 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="vgops-users-note">
|
<div className="vgops-users-note">
|
||||||
사용자가 제출한 실제 DB 큐만 표시합니다. 해결된 항목은 큐에서 내려 최근
|
해결된 요청은 최근 해결 이력에서 확인할 수 있습니다.
|
||||||
이력으로 분리합니다.
|
|
||||||
</div>
|
</div>
|
||||||
<section className="vgops-ticket-filter" aria-label="운영 티켓 필터">
|
<section className="vgops-ticket-filter" aria-label="운영 티켓 필터">
|
||||||
<input
|
<input
|
||||||
value={ticketSearch}
|
value={ticketSearch}
|
||||||
onChange={(event) => setTicketSearch(event.target.value)}
|
onChange={(event) => setTicketSearch(event.target.value)}
|
||||||
placeholder="제목, 본문, 신고자, 경로 검색"
|
placeholder="지원 요청 검색"
|
||||||
aria-label="티켓 검색"
|
aria-label="지원 요청 검색"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={ticketStatusFilter}
|
value={ticketStatusFilter}
|
||||||
|
|
@ -3077,19 +3075,19 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
<div className="vgops-kpi">
|
<div className="vgops-kpi">
|
||||||
<span className="vgops-kpi__lab">전체 접수</span>
|
<span className="vgops-kpi__lab">전체 접수</span>
|
||||||
<b>{tickets ? countLabel(tickets.summary.total) : "-"}</b>
|
<b>{tickets ? countLabel(tickets.summary.total) : "-"}</b>
|
||||||
<small>{tickets?.durable ? "DB 티켓" : "저장소 확인 중"}</small>
|
<small>{tickets?.durable ? "저장된 요청" : "저장소 확인 중"}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="vgops-kpi">
|
<div className="vgops-kpi">
|
||||||
<span className="vgops-kpi__lab">미해결</span>
|
<span className="vgops-kpi__lab">미해결</span>
|
||||||
<b>{tickets ? countLabel(tickets.summary.open_count) : "-"}</b>
|
<b>{tickets ? countLabel(tickets.summary.open_count) : "-"}</b>
|
||||||
<small>open, triaged, in_progress</small>
|
<small>접수 · 분류 · 처리 중</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="vgops-kpi">
|
<div className="vgops-kpi">
|
||||||
<span className="vgops-kpi__lab">높은 우선순위</span>
|
<span className="vgops-kpi__lab">높은 우선순위</span>
|
||||||
<b>
|
<b>
|
||||||
{tickets ? countLabel(tickets.summary.high_priority_count) : "-"}
|
{tickets ? countLabel(tickets.summary.high_priority_count) : "-"}
|
||||||
</b>
|
</b>
|
||||||
<small>미해결 high, urgent</small>
|
<small>높음 · 긴급 요청</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="vgops-kpi">
|
<div className="vgops-kpi">
|
||||||
<span className="vgops-kpi__lab">정체</span>
|
<span className="vgops-kpi__lab">정체</span>
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
// 데스크톱(>1180px)은 2컬럼 그리드 유지 — 탭 바는 CSS로 숨겨지고 이 상태는 무시된다.
|
// 데스크톱(>1180px)은 2컬럼 그리드 유지 — 탭 바는 CSS로 숨겨지고 이 상태는 무시된다.
|
||||||
const [dashTab, setDashTab] = useState<DashboardTab>("today");
|
const [dashTab, setDashTab] = useState<DashboardTab>("today");
|
||||||
const dashTabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
const dashTabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
const personaListRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [showUnpracticedPersonaProgress, setShowUnpracticedPersonaProgress] = useState(false);
|
||||||
|
|
||||||
const updateHistorySearchParams = (
|
const updateHistorySearchParams = (
|
||||||
update: (params: URLSearchParams) => void,
|
update: (params: URLSearchParams) => void,
|
||||||
|
|
@ -489,6 +491,54 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
const reviewCount = overview?.review_ready_sessions ?? sessionReviewCount;
|
const reviewCount = overview?.review_ready_sessions ?? sessionReviewCount;
|
||||||
const completedCount = overview?.completed_sessions ?? sessionCompletedCount;
|
const completedCount = overview?.completed_sessions ?? sessionCompletedCount;
|
||||||
const totalCount = overview?.total_sessions ?? sessions.length;
|
const totalCount = overview?.total_sessions ?? sessions.length;
|
||||||
|
const endedWithoutDialogueCount = Math.max(
|
||||||
|
0,
|
||||||
|
totalCount - completedCount - activeCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "practice" || loadState !== "ready" || !selected?.code) return;
|
||||||
|
const list = personaListRef.current;
|
||||||
|
const option = list?.querySelector<HTMLElement>(`[data-persona-code="${selected.code}"]`);
|
||||||
|
if (!list || !option) return;
|
||||||
|
let frame: number | null = null;
|
||||||
|
const ensureSelectedPersonaVisible = () => {
|
||||||
|
if (frame !== null) cancelAnimationFrame(frame);
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = null;
|
||||||
|
const listRect = list.getBoundingClientRect();
|
||||||
|
const optionRect = option.getBoundingClientRect();
|
||||||
|
const nextTop =
|
||||||
|
optionRect.top < listRect.top
|
||||||
|
? list.scrollTop + optionRect.top - listRect.top
|
||||||
|
: optionRect.bottom > listRect.bottom
|
||||||
|
? list.scrollTop + optionRect.bottom - listRect.bottom
|
||||||
|
: null;
|
||||||
|
const nextLeft =
|
||||||
|
optionRect.left < listRect.left
|
||||||
|
? list.scrollLeft + optionRect.left - listRect.left
|
||||||
|
: optionRect.right > listRect.right
|
||||||
|
? list.scrollLeft + optionRect.right - listRect.right
|
||||||
|
: null;
|
||||||
|
if (nextTop !== null || nextLeft !== null) {
|
||||||
|
list.scrollTo({
|
||||||
|
top: nextTop ?? list.scrollTop,
|
||||||
|
left: nextLeft ?? list.scrollLeft,
|
||||||
|
behavior: "auto",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureSelectedPersonaVisible();
|
||||||
|
const observer = new ResizeObserver(ensureSelectedPersonaVisible);
|
||||||
|
observer.observe(list);
|
||||||
|
observer.observe(option);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
if (frame !== null) cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [loadState, selected?.code, view]);
|
||||||
const recentFeedback = dashboard?.recent_feedback ?? [];
|
const recentFeedback = dashboard?.recent_feedback ?? [];
|
||||||
const historyReady = sessionLoadState === "ready";
|
const historyReady = sessionLoadState === "ready";
|
||||||
const dashboardStatus =
|
const dashboardStatus =
|
||||||
|
|
@ -915,11 +965,16 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
|
|
||||||
const dashboardMetrics = [
|
const dashboardMetrics = [
|
||||||
{
|
{
|
||||||
label: "진행 회기",
|
label: "전체 회기",
|
||||||
value: dashboardReady ? `${totalCount}회` : "-",
|
value: dashboardReady ? `${totalCount}회` : "-",
|
||||||
meta: dashboardReady
|
meta: dashboardReady
|
||||||
? `${completedCount}회 종료 · ${activeCount}회 진행`
|
? `상담 완료 ${completedCount}회 · 진행 중 ${activeCount}회`
|
||||||
: dashboardStatus,
|
: dashboardStatus,
|
||||||
|
note:
|
||||||
|
dashboardReady && endedWithoutDialogueCount > 0
|
||||||
|
? "대화 없이 종료한 회기 포함"
|
||||||
|
: null,
|
||||||
|
title: "전체 회기에는 대화 없이 종료한 회기도 포함합니다.",
|
||||||
to: "/learn/history",
|
to: "/learn/history",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -927,6 +982,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
value: dashboardReady ? `${reviewCount}건` : "-",
|
value: dashboardReady ? `${reviewCount}건` : "-",
|
||||||
meta:
|
meta:
|
||||||
reviewCount > 0 ? "종료 회기 피드백 확인 필요" : "대기 중인 리뷰 없음",
|
reviewCount > 0 ? "종료 회기 피드백 확인 필요" : "대기 중인 리뷰 없음",
|
||||||
|
note: null,
|
||||||
to: "/learn/history",
|
to: "/learn/history",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -936,6 +992,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
dashboardReady && growth?.evaluated_sessions
|
dashboardReady && growth?.evaluated_sessions
|
||||||
? `${growth.evaluated_sessions}회 평가 · ${trendLabel(growth.trend)}`
|
? `${growth.evaluated_sessions}회 평가 · ${trendLabel(growth.trend)}`
|
||||||
: "첫 회기 후 표시",
|
: "첫 회기 후 표시",
|
||||||
|
note: null,
|
||||||
to: "/learn/history",
|
to: "/learn/history",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -944,6 +1001,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
meta: topPersona?.total
|
meta: topPersona?.total
|
||||||
? `${topPersona.name} · ${topPersona.total}회`
|
? `${topPersona.name} · ${topPersona.total}회`
|
||||||
: "아직 연습 없음",
|
: "아직 연습 없음",
|
||||||
|
note: null,
|
||||||
to: "/learn/practice",
|
to: "/learn/practice",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -1157,7 +1215,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="lh-personas" role="listbox" aria-label="연습 페르소나">
|
<div ref={personaListRef} className="lh-personas" role="listbox" aria-label="연습 페르소나">
|
||||||
{loadState === "loading"
|
{loadState === "loading"
|
||||||
? Array.from({ length: 3 }).map((_, i) => (
|
? Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1192,6 +1250,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
key={persona.code}
|
key={persona.code}
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
|
data-persona-code={persona.code}
|
||||||
aria-selected={selectedPersona}
|
aria-selected={selectedPersona}
|
||||||
aria-disabled={!usablePersona}
|
aria-disabled={!usablePersona}
|
||||||
disabled={!usablePersona}
|
disabled={!usablePersona}
|
||||||
|
|
@ -1247,26 +1306,17 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
navRole="learner"
|
navRole="learner"
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<main className={rootClassName}>
|
<div className={rootClassName}>
|
||||||
{view === "dashboard" ? (
|
{view === "dashboard" ? (
|
||||||
<>
|
<>
|
||||||
<header className="lh-head">
|
<header className="lh-head">
|
||||||
<div className="lh-head__copy">
|
<div className="lh-head__copy">
|
||||||
{/* D1: 단순 섹션 라벨이라 상태 dot 제거(대시보드 화면 dot 밀집 정리) */}
|
<h1 className="lh-title">오늘의 상담 연습</h1>
|
||||||
<Kicker dot={false}>학습 대시보드</Kicker>
|
|
||||||
<h1 className="lh-title">오늘 이어갈 회기를 먼저 봅니다.</h1>
|
|
||||||
<p className="lh-sub">
|
<p className="lh-sub">
|
||||||
진행 중인 상담, 리뷰 대기, 다음 연습 기준을 한 화면에서
|
진행 중인 회기를 이어가거나, 지난 상담의 피드백을 확인하세요.
|
||||||
정리합니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="lh-head__tools">
|
<div className="lh-head__tools">
|
||||||
<div className="lh-head__status" aria-live="polite">
|
|
||||||
<Icon name="users" size={17} />
|
|
||||||
{loadState === "ready"
|
|
||||||
? `${usablePersonas.length}명 · ${historyStatus}`
|
|
||||||
: "확인 중"}
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
className="lh-head__primary"
|
className="lh-head__primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -1279,23 +1329,6 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="lh-dashboard-status" aria-label="학습 현황">
|
|
||||||
{dashboardMetrics.map((metric) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={surfaceClassName("lh-metric-card", {
|
|
||||||
variant: "interactive",
|
|
||||||
})}
|
|
||||||
key={metric.label}
|
|
||||||
onClick={() => navigate(metric.to)}
|
|
||||||
>
|
|
||||||
<span>{metric.label}</span>
|
|
||||||
<b>{metric.value}</b>
|
|
||||||
<p>{metric.meta}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 CSS로 숨김 */}
|
{/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 CSS로 숨김 */}
|
||||||
<div
|
<div
|
||||||
className="lh-tabs"
|
className="lh-tabs"
|
||||||
|
|
@ -1335,14 +1368,6 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
hidden={dashTab !== "today"}
|
hidden={dashTab !== "today"}
|
||||||
>
|
>
|
||||||
<div className="lh-work-cluster__head">
|
|
||||||
<div>
|
|
||||||
{/* D1: 섹션 라벨 — 상태가 아니므로 dot 제거 */}
|
|
||||||
<Kicker dot={false}>오늘의 회기 작업</Kicker>
|
|
||||||
<h2>이어가기, 코칭, 다음 연습을 한 흐름으로 봅니다.</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lh-work-cluster__primary">
|
<div className="lh-work-cluster__primary">
|
||||||
<article
|
<article
|
||||||
className={surfaceClassName("lh-session-focus", {
|
className={surfaceClassName("lh-session-focus", {
|
||||||
|
|
@ -1715,6 +1740,28 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
hidden={dashTab !== "growth"}
|
hidden={dashTab !== "growth"}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="lh-dashboard-status lh-growth-status"
|
||||||
|
aria-label="학습 현황"
|
||||||
|
>
|
||||||
|
{dashboardMetrics.map((metric) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={surfaceClassName("lh-metric-card", {
|
||||||
|
variant: "interactive",
|
||||||
|
})}
|
||||||
|
key={metric.label}
|
||||||
|
onClick={() => navigate(metric.to)}
|
||||||
|
title={metric.title}
|
||||||
|
>
|
||||||
|
<span>{metric.label}</span>
|
||||||
|
<b>{metric.value}</b>
|
||||||
|
<p>{metric.meta}</p>
|
||||||
|
{metric.note ? <small>{metric.note}</small> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
{weaknessInsights.length > 0 ? (
|
{weaknessInsights.length > 0 ? (
|
||||||
<article
|
<article
|
||||||
className={surfaceClassName(
|
className={surfaceClassName(
|
||||||
|
|
@ -2489,7 +2536,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
<Kicker>페르소나별 진행</Kicker>
|
<Kicker>페르소나별 진행</Kicker>
|
||||||
<span>
|
<span>
|
||||||
{historyReady
|
{historyReady
|
||||||
? `${personaProgressRows.length}명`
|
? `연습한 내담자 ${personaProgressRows.filter((row) => row.total > 0).length}명`
|
||||||
: "확인 중"}
|
: "확인 중"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2497,9 +2544,9 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
<p className="lh-persona-progress__note">
|
<p className="lh-persona-progress__note">
|
||||||
회기 기록을 불러오고 있습니다.
|
회기 기록을 불러오고 있습니다.
|
||||||
</p>
|
</p>
|
||||||
) : personaProgressRows.length > 0 ? (
|
) : showUnpracticedPersonaProgress || personaProgressRows.some((row) => row.total > 0) ? (
|
||||||
<div className="lh-persona-progress__rows">
|
<div className="lh-persona-progress__rows">
|
||||||
{personaProgressRows.map((row) => {
|
{personaProgressRows.filter((row) => showUnpracticedPersonaProgress || row.total > 0).map((row) => {
|
||||||
const progressWidth = Math.round(
|
const progressWidth = Math.round(
|
||||||
(row.total / maxPersonaSessionCount) * 100,
|
(row.total / maxPersonaSessionCount) * 100,
|
||||||
);
|
);
|
||||||
|
|
@ -2554,16 +2601,24 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="lh-persona-progress__empty">
|
||||||
<p className="lh-persona-progress__note">
|
<p className="lh-persona-progress__note">
|
||||||
표시할 페르소나 진행 기록이 없습니다.
|
아직 연습 기록이 없습니다.
|
||||||
</p>
|
</p>
|
||||||
|
<Button size="sm" onClick={() => navigate("/learn/practice")}>상담 연습으로</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sessionLoadState === "ready" && personaProgressRows.some((row) => row.total === 0) ? (
|
||||||
|
<button type="button" className="lh-persona-progress__toggle" onClick={() => setShowUnpracticedPersonaProgress((current) => !current)}>
|
||||||
|
{showUnpracticedPersonaProgress ? "미진행 내담자 숨기기" : `미진행 내담자 ${personaProgressRows.filter((row) => row.total === 0).length}명 보기`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -345,15 +345,15 @@ export default function Onboarding() {
|
||||||
<div className="ob-legal">
|
<div className="ob-legal">
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
<span>{docs?.terms.title ?? "서비스 이용약관"}</span>
|
<span className="ob-legal__title">{docs?.terms.title ?? "서비스 이용약관"}</span>
|
||||||
<b>{docs?.terms.version ?? "확인 중"}</b>
|
<b className="ob-legal__version">{docs?.terms.version ?? "확인 중"}</b>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="ob-doc-text">{docs?.terms.body ?? "문서를 불러오는 중입니다."}</div>
|
<div className="ob-doc-text">{docs?.terms.body ?? "문서를 불러오는 중입니다."}</div>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
<span>{docs?.privacy.title ?? "개인정보 처리방침"}</span>
|
<span className="ob-legal__title">{docs?.privacy.title ?? "개인정보 처리방침"}</span>
|
||||||
<b>{docs?.privacy.version ?? "확인 중"}</b>
|
<b className="ob-legal__version">{docs?.privacy.version ?? "확인 중"}</b>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="ob-doc-text">{docs?.privacy.body ?? "문서를 불러오는 중입니다."}</div>
|
<div className="ob-doc-text">{docs?.privacy.body ?? "문서를 불러오는 중입니다."}</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -2479,7 +2479,7 @@ export default function PersonaStudio() {
|
||||||
|
|
||||||
const workspaceView = (
|
const workspaceView = (
|
||||||
<AppShell contextLabel="페르소나" navRole="teacher" wide>
|
<AppShell contextLabel="페르소나" navRole="teacher" wide>
|
||||||
<main className="ps-root">
|
<div className="ps-root">
|
||||||
<header className="ps-head">
|
<header className="ps-head">
|
||||||
<div>
|
<div>
|
||||||
{/* eyebrow "페르소나"는 아래 h1(페르소나 운영/카탈로그/관리/상세)과 같은 말이라 제거 */}
|
{/* eyebrow "페르소나"는 아래 h1(페르소나 운영/카탈로그/관리/상세)과 같은 말이라 제거 */}
|
||||||
|
|
@ -2550,14 +2550,14 @@ export default function PersonaStudio() {
|
||||||
: workspaceTab === "catalog"
|
: workspaceTab === "catalog"
|
||||||
? catalogView
|
? catalogView
|
||||||
: personaListView}
|
: personaListView}
|
||||||
</main>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
const draftName = splitPersonaName(draft.display_name.trim());
|
const draftName = splitPersonaName(draft.display_name.trim());
|
||||||
const legacyAuthoring = (
|
const legacyAuthoring = (
|
||||||
<AppShell contextLabel="페르소나" navRole="teacher" wide>
|
<AppShell contextLabel="페르소나" navRole="teacher" wide>
|
||||||
<main className="ps-root">
|
<div className="ps-root">
|
||||||
<header className="ps-head">
|
<header className="ps-head">
|
||||||
<div>
|
<div>
|
||||||
<Kicker dot={false}>
|
<Kicker dot={false}>
|
||||||
|
|
@ -2977,7 +2977,7 @@ export default function PersonaStudio() {
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -420,7 +420,7 @@ export default function Professor({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell navRole="teacher" wide>
|
<AppShell navRole="teacher" wide>
|
||||||
<main
|
<div
|
||||||
className={`pf-root ${isAnalysisView ? "pf-root--analysis" : "pf-root--console"}`}
|
className={`pf-root ${isAnalysisView ? "pf-root--analysis" : "pf-root--console"}`}
|
||||||
>
|
>
|
||||||
<header className="pf-head">
|
<header className="pf-head">
|
||||||
|
|
@ -431,9 +431,7 @@ export default function Professor({
|
||||||
</Kicker>
|
</Kicker>
|
||||||
{isAnalysisView ? (
|
{isAnalysisView ? (
|
||||||
<>
|
<>
|
||||||
<h1>
|
<h1>학생별 학습 현황</h1>
|
||||||
전체 학습자를 먼저 보고, 필요한 학습자만 상세로 들어갑니다.
|
|
||||||
</h1>
|
|
||||||
{/* [D3] 탭 이름 나열의 가운뎃점 2개를 쉼표로 풀어 한 줄 1개 규칙을 지킨다. */}
|
{/* [D3] 탭 이름 나열의 가운뎃점 2개를 쉼표로 풀어 한 줄 1개 규칙을 지킨다. */}
|
||||||
<p>
|
<p>
|
||||||
검색 테이블에서 학습자별 회기 수, 최근 점수, 변화폭, 라포와
|
검색 테이블에서 학습자별 회기 수, 최근 점수, 변화폭, 라포와
|
||||||
|
|
@ -1036,7 +1034,7 @@ export default function Professor({
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,8 +221,8 @@ function AttentionWorkbench({ items }: { items: AttentionItem[] }) {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
return (
|
return (
|
||||||
<Notice
|
<Notice
|
||||||
title="현재 우선 검토 항목이 없어"
|
title="현재 우선 검토 항목이 없습니다"
|
||||||
description="새 원장 근거가 들어오면 위험·정체·미해결 관계 사건 순서로 이 큐에 나타나."
|
description="새 상담 근거가 들어오면 위험·정체·미해결 관계 사건 순서로 이 목록에 표시됩니다."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -284,7 +284,7 @@ function AttentionWorkbench({ items }: { items: AttentionItem[] }) {
|
||||||
|
|
||||||
<section className="g6-ledger" aria-labelledby="g6-ledger-title">
|
<section className="g6-ledger" aria-labelledby="g6-ledger-title">
|
||||||
<div className="g6-ledger__head">
|
<div className="g6-ledger__head">
|
||||||
<h4 id="g6-ledger-title">분류를 만든 원장 근거</h4>
|
<h4 id="g6-ledger-title">분류 근거</h4>
|
||||||
<span>{evidence.length}/3 표시</span>
|
<span>{evidence.length}/3 표시</span>
|
||||||
</div>
|
</div>
|
||||||
{evidence.length > 0 ? (
|
{evidence.length > 0 ? (
|
||||||
|
|
@ -298,20 +298,20 @@ function AttentionWorkbench({ items }: { items: AttentionItem[] }) {
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<Notice
|
<Notice
|
||||||
title="원장 포인터가 비어 있어"
|
title="근거 연결이 비어 있습니다"
|
||||||
description="분류는 보이지만 근거 연결이 없어 저하 상태로 취급해."
|
description="분류는 보이지만 근거 연결이 없어 저하 상태로 표시됩니다."
|
||||||
tone="warn"
|
tone="warn"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="g6-ledger__policy">
|
<p className="g6-ledger__policy">
|
||||||
원문 축어록은 표시하지 않아. 한 항목에서 근거 포인터는 최대 3개만 노출해.
|
원문 축어록은 표시하지 않습니다. 한 항목에서 근거 연결은 최대 3개만 표시합니다.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<nav className="g6-drilldown" aria-label={`${selected.learner_ref} 근거 드릴다운`}>
|
<nav className="g6-drilldown" aria-label={`${selected.learner_ref} 근거 드릴다운`}>
|
||||||
<div>
|
<div>
|
||||||
<strong>근거까지 바로 이동</strong>
|
<strong>상세 근거</strong>
|
||||||
<span data-testid="drilldown-depth">항목 선택 후 최대 2번 · 계약 상한 3번</span>
|
<span data-testid="drilldown-depth">선택한 항목의 근거를 확인합니다.</span>
|
||||||
</div>
|
</div>
|
||||||
{drilldowns.length > 0 ? (
|
{drilldowns.length > 0 ? (
|
||||||
<ol>
|
<ol>
|
||||||
|
|
@ -319,7 +319,7 @@ function AttentionWorkbench({ items }: { items: AttentionItem[] }) {
|
||||||
<li key={route}>
|
<li key={route}>
|
||||||
<a href={route}>
|
<a href={route}>
|
||||||
<span>{index + 1}</span>
|
<span>{index + 1}</span>
|
||||||
원장 근거 열기
|
상담 근거 확인
|
||||||
<Icon name="chevron-right" size={15} />
|
<Icon name="chevron-right" size={15} />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -338,8 +338,8 @@ function CurriculumGaps({ gaps }: { gaps: CurriculumGap[] }) {
|
||||||
if (gaps.length === 0) {
|
if (gaps.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Notice
|
<Notice
|
||||||
title="현재 교육과정 공백이 없어"
|
title="현재 교육과정 공백이 없습니다"
|
||||||
description="코호트 수준에서 반복되는 근거가 생기면 역량·공백 유형별로 나타나."
|
description="코호트 수준에서 반복되는 근거가 생기면 역량·공백 유형별로 표시됩니다."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -366,12 +366,12 @@ function CurriculumGaps({ gaps }: { gaps: CurriculumGap[] }) {
|
||||||
<dd>{gap.affected_learner_count}명</dd>
|
<dd>{gap.affected_learner_count}명</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>근거 포인터</dt>
|
<dt>근거 연결</dt>
|
||||||
<dd>{Math.min(gap.evidence_pointer_ids.length, 3)}개 표시</dd>
|
<dd>{Math.min(gap.evidence_pointer_ids.length, 3)}개 표시</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{gap.status === "insufficient_evidence" ? (
|
{gap.status === "insufficient_evidence" ? (
|
||||||
<span className="g6-inline-degraded">근거 없는 상태를 유지하고 해석하지 않아.</span>
|
<span className="g6-inline-degraded">근거가 부족한 상태를 유지하고 해석하지 않습니다.</span>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
@ -383,8 +383,8 @@ function DisagreementMetadata({ rows }: { rows: CalibrationDatasetMetadata[] })
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Notice
|
<Notice
|
||||||
title="교수자–AI 불일치 메타데이터가 없어"
|
title="교수자–AI 불일치 메타데이터가 없습니다"
|
||||||
description="교수자 교정이 기록되면 축어록 없이 버전과 근거 포인터만 연구 행으로 들어와."
|
description="교수자 교정이 기록되면 축어록 없이 버전과 근거 연결만 연구 행으로 표시됩니다."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -440,11 +440,11 @@ function DisagreementMetadata({ rows }: { rows: CalibrationDatasetMetadata[] })
|
||||||
</dl>
|
</dl>
|
||||||
<footer>
|
<footer>
|
||||||
<span>행 해시 {shortId(row.row_hash, 14)}</span>
|
<span>행 해시 {shortId(row.row_hash, 14)}</span>
|
||||||
<span>근거 {row.evidence_pointer_ids.length}개</span>
|
<span>근거 연결 {row.evidence_pointer_ids.length}개</span>
|
||||||
</footer>
|
</footer>
|
||||||
{!versionReady ? (
|
{!versionReady ? (
|
||||||
<span className="g6-inline-degraded">
|
<span className="g6-inline-degraded">
|
||||||
읽기 API에 모델·프롬프트·도구 버전 일부가 없어 메타데이터 저하 상태야.
|
모델·프롬프트·도구 버전 중 제공되지 않은 정보가 있습니다. 값을 추정하지 않습니다.
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -495,8 +495,8 @@ function DriftReport({ report }: { report: EvaluationDriftReport }) {
|
||||||
|
|
||||||
{report.status === "insufficient_evidence" ? (
|
{report.status === "insufficient_evidence" ? (
|
||||||
<Notice
|
<Notice
|
||||||
title="비교 근거가 부족해"
|
title="비교 근거가 부족합니다"
|
||||||
description="계약대로 정확도와 변화값을 비워 둔 채 판정을 보류해."
|
description="정확도와 변화값을 비워 둔 채 판정을 보류합니다."
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -521,8 +521,8 @@ function DriftReport({ report }: { report: EvaluationDriftReport }) {
|
||||||
|
|
||||||
{!versionReady ? (
|
{!versionReady ? (
|
||||||
<Notice
|
<Notice
|
||||||
title="버전 출처가 일부 비어 있어"
|
title="버전 출처가 일부 비어 있습니다"
|
||||||
description="현재 요약 API가 모델·프롬프트·도구 버전을 모두 주지 않아 저하 상태로 표시해. 값을 추정하지 않아."
|
description="모델·프롬프트·도구 버전 중 제공되지 않은 정보가 있습니다. 값을 추정하지 않습니다."
|
||||||
tone="warn"
|
tone="warn"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -579,8 +579,8 @@ function ManifestArtifact({
|
||||||
<dd>{artifact?.artifact_id ?? "미제공"}</dd>
|
<dd>{artifact?.artifact_id ?? "미제공"}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>provenance</dt>
|
<dt>출처 연결</dt>
|
||||||
<dd>{artifact?.provenance_uri ?? "원본 provenance 미제공"}</dd>
|
<dd>{artifact?.provenance_uri ?? "원본 출처 미제공"}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>sha256</dt>
|
<dt>sha256</dt>
|
||||||
|
|
@ -617,7 +617,7 @@ function ResearchLens({
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<Notice
|
<Notice
|
||||||
title="연구 읽기 모델이 저하 상태야"
|
title="연구 정보를 일부 불러오지 못했습니다"
|
||||||
description={error}
|
description={error}
|
||||||
tone="warn"
|
tone="warn"
|
||||||
alert
|
alert
|
||||||
|
|
@ -639,8 +639,8 @@ function ResearchLens({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Notice
|
<Notice
|
||||||
title="버전 드리프트 비교가 없어"
|
title="버전 드리프트 비교가 없습니다"
|
||||||
description="기준·후보 배치가 같은 사례와 역량으로 연결되면 여기에서 비교할 수 있어."
|
description="기준·후보 배치가 같은 사례와 역량으로 연결되면 여기에서 비교할 수 있습니다."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -652,7 +652,7 @@ function ResearchLens({
|
||||||
description="동맹, 파열·수선, 전이, 보정이 각각 schema·hash·원천 URI를 가져야 파일럿 근거가 완성됩니다."
|
description="동맹, 파열·수선, 전이, 보정이 각각 schema·hash·원천 URI를 가져야 파일럿 근거가 완성됩니다."
|
||||||
side={
|
side={
|
||||||
<Badge tone={manifestComplete ? "accent" : "warn"}>
|
<Badge tone={manifestComplete ? "accent" : "warn"}>
|
||||||
{manifestComplete ? "4/4 provenance 연결" : "provenance 저하"}
|
{manifestComplete ? "4/4 출처 연결" : "출처 연결 저하"}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -665,8 +665,8 @@ function ResearchLens({
|
||||||
</div>
|
</div>
|
||||||
{!manifestComplete ? (
|
{!manifestComplete ? (
|
||||||
<Notice
|
<Notice
|
||||||
title="매니페스트 요약만 도착했어"
|
title="매니페스트 요약만 있습니다"
|
||||||
description="artifact_count는 보이지만 네 도메인의 provenance 상세가 읽기 API에 없어 저하 상태야. 출처를 추정하지 않아."
|
description="artifact_count는 보이지만 네 도메인의 출처 상세가 읽기 API에 없어 저하 상태입니다. 출처를 추정하지 않습니다."
|
||||||
tone="warn"
|
tone="warn"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -682,8 +682,8 @@ function ResearchLens({
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Notice
|
<Notice
|
||||||
title="Phase 3 매니페스트가 없어"
|
title="Phase 3 매니페스트가 없습니다"
|
||||||
description="네 도메인의 증거 artifact와 provenance가 모두 연결된 뒤에만 완성 상태로 표시해."
|
description="네 도메인의 증거 artifact와 출처가 모두 연결된 뒤에만 완성 상태로 표시됩니다."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -691,8 +691,8 @@ function ResearchLens({
|
||||||
<section className="g6-section">
|
<section className="g6-section">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
kicker="CALIBRATION DATASET"
|
kicker="CALIBRATION DATASET"
|
||||||
title="교수자–AI 불일치 메타데이터"
|
title="교수자와 AI의 평가 차이"
|
||||||
description="라벨 차이와 버전 출처, 해시, 근거 포인터만 사용합니다. 원문 축어록은 데이터셋에 넣지 않습니다."
|
description="라벨 차이와 버전 출처, 해시, 근거 연결만 사용합니다. 원문 축어록은 데이터셋에 넣지 않습니다."
|
||||||
/>
|
/>
|
||||||
<DisagreementMetadata rows={data?.calibration_dataset ?? []} />
|
<DisagreementMetadata rows={data?.calibration_dataset ?? []} />
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -720,7 +720,7 @@ function SupervisionLens({
|
||||||
>
|
>
|
||||||
{supervisionError ? (
|
{supervisionError ? (
|
||||||
<Notice
|
<Notice
|
||||||
title="감독 읽기 모델이 저하 상태야"
|
title="감독 정보를 일부 불러오지 못했습니다"
|
||||||
description={supervisionError}
|
description={supervisionError}
|
||||||
tone="warn"
|
tone="warn"
|
||||||
alert
|
alert
|
||||||
|
|
@ -729,9 +729,9 @@ function SupervisionLens({
|
||||||
|
|
||||||
<section className="g6-section g6-section--attention">
|
<section className="g6-section g6-section--attention">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
kicker="ATTENTION QUEUE"
|
kicker="검토 대기"
|
||||||
title="개입이 필요한 학습 경로부터"
|
title="개입이 필요한 학습 경로부터"
|
||||||
description="악화·위험 경계, 정체, 미해결 관계 사건을 원장 근거와 함께 우선 배치합니다."
|
description="악화·위험 경계, 정체, 미해결 관계 사건을 상담 근거와 함께 우선 배치합니다."
|
||||||
side={<Badge tone="warn">분류 근거 최대 3개</Badge>}
|
side={<Badge tone="warn">분류 근거 최대 3개</Badge>}
|
||||||
/>
|
/>
|
||||||
<AttentionRadar items={supervision?.attention_items ?? []} />
|
<AttentionRadar items={supervision?.attention_items ?? []} />
|
||||||
|
|
@ -740,8 +740,8 @@ function SupervisionLens({
|
||||||
|
|
||||||
<section className="g6-section">
|
<section className="g6-section">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
kicker="CURRICULUM GAPS"
|
kicker="교육과정 보완"
|
||||||
title="개인의 순위를 만들지 않는 교육과정 공백"
|
title="반복되는 학습 보완점"
|
||||||
description="코호트에서 반복되는 역량 공백만 보며, 근거 부족 상태는 해석 없이 그대로 유지합니다."
|
description="코호트에서 반복되는 역량 공백만 보며, 근거 부족 상태는 해석 없이 그대로 유지합니다."
|
||||||
/>
|
/>
|
||||||
<CurriculumGaps gaps={supervision?.curriculum_gaps ?? []} />
|
<CurriculumGaps gaps={supervision?.curriculum_gaps ?? []} />
|
||||||
|
|
@ -749,14 +749,14 @@ function SupervisionLens({
|
||||||
|
|
||||||
<section className="g6-section">
|
<section className="g6-section">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
kicker="TEACHER × AI"
|
kicker="교수자·AI 평가 비교"
|
||||||
title="교수자 교정을 연구 가능한 메타데이터로"
|
title="교수자와 AI의 평가 차이"
|
||||||
description="교수자 라벨과 AI 라벨의 차이만 연결합니다. 사례 원문이나 임상 판정은 이 화면에 들어오지 않습니다."
|
description="교수자 라벨과 AI 라벨의 차이만 연결합니다. 사례 원문이나 임상 판정은 이 화면에 들어오지 않습니다."
|
||||||
side={<Badge tone="accent">metadata only</Badge>}
|
side={<Badge tone="accent">평가 정보만 표시</Badge>}
|
||||||
/>
|
/>
|
||||||
{researchError ? (
|
{researchError ? (
|
||||||
<Notice
|
<Notice
|
||||||
title="불일치 메타데이터가 저하 상태야"
|
title="평가 정보를 일부 불러오지 못했습니다"
|
||||||
description={researchError}
|
description={researchError}
|
||||||
tone="warn"
|
tone="warn"
|
||||||
/>
|
/>
|
||||||
|
|
@ -791,12 +791,12 @@ export default function SupervisionResearch() {
|
||||||
if (supervisionResult.status === "fulfilled") {
|
if (supervisionResult.status === "fulfilled") {
|
||||||
setSupervision(supervisionResult.value);
|
setSupervision(supervisionResult.value);
|
||||||
} else {
|
} else {
|
||||||
setSupervisionError("우선순위 큐와 교육과정 공백을 불러오지 못했어. 원장 데이터가 수정된 것은 아니야.");
|
setSupervisionError("우선순위 목록과 교육과정 공백을 불러오지 못했습니다. 기록 데이터는 수정되지 않았습니다.");
|
||||||
}
|
}
|
||||||
if (researchResult.status === "fulfilled") {
|
if (researchResult.status === "fulfilled") {
|
||||||
setResearch(researchResult.value);
|
setResearch(researchResult.value);
|
||||||
} else {
|
} else {
|
||||||
setResearchError("불일치·드리프트·매니페스트 요약을 불러오지 못했어. 감독 큐는 계속 확인할 수 있어.");
|
setResearchError("불일치·드리프트·매니페스트 요약을 불러오지 못했습니다. 감독 목록은 계속 확인할 수 있습니다.");
|
||||||
}
|
}
|
||||||
setUpdatedAt(new Date());
|
setUpdatedAt(new Date());
|
||||||
setLoadState("ready");
|
setLoadState("ready");
|
||||||
|
|
@ -819,11 +819,13 @@ export default function SupervisionResearch() {
|
||||||
<div className="g6-root">
|
<div className="g6-root">
|
||||||
<header className="g6-hero">
|
<header className="g6-hero">
|
||||||
<div className="g6-hero__copy">
|
<div className="g6-hero__copy">
|
||||||
<Kicker dot={false}>G6 · SUPERVISION & RESEARCH OS</Kicker>
|
<Kicker dot={false}>학습 검토</Kicker>
|
||||||
<h1>먼저 볼 경로와, 믿을 수 있는 출처를 분리해 본다.</h1>
|
<h1>감독·연구</h1>
|
||||||
<p>
|
<p>
|
||||||
교수 감독은 개입 순서를, 연구 품질은 평가 버전과 provenance를 봐. 이 화면은 교육용
|
검토가 필요한 학습 회기와 평가 근거를 확인합니다.
|
||||||
운영 근거이며 개인을 한 숫자로 줄이거나 진단·임상 판정을 만들지 않아.
|
{canUseResearchLens
|
||||||
|
? " 연구 품질 탭에서는 평가 버전과 출처를 확인할 수 있습니다."
|
||||||
|
: null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="g6-hero__status" aria-label="데이터 안전 경계">
|
<div className="g6-hero__status" aria-label="데이터 안전 경계">
|
||||||
|
|
@ -877,7 +879,7 @@ export default function SupervisionResearch() {
|
||||||
{loadState === "loading" && !supervision && !research ? (
|
{loadState === "loading" && !supervision && !research ? (
|
||||||
<div className="g6-loading" role="status">
|
<div className="g6-loading" role="status">
|
||||||
<span aria-hidden="true" />
|
<span aria-hidden="true" />
|
||||||
감독 원장과 연구 provenance를 동기화하는 중
|
감독 근거와 연구 출처를 동기화하는 중입니다
|
||||||
</div>
|
</div>
|
||||||
) : lens === "research" && canUseResearchLens ? (
|
) : lens === "research" && canUseResearchLens ? (
|
||||||
<ResearchLens data={research} error={researchError} />
|
<ResearchLens data={research} error={researchError} />
|
||||||
|
|
|
||||||
|
|
@ -118,11 +118,11 @@ function decisionIntentLabel(intent: DecisionIntent | null): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordedDecisionLabel(decision: ApprovalDecision): string {
|
function recordedDecisionLabel(decision: ApprovalDecision): string {
|
||||||
if (decision === "approve_content") return "콘텐츠 승인 원장 기록됨";
|
if (decision === "approve_content") return "콘텐츠 승인 기록됨";
|
||||||
if (decision === "approve_promotion") return "승격 승인 원장 기록됨";
|
if (decision === "approve_promotion") return "변경 승인 기록됨";
|
||||||
if (decision === "authorize_rollback") return "롤백 승인 원장 기록됨";
|
if (decision === "authorize_rollback") return "복구 승인 기록됨";
|
||||||
if (decision === "keep_quarantine") return "보류 원장 기록됨";
|
if (decision === "keep_quarantine") return "보류 기록됨";
|
||||||
return "반려 원장 기록됨";
|
return "반려 기록됨";
|
||||||
}
|
}
|
||||||
|
|
||||||
function decisionLabel(item: GateItem): string {
|
function decisionLabel(item: GateItem): string {
|
||||||
|
|
@ -181,10 +181,10 @@ function isBoundarySafe(view: ContinuousImprovementViewResponse): boolean {
|
||||||
|
|
||||||
function BoundaryStrip({ view }: { view: ContinuousImprovementViewResponse }) {
|
function BoundaryStrip({ view }: { view: ContinuousImprovementViewResponse }) {
|
||||||
const boundaries = [
|
const boundaries = [
|
||||||
["입력", "합성 replay · red-team · coverage drift"],
|
["입력", "상담 재현 검증 · 독립 안전성 검증 · 평가 범위 점검"],
|
||||||
["승격", "silent auto-promotion 금지"],
|
["승격", "관리자 승인 후 적용"],
|
||||||
["효과", "사람 승인 후 append-only"],
|
["효과", "변경 이력 보존"],
|
||||||
["데이터", "원문 · PII · 임상 주장 미포함"],
|
["데이터", "원문 · 개인정보 · 임상 주장 미포함"],
|
||||||
];
|
];
|
||||||
const safe = isBoundarySafe(view);
|
const safe = isBoundarySafe(view);
|
||||||
|
|
||||||
|
|
@ -386,19 +386,19 @@ function ContentQualificationCard({
|
||||||
const approvalBlockedReason = !boundarySafe
|
const approvalBlockedReason = !boundarySafe
|
||||||
? "데이터 경계 위반으로 승인 차단"
|
? "데이터 경계 위반으로 승인 차단"
|
||||||
: !payload
|
: !payload
|
||||||
? "검수 가능한 visible payload가 없어 승인 차단"
|
? "검수할 수 있는 콘텐츠가 없어 승인할 수 없습니다"
|
||||||
: !evidenceReady
|
: !evidenceReady
|
||||||
? "repo provenance가 없어 승인 차단"
|
? "출처 기록이 없어 승인할 수 없습니다"
|
||||||
: "승인 조건 충족";
|
: "승인 조건 충족";
|
||||||
const requirementCopy = catalog
|
const requirementCopy = catalog
|
||||||
? "이미 카탈로그에 반영되어 추가 결정을 기록할 수 없음"
|
? "이미 카탈로그에 반영되어 추가 결정을 기록할 수 없습니다"
|
||||||
: !intent
|
: !intent
|
||||||
? "승인·보류·반려 중 하나를 명시적으로 선택해야 함"
|
? "승인·보류·반려 중 하나를 명시적으로 선택해야 함"
|
||||||
: intent === "approve" && !approvalAllowed
|
: intent === "approve" && !approvalAllowed
|
||||||
? approvalBlockedReason
|
? approvalBlockedReason
|
||||||
: !reason.trim()
|
: !reason.trim()
|
||||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
|
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 합니다`
|
||||||
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
|
: `${decisionIntentLabel(intent)} 결정과 사유를 변경 이력에 기록할 준비가 되었습니다`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
|
|
@ -474,7 +474,7 @@ function ContentQualificationCard({
|
||||||
</details>
|
</details>
|
||||||
) : (
|
) : (
|
||||||
<div className="cic-content-card__blocked" role="alert">
|
<div className="cic-content-card__blocked" role="alert">
|
||||||
메타데이터 후보라 visible payload를 검수할 수 없어. 카탈로그 승인은 닫혀 있어.
|
검수할 수 있는 콘텐츠가 없어 카탈로그 승인을 진행할 수 없습니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -562,19 +562,19 @@ function PipelineSection({
|
||||||
<section className="cic-panel cic-pipeline" aria-labelledby="cic-pipeline-title">
|
<section className="cic-panel cic-pipeline" aria-labelledby="cic-pipeline-title">
|
||||||
<header className="cic-section-head">
|
<header className="cic-section-head">
|
||||||
<div>
|
<div>
|
||||||
<Kicker dot={false}>CONTENT QUALIFICATION</Kicker>
|
<Kicker dot={false}>콘텐츠 검증</Kicker>
|
||||||
<h2 id="cic-pipeline-title">합성 콘텐츠 검증 파이프라인</h2>
|
<h2 id="cic-pipeline-title">생성 콘텐츠 검토</h2>
|
||||||
<p>생성물은 독립 red-team과 변형 benchmark를 통과해도 카탈로그에 자동 게시되지 않아.</p>
|
<p>생성 콘텐츠는 독립 안전성 검증과 평가 범위 점검을 통과해도 자동으로 게시되지 않습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="cic-state-chip">사람 검토 대기</span>
|
<span className="cic-state-chip">사람 검토 대기</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="cic-pipeline__flow" aria-label="콘텐츠 검증 단계">
|
<div className="cic-pipeline__flow" aria-label="콘텐츠 검증 단계">
|
||||||
{[
|
{[
|
||||||
["01", "Synthetic replay", "비식별 합성 입력"],
|
["01", "상담 재현 검증", "비식별 합성 입력"],
|
||||||
["02", "Independent red-team", "안전 · 누출 · 편향"],
|
["02", "독립 안전성 검증", "안전 · 누출 · 편향"],
|
||||||
["03", "Coverage drift", "변형 benchmark"],
|
["03", "평가 범위 점검", "변형 평가"],
|
||||||
["04", "Human catalog gate", "append-only 승인"],
|
["04", "관리자 게시 승인", "변경 이력 보존"],
|
||||||
].map(([step, title, detail]) => (
|
].map(([step, title, detail]) => (
|
||||||
<div className="cic-pipeline__step" key={step}>
|
<div className="cic-pipeline__step" key={step}>
|
||||||
<span>{step}</span>
|
<span>{step}</span>
|
||||||
|
|
@ -588,8 +588,8 @@ function PipelineSection({
|
||||||
<div className="cic-empty" role="status">
|
<div className="cic-empty" role="status">
|
||||||
<Icon name="hourglass" size={20} />
|
<Icon name="hourglass" size={20} />
|
||||||
<div>
|
<div>
|
||||||
<strong>검토할 콘텐츠 후보가 없어</strong>
|
<strong>검토할 콘텐츠 후보가 없습니다</strong>
|
||||||
<p>에이전트 파이프라인이 qualified candidate를 적재하면 여기에 나타나.</p>
|
<p>검토 대상이 생성되면 여기에 표시됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -655,11 +655,11 @@ function GateCard({
|
||||||
const approvalBlockedReason = !boundarySafe
|
const approvalBlockedReason = !boundarySafe
|
||||||
? "데이터 경계 위반으로 승인 차단"
|
? "데이터 경계 위반으로 승인 차단"
|
||||||
: !complete
|
: !complete
|
||||||
? "필수 증거 4종 미완료로 승인 차단"
|
? "필수 증거 4종이 갖춰지지 않아 승인할 수 없습니다"
|
||||||
: !approvalCandidateReady
|
: !approvalCandidateReady
|
||||||
? item.kind === "release_gate"
|
? item.kind === "release_gate"
|
||||||
? "계산 게이트 미통과로 승격 승인 차단"
|
? "계산 게이트를 통과하지 않아 승인할 수 없습니다"
|
||||||
: "계산 결과가 격리 유지라 승격 승인 차단"
|
: "계산 결과가 격리 유지여서 승인할 수 없습니다"
|
||||||
: "승인 조건 충족";
|
: "승인 조건 충족";
|
||||||
const requirementId = `approval-requirement-${id}`;
|
const requirementId = `approval-requirement-${id}`;
|
||||||
const requirementCopy = !intent
|
const requirementCopy = !intent
|
||||||
|
|
@ -667,8 +667,8 @@ function GateCard({
|
||||||
: intent === "approve" && !approvalAllowed
|
: intent === "approve" && !approvalAllowed
|
||||||
? approvalBlockedReason
|
? approvalBlockedReason
|
||||||
: !reason.trim()
|
: !reason.trim()
|
||||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
|
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 합니다`
|
||||||
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
|
: `${decisionIntentLabel(intent)} 결정과 사유를 변경 이력에 기록할 준비가 되었습니다`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="cic-gate-card" data-gate-id={id}>
|
<article className="cic-gate-card" data-gate-id={id}>
|
||||||
|
|
@ -787,17 +787,17 @@ function GateSection({
|
||||||
<section className="cic-gates" aria-labelledby="cic-gates-title">
|
<section className="cic-gates" aria-labelledby="cic-gates-title">
|
||||||
<header className="cic-section-head">
|
<header className="cic-section-head">
|
||||||
<div>
|
<div>
|
||||||
<Kicker dot={false}>PROMOTION CONTROL</Kicker>
|
<Kicker dot={false}>변경 승인</Kicker>
|
||||||
<h2 id="cic-gates-title">모델 변경 · 릴리스 게이트</h2>
|
<h2 id="cic-gates-title">모델 변경과 릴리스 검토</h2>
|
||||||
<p>계산 결과는 제안일 뿐이야. 4종 증거와 명시적 사람 승인 없이는 효과가 발생하지 않아.</p>
|
<p>계산 결과는 제안입니다. 필수 증거와 관리자 승인을 거친 뒤에만 적용할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="cic-empty" role="status">
|
<div className="cic-empty" role="status">
|
||||||
<Icon name="shield" size={20} />
|
<Icon name="shield" size={20} />
|
||||||
<div>
|
<div>
|
||||||
<strong>대기 중인 게이트가 없어</strong>
|
<strong>대기 중인 변경 검토가 없습니다</strong>
|
||||||
<p>모델 변경 또는 에이전틱 릴리스 후보가 생성되면 여기서 검토해.</p>
|
<p>모델 변경 또는 릴리스 후보가 생성되면 여기에서 검토할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -828,17 +828,17 @@ function IncidentDag({ view }: { view: ContinuousImprovementViewResponse }) {
|
||||||
<section className="cic-panel" aria-labelledby="cic-incidents-title">
|
<section className="cic-panel" aria-labelledby="cic-incidents-title">
|
||||||
<header className="cic-section-head cic-section-head--compact">
|
<header className="cic-section-head cic-section-head--compact">
|
||||||
<div>
|
<div>
|
||||||
<Kicker dot={false}>INCIDENT → REGRESSION</Kicker>
|
<Kicker dot={false}>문제 재발 방지</Kicker>
|
||||||
<h2 id="cic-incidents-title">사건 회귀 DAG</h2>
|
<h2 id="cic-incidents-title">문제 재발 방지 기록</h2>
|
||||||
<p>운영 사건을 재현 테스트부터 런타임 증명까지 끊김 없이 추적해.</p>
|
<p>운영 문제를 재현 검증부터 실제 환경 확인까지 연결해 추적합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{incidents.length === 0 ? (
|
{incidents.length === 0 ? (
|
||||||
<div className="cic-empty cic-empty--plain" role="status">
|
<div className="cic-empty cic-empty--plain" role="status">
|
||||||
<Icon name="check" size={19} />
|
<Icon name="check" size={19} />
|
||||||
<div>
|
<div>
|
||||||
<strong>등록된 운영 사건이 없어</strong>
|
<strong>등록된 운영 문제가 없습니다</strong>
|
||||||
<p>합성 증거 기반 사건이 적재되면 회귀 DAG가 생성돼.</p>
|
<p>재발 방지 대상이 등록되면 검증 기록이 여기에 표시됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -856,7 +856,7 @@ function IncidentDag({ view }: { view: ContinuousImprovementViewResponse }) {
|
||||||
</div>
|
</div>
|
||||||
<span>{dateTimeLabel(incident.created_at)}</span>
|
<span>{dateTimeLabel(incident.created_at)}</span>
|
||||||
</header>
|
</header>
|
||||||
<ol className="cic-dag" aria-label={`${incident.incident_id} 회귀 DAG`}>
|
<ol className="cic-dag" aria-label={`${incident.incident_id} 재발 방지 기록`}>
|
||||||
{(["reproduction_test", "implementation", "e2e", "runtime_proof"] as const).map(
|
{(["reproduction_test", "implementation", "e2e", "runtime_proof"] as const).map(
|
||||||
(kind) => {
|
(kind) => {
|
||||||
const node = nodes.find((candidate) => candidate.node_type === kind);
|
const node = nodes.find((candidate) => candidate.node_type === kind);
|
||||||
|
|
@ -886,17 +886,17 @@ function LifecycleLedger({ events }: { events: LifecycleEventView[] }) {
|
||||||
<section className="cic-panel" aria-labelledby="cic-lifecycle-title">
|
<section className="cic-panel" aria-labelledby="cic-lifecycle-title">
|
||||||
<header className="cic-section-head cic-section-head--compact">
|
<header className="cic-section-head cic-section-head--compact">
|
||||||
<div>
|
<div>
|
||||||
<Kicker dot={false}>APPEND-ONLY EFFECTS</Kicker>
|
<Kicker dot={false}>적용·복구 기록</Kicker>
|
||||||
<h2 id="cic-lifecycle-title">모니터 · 롤백 원장</h2>
|
<h2 id="cic-lifecycle-title">적용과 복구 이력</h2>
|
||||||
<p>승인 이후 효과와 사후 검증은 별도 이벤트로 남아.</p>
|
<p>승인 뒤 적용 결과와 사후 검증을 변경 이력으로 보존합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<div className="cic-empty cic-empty--plain" role="status">
|
<div className="cic-empty cic-empty--plain" role="status">
|
||||||
<Icon name="hourglass" size={19} />
|
<Icon name="hourglass" size={19} />
|
||||||
<div>
|
<div>
|
||||||
<strong>아직 발생한 효과가 없어</strong>
|
<strong>기록된 적용 결과가 없습니다</strong>
|
||||||
<p>사람 승인 전에는 정상적인 빈 상태야.</p>
|
<p>관리자 승인 전에는 정상적인 빈 상태입니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -922,7 +922,7 @@ function LifecycleLedger({ events }: { events: LifecycleEventView[] }) {
|
||||||
{event.target_kind === "model_change_gate" ? "모델" : "릴리스"} · {shortId(event.target_id)}
|
{event.target_kind === "model_change_gate" ? "모델" : "릴리스"} · {shortId(event.target_id)}
|
||||||
</span>
|
</span>
|
||||||
{rollbackVerificationPending ? (
|
{rollbackVerificationPending ? (
|
||||||
<em>롤백은 실행됐지만 verification 이벤트가 아직 없어</em>
|
<em>복구는 실행됐지만 검증 기록이 아직 없습니다</em>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<time dateTime={event.created_at}>{dateTimeLabel(event.created_at)}</time>
|
<time dateTime={event.created_at}>{dateTimeLabel(event.created_at)}</time>
|
||||||
|
|
@ -952,7 +952,7 @@ export function ContinuousImprovementCockpit() {
|
||||||
setView(next);
|
setView(next);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (signal?.aborted) return;
|
if (signal?.aborted) return;
|
||||||
setError(cause instanceof Error ? cause.message : "개선 원장을 불러오지 못했어.");
|
setError(cause instanceof Error ? cause.message : "개선 기록을 불러오지 못했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
if (!signal?.aborted) setLoading(false);
|
if (!signal?.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1004,14 +1004,14 @@ export function ContinuousImprovementCockpit() {
|
||||||
});
|
});
|
||||||
setNotice(
|
setNotice(
|
||||||
intent === "approve"
|
intent === "approve"
|
||||||
? `${decisionIntentLabel(intent)}과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
|
? `${decisionIntentLabel(intent)}과 적용 기록 ${shortId(result.effect_record_id, 8)}을 보존했습니다`
|
||||||
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
|
: `${decisionIntentLabel(intent)} 결정을 변경 이력에 기록했습니다`,
|
||||||
);
|
);
|
||||||
setDecisions((current) => ({ ...current, [id]: undefined }));
|
setDecisions((current) => ({ ...current, [id]: undefined }));
|
||||||
setReasons((current) => ({ ...current, [id]: "" }));
|
setReasons((current) => ({ ...current, [id]: "" }));
|
||||||
await load();
|
await load();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
setError(cause instanceof Error ? cause.message : "사람 결정 원장을 기록하지 못했어.");
|
setError(cause instanceof Error ? cause.message : "관리자 결정 기록을 저장하지 못했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setApprovingId(null);
|
setApprovingId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -1068,14 +1068,14 @@ export function ContinuousImprovementCockpit() {
|
||||||
});
|
});
|
||||||
setNotice(
|
setNotice(
|
||||||
intent === "approve"
|
intent === "approve"
|
||||||
? `콘텐츠 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
|
? `콘텐츠 승인과 적용 기록 ${shortId(result.effect_record_id, 8)}을 보존했습니다`
|
||||||
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
|
: `${decisionIntentLabel(intent)} 결정을 변경 이력에 기록했습니다`,
|
||||||
);
|
);
|
||||||
setDecisions((current) => ({ ...current, [id]: undefined }));
|
setDecisions((current) => ({ ...current, [id]: undefined }));
|
||||||
setReasons((current) => ({ ...current, [id]: "" }));
|
setReasons((current) => ({ ...current, [id]: "" }));
|
||||||
await load();
|
await load();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
setError(cause instanceof Error ? cause.message : "콘텐츠 결정 원장을 기록하지 못했어.");
|
setError(cause instanceof Error ? cause.message : "콘텐츠 결정 기록을 저장하지 못했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setApprovingId(null);
|
setApprovingId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -1105,9 +1105,9 @@ export function ContinuousImprovementCockpit() {
|
||||||
<section className="cic-degraded" role="alert">
|
<section className="cic-degraded" role="alert">
|
||||||
<Icon name="alert" size={22} />
|
<Icon name="alert" size={22} />
|
||||||
<div>
|
<div>
|
||||||
<Kicker dot={false}>DEGRADED READ MODEL</Kicker>
|
<Kicker dot={false}>정보 불러오기 실패</Kicker>
|
||||||
<h1>개선 원장을 확인할 수 없어</h1>
|
<h1>개선 기록을 확인할 수 없습니다</h1>
|
||||||
<p>{error ?? "운영 원장이 응답하지 않았어."} 승인 동작은 닫힌 상태로 유지돼.</p>
|
<p>{error ?? "운영 기록이 응답하지 않았습니다."} 승인 동작은 사용할 수 없습니다.</p>
|
||||||
<Button size="sm" variant="secondary" onClick={() => void load()}>
|
<Button size="sm" variant="secondary" onClick={() => void load()}>
|
||||||
다시 확인
|
다시 확인
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1125,12 +1125,9 @@ export function ContinuousImprovementCockpit() {
|
||||||
>
|
>
|
||||||
<header className="cic-hero">
|
<header className="cic-hero">
|
||||||
<div>
|
<div>
|
||||||
<Kicker>관리자 · CONTINUOUS IMPROVEMENT OS</Kicker>
|
<Kicker>관리자 · 지속 개선</Kicker>
|
||||||
<h1>승격보다 근거를 먼저 본다</h1>
|
<h1>개선안 검토와 승인</h1>
|
||||||
<p>
|
<p>생성 콘텐츠와 모델 변경의 검증 결과를 확인하고, 승인 이후 적용·복구 기록을 추적합니다.</p>
|
||||||
합성 콘텐츠, 모델 변경, 에이전틱 릴리스를 한 원장에서 검토하고 사람 승인 이후의
|
|
||||||
효과와 롤백 검증까지 닫아.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1139,7 +1136,7 @@ export function ContinuousImprovementCockpit() {
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => void load()}
|
onClick={() => void load()}
|
||||||
>
|
>
|
||||||
원장 새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -1188,8 +1185,8 @@ export function ContinuousImprovementCockpit() {
|
||||||
<footer className="cic-footnote">
|
<footer className="cic-footnote">
|
||||||
<Icon name="info" size={16} />
|
<Icon name="info" size={16} />
|
||||||
<p>
|
<p>
|
||||||
이 표면은 관리자 전용 운영 메타데이터만 표시해. 학습자·교수자 화면, 원문 트랜스크립트,
|
이 화면은 관리자 전용 운영 메타데이터만 표시합니다. 학습자·교수자 화면, 원문 대화 기록,
|
||||||
PII, 임상 주장, 합산 총점은 이 계약에 포함하지 않아.
|
개인정보, 임상 주장, 합산 총점은 이 범위에 포함하지 않습니다.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ export function ProviderConnectionsPanel() {
|
||||||
className={`aic-runtime ${data?.gateway_sync.synced ? "aic-runtime--ok" : ""}`}
|
className={`aic-runtime ${data?.gateway_sync.synced ? "aic-runtime--ok" : ""}`}
|
||||||
title={data?.gateway_sync.detail ?? undefined}
|
title={data?.gateway_sync.detail ?? undefined}
|
||||||
>
|
>
|
||||||
{data === null ? "확인 중" : data.gateway_sync.synced ? "게이트웨이 적용됨" : "적용 대기"}
|
{data === null ? (error ? "연결 상태 확인 실패" : "확인 중") : data.gateway_sync.synced ? "게이트웨이 적용됨" : "적용 대기"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -234,6 +234,7 @@ export function ProviderConnectionsPanel() {
|
||||||
<div className="aic-alert" role="alert">
|
<div className="aic-alert" role="alert">
|
||||||
<Icon name="alert" size={16} />
|
<Icon name="alert" size={16} />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
|
{data === null ? <Button onClick={() => void load()}>다시 확인</Button> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{flash ? (
|
{flash ? (
|
||||||
|
|
|
||||||
|
|
@ -823,6 +823,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.aic-head__actions .vg-btn,
|
||||||
|
.aic-window button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.aic-chart__bar {
|
.aic-chart__bar {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
.vgops-root {
|
.vgops-root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
|
|
@ -600,6 +602,7 @@
|
||||||
.vgops-tabs {
|
.vgops-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|
@ -1509,7 +1512,7 @@
|
||||||
.vgops-ticket-filter input,
|
.vgops-ticket-filter input,
|
||||||
.vgops-ticket-filter select {
|
.vgops-ticket-filter select {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 36px;
|
min-height: 44px;
|
||||||
border: 1px solid var(--hair);
|
border: 1px solid var(--hair);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
|
|
@ -1520,7 +1523,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.vgops-ticket-filter__check {
|
.vgops-ticket-filter__check {
|
||||||
min-height: 36px;
|
min-height: 44px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -1535,6 +1538,10 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vgops-ticket-filter .vg-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.vgops-ticket-filter__check input {
|
.vgops-ticket-filter__check input {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
|
|
@ -1897,6 +1904,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.vgops-root[data-admin-section="users"] {
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.vgops-head .vg-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.vgops-status {
|
.vgops-status {
|
||||||
grid-template-columns: 12px minmax(0, 1fr);
|
grid-template-columns: 12px minmax(0, 1fr);
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@
|
||||||
.cic-hero h1 {
|
.cic-hero h1 {
|
||||||
max-width: 780px;
|
max-width: 780px;
|
||||||
margin: 9px 0 0;
|
margin: 9px 0 0;
|
||||||
font-size: clamp(2rem, 4vw, 3.35rem);
|
font-size: clamp(1.5rem, 2.5vw, 2rem);
|
||||||
line-height: 0.98;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cic-hero p {
|
.cic-hero p {
|
||||||
|
|
@ -34,6 +34,10 @@
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cic .vg-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.cic-boundary {
|
.cic-boundary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto repeat(4, minmax(0, 1fr));
|
grid-template-columns: auto repeat(4, minmax(0, 1fr));
|
||||||
|
|
@ -368,6 +372,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.cic-content-review summary {
|
.cic-content-review summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -1112,8 +1119,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.cic-hero h1 {
|
.cic-hero h1 {
|
||||||
font-size: 2rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1.03;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cic-boundary {
|
.cic-boundary {
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,12 @@
|
||||||
line-height:1.45;
|
line-height:1.45;
|
||||||
word-break:keep-all;
|
word-break:keep-all;
|
||||||
}
|
}
|
||||||
|
.lh-metric-card small{
|
||||||
|
color:var(--text-muted);
|
||||||
|
font-size:11px;
|
||||||
|
font-weight:650;
|
||||||
|
line-height:1.4;
|
||||||
|
}
|
||||||
.lh-dashboard-columns,
|
.lh-dashboard-columns,
|
||||||
.lh-history-layout{
|
.lh-history-layout{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
|
|
@ -1933,6 +1939,19 @@
|
||||||
background:var(--accent);
|
background:var(--accent);
|
||||||
}
|
}
|
||||||
.lh-persona-progress__note{margin:0;}
|
.lh-persona-progress__note{margin:0;}
|
||||||
|
.lh-persona-progress__empty{display:grid;gap:8px;}
|
||||||
|
.lh-persona-progress__toggle{
|
||||||
|
min-height:44px;
|
||||||
|
border:1px solid var(--border-subtle);
|
||||||
|
border-radius:var(--radius-sm);
|
||||||
|
background:var(--bg-surface-2);
|
||||||
|
color:var(--accent-deep);
|
||||||
|
font:inherit;
|
||||||
|
font-size:12px;
|
||||||
|
font-weight:760;
|
||||||
|
text-align:left;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
.lh-archive-note{
|
.lh-archive-note{
|
||||||
display:grid;
|
display:grid;
|
||||||
gap:12px;
|
gap:12px;
|
||||||
|
|
@ -2133,10 +2152,16 @@
|
||||||
gap:8px;
|
gap:8px;
|
||||||
overflow-x:auto;
|
overflow-x:auto;
|
||||||
overflow-y:hidden;
|
overflow-y:hidden;
|
||||||
padding-bottom:2px;
|
padding:0 1px 2px 0;
|
||||||
|
}
|
||||||
|
.lh-personas::after{
|
||||||
|
content:"";
|
||||||
|
flex:0 0 1px;
|
||||||
}
|
}
|
||||||
.lh-persona{
|
.lh-persona{
|
||||||
flex:0 0 min(82vw,320px);
|
flex:0 0 min(78vw,calc(100% - 1px));
|
||||||
|
width:min(78vw,calc(100% - 1px));
|
||||||
|
max-width:100%;
|
||||||
min-height:66px;
|
min-height:66px;
|
||||||
grid-template-columns:38px minmax(0,1fr);
|
grid-template-columns:38px minmax(0,1fr);
|
||||||
align-items:center;
|
align-items:center;
|
||||||
|
|
@ -2195,6 +2220,15 @@
|
||||||
.lh-session-actions{
|
.lh-session-actions{
|
||||||
justify-content:flex-start;
|
justify-content:flex-start;
|
||||||
}
|
}
|
||||||
|
.lh-history-filter button,
|
||||||
|
.lh-session-actions button{
|
||||||
|
min-width:44px;
|
||||||
|
min-height:44px;
|
||||||
|
}
|
||||||
|
.lh-persona-progress__empty .vg-btn,
|
||||||
|
.lh-persona-progress__toggle{
|
||||||
|
min-height:44px;
|
||||||
|
}
|
||||||
.lh-persona-progress__row{
|
.lh-persona-progress__row{
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
gap:6px;
|
gap:6px;
|
||||||
|
|
@ -2218,9 +2252,7 @@
|
||||||
.lh-head__primary{
|
.lh-head__primary{
|
||||||
width:100%;
|
width:100%;
|
||||||
}
|
}
|
||||||
.lh-history-overview{
|
.lh-history-overview{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||||
grid-template-columns:1fr;
|
|
||||||
}
|
|
||||||
.lh-session-focus__head{
|
.lh-session-focus__head{
|
||||||
display:grid;
|
display:grid;
|
||||||
gap:10px;
|
gap:10px;
|
||||||
|
|
@ -2229,8 +2261,8 @@
|
||||||
justify-self:start;
|
justify-self:start;
|
||||||
}
|
}
|
||||||
.lh-persona{
|
.lh-persona{
|
||||||
flex-basis:min(78vw,300px);
|
flex-basis:min(78vw,calc(100% - 1px));
|
||||||
width:min(78vw,300px);
|
width:min(78vw,calc(100% - 1px));
|
||||||
}
|
}
|
||||||
.lh-recap__facts{
|
.lh-recap__facts{
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
|
|
@ -2242,10 +2274,6 @@
|
||||||
flex-direction:row;
|
flex-direction:row;
|
||||||
justify-content:flex-start;
|
justify-content:flex-start;
|
||||||
}
|
}
|
||||||
.lh-review-link{
|
|
||||||
min-height:32px;
|
|
||||||
padding:4px 7px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/* 초소형 폭(320~420px, iPhone SE급) 전용 밴드.
|
/* 초소형 폭(320~420px, iPhone SE급) 전용 밴드.
|
||||||
기존 최소 브레이크포인트가 460px이라 이 구간이 비어 있었다.
|
기존 최소 브레이크포인트가 460px이라 이 구간이 비어 있었다.
|
||||||
|
|
@ -2610,6 +2638,46 @@
|
||||||
.vg-shell--learner-dashboard .lh-dashboard-side__tabpanel {
|
.vg-shell--learner-dashboard .lh-dashboard-side__tabpanel {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.vg-shell--learner-dashboard .lh-growth-status {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width:721px) and (max-width:1180px){
|
||||||
|
.lh-personas{
|
||||||
|
grid-auto-rows:max-content;
|
||||||
|
max-height:240px;
|
||||||
|
overflow-x:hidden;
|
||||||
|
overflow-y:auto;
|
||||||
|
padding-right:4px;
|
||||||
|
}
|
||||||
|
.lh-persona{align-self:start;}
|
||||||
|
.lh-persona__summary{
|
||||||
|
overflow:hidden;
|
||||||
|
display:-webkit-box;
|
||||||
|
-webkit-line-clamp:3;
|
||||||
|
-webkit-box-orient:vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width:1181px){
|
||||||
|
.lh-personas{
|
||||||
|
grid-auto-rows:max-content;
|
||||||
|
max-height:max(300px,calc(100dvh - 260px));
|
||||||
|
overflow-x:hidden;
|
||||||
|
overflow-y:auto;
|
||||||
|
padding-right:4px;
|
||||||
|
}
|
||||||
|
.lh-persona{align-self:start;}
|
||||||
|
.lh-persona__summary{
|
||||||
|
overflow:hidden;
|
||||||
|
display:-webkit-box;
|
||||||
|
-webkit-line-clamp:3;
|
||||||
|
-webkit-box-orient:vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1421px) {
|
||||||
|
.vg-shell--learner-dashboard .lh-work-cluster__primary {
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(280px, .75fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.vg-shell--learner-dashboard .lh-root {
|
.vg-shell--learner-dashboard .lh-root {
|
||||||
|
|
@ -2675,14 +2743,6 @@
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.vg-shell--learner-dashboard .lh-work-cluster__head {
|
|
||||||
min-height: 68px;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
}
|
|
||||||
.vg-shell--learner-dashboard .lh-work-cluster__head h2 {
|
|
||||||
font-size: clamp(22px, 2vw, 27px);
|
|
||||||
}
|
|
||||||
.vg-shell--learner-dashboard .lh-session-focus {
|
.vg-shell--learner-dashboard .lh-session-focus {
|
||||||
--lh-focus-art-right: -20px;
|
--lh-focus-art-right: -20px;
|
||||||
--lh-focus-art-bottom: -28px;
|
--lh-focus-art-bottom: -28px;
|
||||||
|
|
@ -2690,7 +2750,7 @@
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
min-height: 466px;
|
min-height: 0;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
|
|
@ -2753,7 +2813,7 @@
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
.vg-shell--learner-dashboard .lh-coach-card {
|
.vg-shell--learner-dashboard .lh-coach-card {
|
||||||
min-height: 300px;
|
min-height: 0;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,12 @@ export function LoginBrand({ oauthChecking, oauthReady }: LoginBrandProps) {
|
||||||
상담 시뮬레이션 학습
|
상담 시뮬레이션 학습
|
||||||
</span>
|
</span>
|
||||||
<h1>
|
<h1>
|
||||||
<span className="lg-highlight">실제 계정으로 들어가고</span>, <br />
|
<span className="lg-highlight">상담을 연습하고,</span>
|
||||||
실제 회기만 남깁니다.
|
<br />
|
||||||
|
함께 돌아봅니다.
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
Vignette는 상담 연습, 교수자 피드백, 운영 관리 권한을 안전한 로그인 세션으로 분리합니다.
|
가상 내담자와 상담을 연습하고, 회기 기록과 피드백으로 다음 연습을 준비하세요.
|
||||||
브라우저에는 인증 토큰을 저장하지 않습니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,28 +227,27 @@
|
||||||
}
|
}
|
||||||
.ob-legal summary{
|
.ob-legal summary{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
display:flex;
|
display:grid;
|
||||||
align-items:center;
|
grid-template-columns:minmax(0, 1fr);
|
||||||
justify-content:space-between;
|
align-content:center;
|
||||||
gap:10px;
|
gap:2px;
|
||||||
min-height:42px;
|
min-height:44px;
|
||||||
padding:0 12px;
|
padding:10px 12px;
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
}
|
}
|
||||||
.ob-legal summary span{
|
.ob-legal__title{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
color:var(--text-strong);
|
color:var(--text-strong);
|
||||||
font-size:13px;
|
font-size:13px;
|
||||||
font-weight:760;
|
font-weight:760;
|
||||||
overflow:hidden;
|
line-height:1.35;
|
||||||
text-overflow:ellipsis;
|
overflow-wrap:anywhere;
|
||||||
white-space:nowrap;
|
|
||||||
}
|
}
|
||||||
.ob-legal summary b{
|
.ob-legal__version{
|
||||||
flex:none;
|
|
||||||
color:var(--text-muted);
|
color:var(--text-muted);
|
||||||
font-size:11px;
|
font-size:11px;
|
||||||
font-weight:700;
|
font-weight:700;
|
||||||
|
line-height:1.35;
|
||||||
}
|
}
|
||||||
.ob-note,
|
.ob-note,
|
||||||
.ob-error{
|
.ob-error{
|
||||||
|
|
|
||||||
|
|
@ -907,7 +907,8 @@
|
||||||
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--hair);color:var(--text-body);font-size:var(--fs-xs);}
|
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--hair);color:var(--text-body);font-size:var(--fs-xs);}
|
||||||
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
|
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
|
||||||
.ps-point-list small{color:var(--text-muted);text-align:right;}
|
.ps-point-list small{color:var(--text-muted);text-align:right;}
|
||||||
.ps-stepper button{min-width:0;display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:center;padding:13px 14px;border:0;border-right:1px solid var(--border-subtle);background:transparent;color:var(--text-muted);text-align:left;cursor:pointer;transition:transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);}
|
.ps-stepper{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));}
|
||||||
|
.ps-stepper button{min-width:0;min-height:44px;display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:center;padding:13px 14px;border:0;border-right:1px solid var(--border-subtle);background:transparent;color:var(--text-muted);text-align:left;cursor:pointer;transition:transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);}
|
||||||
.ps-stepper button:active{transform:scale(0.96);}
|
.ps-stepper button:active{transform:scale(0.96);}
|
||||||
.ps-stepper button:last-child{border-right:0;}
|
.ps-stepper button:last-child{border-right:0;}
|
||||||
.ps-stepper button > span:first-child{display:grid;place-items:center;width:30px;height:30px;border:1px solid var(--border-subtle);border-radius:50%;font-family:var(--font-num);font-size:11px;font-weight:800;}
|
.ps-stepper button > span:first-child{display:grid;place-items:center;width:30px;height:30px;border:1px solid var(--border-subtle);border-radius:50%;font-family:var(--font-num);font-size:11px;font-weight:800;}
|
||||||
|
|
@ -984,8 +985,8 @@
|
||||||
.ps-drill-head__actions{justify-content:flex-start;}
|
.ps-drill-head__actions{justify-content:flex-start;}
|
||||||
.ps-detail-grid{grid-template-columns:1fr;}
|
.ps-detail-grid{grid-template-columns:1fr;}
|
||||||
.ps-detail-wide{grid-column:auto;}
|
.ps-detail-wide{grid-column:auto;}
|
||||||
.ps-stepper{grid-template-columns:repeat(4,minmax(54px,1fr));overflow-x:auto;}
|
.ps-stepper{grid-template-columns:repeat(4,minmax(0,1fr));overflow:visible;}
|
||||||
.ps-stepper button{grid-template-columns:1fr;justify-items:center;padding:10px 7px;text-align:center;}
|
.ps-stepper button{min-height:44px;grid-template-columns:1fr;justify-items:center;padding:10px 7px;text-align:center;}
|
||||||
.ps-stepper button > span:last-child small{display:none;}
|
.ps-stepper button > span:last-child small{display:none;}
|
||||||
.ps-authoring-layout .ps-inspector{grid-template-columns:1fr;}
|
.ps-authoring-layout .ps-inspector{grid-template-columns:1fr;}
|
||||||
.ps-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));}
|
.ps-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||||
|
|
@ -993,3 +994,7 @@
|
||||||
.ps-point-list > div{grid-template-columns:minmax(0,1fr) 64px;}
|
.ps-point-list > div{grid-template-columns:minmax(0,1fr) 64px;}
|
||||||
.ps-point-list small{grid-column:1 / -1;text-align:left;}
|
.ps-point-list small{grid-column:1 / -1;text-align:left;}
|
||||||
}
|
}
|
||||||
|
@media (max-width:720px){
|
||||||
|
.ps-root .vg-btn{min-height:44px;}
|
||||||
|
.ps-root .ps-tabs button{min-height:44px;}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
.pf-root{
|
.pf-root{
|
||||||
max-width:1280px;
|
max-width:1280px;
|
||||||
margin:0 auto;
|
margin:0 auto;
|
||||||
|
width:100%;
|
||||||
|
min-width:0;
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
gap:16px;
|
gap:16px;
|
||||||
|
|
@ -183,6 +185,7 @@
|
||||||
}
|
}
|
||||||
.pf-analysis-page{
|
.pf-analysis-page{
|
||||||
order:2;
|
order:2;
|
||||||
|
width:100%;
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
gap:12px;
|
gap:12px;
|
||||||
|
|
@ -327,9 +330,13 @@
|
||||||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
||||||
}
|
}
|
||||||
.pf-learner-overview{
|
.pf-learner-overview{
|
||||||
|
width:100%;
|
||||||
|
min-width:0;
|
||||||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
|
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
|
||||||
}
|
}
|
||||||
.pf-learner-toolbar{
|
.pf-learner-toolbar{
|
||||||
|
min-width:0;
|
||||||
|
width:100%;
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:minmax(240px,.72fr) auto;
|
grid-template-columns:minmax(240px,.72fr) auto;
|
||||||
gap:10px;
|
gap:10px;
|
||||||
|
|
@ -403,6 +410,8 @@
|
||||||
}
|
}
|
||||||
.pf-learner-table{
|
.pf-learner-table{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
|
width:100%;
|
||||||
|
max-width:100%;
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
/* 세로 가상화 스크롤 컨테이너. height:auto + max-height라 행이 적으면
|
/* 세로 가상화 스크롤 컨테이너. height:auto + max-height라 행이 적으면
|
||||||
|
|
@ -1785,6 +1794,35 @@
|
||||||
justify-self:start;
|
justify-self:start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width:720px){
|
||||||
|
.pf-root--analysis{
|
||||||
|
width:calc(100vw - 32px);
|
||||||
|
max-width:calc(100vw - 32px);
|
||||||
|
margin:0;
|
||||||
|
}
|
||||||
|
.pf-head__actions .vg-btn,
|
||||||
|
.pf-studio-card__actions .vg-btn,
|
||||||
|
.pf-learner-row__action .vg-btn,
|
||||||
|
.pf-analysis-detail-head > .vg-btn{
|
||||||
|
min-height:44px;
|
||||||
|
white-space:normal;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
.pf-sort-segment button{
|
||||||
|
min-height:44px;
|
||||||
|
}
|
||||||
|
.pf-studio-card{
|
||||||
|
grid-template-columns:1fr;
|
||||||
|
}
|
||||||
|
.pf-studio-card__actions{
|
||||||
|
justify-content:flex-start;
|
||||||
|
}
|
||||||
|
.pf-session--action,
|
||||||
|
.pf-recent-row--action{
|
||||||
|
flex:0 0 auto;
|
||||||
|
min-height:44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width:520px){
|
@media (max-width:520px){
|
||||||
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
|
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||||
.pf-kpi,
|
.pf-kpi,
|
||||||
|
|
|
||||||
|
|
@ -666,6 +666,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.ap-self-form__commit .vg-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
.ap-card {
|
.ap-card {
|
||||||
padding: var(--sp-4);
|
padding: var(--sp-4);
|
||||||
|
|
|
||||||
|
|
@ -6185,7 +6185,8 @@
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.vg-main:has(.sx-page--prestart),
|
.vg-main:has(.sx-page--prestart),
|
||||||
.vg-main:has(.sx-page--prestart .sx-prestart--prescribed) {
|
.vg-main:has(.sx-page--prestart .sx-prestart--prescribed) {
|
||||||
padding-bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-6));
|
--prestart-actions-reserve: 10rem;
|
||||||
|
padding-bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--prestart-actions-reserve));
|
||||||
}
|
}
|
||||||
.sx-page--prestart .sx-prestart {
|
.sx-page--prestart .sx-prestart {
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
|
|
@ -6222,6 +6223,16 @@
|
||||||
.sx-page--prestart .sx-prestart__facts {
|
.sx-page--prestart .sx-prestart__facts {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
|
||||||
}
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__facts > div:first-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__facts dd {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__main {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
.sx-page--prestart .sx-prestart__focus-head {
|
.sx-page--prestart .sx-prestart__focus-head {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
|
@ -6255,13 +6266,15 @@
|
||||||
}
|
}
|
||||||
.sx-page--prestart .sx-prestart__actions,
|
.sx-page--prestart .sx-prestart__actions,
|
||||||
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__actions {
|
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__actions {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
z-index: 3;
|
z-index: 70;
|
||||||
bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-2));
|
right: 16px;
|
||||||
|
bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom));
|
||||||
|
left: 16px;
|
||||||
padding: var(--sp-2);
|
padding: var(--sp-2);
|
||||||
border: 1px solid var(--glass-inset-border);
|
border: 1px solid var(--glass-inset-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--glass-surface);
|
background: var(--bg-app);
|
||||||
box-shadow: var(--glass-shadow);
|
box-shadow: var(--glass-shadow);
|
||||||
}
|
}
|
||||||
.sx-page--prestart .sx-prestart__actions .vg-btn {
|
.sx-page--prestart .sx-prestart__actions .vg-btn {
|
||||||
|
|
|
||||||
|
|
@ -1419,6 +1419,23 @@ body[data-page="settings"] .vg-set__forms {
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
max-width: 820px;
|
max-width: 820px;
|
||||||
}
|
}
|
||||||
|
body[data-page="settings"] .vg-set,
|
||||||
|
body[data-page="settings"] .vg-set__rail,
|
||||||
|
body[data-page="settings"] .vg-set__forms,
|
||||||
|
body[data-page="settings"] .vg-set__group,
|
||||||
|
body[data-page="settings"] .vg-set__field,
|
||||||
|
body[data-page="settings"] .vg-set__control-block,
|
||||||
|
body[data-page="settings"] .vg-set__opt,
|
||||||
|
body[data-page="settings"] .vg-set__range-row {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set input,
|
||||||
|
body[data-page="settings"] .vg-set select,
|
||||||
|
body[data-page="settings"] .vg-set textarea {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
body[data-page="settings"] #set-account {
|
body[data-page="settings"] #set-account {
|
||||||
order: 1;
|
order: 1;
|
||||||
}
|
}
|
||||||
|
|
@ -1614,6 +1631,9 @@ body[data-page="settings"] .vg-set__pill {
|
||||||
padding: 14px 16px 10px;
|
padding: 14px 16px 10px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
body[data-page="settings"] .vg-set__group {
|
||||||
|
scroll-margin-top: calc(var(--topbar-h) + 140px);
|
||||||
|
}
|
||||||
body[data-page="settings"] .vg-set__rail-title {
|
body[data-page="settings"] .vg-set__rail-title {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 0 0 12px;
|
padding: 0 0 12px;
|
||||||
|
|
@ -1631,19 +1651,18 @@ body[data-page="settings"] .vg-set__pill {
|
||||||
body[data-page="settings"] .vg-set__rail-status {
|
body[data-page="settings"] .vg-set__rail-status {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
/* 탭이 많아 폭이 모자라면 가로 스크롤로 흘린다(라벨은 계속 노출). */
|
/* 태블릿에서는 폭에 맞춰 자연스럽게 다음 줄로 넘긴다. */
|
||||||
body[data-page="settings"] .vg-set__nav {
|
body[data-page="settings"] .vg-set__nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
overflow-x: auto;
|
overflow: visible;
|
||||||
padding: 0 0 2px;
|
padding: 0;
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__nav-item {
|
body[data-page="settings"] .vg-set__nav-item {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 38px;
|
min-height: 44px;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -1655,6 +1674,9 @@ body[data-page="settings"] .vg-set__pill {
|
||||||
body[data-page="settings"] .vg-set__forms {
|
body[data-page="settings"] .vg-set__forms {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
body[data-page="settings"] .vg-set__voicelist {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ≤720px 는 위 1080 가로 탭 배치를 그대로 이어받고 여백·타이포만 더 좁힌다.
|
/* ≤720px 는 위 1080 가로 탭 배치를 그대로 이어받고 여백·타이포만 더 좁힌다.
|
||||||
|
|
@ -1668,10 +1690,13 @@ body[data-page="settings"] .vg-set__pill {
|
||||||
}
|
}
|
||||||
/* ≤720px 만 full-bleed 상단 바(모바일 헤더 패턴)로 되돌린다. */
|
/* ≤720px 만 full-bleed 상단 바(모바일 헤더 패턴)로 되돌린다. */
|
||||||
body[data-page="settings"] .vg-set__rail {
|
body[data-page="settings"] .vg-set__rail {
|
||||||
margin: 0 -16px 12px;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 0 36px;
|
||||||
padding: 13px 16px 10px;
|
padding: 13px 16px 10px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom-color: var(--glass-border);
|
border-bottom-color: var(--glass-border);
|
||||||
|
background: var(--bg-app);
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__rail-title {
|
body[data-page="settings"] .vg-set__rail-title {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -1686,14 +1711,25 @@ body[data-page="settings"] .vg-set__pill {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__nav {
|
body[data-page="settings"] .vg-set__nav {
|
||||||
justify-content: center;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
justify-content: initial;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__nav-item {
|
body[data-page="settings"] .vg-set__nav-item {
|
||||||
min-height: 34px;
|
min-width: 0;
|
||||||
gap: 5px;
|
min-height: 44px;
|
||||||
padding: 0 7px;
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set__nav-item span {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
body[data-page="settings"] .vg-set__nav-item svg {
|
body[data-page="settings"] .vg-set__nav-item svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
|
|
@ -1705,6 +1741,39 @@ body[data-page="settings"] .vg-set__pill {
|
||||||
body[data-page="settings"] .vg-set__group {
|
body[data-page="settings"] .vg-set__group {
|
||||||
padding: 13px;
|
padding: 13px;
|
||||||
}
|
}
|
||||||
|
body[data-page="settings"] #set-support .vg-set__group-head {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] #set-support .vg-set__group-head > div:first-child,
|
||||||
|
body[data-page="settings"] #set-support .vg-set__support-head-ops {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] #set-support .vg-set__support-head-ops {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] #set-consent .vg-set__opt {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] #set-consent .vg-set__opt-text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] #set-consent .vg-set__opt > .vg-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set__profile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set__avatar-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
body[data-page="settings"] .vg-set__field-grid--account,
|
body[data-page="settings"] .vg-set__field-grid--account,
|
||||||
body[data-page="settings"] .vg-set__field-grid--engine,
|
body[data-page="settings"] .vg-set__field-grid--engine,
|
||||||
body[data-page="settings"] .vg-set__seg,
|
body[data-page="settings"] .vg-set__seg,
|
||||||
|
|
@ -1720,7 +1789,25 @@ body[data-page="settings"] .vg-set__pill {
|
||||||
body[data-page="settings"] .vg-set__ops {
|
body[data-page="settings"] .vg-set__ops {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
body[data-page="settings"] .vg-set .vg-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set .vg-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set .vg-toggle__track {
|
||||||
|
inset: 10px 1px;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set .vg-toggle__knob {
|
||||||
|
top: 13px;
|
||||||
|
left: 4px;
|
||||||
|
}
|
||||||
body[data-page="settings"] .vg-set__voice {
|
body[data-page="settings"] .vg-set__voice {
|
||||||
min-height: 0;
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
body[data-page="settings"] .vg-set__voice-play {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
.g6-root {
|
.g6-root {
|
||||||
width: min(100%, 1440px);
|
width: min(100%, 1440px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px clamp(18px, 3vw, 44px) 72px;
|
padding: 24px clamp(18px, 3vw, 44px) 72px;
|
||||||
color: var(--text-body);
|
color: var(--text-body);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
margin: 10px 0 12px;
|
margin: 10px 0 12px;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: clamp(2rem, 4vw, 3.65rem);
|
font-size: clamp(1.75rem, 3vw, 2rem);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
line-height: 1.06;
|
line-height: 1.06;
|
||||||
letter-spacing: -0.048em;
|
letter-spacing: -0.048em;
|
||||||
|
|
@ -349,6 +349,10 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.g6-case__head > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.g6-case__eyebrow {
|
.g6-case__eyebrow {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--fs-kicker);
|
font-size: var(--fs-kicker);
|
||||||
|
|
@ -362,6 +366,7 @@
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: var(--fs-h2);
|
font-size: var(--fs-h2);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.g6-case__facts {
|
.g6-case__facts {
|
||||||
|
|
@ -1044,7 +1049,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.g6-hero h1 {
|
.g6-hero h1 {
|
||||||
font-size: clamp(2rem, 8vw, 3rem);
|
font-size: clamp(1.75rem, 6vw, 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.g6-hero__status {
|
.g6-hero__status {
|
||||||
|
|
@ -1098,7 +1103,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.g6-hero h1 {
|
.g6-hero h1 {
|
||||||
font-size: clamp(1.85rem, 10vw, 2.55rem);
|
font-size: 1.5rem;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -716,9 +716,10 @@ body[data-role="admin"] { --accent:#5B5F6B; --accent-bright:#7D818E; --acce
|
||||||
같은 골격 공유, 좌측 네비 항목 + 좌상단 컨텍스트 라벨만 차이. 역할 전환 시 화면 리셋이 아니라 메뉴·accent만 바뀜.
|
같은 골격 공유, 좌측 네비 항목 + 좌상단 컨텍스트 라벨만 차이. 역할 전환 시 화면 리셋이 아니라 메뉴·accent만 바뀜.
|
||||||
|
|
||||||
- 톱바 56px `background:var(--surface); border-bottom:1px solid var(--hair);` (그림자 없음).
|
- 톱바 56px `background:var(--surface); border-bottom:1px solid var(--hair);` (그림자 없음).
|
||||||
- 좌측 네비 기본 72px 아이콘 전용(Lucide stroke 1.75), hover/포커스 시 220px 슬라이드(160ms). 활성 = 아이콘 `color:var(--accent)` + **3px 강조바 금지** → 아이콘 배경 `var(--accent-tint)` `border-radius:8px` 알약형(네비 알약만 8px 관용).
|
- 좌측 네비는 넓은 화면에서 항목 텍스트를 항상 함께 보이고, 721–1024px에서는 168px 레일로 줄여도 라벨을 숨기지 않는다. 모바일에는 역할별 주요 3개 목적지와 `전체 메뉴`를 고정해 현재 위치와 추가 경로를 모두 찾을 수 있게 한다. `전체 메뉴`는 불투명 표면의 native dialog로 열고 Escape·Tab·호출자 포커스 복원을 보장한다. 활성 = 아이콘 `color:var(--accent)` + **3px 강조바 금지** → 아이콘 배경 `var(--accent-tint)` `border-radius:8px` 알약형(네비 알약만 8px 관용).
|
||||||
- 앱 셸은 뷰포트 높이의 고정 프레임이다. 톱바와 GNB는 화면에 남고, 라우트 콘텐츠를 담는 메인 영역만 독립적으로 세로 스크롤한다. GNB 장식은 네비 자체의 `background` 레이어로만 합성하며 문서 위에 절대 위치 의사요소로 띄우지 않는다.
|
- 앱 셸은 뷰포트 높이의 고정 프레임이다. 톱바와 GNB는 화면에 남고, 라우트 콘텐츠를 담는 메인 영역만 독립적으로 세로 스크롤한다. GNB 장식은 네비 자체의 `background` 레이어로만 합성하며 문서 위에 절대 위치 의사요소로 띄우지 않는다.
|
||||||
- **역할 컨텍스트 라벨**: 톱바 좌측, `font-family:var(--font-num); font-size:13px; letter-spacing:0.04em; color:var(--accent);` — "학습 대시보드"/"교수 콘솔"/"운영 콘솔". "지금 누구로 보고 있나"의 유일하고 조용한 신호.
|
- **역할 컨텍스트 라벨**: 톱바 좌측, `font-family:var(--font-num); font-size:13px; letter-spacing:0.04em; color:var(--accent);` — "학습 대시보드"/"교수 콘솔"/"운영 콘솔". "지금 누구로 보고 있나"의 유일하고 조용한 신호.
|
||||||
|
- 모바일 역할 전환은 현재 역할명이 보이는 native select(접근성 이름: `공간 선택`)로 제공하며, 조작면과 인접 아이콘 버튼은 각각 최소 44×44px을 유지한다. `Ctrl+W`·`Ctrl+N`·`Ctrl+T`의 브라우저 예약 단축키는 가로채지 않고, 기존 Alt 단축키를 보존하며 `Alt+H`는 역할 홈으로 이동한다.
|
||||||
- **권한 표현 원칙: 권한 없으면 DOM에서 제거.** disabled 회색처리도 안 함 — 존재를 숨김. 화면이 깨끗해지고 "내가 못 하는 것" 노이즈 제거. 임상 데이터 프라이버시(관리자는 학생 개별 성장곡선·transcript 기본 접근 불가, 메뉴 자체 렌더 안 함).
|
- **권한 표현 원칙: 권한 없으면 DOM에서 제거.** disabled 회색처리도 안 함 — 존재를 숨김. 화면이 깨끗해지고 "내가 못 하는 것" 노이즈 제거. 임상 데이터 프라이버시(관리자는 학생 개별 성장곡선·transcript 기본 접근 불가, 메뉴 자체 렌더 안 함).
|
||||||
|
|
||||||
### 6.4 학습자 대시보드 (홈 = "당신은 나아지고 있다")
|
### 6.4 학습자 대시보드 (홈 = "당신은 나아지고 있다")
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`guides/local-development.md`](./guides/local-development.md) | Windows 로컬 개발 환경·dev 스택 실행 |
|
| [`guides/local-development.md`](./guides/local-development.md) | Windows 로컬 개발 환경·dev 스택 실행 |
|
||||||
| [`guides/testing.md`](./guides/testing.md) | pytest·typecheck·Playwright 검증 명령 |
|
| [`guides/testing.md`](./guides/testing.md) | pytest·typecheck·Playwright 검증 명령 |
|
||||||
|
| [`ops/ux-audit-2026-09-12.md`](./ops/ux-audit-2026-09-12.md) | 로컬 전 화면 UX·시각 감사의 수용 범위·남은 결함·증거 경계 (진행 중) |
|
||||||
| [`guides/architecture.md`](./guides/architecture.md) | API·web·engine·데이터 흐름 |
|
| [`guides/architecture.md`](./guides/architecture.md) | API·web·engine·데이터 흐름 |
|
||||||
| [`ops/deployment-pipeline.md`](./ops/deployment-pipeline.md) | production NAS 배포 절차·승인·rollback 경계 |
|
| [`ops/deployment-pipeline.md`](./ops/deployment-pipeline.md) | production NAS 배포 절차·승인·rollback 경계 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,7 @@
|
||||||
<span class="eyebrow">project pulse</span>
|
<span class="eyebrow">project pulse</span>
|
||||||
<h1>지금 위치: <em>개선관리 원본 14개 재점검</em> — 2026-09-09 읽기 전용 확인에서 완료 13·검토 1(C-001)이다. REQ-009~011의 로컬 재검증은 수용했고, SHA `1306c524` 운영 배포 뒤 H09를 새 live 검증으로 PASS 확인했다. 2026-09-01·09-08 배포는 역사 증거이며, 9/9 배포가 기존 PASS 전체의 새 검증을 뜻하지는 않는다.</h1>
|
<h1>지금 위치: <em>개선관리 원본 14개 재점검</em> — 2026-09-09 읽기 전용 확인에서 완료 13·검토 1(C-001)이다. REQ-009~011의 로컬 재검증은 수용했고, SHA `1306c524` 운영 배포 뒤 H09를 새 live 검증으로 PASS 확인했다. 2026-09-01·09-08 배포는 역사 증거이며, 9/9 배포가 기존 PASS 전체의 새 검증을 뜻하지는 않는다.</h1>
|
||||||
<p class="pulse-state"><b>실배포 CUA 128:</b> 카탈로그는 생성됐지만 전체 검증은 미완료다. H09(기록 검색·필터 복귀)는 SHA `1306c524`에서 PASS로 재확인했고 T18(감독·연구 drilldown)은 수정 배포 뒤에도 learner 브라우저만 있어 RED를 종결하지 않았다. 관리자와 일반 학습자 로그인에서 학습자의 `/admin`·`/teach`→`/learn` 차단, P4 추천·R09 잠금·R14 워크시트 보존을 확인했다. 순수 teacher·OFF 계정, 교수자 쓰기, 모바일과 독립 관찰자 공개 검증은 남아 있다.</p>
|
<p class="pulse-state"><b>실배포 CUA 128:</b> 카탈로그는 생성됐지만 전체 검증은 미완료다. H09(기록 검색·필터 복귀)는 SHA `1306c524`에서 PASS로 재확인했고 T18(감독·연구 drilldown)은 수정 배포 뒤에도 learner 브라우저만 있어 RED를 종결하지 않았다. 관리자와 일반 학습자 로그인에서 학습자의 `/admin`·`/teach`→`/learn` 차단, P4 추천·R09 잠금·R14 워크시트 보존을 확인했다. 순수 teacher·OFF 계정, 교수자 쓰기, 모바일과 독립 관찰자 공개 검증은 남아 있다.</p>
|
||||||
|
<p class="pulse-state"><b>2026-09-12 비넷 UX 감사:</b> <b>DONE · 로컬 시각 검수 수용</b>. 역할별 메뉴·모바일 라벨, 학습자 정보 위계와 긴 목록, 상담 시작 버튼 겹침, 교수·관리자 표의 넘침, 설정과 약관 배치를 수정했다. 전체 레이아웃 15 passed, 마지막 관리자 폭 보정 후 2 passed, 회기 8 passed, 접근성 44개(38+6) 통과와 직접 이미지 검수를 수용했다. 상세 판정·반려 기록·실제 API와 fixture 증거 경계는 <a href="./ops/ux-audit-2026-09-12.md">감사 기록</a>에 있다. 로컬 web 5273·API 8200 기준이며 운영 배포·전체 기능 출시 판정은 포함하지 않는다.</p>
|
||||||
<p class="pulse-state"><b>2026-08-31 배포 파이프라인 재정립(역사 기록):</b> git 관리·배포는 <code>git.chanpaca.net</code>(Forgejo) 중심으로 이관했고, <code>github.com</code>(origin)은 private 백업/미러로 유지한다. 배포 대상은 NAS Production(<code>docker-compose.nas.yml</code>, <code>vignette-prod</code>)이며 이 PC는 개발용이다. Cloudflare tunnel은 NAS ingress의 현재 경로이므로 대체 ingress 검증 전에는 제거하지 않는다. 문서 <a href="./ops/deployment-pipeline.md">deployment-pipeline.md</a>.</p>
|
<p class="pulse-state"><b>2026-08-31 배포 파이프라인 재정립(역사 기록):</b> git 관리·배포는 <code>git.chanpaca.net</code>(Forgejo) 중심으로 이관했고, <code>github.com</code>(origin)은 private 백업/미러로 유지한다. 배포 대상은 NAS Production(<code>docker-compose.nas.yml</code>, <code>vignette-prod</code>)이며 이 PC는 개발용이다. Cloudflare tunnel은 NAS ingress의 현재 경로이므로 대체 ingress 검증 전에는 제거하지 않는다. 문서 <a href="./ops/deployment-pipeline.md">deployment-pipeline.md</a>.</p>
|
||||||
<p class="pulse-state"><b>최근 확인된 배포 증거(2026-09-01 당시):</b> Forgejo master <code>dce85620</code>를 NAS에서 직접 clone·빌드해 <code>vignette-prod</code> api/engine/web을 교체했고, Cloudflare Pages production도 새 빌드(<code>index-BFKGXJQi.js</code>, 이전 세대 자산 보존)로 배포했다. 사전 dump <code>cab6826b…</code>(11.0MB·TOC 1783)·env 백업·구 이미지 보존으로 롤백 경로를 고정했다. 중복 활성 회기 14쌍 30행은 최신 유지 정책으로 종료(영수증 보존·삭제 0)하고 마이그레이션 20/21/22를 온라인 적용했다. 이 과정에서 mig22의 <code>name[]=text[]</code> 캐스트 결함과 master의 게이트웨이 openai provider 누락 회귀를 발견해 즉시 교정했다(테스트 71 passed). 배포 당시 public health <code>ok·db/engine true</code>, OpenAPI 200, <code>/auth/me</code> 401, dev-login 404, 데이터 집계 전후 동일(app_user 1380/sessions 618/turns 1743)을 확인했다. 증거: <a href="./ops/evidence/nas-prod-deploy-2026-09-01.json">nas-prod-deploy-2026-09-01.json</a>. <b>headful 실계정 폐루프도 당시 완료:</b> NAS env의 OAuth secret 오류(<code>invalid_client</code>)를 교정한 뒤 소유자 실계정 로그인→기존 회기 20건 표시(데이터 보존)→새 사례 회기 생성(mig22 실증)→실턴 SSE·AI 응답→종료→리뷰 생성까지 GREEN(sessions 619·turns 1745·case_profile 566). 일일 검증형 DB 백업 sidecar 첫 덤프도 11.2MB로 성공했다. <b>남은 운영·외부 게이트:</b> C-001 외부 임상 검수, NAS 재부팅 자동복구 smoke, 백업 실패 알림·off-host 복제.</p>
|
<p class="pulse-state"><b>최근 확인된 배포 증거(2026-09-01 당시):</b> Forgejo master <code>dce85620</code>를 NAS에서 직접 clone·빌드해 <code>vignette-prod</code> api/engine/web을 교체했고, Cloudflare Pages production도 새 빌드(<code>index-BFKGXJQi.js</code>, 이전 세대 자산 보존)로 배포했다. 사전 dump <code>cab6826b…</code>(11.0MB·TOC 1783)·env 백업·구 이미지 보존으로 롤백 경로를 고정했다. 중복 활성 회기 14쌍 30행은 최신 유지 정책으로 종료(영수증 보존·삭제 0)하고 마이그레이션 20/21/22를 온라인 적용했다. 이 과정에서 mig22의 <code>name[]=text[]</code> 캐스트 결함과 master의 게이트웨이 openai provider 누락 회귀를 발견해 즉시 교정했다(테스트 71 passed). 배포 당시 public health <code>ok·db/engine true</code>, OpenAPI 200, <code>/auth/me</code> 401, dev-login 404, 데이터 집계 전후 동일(app_user 1380/sessions 618/turns 1743)을 확인했다. 증거: <a href="./ops/evidence/nas-prod-deploy-2026-09-01.json">nas-prod-deploy-2026-09-01.json</a>. <b>headful 실계정 폐루프도 당시 완료:</b> NAS env의 OAuth secret 오류(<code>invalid_client</code>)를 교정한 뒤 소유자 실계정 로그인→기존 회기 20건 표시(데이터 보존)→새 사례 회기 생성(mig22 실증)→실턴 SSE·AI 응답→종료→리뷰 생성까지 GREEN(sessions 619·turns 1745·case_profile 566). 일일 검증형 DB 백업 sidecar 첫 덤프도 11.2MB로 성공했다. <b>남은 운영·외부 게이트:</b> C-001 외부 임상 검수, NAS 재부팅 자동복구 smoke, 백업 실패 알림·off-host 복제.</p>
|
||||||
<details class="pulse-details">
|
<details class="pulse-details">
|
||||||
|
|
@ -1136,7 +1137,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
|
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
|
||||||
<tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr>
|
<tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr>
|
||||||
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-09-09 현재 수집은 <b>1188 tests / 66 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
|
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-09-12 <code>.devlogs/playwright-accepted-list.out.log</code> 기준 현재 수집은 <b>1219 tests / 70 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
|
||||||
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>archive/project-cleanup-2026-09-07/refactor-governance-2026-07-15.md</code>.</td></tr>
|
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>archive/project-cleanup-2026-09-07/refactor-governance-2026-07-15.md</code>.</td></tr>
|
||||||
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
|
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
|
||||||
<tr><td>Outcome & Alliance OS G0</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료.</td></tr>
|
<tr><td>Outcome & Alliance OS G0</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료.</td></tr>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
|
||||||
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||||
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||||
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||||
| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 1188 tests / 66 files · 현 작업트리 전체 GREEN 미검증 |
|
| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 1219 tests / 70 files · 현 작업트리 전체 GREEN 미검증 |
|
||||||
|
|
||||||
핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.**
|
핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.**
|
||||||
**E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다.
|
**E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다.
|
||||||
|
|
@ -321,7 +321,7 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
|
||||||
|
|
||||||
### 3.6 실측 테스트 개수 (현재)
|
### 3.6 실측 테스트 개수 (현재)
|
||||||
|
|
||||||
2026-09-09 `npx playwright test --list` 기준 **현재 수집 1188 tests / 66 files**다
|
2026-09-12 `.devlogs/playwright-accepted-list.out.log`의 `npx playwright test --list` 기준 **현재 수집 1219 tests / 70 files**다
|
||||||
(유스케이스 16테마 `uc-*.spec.ts` 239 시나리오 포함).
|
(유스케이스 16테마 `uc-*.spec.ts` 239 시나리오 포함).
|
||||||
이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 1109개 완주는 아직 증거가 없으며,
|
이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 1109개 완주는 아직 증거가 없으며,
|
||||||
과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다.
|
과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다.
|
||||||
|
|
|
||||||
33
docs/ops/ux-audit-2026-09-12.md
Normal file
33
docs/ops/ux-audit-2026-09-12.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# 2026-09-12 비넷 UX·시각 감사
|
||||||
|
|
||||||
|
> 판정: **DONE · 로컬 시각 검수 수용**. 업그레이드된 designpaca와 설치된 디자인 스킬을 검토하고 비넷의 기존 디자인 SSOT에 맞춰 수정했다. 로컬 web 5273 / API 8200 기준이며 운영 배포는 포함하지 않는다.
|
||||||
|
|
||||||
|
## 수용한 변경
|
||||||
|
|
||||||
|
| 영역 | 수정과 직접 검수 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 공통 메뉴 | 역할별 주요 목적지 3개와 전체 메뉴, 명확한 한글 라벨, 태블릿 텍스트 레일, 모바일 역할 선택, 불투명 메뉴와 포커스 복원을 확인했다. 브라우저 예약 Ctrl 단축키를 보존하고 Alt+H를 역할 홈에 연결했다. |
|
||||||
|
| 로그인·온보딩 | 상담 훈련 목적의 문구, 잘리지 않는 약관 제목·버전과 44px 조작면을 확인했다. 법률 본문과 동의 절차는 유지했다. |
|
||||||
|
| 학습자 홈·연습·기록 | 통계 정의와 정보 위계를 정리했다. 선택한 P4의 초기·리사이즈 가시성, 빈 기록 안내, 2열 모바일 통계, 기록 조작면을 확인했다. 긴 내담자 목록은 내부에서 스크롤하며 요약만 3줄로 제한하고 상세 본문은 유지한다. 768/1024/1440의 카드 비중첩·선택 항목 hit-test·시작 CTA를 확인했다. |
|
||||||
|
| 상담 시작·리뷰 | 긴 호소 본문을 읽기 좋게 배치했다. 모바일 시작 버튼을 내비게이션 위에 고정하고 중간 카드가 덮지 않도록 조상 stacking을 수정했다. 320/390의 상·중·하단 hit-test와 실제 이미지를 확인했다. 리뷰 자기평가 제출 버튼도 44px로 보정했다. |
|
||||||
|
| 교수자·내담자 설정 | 분석 표가 페이지 전체를 넓히던 문제를 고쳐 표 내부 가로 스크롤을 유지했다. 전체 목록·정렬·상세 버튼을 보정했다. 내담자 작성 4단계를 가로 한 줄로 복원하고 모바일 탭을 확인했다. |
|
||||||
|
| 관리자·감독 | 감독·연구와 개선안 검토 문구, 긴 ID 줄바꿈, 접근 관리 탭, 지원 요청 필터를 정리했다. 사용자 관리의 모바일 제목·탭·검색은 뷰포트 안에 두고 표만 내부 스크롤한다. AI 제공자 조회 실패는 확인 중으로 남지 않고 실패와 다시 확인을 표시한다. |
|
||||||
|
| 설정 | 320px 최소 폭 문제, 지원 요청과 동의의 좁은 배치, sticky 메뉴의 투명 배경을 수정했다. 역할에 맞는 메뉴 줄바꿈과 버튼·토글 조작면을 확인했다. |
|
||||||
|
|
||||||
|
## 검증 근거
|
||||||
|
|
||||||
|
- 전체 레이아웃: `.devlogs/layout-accepted.out.log` **15 passed (2.7m)**. 마지막 사용자 관리 폭 보정 후 관리자 관련 재검사도 `.devlogs/admin-final-geometry.out.log` **2 passed (18.2s)**.
|
||||||
|
- 회기: `.devlogs/session-final-v3.out.log` **8 passed (28.8s)**.
|
||||||
|
- 접근성·모바일: `.devlogs/a11y-final.out.log`의 38 passed와 구 제목을 새 제목으로 맞춘 `.devlogs/a11y-home-final.out.log`의 **6 passed**로 44개 모두 통과했다. 검사 기준은 완화하지 않았다.
|
||||||
|
- 학습자: 독립 40 passed, 마지막 긴 목록 focused 1 passed와 P4/resize/빈 기록 회귀 6 passed. `learner-long-list-final5`의 768/1440 원본을 직접 수용했다.
|
||||||
|
- 감독·관리자: 독립 **18 passed**. AI 제공자 실패→재시도 성공은 `.devlogs/provider-retry-accepted.out.log` **2 passed**.
|
||||||
|
- 타입 검사 포함 프로덕션 빌드와 디자인 SSOT 검사 통과: `.devlogs/ux-accepted-build.out.log`, `.devlogs/ux-accepted-design.out.log`.
|
||||||
|
- 전체 Playwright 수집은 **1219 tests / 70 files**다. 전체 1219개를 실행했다는 의미는 아니다.
|
||||||
|
|
||||||
|
## 시각 증거와 판정 경계
|
||||||
|
|
||||||
|
`outputs/ux-audit-2026-09-12/`에 수정 전·실패·수정 후 증거를 보존했다. `surfaces-final`은 추가 8개 경로 × 5개 폭의 내부 스크롤 캡처 113개다. `layout-accepted`, `session-prestart-fixed-v5`, `learner-long-list-final5`, 설정·셸·교수자·관리자 focused 캡처로 최종 상태를 확인했다. 목록 수집량만으로 합격을 판정하지 않았으며, 오케스트레이터가 화면과 diff를 직접 보고 수용했다.
|
||||||
|
|
||||||
|
카드가 겹친 첫 목록 수정, 반투명 메뉴, 시작 버튼 가림, 초기 로딩 화면을 완료 화면으로 찍던 캡처는 반려했다. 캡처 도구의 stale owner ID와 native 스크롤 끝점 오차도 수정했다. 빈 회기 fixture는 정확한 API 빈 상태 계약으로 보강했으며 실제 API 증거와 구분한다. `practice-real-api-final`의 후속 전체 화면 캡처는 로딩 중이어서 무효로 보존했다. 해당 배치의 수용 근거는 안정된 긴 목록 fixture 원본과 동작 검사다.
|
||||||
|
|
||||||
|
로컬 AI 게이트웨이는 비가용이고 제공자 조회 API는 500을 반환한다. 이번 감사는 이 상태를 숨기지 않고 실패·재시도 화면을 검수했다. 운영 서버 정상성, 실제 모델 생성·음성 연결, 전체 기능 출시 판정과 배포는 이 디자인 감사의 완료 범위에 포함하지 않는다. 소스 변경은 커밋·push·배포하지 않았다.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue