세션 화면 디자인 개편

This commit is contained in:
Yun Chan 2026-07-31 09:19:42 +09:00
parent 04a5f485ed
commit 12bdc59497
13 changed files with 1430 additions and 386 deletions

View file

@ -673,7 +673,8 @@ test.describe("full sweep — counseling session", () => {
await routeSessionFixtureApi(page);
await startFixtureSession(page);
await expect(page.locator(".sx-signal__rest")).toContainText("아직 표시할 신호가 없어요");
await expect(page.locator(".sx-signal__rest")).toHaveCount(0);
await expect(page.locator(".sx-coach-card")).toBeVisible();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -702,6 +703,7 @@ test.describe("full sweep — counseling session", () => {
test("shows the coach nudge for a pending turn and requests coaching immediately when opened", async ({
page,
}) => {
await page.setViewportSize({ width: 1366, height: 640 });
const api = await routeSessionFixtureApi(page);
await startFixtureSession(page);
@ -716,12 +718,175 @@ test.describe("full sweep — counseling session", () => {
expect(api.liveCoachRequests).toHaveLength(0);
await nudge.click();
await expect(page.locator(".sx-coach-card").getByText("감정 반영이 선명합니다")).toBeVisible();
const coachCard = page.locator(".sx-coach-card");
await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible();
await expect(page.locator(".sx-signal__wave")).toHaveCount(0);
const railLayout = await page.evaluate(() => {
const panel = document.querySelector<HTMLElement>(".sx-signal");
const coach = document.querySelector<HTMLElement>(".sx-coach-card");
const signal = document.querySelector<HTMLElement>(".sx-signal__one");
if (!panel || !coach || !signal) return null;
const panelRect = panel.getBoundingClientRect();
const coachRect = coach.getBoundingClientRect();
const signalRect = signal.getBoundingClientRect();
return {
coachBeforeSignal: coachRect.top < signalRect.top,
coachInsidePanel:
coachRect.top >= panelRect.top - 1 && coachRect.bottom <= panelRect.bottom + 1,
coachInsideViewport: coachRect.bottom <= window.innerHeight + 1,
};
});
expect(railLayout).toEqual({
coachBeforeSignal: true,
coachInsidePanel: true,
coachInsideViewport: true,
});
await page.setViewportSize({ width: 1024, height: 640 });
await expect(coachCard).toBeVisible();
await expect(page.locator(".sx-page--active .sx-col-right")).toBeVisible();
await expect(page.locator(".sx-signal__one")).toBeHidden();
const compactCoachInsideViewport = await coachCard.evaluate((element) => {
const rect = element.getBoundingClientRect();
return rect.top >= -1 && rect.bottom <= window.innerHeight + 1;
});
expect(compactCoachInsideViewport).toBe(true);
await expect(nudge).toHaveCount(0);
expect(api.liveCoachRequests).toHaveLength(1);
expect(api.liveCoachRequests[0]).toMatchObject({ learner_text: learnerText });
});
test("uses the global light theme tokens throughout an active session", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("vignette.theme", "light");
});
await routeSessionFixtureApi(page);
await startFixtureSession(page);
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await page.getByRole("button", { name: /코칭 모드, 남은 기회/ }).click();
await expect(page.locator(".sx-coach-bubble")).toBeVisible();
const themeState = await page.evaluate(() => {
const rootStyle = getComputedStyle(document.documentElement);
const session = document.querySelector<HTMLElement>(".sx-page--active");
const coachBubble = document.querySelector<HTMLElement>(".sx-coach-bubble");
if (!session || !coachBubble) return null;
const sessionStyle = getComputedStyle(session);
const coachStyle = getComputedStyle(coachBubble);
return {
rootSurface: rootStyle.getPropertyValue("--bg-surface").trim(),
sessionSurface: sessionStyle.getPropertyValue("--bg-surface").trim(),
rootText: rootStyle.getPropertyValue("--text-strong").trim(),
sessionText: sessionStyle.getPropertyValue("--text-strong").trim(),
colorScheme: sessionStyle.colorScheme,
coachInsetSurface:
coachBubble.classList.contains("vg-surface--inset") &&
coachStyle.getPropertyValue("--glass-surface-inset").trim() ===
rootStyle.getPropertyValue("--glass-surface-inset").trim(),
};
});
expect(themeState).not.toBeNull();
expect(themeState!.sessionSurface).toBe(themeState!.rootSurface);
expect(themeState!.sessionText).toBe(themeState!.rootText);
expect(themeState!.colorScheme).toBe("light");
expect(themeState!.coachInsetSurface).toBe(true);
});
test("matches the 1536px botanical session workspace composition", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1536, height: 1024 });
await page.addInitScript(() => {
localStorage.setItem("vignette.theme", "light");
});
await routeSessionFixtureApi(page);
await startFixtureSession(page);
await expect(page.getByText("라이브 코칭", { exact: true })).toBeVisible();
await expect(page.locator(".sx-coach-card")).toBeVisible();
await expect(page.getByRole("button", { name: "상태 신호" })).toHaveClass(/is-on/);
const layout = await page.evaluate(async () => {
const pageRoot = document.querySelector<HTMLElement>(".sx-page--active");
const bar = document.querySelector<HTMLElement>(".sx-sessionbar");
const grid = document.querySelector<HTMLElement>(".sx-grid");
const left = document.querySelector<HTMLElement>(".sx-col-left");
const center = document.querySelector<HTMLElement>(".sx-col-center");
const right = document.querySelector<HTMLElement>(".sx-col-right");
const controls = document.querySelector<HTMLElement>(".sx-controlbar");
const mic = document.querySelector<HTMLElement>(".sx-mic");
const micText = document.querySelector<HTMLElement>(".sx-mic-block__ms");
if (!pageRoot || !bar || !grid || !left || !center || !right || !controls || !mic || !micText) {
return null;
}
const rect = (element: HTMLElement) => {
const value = element.getBoundingClientRect();
return {
left: Math.round(value.left),
top: Math.round(value.top),
right: Math.round(value.right),
bottom: Math.round(value.bottom),
width: Math.round(value.width),
height: Math.round(value.height),
};
};
const leafResponse = await fetch("/session-botanical/leaf-1.webp");
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
page: rect(pageRoot),
bar: rect(bar),
grid: rect(grid),
left: rect(left),
center: rect(center),
right: rect(right),
controls: rect(controls),
mic: rect(mic),
micText: rect(micText),
pageLeaf: getComputedStyle(pageRoot, "::before").backgroundImage,
stageLeaf: getComputedStyle(
document.querySelector<HTMLElement>(".sx-orb-wrap")!,
"::before",
).backgroundImage,
leafAsset: {
ok: leafResponse.ok,
contentType: leafResponse.headers.get("content-type"),
},
};
});
expect(layout).not.toBeNull();
expect(layout!.viewport).toEqual({ width: 1536, height: 1024 });
expect(layout!.page).toMatchObject({ left: 0, top: 0, width: 1536, height: 1024 });
expect(layout!.bar.width).toBeGreaterThanOrEqual(1400);
expect(layout!.bar.width).toBeLessThanOrEqual(1410);
expect(layout!.grid.width).toBeGreaterThanOrEqual(1400);
expect(layout!.grid.width).toBeLessThanOrEqual(1410);
expect(layout!.left.width).toBeGreaterThanOrEqual(324);
expect(layout!.left.width).toBeLessThanOrEqual(328);
expect(layout!.right.width).toBeGreaterThanOrEqual(355);
expect(layout!.right.width).toBeLessThanOrEqual(359);
expect(layout!.center.left).toBeGreaterThan(layout!.left.right);
expect(layout!.right.left).toBeGreaterThan(layout!.center.right);
expect(layout!.controls.width).toBeGreaterThanOrEqual(1460);
expect(layout!.controls.width).toBeLessThanOrEqual(1470);
expect(layout!.controls.bottom).toBeLessThanOrEqual(1024);
expect(layout!.micText.left).toBeGreaterThanOrEqual(layout!.mic.right + 8);
expect(layout!.pageLeaf).toContain("leaf-5.webp");
expect(layout!.stageLeaf).toContain("leaf-1.webp");
expect(layout!.leafAsset).toEqual({ ok: true, contentType: "image/webp" });
const screenshotPath = testInfo.outputPath("session-botanical-1536x1024.png");
await page.screenshot({ path: screenshotPath, animations: "disabled" });
await testInfo.attach("session-botanical-1536x1024", {
path: screenshotPath,
contentType: "image/png",
});
});
// checklist: session-meters-collapse
// 모바일(≤880px) 압축 레이아웃은 관찰 게이지 패널을 의도적으로 숨긴다(session.css) —
// 데스크톱 전용 UI라 desktop 프로젝트에서만 검증한다.

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -9,6 +9,9 @@ import type { ReactNode, SVGProps } from "react";
export type IconName =
| "home"
| "clock"
| "hourglass"
| "spark"
| "session"
| "review"
| "users"
@ -50,6 +53,24 @@ export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
// 각 아이콘의 path/children (24x24 viewBox 기준). google 만 멀티컬러 예외.
const PATHS: Record<IconName, ReactNode> = {
clock: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</>
),
hourglass: (
<>
<path d="M6 3h12M6 21h12" />
<path d="M7 3c0 4 1.5 6.2 5 9-3.5 2.8-5 5-5 9M17 3c0 4-1.5 6.2-5 9 3.5 2.8 5 5 5 9" />
</>
),
spark: (
<>
<path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" />
<path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" />
</>
),
refresh: (
<>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />

View file

@ -1959,8 +1959,8 @@ export default function Session() {
voiceAvailable,
micOn,
});
const elapsedLabel = formatElapsed(elapsed);
const remainingLabel = formatElapsed(remainingSeconds);
const elapsedLabel = formatTimecode(elapsed);
const remainingLabel = formatTimecode(remainingSeconds);
const limitMinutesLabel = Math.round(sessionLimitSeconds / 60);
const warningMinutesLabel = Math.max(1, Math.round(sessionWarningSeconds / 60));
const selectedTheoryOption =
@ -2544,15 +2544,24 @@ export default function Session() {
<div className="sx-session-progress__grid">
<span>
<b>{elapsedLabel}</b>
<small> </small>
<small>
<Icon name="clock" size={18} />
</small>
</span>
<span className={inWarningWindow || timeUp ? "is-warning" : ""}>
<b>{timeUp ? "정리 시간" : remainingLabel}</b>
<small>{limitMinutesLabel} · </small>
<small>
<Icon name="hourglass" size={18} />
</small>
</span>
<span>
<b>{turnCount}</b>
<small> </small>
<small>
<Icon name="review" size={18} />
</small>
</span>
</div>
{goalsAchieved && !timeUp && !sessionEnded ? (
@ -2610,6 +2619,7 @@ export default function Session() {
</div>
<div className="sx-stage__now">
<Icon name="users" size={20} />
<span>: {stageStateText[avatarState]}</span>
<small>{avatarExpressionLabel}</small>
</div>
@ -2783,8 +2793,7 @@ export default function Session() {
{feedbackMode !== "immersive" ? (
<section className={surfaceClassName("sx-panel sx-signal")}>
<div className="sx-signal__head">
{/* D1 — 신호 상태는 아래 sx-signal__one-dot 이 색으로 전달 → 라벨 dot 제거 */}
<Kicker dot={false}> </Kicker>
<Kicker dot={false}> </Kicker>
</div>
{liveSignal ? (
@ -2811,148 +2820,135 @@ export default function Session() {
<span className="sx-signal__one-when"></span>
)}
</div>
) : (
<p className={surfaceClassName("sx-signal__rest", { variant: "inset", flat: true })}>
. .
</p>
)}
) : null}
<div className="sx-signal__wave" aria-hidden="true">
<i />
<i />
<i />
<i />
<i />
<i />
<i />
<i />
</div>
<div className="sx-signal__rows">
<div className="sx-signal__status" aria-label="연결 상태">
<span>
<b> </b>
<Icon name="mic" size={23} />
<b></b>
<small>{voiceInputStatus}</small>
</span>
<span>
<b> </b>
<Icon name="spark" size={23} />
<b>AI </b>
<small>{responseStatus}</small>
</span>
<span>
<b> </b>
<small>
{feedbackMode === "ambient"
? "상태 신호"
: feedbackMode === "coached"
? "코칭"
: "몰입"}
</small>
</span>
</div>
<div className="sx-signal__seq">
<span className="sx-signal__seq-label"> </span>
<span className="sx-signal__seq-dots">
{signalSeq.map((t, i) => (
<i
key={i}
className={
(t === "pos" ? "is-pos" : t === "warn" ? "is-warn" : "") +
(i === signalSeq.length - 1 ? " is-now" : "")
}
/>
))}
</span>
</div>
{feedbackMode === "coached" ? (
<div
className={
"sx-coach-card" +
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
(coachLoading ? " is-loading" : "") +
(coachIsDegraded ? " is-degraded" : "")
}
>
<div className="sx-coach-avatar" aria-hidden="true">
<span className="sx-coach-avatar__lens" />
<span className="sx-coach-avatar__face">
<i />
<i />
</span>
</div>
<div className={surfaceClassName("sx-coach-bubble", { variant: "inset", flat: true })}>
<div className="sx-coach-bubble__meta">
<b>{coachIsDegraded ? "대체 코칭" : "AI 코치"}</b>
<span>{coachStatusText}</span>
</div>
{coachIsDegraded ? (
<p className="sx-coach-degraded-note">{coachDegradedNote}</p>
) : null}
{coachSyncWarning ? (
<p
{signalSeq.length > 0 ? (
<div className="sx-signal__seq">
<span className="sx-signal__seq-label"> </span>
<span className="sx-signal__seq-dots">
{signalSeq.map((t, i) => (
<i
key={i}
className={
"sx-coach-persistence-note" +
(coachPersistenceSource === "runtime" ? " is-runtime" : "")
(t === "pos" ? "is-pos" : t === "warn" ? "is-warn" : "") +
(i === signalSeq.length - 1 ? " is-now" : "")
}
role="status"
>
{coachSyncWarning}
</p>
) : null}
<div className="sx-coach-quota" aria-live="polite">
<span> </span>
<span className="sx-coach-quota__dots" aria-hidden="true">
{coachQuotaSlots.map((slot) => (
<i
key={slot}
className={slot < coachQuotaRemaining ? "is-filled" : ""}
/>
))}
</span>
<b>
{coachQuotaRemaining}/{coachQuotaMax}
</b>
</div>
{coachCreditPulseText ? (
<div
className={
"sx-coach-credit-pulse" +
(coachCreditPulse?.event_type === "recharge" ? " is-recharge" : "")
}
aria-live="polite"
>
{coachCreditPulseText}
</div>
) : null}
{coachLoading ? (
<p> .</p>
) : coachError ? (
<p>{coachError}</p>
) : coachSuggestion ? (
<>
<strong>{coachSuggestion.title}</strong>
<p>{coachSuggestion.message}</p>
{coachSuggestion.next_utterance ? (
<blockquote>{coachSuggestion.next_utterance}</blockquote>
) : null}
<div className="sx-coach-bubble__actions">
<button type="button" onClick={() => setCoachEvidenceOpen(true)}>
</button>
<button type="button" onClick={() => openCoachHistory(null)}>
</button>
</div>
</>
) : coachQuotaRemaining <= 0 ? (
<p> . 1 .</p>
) : (
<p> .</p>
)}
</div>
/>
))}
</span>
</div>
) : null}
<div
className={
"sx-coach-card" +
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
(coachLoading ? " is-loading" : "") +
(coachIsDegraded ? " is-degraded" : "")
}
>
<div className="sx-coach-avatar" aria-hidden="true">
<span className="sx-coach-avatar__lens" />
<span className="sx-coach-avatar__face">
<i />
<i />
</span>
</div>
<div className="sx-coach-summary">
<div className="sx-coach-bubble__meta">
<b>{coachIsDegraded ? "대체 코칭" : "AI 코치"}</b>
<span>{coachStatusText}</span>
</div>
<div className="sx-coach-quota" aria-live="polite">
<span> </span>
<span className="sx-coach-quota__dots" aria-hidden="true">
{coachQuotaSlots.map((slot) => (
<i
key={slot}
className={slot < coachQuotaRemaining ? "is-filled" : ""}
/>
))}
</span>
<b>
{coachQuotaRemaining}/{coachQuotaMax}
</b>
</div>
</div>
<div
className={surfaceClassName("sx-coach-bubble", {
variant: "inset",
flat: true,
})}
>
{coachIsDegraded ? (
<p className="sx-coach-degraded-note">{coachDegradedNote}</p>
) : null}
{coachSyncWarning ? (
<p
className={
"sx-coach-persistence-note" +
(coachPersistenceSource === "runtime" ? " is-runtime" : "")
}
role="status"
>
{coachSyncWarning}
</p>
) : null}
{coachCreditPulseText ? (
<div
className={
"sx-coach-credit-pulse" +
(coachCreditPulse?.event_type === "recharge" ? " is-recharge" : "")
}
aria-live="polite"
>
{coachCreditPulseText}
</div>
) : null}
{coachLoading ? (
<p> .</p>
) : coachError ? (
<p>{coachError}</p>
) : coachSuggestion ? (
<>
<strong>{coachSuggestion.title}</strong>
<p>{coachSuggestion.message}</p>
{coachSuggestion.next_utterance ? (
<blockquote>{coachSuggestion.next_utterance}</blockquote>
) : null}
<div className="sx-coach-bubble__actions">
<button type="button" onClick={() => setCoachEvidenceOpen(true)}>
</button>
<button type="button" onClick={() => openCoachHistory(null)}>
</button>
</div>
</>
) : coachQuotaRemaining <= 0 ? (
<p>
. 1
.
</p>
) : (
<p> .</p>
)}
</div>
</div>
<div className="sx-signal__defer">
{feedbackMode === "coached" ? (
<>

File diff suppressed because it is too large Load diff

View file

@ -514,27 +514,32 @@ function useAvatarMotion(state, affect, analyser) {
```
┌──────────────────────────────────────────────────────────────────────────┐
│ 상단 바 (56px) 좌:세션 제목/시나리오 중앙:회기 단계 우:경과시간·종료
│ 상단 바 (80px) 좌:기록 중앙:회기 상태·단계·시간 우:학습 홈
├──────────────────┬──────────────────────────────────┬──────────────────────┤
│ LEFT (320px) │ CENTER (flexible) │ RIGHT (300px) │
│ LEFT (326px) │ CENTER (flexible) │ RIGHT (357px) │
│ ───────────── │ ───────────────── │ ───────────── │
│ 회기 단계 트랙커 │ 내담자 아바타 / 음성 STAGE │ 피드백 신호 패널 │
│ (라포→탐색→ │ (어두운 배경, 중앙 집중) │ (은은한, 평시 접힘) │
│ 개입→정리) │ │ │
│ 내담자 컨텍스트 │ [ 아바타 + 음성 오브 ] │ ── 내담자 상태 ── │
│ 카드 │ │ ── 라이브 신호 ── │
│ │ 실시간 자막 (아래→위 흐름) │ (최대 1개, 페이드) │
│ │ │ ── 셀프 노트 ── │
│ 내담자 컨텍스트 │ [ 아바타 + 이름·사례 요약 ] │ AI 코치·코칭 기회 │
│ 회기 단계 트랙커 │ [ 현재 표정·발화 상태 ] │ 코칭 포인트 │
│ (라포→탐색→ │ │ 음성·AI 응답 상태 │
│ 개입→정리) │ 실시간 자막 (아래→위 흐름) │ ── 관찰 신호 ── │
│ 세션 진행 │ │ 방어·개방·라포 게이지 │
│ 시간·남은시간·턴 │ │ ── 안전 점검 ── │
├──────────────────┴──────────────────────────────────┴──────────────────────┤
│ 하단 컨트롤 바 (80px) 마이크 · 일시정지 · [몰입│은은│코칭] · 회기 종료 확인
│ 하단 컨트롤 바 (116px) 마이크 · [몰입│상태 신호│코칭] · 일시정지 · 회기 종료
└──────────────────────────────────────────────────────────────────────────┘
```
**비율 의도: 시각적 무게 = 주의 배분.** 내담자 stage 60%, 평가 패널 15%(평시 대부분 접힘). 화면은 대시보드가 아니라 상담실이다.
활성 세션도 전역 `data-theme``tokens.css` 의미 토큰을 그대로 따른다. 세션 전용 로컬 팔레트로 `--bg-surface`·`--text-*`·`--accent`를 다시 정의하지 않으며, 패널과 AI 코치 인셋은 공통 `Surface`가 소유한다. 라이트에서는 종이·글래스 무대, 다크에서는 차콜·세이지 무대로 함께 전환한다.
라이트 데스크톱의 시각 기준은 1536×1024 보태니컬 워크스페이스다. 좌·우 레일은 작은 카드를 쌓지 않고 각각 하나의 연속 표면으로 보이며, 중앙만 인물 무대와 자막으로 분리한다. 장식은 `apps/web/public/session-botanical/leaf-1.webp`~`leaf-5.webp`의 투명 수채화 자산을 좌우 캔버스와 아바타 뒤에 배치한다. 자산은 상호작용과 정보보다 뒤에 놓고 `pointer-events:none`을 유지하며, 다크 테마에서는 밝기·채도를 낮춘다.
**반응형:**
- 태블릿(768~1279px): RIGHT 패널이 오버레이 drawer(평시 숨김, 우상단 신호 도트만). LEFT 280px.
- 모바일/RN(<768px): 단일 컬럼. stage 상단 고정(40vh), 자막 스크롤, 단계는 상단 가는 진행선, 평가는 하단 시트(bottom sheet). 컨트롤 하단 고정.
- 중간 폭(881~1180px): 단계·현재 신호는 상단 회기 요약으로 압축한다. 평시 RIGHT 진단 레일은 숨기되 코칭 모드에서는 AI 코치 카드만 하단 전폭으로 보존한다.
- 모바일(≤880px): 단일 컬럼. stage·자막을 우선하고 단계·상태는 상단 요약으로 압축한다. 코칭 모드에서는 진단·장식을 제외한 AI 코치 카드만 본문 아래에 유지한다.
- 넓지만 짧은 화면(≥1280px, 높이 ≤820px): 스테이지를 약 182px까지 압축해 자막을 주 작업영역으로 보존하고, 발화 예시·보조 흐름 설명을 먼저 접는다.
- 그 외 짧은 높이(≤700px): AI 코치의 판단·본문·근거/이력 액션을 먼저 보존하고 발화 예시와 보조 흐름 설명을 접는다.
### 5.2 중앙 STAGE — 음성 오브 (Voice Orb, 4상태)
@ -634,6 +639,9 @@ LEFT 패널 세로 트랙커 + 상단 바 가로 미니. **현재 단계만 또
[ 알겠어요 ] ← 가볍게 dismiss
```
- `--accent-tint` 배경 틴트 블록(border 없음), 13~14px. 예시 문장 `--ink-2` 이탤릭. 한 번에 1개, 새 힌트 오면 이전 것 교체(stack 안 함).
- 우측 레일의 순서는 **AI 코치 → 현재 신호 → 연결 상태**다. 코치가 장식성 신호나 운영 상태 아래로 밀려 뷰포트 밖에서 잘리면 안 된다.
- 연결 상태는 `음성`·`AI 응답` 두 값만 짧게 표시한다. 화면 모드는 하단 토글이 이미 소유하므로 우측에서 중복하지 않는다.
- 라이브 신호 영역에는 의미 없는 equalizer/막대 파형을 두지 않는다. 최근 흐름 도트는 보조 정보이며 코치 가시성을 침범하면 먼저 접는다.
- **위험 신호만 예외적 즉시 표시(모드 무관):** 자해·위기 언급을 학습자가 놓치면 모드 무관하게 우측에 차분한(빨강 아닌 `--warn-solid`) 알림 — "안전 점검: 방금 위기 신호가 있었어요." 교육적으로 놓치면 안 되는 지점이라 항상 노출.
#### 내담자 상태 미터 (우측 상단) — "반응 읽기 연습"의 거울

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -606,6 +606,8 @@
<p class="dg-note">153차 적용(2026-07-30): 관리자 AI 엔진 설정을 자유 문자열 입력에서 게이트웨이 capability 기반 선택으로 바꿨다. <code>claude_cli</code>·<code>claude_api</code>·<code>codex_cli</code>·<code>agy_cli</code>·<code>openai</code>·<code>solar</code> 6종을 같은 계약으로 관리하고, <code>GET /v1/capabilities</code><code>GET /admin/engine-capabilities</code>가 설치·인증 상태, 사용 가능 모델, 모델별 추론 강도, 기본값을 전달한다. 관리자 콘솔과 설정은 provider·모델·추론 강도를 드롭다운으로 제한하며 Codex 기본은 <code>gpt-5.6-terra / medium</code>, Agy 기본은 <code>gemini-3.6-flash-high / high</code>다. 연결 주소를 바꾸면 이전 주소의 catalog를 즉시 폐기하고, 편집 중인 새 URL을 <code>engine_url</code> query로 넘겨 목록을 다시 확인하기 전에는 저장할 수 없다. PATCH도 제안된 게이트웨이의 capability를 다시 확인해 연결 불가·존재하지 않는 모델·지원하지 않는 강도를 422로 막고 durable 설정을 보존한다. 기존에는 <code>engine_mode</code>가 사실상 설정 표시값에 가까웠으나 이제 generate/stream/readiness가 실제 provider 실행기로 라우팅되며 <code>reasoning_effort</code>도 DB·API·게이트웨이까지 영속된다. Codex app-server <code>model/list</code>에서 실제 모델을 조회하고 Terra/Medium 생성 응답 <code>OK</code>, Agy <code>models</code>에서 실제 모델을 조회하고 Gemini 3.6 Flash/High 생성 응답 <code>VIGNETTE_ISOLATED_OK</code>를 격리 작업 디렉터리에서 실측했다. Anthropic API는 현재 키가 없어 의도대로 선택 불가이며 live 동일성은 B2에 남긴다. 임시 신규 게이트웨이/API 연결에서는 Codex·Agy 선택 저장과 Claude CLI 원복을 확인했고, backend 전체 <code>466 passed</code>, web build, 관리자 AI desktop/mobile <code>1+1</code>, admin AI 전수 <code>7</code>, settings 레이아웃·모바일 <code>3</code>이 통과했다. 현재 로컬 9099도 신규 게이트웨이 코드로 구동되어 Codex <code>7개 / Terra / medium</code>, Agy <code>11개 / Gemini 3.6 Flash High / high</code> live catalog를 반환하고, 로컬 API의 관리자 capability 라우트는 미인증 요청을 401로 차단한다. 121차의 “고장 URL을 저장해 실패 row 생성” E2E는 새 fail-closed 계약과 충돌해 저장 거부·기존 설정 보존 증거로 대체했고, 평가 재시도 UI 회귀는 route fixture spec이 계속 소유한다. <b>현재 상태</b>: 로컬 작업트리·게이트웨이 검증 완료, 공개 웹 배포 전이다.</p>
<p class="dg-note">154차 적용(2026-07-30): 153차 AI 엔진 capability 변경을 공개 런타임까지 반영했다. API PID <code>11788</code>과 gateway PID <code>44488</code>를 신규 코드로 교체하고 Cloudflare Pages production <code>2f52f8e3-f992-441f-ada4-f6396cbb1a2b</code>를 게시했다. 최종 custom domain은 <code>index-B54GMBSf.js</code><code>AdminAi-ClAJvCvx.js</code><code>application/javascript</code> 200으로 제공한다. 최초 production <code>a3819e6e</code> 검증에서 5세대 전 엔트리 <code>index-Cwtfyfq5.js</code>의 보존 누락을 발견했기 때문에, <code>scripts/preserve-pages-assets.ps1</code>가 최근 production preview의 HTML·JS·CSS 의존 그래프를 재귀적으로 수집하고 MIME을 검사해 현재 dist에 병합하도록 고친 뒤 최종 배포로 교체했다. custom domain에서 현재와 이전 4세대 엔트리가 모두 JavaScript 200이며, lazy 청크 첫 요청 강제 실패도 문서 재로드 1회 뒤 1/1 복구됐다. 실제 Google 슈퍼 관리자 사용자 행에 연결한 15분 임시 세션으로 공개 <code>/admin/ai</code>에서 공급자 6개, Codex <code>7개 / gpt-5.6-terra / medium</code>, Agy <code>11개 / gemini-3.6-flash-high / high</code>를 확인했고 저장 없이 세션을 즉시 삭제했다. 공개 health는 <code>status=ok</code>, <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>이며 auth config 200, 비인증 personas·admin capability 401, watchdog <code>LastTaskResult=0</code>다. 153차의 공개 배포 전 상태 표기는 이 증거로 대체한다.</p>
<p class="dg-note">155차 적용(2026-07-31): 세션 텍스트 입력이 AI 생성·TTS 종료까지 잠기고 답변 표시 뒤에도 evaluator가 끝날 때까지 다음 전송이 약 24초 묶이던 학습 UX를 실제 브라우저와 gateway/API 계량으로 분해했다. textarea는 회기 종료·일시정지에서만 잠기며 생성·음성 준비·재생 중에는 다음 질문 초안을 계속 작성·보존하고, 내담자 응답 저장 직후에는 음성 재생과 무관하게 다음 전송을 허용한다. 관리자 DB의 Agy/Gemini 설정은 evaluator/review에 유지하되 실시간 client 역할은 <code>VIGNETTE_LIVE_CLIENT_PROVIDER=claude_cli</code> 전용 lane으로 분리해 회기별 <code>claude -p</code> 프로세스를 재사용한다. stream learner/client turn과 결정론 상태를 먼저 durable 저장하고 SSE <code>done</code>을 보낸 뒤 fast-loop 평가는 백그라운드에서 같은 learner turn normalized row에 붙이며 평가 기반 코칭 충전도 한 번만 적용한다. Agy fallback도 <code>stream-json</code> delta를 실제 SSE token으로 전달한다. 로컬 음성은 <code>-UseHiggsVoice</code>로 설치된 <code>higgs-audio-v3-tts-4b</code>를 loopback 상주시켜 P1 실제 답변을 24kHz WAV로 합성하며 저장소의 무참조 synthetic seed만 reference로 허용하고 non-dev는 fail-closed한다. <b>실증</b>: 변경 전 full API 완료 24.17/24.57초, 변경 후 실제 UI 첫 응답·전송 해제 9.58초와 상주 연속 턴 6.96초, 생성 중 초안 enabled/보존, TTS 중 보내기 enabled. Higgs full API 362,924 bytes/7.56초 WAV, provider/model 헤더 확인. backend 432/432, gateway 44/44, typecheck·API types·build, session stream 1/1, voice skip/draft 1/1 통과. <b>배포·공개 실증</b>: commit <code>c7883434</code>를 origin/master에 push하고 Cloudflare Pages production <code>505155c4-01f3-4eac-ad59-c2d323a0bb51</code>로 게시했다. custom/preview 모두 <code>index-BU5JjRnR.js</code>를 JavaScript 200으로 서빙하고 최근 5개 배포의 의존 자산을 보존한다. 공개 API는 prod·db=true·engine=true, auth config Google configured/dev login disabled, 비인증 personas·voice speech 401, Google 시작 302다. 실제 공개 로그인 1280×720 렌더에서 버튼 2개 enabled, 브라우저 warning/error 0을 확인했다. 공개 음성은 라이선스 경계상 <code>openai / gpt-4o-mini-tts</code>를 유지한다.</p>
<p class="dg-note">156차 적용(2026-07-31): 활성 상담 세션이 전역 테마와 무관하게 <code>.sx-page--active</code>에서 다크 팔레트를 다시 정의해, 라이트 설정에서도 화면은 다크인데 공통 <code>Surface inset</code>인 AI 코치만 밝게 뜨는 혼합 테마 결함을 수정했다. 세션 전용 <code>--bg-surface</code>/<code>--text-*</code>/<code>--accent</code> 덮어쓰기를 제거하고 전역 <code>data-theme</code>·<code>tokens.css</code>·공통 <code>Surface</code>를 그대로 사용해 라이트는 종이·글래스, 다크는 차콜·세이지로 함께 전환한다. 우측 레일은 <b>AI 코치 → 현재 신호 → 음성·AI 응답 상태</b> 순으로 재배치하고 의미 없는 equalizer 막대와 중복 화면 모드 행을 제거했다. 700px 이하 높이에서는 발화 예시·보조 흐름을 먼저 접고 코치 판단과 근거/이력 액션을 보존하며, 기존 사각지대였던 881~1180px에서도 코칭 모드에 AI 코치 카드만 전폭으로 남긴다. <b>검증</b>: <code>npm run check:design-ssot</code>, typecheck, build 통과, 세션 전수 desktop/mobile <code>30 passed / 2 skipped</code>, session-layout <code>8/8</code>, 7폭 layout-visual-gate <code>15/15</code>, 라이트 1366×640 코치·1024×640 압축 코치 및 다크 1440/1024/390 캡처 직접 확인. 로컬 작업트리 기준이며 배포 전이다.</p>
<p class="dg-note">157차 적용(2026-07-31): 사용자 제공 1536×1024 활성 세션 시안을 실제 세션 화면에 반영했다. 데스크톱을 <b>좌측 단일 컨텍스트 레일 326px / 중앙 인물 무대+자막 / 우측 단일 라이브 코칭 레일 357px / 하단 116px 제어바</b>로 재구성하고, 상태 신호 모드에서도 AI 코치의 개입 시점·남은 기회·코칭 포인트를 미리 이해할 수 있게 했다. 제공된 수채화 잎 5장은 BEN2로 투명 분리한 뒤 600×800 이하 알파 WebP로 최적화해 합계 약 158KB로 줄였고, 좌우 캔버스와 아바타 뒤에 장식 계층으로 연결했다. 새 E2E는 1408px 상단/본문, 1468px 제어바, 326/357px 레일, WebP 응답과 computed background 연결을 1536×1024에서 고정한다. 1366×768 이하에서는 스테이지를 압축해 자막이 주 작업영역을 잃지 않게 했다. <b>검증</b>: <code>npm run check:design-ssot</code>, typecheck, build 통과, 세션 전수 desktop/mobile <code>32 passed / 2 skipped</code>, session-layout <code>8/8</code>, 7폭 layout-visual-gate <code>15/15</code>, 1536×1024 라이트 캡처 직접 확인. 로컬 작업트리 기준이며 배포 전이다.</p>
<div class="dg-principles" aria-label="디자인 생성 가드레일">
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
@ -648,7 +650,7 @@
</article>
<article class="dg-card">
<img src="design-concepts/generated/03-session-responsive-v2-dark-unified.png" alt="라이브 상담 세션 PC 태블릿 모바일 다크 테마 디자인 시안" loading="lazy">
<div class="dg-card-body"><b>라이브 상담 세션</b><span>내담자 무대, 자막, 입력, 라이브 신호, 안전 상태를 다크 몰입형 훈련 화면으로 구성.</span><code>03-session-responsive-v2-dark-unified.png</code></div>
<div class="dg-card-body"><b>라이브 상담 세션</b><span>내담자 무대, 자막, 입력, AI 코치, 안전 상태를 전역 라이트·다크 테마와 같은 토큰으로 구성.</span><code>03-session-responsive-v2-dark-unified.png</code></div>
</article>
<article class="dg-card">
<img src="design-concepts/generated/04-session-review-responsive-v2-dark-unified.png" alt="회기 리뷰 PC 태블릿 모바일 다크 테마 디자인 시안" loading="lazy">
@ -955,7 +957,7 @@
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>엄격한 레이아웃 시각 게이트 구축 · 병렬 수정 · 적대적 재검수</b><p>핸드오프가 요구한 7개 권장 너비(390/720/861/900/1024/1280/1440) 시각 수용을 자동 게이트로 고정했다. 현재는 빈 회기리뷰 전용 화면까지 9개 화면을 각 너비에서 렌더링해 가로 overflow 0, 컨트롤 하드클립/텍스트클립 0, 다크 테마 캡처를 강제하고 화면당 7장 총 63장 풀페이지 스크린샷을 남긴다. 2026-06-28 당시 화면별 1에이전트 병렬 시각 리뷰로 49장 직접 판독 → 6개 병렬 수정 에이전트(파일 비중첩)가 결함 수정 → 화면별 적대적 재검수로 해소·무회귀를 확정했다.</p></div><div><b>산출물</b><p><code>apps/web/e2e/layout-visual-gate.spec.ts</code>, <code>node_modules/.tmp/layout-gate/*.png</code> 63장, 2× workflow(review/verify)·6× fix agent</p></div><div><b>검증</b><p>최신 게이트 9 passed(재스크린샷). 해소된 핵심 결함: 학습자홈 1280/1440 3열 가운데 컬럼 붕괴(critical → 2열 분기 상향 + word-break:keep-all), 교수 'API 404' raw 배너 제거, 리뷰 빈상태 위계 역전과 1280 sparse third-column, 관리자 스켈레톤, 설정 와이드 좌측 데드존 제거, 세션 모바일 44px 터치타깃. 2026-06-28 적대적 재검수 7/7 accept, regression 0.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>레이아웃 cosmetic minor 폴리시 일괄 처리</b><p>적대적 재검수 잔여 cosmetic minor를 5개 병렬 폴리시 에이전트로 처리했다. 학습자홈 헬퍼문구 고아 글자, 세션 1024 일시정지 아이콘 정리 + 보내기 버튼 대비 강화, 설정 2x2 칩 행 높이 균일, 교수 검토 큐 카드 갭 제거, 관리자 2열 높이 동기화를 적용했다.</p></div><div><b>산출물</b><p>page-by-page polish patches(LearnerHome/Session/Settings/Professor/Admin)</p></div><div><b>검증</b><p><code>npm run typecheck</code> OK, 당시 cosmetic 시각 게이트 7 passed, 최신 통합 시각 게이트 9 passed, 레이아웃 E2E desktop/mobile 54 passed, session-layout 8/8 — 회귀 0.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>회기 아카이브 저장/복원 API</b><p><code>/learn/history</code><code>보관됨</code>을 실제 학습자별 저장 상태로 연결했다. 종료 회기는 <code>POST /sessions/{id}/archive</code>/<code>restore</code>로 보관·복원하고, <code>app.session_archive_state</code>는 보기 상태만 저장한다. 진행 중 회기는 보관하지 못하며, 보관은 transcript/review/share/audit evidence를 삭제하지 않는다.</p></div><div><b>산출물</b><p><code>SessionArchiveResponse</code>, <code>LearnerSessionSummary.archived</code>, <code>LearnerDashboardOverview.archived_sessions</code>, <code>session_archive_state</code> RLS, <code>LearnerHome</code> 보관/복원 버튼</p></div><div><b>검증</b><p><code>python -B -m pytest app/ -q</code> 178 passed, <code>python -B -m pytest engine_gateway/ -q</code> 11 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>, <code>npx playwright test e2e/learner.spec.ts --project=chromium-desktop --workers=1</code> 6 passed.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>세션 종료 UX와 다크 테마 SSOT</b><p>세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 <code>lib/theme.ts</code> 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다. API 기본 preference <code>system</code>은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따른다. active session 스테이지 상태 배지는 아바타와 겹치지 않는 3행 구조로 정리했다.</p></div><div><b>산출물</b><p><code>Session.tsx</code>, <code>AvatarPreview.tsx</code>, <code>session.css</code>, <code>avatar-expression.spec.ts</code>, <code>session-layout.spec.ts</code></p></div><div><b>검증</b><p>세션 종료·다크 테마·3행 스테이지 구조를 시각 회귀 묶음으로 확인했다. 래스터 파츠 실험 자산은 후속 결정 전 보존 상태다.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>세션 종료 UX와 테마 SSOT</b><p>세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. 테마 저장·초기화·OS 추종은 <code>lib/theme.ts</code> 단일 경로가 소유하며, 활성 세션도 전역 <code>data-theme</code><code>tokens.css</code> 의미 토큰을 그대로 따른다. 세션 전용 다크 팔레트 재정의는 제거했고 active session 스테이지 상태 배지는 아바타와 겹치지 않는 3행 구조로 유지한다.</p></div><div><b>산출물</b><p><code>Session.tsx</code>, <code>AvatarPreview.tsx</code>, <code>session.css</code>, <code>full-sweep-session.spec.ts</code>, <code>session-layout.spec.ts</code></p></div><div><b>검증</b><p>라이트·다크 세션, 종료 UX, 짧은 높이·중간 폭 AI 코치 가시성, 3행 스테이지 구조를 시각 회귀 묶음으로 확인했다.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>래스터 아바타 비활성화·SVG 리그 복귀</b><p>사용자 시각 피드백에 따라 서연 P1과 P4~P7의 생성 이미지/PSD 파츠 연결을 전부 제거했다. 제품·학습자 홈·세션·dev 미리보기는 기존 SVG 도형 기반 파라미터 리그만 렌더링한다.</p></div><div><b>산출물</b><p><code>ClientAvatar.tsx</code>, <code>persona.ts</code>, <code>Session.tsx</code>, <code>LearnerHome.tsx</code>, <code>AvatarPreview.tsx</code>, <code>avatar-expression.spec.ts</code></p></div><div><b>검증</b><p>typecheck/build 통과, 아바타·세션 desktop/mobile 18 passed, 전체 레이아웃 시각 게이트 12 passed, 커스텀 도메인 P1 공개 번들 desktop/mobile 2 passed. <code>data-render-mode="svg"</code>, 래스터 DOM 0개, SVG primitive·neck·표정 전환을 확인했다. 래스터 렌더러와 자산은 재검토용으로 삭제하지 않았다.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>잔여(비차단) — 공용 셸 단일 항목 → 처리</b><p>축소 사이드바 세로 구분선이 본문 전체 높이까지 닿지 않던 건을 <code>components/shell/shell.css</code>에서 처리했다. <code>.vg-nav border-right</code> 제거 후 <code>.vg-shell__body</code> 컨테이너 배경 하어라인으로 본문 그리드 전체 높이 구분선을 그리고, 그리드·구분선 폭을 <code>--nav-cur</code>로 동기화. learner-home 로딩 스켈레톤 밀도도 실제 카드 구조 모사로 보강했다.</p></div><div><b>판정</b><p>구현 완료. 검증: <code>npm run typecheck</code> PASS + <code>vite build</code> PASS. 전 페이지 시각 회귀 게이트는 web+api+DB 스택으로 이 워크스테이션 미실행 — 스택 가용 시 1회 시각 확인 권장.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>디자인 SSOT 리팩터링 · 인증 화면 복구</b><p>테마 알림은 <code>lib/theme.ts</code>/<code>lib/useTheme.ts</code>, 앱 크롬은 <code>AppShell</code>, 글래스 표면은 <code>Surface</code>가 각각 단독 소유한다. 로그인·온보딩·승인대기는 <code>AuthShell</code>의 100vw/100dvh 캔버스를 공유하고, 학습·세션·리뷰·교수자·관리자·페르소나·설정의 카드/인셋은 공통 Surface variant로 이관했다. <code>shell.css</code>의 페이지 클래스 나열과 <code>!important</code> 표면 덮어쓰기는 제거했다. 라이트 Surface도 2중 반투명 광원, 24px backdrop blur, 굴절 hairline을 공통 토큰으로 사용하고 모바일 셸의 보태니컬 배경을 유지한다.</p></div><div><b>검증</b><p><code>npm run check:design-ssot</code>, typecheck/build 통과. 인증 라이트·다크 × 390/1280 시각 게이트 1 passed, 14개 화면 × 7개 폭 레이아웃 게이트 14 passed, 학습·세션·리뷰 desktop/mobile 46 passed, 로그인→온보딩→학습 desktop/mobile 2 passed. 라이트 글래스의 gradient/blur/hairline/shadow와 모바일 배경 자산은 computed style 단언으로 고정했다.</p></div></div>

View file

@ -321,17 +321,18 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
- 2026-07-01 focused 검증: `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_teacher_dashboard.py -q` **10 passed**. 교수자 대시보드 API는 `session_persistence.list_all_sessions()`로 전체 담당 세션을 읽고, 학습자 일반 목록은 `list_recent_sessions()`로 최근 100개 제한을 명시해 한 학생의 최신 회기가 다른 학생 분석 목록을 밀어내지 않는지 검증한다. 또한 ended session summary가 `app.session_evaluation``evaluation_status`, `review_ready`, `supervisor_state`, `evaluation_error`를 별도로 싣고, 평가 실패가 수동 review status와 섞이지 않는지 검증한다.
- 2026-07-01 focused 검증: `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding"` **2 passed**. 승인된 `role=admin` 사용자의 세션에 `admin_access=false`, `onboarding_completed_at=null`이 남아도 `/admin``/onboarding`으로 우회하지 않고 관리 콘솔의 `/admin/*` API를 호출하는지 fixture로 고정한다.
- 2026-07-15 보태니컬 글래스 UI·SSOT 검증: `npm run check:design-ssot` + `npm run typecheck` + `npm run build` 통과, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line` 시각 게이트 **15/15**, `npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line` **1/1**, `npx playwright test e2e/session-layout.spec.ts e2e/learner.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --project=chromium-mobile` **46/46**, 로그인→온보딩 focused desktop/mobile **2/2**. 공통 AppShell GNB, Theme store, Surface variant의 소유권과 전 폭 대시보드/리뷰 탭, 로그인·온보딩 라이트/다크 390/1280px, 고해상도 보태니컬 자산을 함께 고정한다. 라이트 테마도 패널당 복수 굴절 그라데이션, backdrop blur, 헤어라인, 그림자를 computed style로 단언하며 모바일 셸이 보태니컬 배경을 제거하지 않는지 검사한다. 관리자 사용자 표는 semantic table, 정렬 헤더, 1440px 최소 폭과 표 전용 가로 스크롤을 desktop/mobile에서 검증한다.
- 2026-07-31 활성 세션 보태니컬 워크스페이스 검증: `full-sweep-session.spec.ts`의 1536×1024 계약이 좌 326px·우 357px 레일, 1408px 상단/본문, 1468px 하단 제어바, 좌우/스테이지 보태니컬 WebP 연결과 라이트 테마를 실측한다. 세션 전수 desktop/mobile **32 passed / 2 skipped**, `session-layout` **8/8**, 7폭 `layout-visual-gate` **15/15**, `check:design-ssot`·typecheck·build를 통과했다. 1366×768 이하는 스테이지보다 자막이 작아지지 않게 별도 압축 계약을 적용한다.
레이아웃·시각 회귀 게이트(핵심 합격선):
| 게이트 | 스펙 | 구성 | 개수 |
|---|---|---|---|
| 세션 레이아웃 | `e2e/session-layout.spec.ts` | 4 테스트 × (desktop+mobile) | **8 / 8** |
| 시각 레이아웃 게이트 | `e2e/layout-visual-gate.spec.ts` | `@single-run`, 14개 화면 × 7개 폭 검사 + 다크 테마 assertion + 학습 대시보드 라이트 자산 연결 | **14 / 14** |
| 시각 레이아웃 게이트 | `e2e/layout-visual-gate.spec.ts` | `@single-run`, 15개 화면 계약 × 7개 폭 검사 + 다크 테마 assertion + 학습 대시보드 라이트 자산 연결 | **15 / 15** |
| 인증 테마 게이트 | `e2e/auth-visual.spec.ts` | 로그인·온보딩 × light/dark × 390/1280px, 100vw/100dvh 및 overflow 검사 | **1 / 1** |
| 레이아웃 포커스(재설계 화면) | `session-layout`·`session-review`·`admin`·`learner`·`settings`·`teacher`, `@single-run` 제외 | desktop+mobile 병렬 | **54** |
> `layout-visual-gate`는 7개 폭(390/720/861/900/1024/1280/1440)에서 14개 핵심 화면(페르소나 운영·작성 단계 포함)의 가로
> `layout-visual-gate`는 7개 폭(390/720/861/900/1024/1280/1440)에서 15개 핵심 화면 계약(페르소나 운영·작성 단계 포함)의 가로
> 오버플로·잘린 컨트롤·다크 테마 적용을 검사하고 전체 페이지 스크린샷을
> `node_modules/.tmp/layout-gate/`에 남긴다. 학습 대시보드는 추가로 라이트 테마의 카드·사이드바·
> 우상단 배경 자산이 실제 computed style에 연결됐는지 확인하고 1200px·390px 라이트 캡처를 남긴 뒤 다크로 복귀한다.