세션 화면 디자인 개편

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