feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -0,0 +1,709 @@
/* =====================================================================
LearnerHome (/learn). .
DESIGN_CONCEPT §6.4 "당신은 나아지고 있다" . - accent.
레이아웃: Hero( ) CTA( )
(, ) (small-multiples).
준수: border-left 0 · 0( = Icon/inline SVG stroke) ·
0 · / 0(paper/ink ) ·
= weight + accent + accent-tint + kicker(+dot).
인터랙션: 성장 ( 8/12) React state로 .
mock( MOCK_* ). .
Login <style> + lh- .
===================================================================== */
import { useMemo, useState } from "react";
import { AppShell } from "../components/shell/AppShell";
import { Button, Icon, Kicker, SectionHead } from "../components/ui";
import { useAuth } from "../lib/auth";
/* ── mock 타입 ────────────────────────────────────────────────────── */
type SessionStatus = "review" | "done" | "draft";
interface RecentSession {
id: string;
/** "06.24" */
date: string;
/** "화 · 어제" 같은 보조 라벨 */
dateNote: string;
persona: {
name: string;
/** 이니셜 아바타 색 키 */
tone: "clay" | "accent" | "info";
/** "17세 · 우울 호소 청소년" */
meta: string;
};
/** "회기 1" */
sessionLabel: string;
/** 도달 단계: 0=라포 진행 1=라포완료 ... (segments 채움 길이) */
reachLabel: string;
/** 4단계 트랙: 'done' | 'cur' | 'todo' */
reach: ("done" | "cur" | "todo")[];
/** 피드백 요약 1줄 + 톤 도트 */
feedback: { tone: "pos" | "warn" | "info" | "neu"; text: string };
status: SessionStatus;
}
interface GrowthSignal {
label: string;
/** 현재 상태를 점수 아닌 자연어로(절대점수 금지) */
state: string;
/** 추세 요약 */
trendNote: string;
trend: "up" | "flat" | "down";
/** 0~1 정규화 시계열. 최근 12회. 윈도우 탭이 뒤에서 N개를 잘라 봄. */
series: number[];
}
/* ── MOCK 데이터 (백엔드 /sessions, /metrics 연동 전 화면용) ──────────── */
const MOCK_LEARNER_NAME_FALLBACK = "도윤";
// MOCK: 이번 주 현황 한 줄
const MOCK_WEEK = {
doneCount: 3,
reviewPending: 2,
weeklyGoal: 4,
};
// MOCK: 다음 연습 추천(주 CTA 콘텐츠)
const MOCK_NEXT = {
title: "서연 회기 2 — 탐색 단계 이어가기",
reason:
"지난 회기에서 닫힌 질문이 반복됐어요. 이번엔 개방형 질문으로 자기개방을 끌어내는 데 집중해 보세요.",
tags: ["우울 호소 청소년", "탐색 단계", "목표: 개방형 질문"],
/** 이 추천이 가리키는 페르소나 코드(연습 시작 시 사용) */
personaCode: "seoyeon",
};
// MOCK: 최근 회기 리스트(시간순, 테이블)
const MOCK_SESSIONS: RecentSession[] = [
{
id: "s-0624",
date: "06.24",
dateNote: "화 · 어제",
persona: { name: "서연", tone: "clay", meta: "17세 · 우울 호소 청소년" },
sessionLabel: "회기 1",
reachLabel: "탐색까지",
reach: ["done", "cur", "todo", "todo"],
feedback: {
tone: "warn",
text: "라포는 좋았으나 닫힌 질문이 반복돼 자기개방이 일찍 멈췄어요",
},
status: "review",
},
{
id: "s-0622",
date: "06.22",
dateNote: "일",
persona: { name: "민준", tone: "accent", meta: "24세 · 불안·공황 호소" },
sessionLabel: "회기 2",
reachLabel: "개입까지",
reach: ["done", "done", "cur", "todo"],
feedback: {
tone: "pos",
text: "반영적 경청이 안정적이었고 개입 시점 판단이 적절했어요",
},
status: "done",
},
{
id: "s-0619",
date: "06.19",
dateNote: "목",
persona: { name: "하은", tone: "info", meta: "16세 · 또래관계·등교 거부" },
sessionLabel: "회기 1",
reachLabel: "라포까지",
reach: ["cur", "todo", "todo", "todo"],
feedback: {
tone: "info",
text: "초반 침묵을 견디는 호흡이 좋았어요. 개방형 질문 연결을 더 연습해 보세요",
},
status: "done",
},
{
id: "s-0617",
date: "06.17",
dateNote: "화",
persona: { name: "서연", tone: "clay", meta: "17세 · 우울 호소 청소년" },
sessionLabel: "회기 1 · 중단",
reachLabel: "라포 진행 중",
reach: ["cur", "todo", "todo", "todo"],
feedback: {
tone: "neu",
text: "8분에 일시정지 후 종료 — 이어서 다시 시도해 볼 수 있어요",
},
status: "draft",
},
];
// MOCK: 성장 신호(절대점수 아님, 추세). 12회 정규화 시계열.
const MOCK_GROWTH: GrowthSignal[] = [
{
label: "라포 형성",
state: "안정",
trendNote: "꾸준히 향상",
trend: "up",
series: [0.42, 0.45, 0.4, 0.48, 0.5, 0.46, 0.55, 0.58, 0.62, 0.66, 0.71, 0.78],
},
{
label: "개방형 질문",
state: "늘어남",
trendNote: "닫힌 질문 감소",
trend: "up",
series: [0.3, 0.28, 0.34, 0.32, 0.4, 0.38, 0.45, 0.43, 0.5, 0.54, 0.62, 0.68],
},
{
label: "개입 정확도",
state: "발전 중",
trendNote: "최근 평탄",
trend: "flat",
series: [0.4, 0.46, 0.44, 0.52, 0.5, 0.55, 0.53, 0.56, 0.58, 0.57, 0.59, 0.6],
},
];
/* ── 윈도우 탭(최근 N회) — 실제 인터랙션 ──────────────────────────── */
const WINDOWS = [
{ key: 8, label: "최근 8회" },
{ key: 12, label: "최근 12회" },
] as const;
type WindowKey = (typeof WINDOWS)[number]["key"];
/* ── 상태 칩 매핑(배경 틴트 + 텍스트, border 없음) ──────────────────── */
const STATUS_META: Record<
SessionStatus,
{ label: string; cls: string; dotCls: string }
> = {
review: { label: "리뷰 도착", cls: "lh-status--review", dotCls: "lh-sd--info" },
done: { label: "완료", cls: "lh-status--done", dotCls: "lh-sd--pos" },
draft: { label: "이어하기", cls: "lh-status--draft", dotCls: "lh-sd--neu" },
};
/* ── sparkline 패스 생성(공통 y축 0~1 정규화 기준) ─────────────────── */
function sparkPath(series: number[], w = 240, h = 56, pad = 5): string {
if (series.length === 0) return "";
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const stepX = series.length > 1 ? innerW / (series.length - 1) : 0;
return series
.map((v, i) => {
const x = pad + stepX * i;
// v(0~1) → y 반전(위가 높은 값)
const y = pad + (1 - clamp01(v)) * innerH;
return `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}
function lastPoint(series: number[], w = 240, h = 56, pad = 5) {
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const stepX = series.length > 1 ? innerW / (series.length - 1) : 0;
const i = series.length - 1;
const x = pad + stepX * i;
const y = pad + (1 - clamp01(series[i] ?? 0)) * innerH;
return { x, y };
}
function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
/* ── 추세 화살표(이모지 금지 → 기하 아이콘) ──────────────────────── */
function TrendMark({ trend }: { trend: "up" | "flat" | "down" }) {
const name = trend === "up" ? "arrow-up" : trend === "down" ? "arrow-down" : "arrow-flat";
const cls = trend === "up" ? "lh-delta--up" : trend === "down" ? "lh-delta--warn" : "lh-delta--flat";
return <Icon name={name} size={12} strokeWidth={2.4} className={cls} />;
}
/* ===================================================================== */
export default function LearnerHome() {
const { user } = useAuth();
// user.name 은 mock 로그인 기본값(auth.tsx ROLE_DEFAULT_NAME)일 수 있음.
const firstName = pickFirstName(user?.name) ?? MOCK_LEARNER_NAME_FALLBACK;
const [windowKey, setWindowKey] = useState<WindowKey>(8);
// 선택 윈도우만큼 뒤에서 잘라 보여줌(추세 흐름 유지).
const windowed = useMemo(
() =>
MOCK_GROWTH.map((g) => ({
...g,
series: g.series.slice(Math.max(0, g.series.length - windowKey)),
})),
[windowKey],
);
const startPractice = () => {
// TODO(backend): sessionApi.start(MOCK_NEXT.personaCode) 후 /session/:id 라우팅.
// 지금은 mock — 콘솔로만 남기고 화면은 정적 데모.
// eslint-disable-next-line no-console
console.info("[learner-home] 연습 시작(mock):", MOCK_NEXT.personaCode);
};
return (
<AppShell>
<style>{LH_CSS}</style>
<div className="lh-root">
{/* ── (1) Hero — 한 화면 한 메시지: "당신은 나아지고 있다" ── */}
<header className="lh-hero">
<Kicker> · 4</Kicker>
<h1 className="lh-hero__title">
<span className="lh-name">{firstName}</span>, 12 .
</h1>
<p className="lh-hero__lead">
<span className="lh-em"> </span> .
.
</p>
<div className="lh-weekline">
<span className="lh-wk-stat">
<span className="lh-wk-num tabular">{MOCK_WEEK.doneCount}</span>
<span className="lh-wk-lab"> </span>
</span>
<span className="lh-vsep" aria-hidden="true" />
<span className="lh-wk-stat">
<span className="lh-wk-num tabular">{MOCK_WEEK.reviewPending}</span>
<span className="lh-wk-lab"> </span>
</span>
<span className="lh-vsep" aria-hidden="true" />
<span className="lh-wk-stat">
<span className="lh-wk-num tabular"> {MOCK_WEEK.weeklyGoal}</span>
<span className="lh-wk-lab">
{Math.max(0, MOCK_WEEK.weeklyGoal - MOCK_WEEK.doneCount)}
</span>
</span>
</div>
</header>
{/* ── (2) 다음 연습 시작 — 주 CTA(accent-tint 패널, 색 박스 강조 아님) ── */}
<section className="lh-cta" aria-labelledby="lh-cta-title">
<div className="lh-cta__body">
<Kicker> </Kicker>
<h2 id="lh-cta-title" className="lh-cta__title">
{MOCK_NEXT.title}
</h2>
<p className="lh-cta__desc">{MOCK_NEXT.reason}</p>
<div className="lh-tags">
{MOCK_NEXT.tags.map((t) => (
<span key={t} className="lh-tag">
{t}
</span>
))}
</div>
</div>
<div className="lh-cta__actions">
<Button
size="lg"
onClick={startPractice}
trailing={<Icon name="chevron-right" size={18} />}
>
</Button>
<button type="button" className="lh-alt" onClick={startPractice}>
<Icon name="users" size={14} />
</button>
</div>
</section>
{/* ── (3) 내 상담 세션 — 테이블(카드 격자 아님, 시간순 한 컬럼 위계) ── */}
<section className="lh-section" aria-labelledby="lh-sessions-title">
<SectionHead
kicker="내 회기"
title={<span id="lh-sessions-title"> </span>}
desc="최근 진행한 회기와 받은 피드백을 한눈에 봅니다"
action={
<a className="lh-link" href="#all-sessions">
<Icon name="chevron-right" size={13} />
</a>
}
/>
<div className="lh-table" role="table" aria-label="최근 상담 세션">
<div className="lh-thead" role="row">
<span role="columnheader"></span>
<span role="columnheader"> ()</span>
<span role="columnheader"></span>
<span role="columnheader"> </span>
<span role="columnheader"> </span>
<span role="columnheader"></span>
</div>
{MOCK_SESSIONS.map((s) => (
<div className="lh-trow" role="row" key={s.id}>
<div className="lh-date tabular" role="cell">
{s.date}
<span className="lh-date__note">{s.dateNote}</span>
</div>
<div className="lh-persona" role="cell">
<span className={"lh-pf lh-pf--" + s.persona.tone} aria-hidden="true">
{s.persona.name.charAt(0)}
</span>
<span className="lh-pn">
{s.persona.name}
<span className="lh-pn__meta">{s.persona.meta}</span>
</span>
</div>
<div className="lh-sess tabular" role="cell">
{s.sessionLabel}
</div>
<div className="lh-reach" role="cell">
<span className="lh-reach__lab">{s.reachLabel}</span>
<span className="lh-seg" aria-hidden="true">
{s.reach.map((r, i) => (
<span key={i} className={"lh-seg__b lh-seg__b--" + r} />
))}
</span>
</div>
<div className="lh-fb" role="cell">
<span className={"lh-fd lh-fd--" + s.feedback.tone} aria-hidden="true" />
<span className="lh-fb__t">{s.feedback.text}</span>
</div>
<div role="cell">
<span className={"lh-status " + STATUS_META[s.status].cls}>
<span className={"lh-sd " + STATUS_META[s.status].dotCls} aria-hidden="true" />
{STATUS_META[s.status].label}
</span>
</div>
</div>
))}
</div>
</section>
{/* ── (4) 성장 한눈에 — small-multiples 라인(레이더 금지, 추세만) ── */}
<section className="lh-section" aria-labelledby="lh-growth-title">
<SectionHead
kicker="성장 신호"
title={<span id="lh-growth-title"> </span>}
desc="점수가 아니라 흐름으로 봅니다"
action={
<div className="lh-tabs" role="tablist" aria-label="추이 범위">
{WINDOWS.map((w) => (
<button
key={w.key}
type="button"
role="tab"
aria-selected={windowKey === w.key}
className={"lh-tab" + (windowKey === w.key ? " is-on" : "")}
onClick={() => setWindowKey(w.key)}
>
{w.label}
</button>
))}
</div>
}
/>
<div className="lh-growth">
{windowed.map((g) => {
const pt = lastPoint(g.series);
return (
<div className="lh-spark" key={g.label}>
<div className="lh-spark__label">{g.label}</div>
<div className="lh-spark__now">
<span className="lh-spark__state">{g.state}</span>
<span
className={
"lh-delta " +
(g.trend === "up"
? "lh-delta--up"
: g.trend === "down"
? "lh-delta--warn"
: "lh-delta--flat")
}
>
<TrendMark trend={g.trend} />
{g.trendNote}
</span>
</div>
<svg
className="lh-chart"
viewBox="0 0 240 56"
preserveAspectRatio="none"
aria-label={`${g.label} 추이`}
role="img"
>
{/* baseline 헤어라인(격자 최소, 세로 격자 없음) */}
<line x1="0" y1="55" x2="240" y2="55" stroke="var(--hair)" strokeWidth="1" />
<path
className="lh-chart__line"
d={sparkPath(g.series)}
fill="none"
stroke="var(--accent-bright)"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 최신 점만 강조 */}
<circle cx={pt.x} cy={pt.y} r="3.2" fill="var(--accent)" />
</svg>
<div className="lh-axis tabular">
<span>{g.series.length} </span>
<span></span>
</div>
</div>
);
})}
</div>
</section>
</div>
</AppShell>
);
}
/* user.name("김도윤"/"도윤") → 이름 끝 2글자 추정. 없으면 null. */
function pickFirstName(name?: string | null): string | null {
if (!name) return null;
const trimmed = name.trim();
if (!trimmed) return null;
// 한국 성씨 1글자 가정: 3글자면 뒤 2글자, 그 외엔 원본.
if (trimmed.length === 3) return trimmed.slice(1);
return trimmed;
}
/* =====================================================================
(Login.tsx <style> + lh- ).
. border-left 0 · 0 · 0 · 0.
===================================================================== */
const LH_CSS = `
.lh-root { display:flex; flex-direction:column; }
/* ── (1) Hero ── */
.lh-hero { margin-bottom: var(--sp-7); }
.lh-hero__title {
margin-top: 10px;
font-size: var(--fs-h1);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.3;
color: var(--text-strong);
}
.lh-name { color: var(--accent-deep); }
.lh-hero__lead {
margin-top: var(--sp-3);
font-size: var(--fs-lead);
color: var(--text-body);
line-height: 1.6;
max-width: 640px;
}
.lh-em { color: var(--accent-deep); font-weight: 600; }
.lh-weekline {
margin-top: var(--sp-5);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-5);
}
.lh-wk-stat { display: inline-flex; align-items: baseline; gap: 7px; }
.lh-wk-num {
font-size: 19px;
font-weight: 700;
color: var(--text-strong);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.lh-wk-lab { font-size: var(--fs-sm); color: var(--text-muted); }
.lh-vsep { width: 1px; height: 16px; background: var(--hair); }
/* ── (2) CTA (accent-tint 패널) ── */
.lh-cta {
margin-top: var(--sp-5);
background: var(--accent-tint);
border-radius: var(--radius-lg);
padding: var(--sp-6) var(--sp-7);
display: flex;
align-items: center;
gap: var(--sp-6);
flex-wrap: wrap;
}
.lh-cta__body { flex: 1; min-width: 280px; }
.lh-cta__title {
margin-top: 8px;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.35;
color: var(--text-strong);
}
.lh-cta__desc {
margin-top: 6px;
font-size: var(--fs-sm);
color: var(--text-body);
max-width: 520px;
line-height: 1.6;
}
.lh-tags { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.lh-tag {
font-size: var(--fs-xs);
font-weight: 500;
color: var(--accent-deep);
background: var(--surface);
padding: 5px 11px;
border-radius: 100px;
}
.lh-cta__actions { display: flex; flex-direction: column; align-items: flex-end; flex: none; }
.lh-alt {
margin-top: 10px;
font: 500 var(--fs-xs)/1 var(--font-sans);
color: var(--text-body);
background: none;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 4px;
transition: color var(--dur-fast) var(--ease-out);
}
.lh-alt:hover { color: var(--text-strong); }
.lh-alt:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus-ring); border-radius: var(--radius-sm); }
/* ── 섹션 공통 ── */
.lh-section { margin-top: var(--sp-8); }
.lh-link {
font-size: var(--fs-xs);
font-weight: 500;
color: var(--accent-deep);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
transition: color var(--dur-fast) var(--ease-out);
}
.lh-link:hover { color: var(--accent); }
/* ── (3) 세션 테이블 ── */
.lh-table {
background: var(--surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
overflow: hidden;
}
.lh-thead, .lh-trow {
display: grid;
grid-template-columns: 104px 1.4fr 70px 1fr 1.6fr 96px;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5);
}
.lh-thead {
background: var(--paper-2);
border-bottom: 1px solid var(--hair);
font-size: var(--fs-xs);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
}
.lh-trow {
border-bottom: 1px solid var(--paper-2);
transition: background var(--dur-fast) var(--ease-out);
}
.lh-trow:last-child { border-bottom: none; }
.lh-trow:hover { background: var(--accent-tint); }
.lh-date {
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-body);
font-variant-numeric: tabular-nums;
}
.lh-date__note { display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.lh-persona { display: flex; align-items: center; gap: 10px; min-width: 0; }
.lh-pf {
width: 30px; height: 30px; border-radius: 50%; flex: none;
display: flex; align-items: center; justify-content: center;
font-size: var(--fs-xs); font-weight: 700;
}
.lh-pf--clay { background: var(--clay-tint); color: var(--clay-deep); }
.lh-pf--accent { background: var(--accent-tint); color: var(--accent-deep); }
.lh-pf--info { background: var(--info-tint); color: var(--info-text); }
.lh-pn { font-size: var(--fs-sm); font-weight: 600; color: var(--text-strong); line-height: 1.35; min-width: 0; }
.lh-pn__meta { display: block; font-size: var(--fs-xs); font-weight: 400; color: var(--text-muted); margin-top: 1px; }
.lh-sess { font-family: var(--font-num); font-size: var(--fs-xs); color: var(--text-body); font-variant-numeric: tabular-nums; }
.lh-reach { display: flex; flex-direction: column; gap: 6px; }
.lh-reach__lab { font-size: var(--fs-xs); color: var(--text-strong); font-weight: 500; }
.lh-seg { display: flex; align-items: center; gap: 4px; }
.lh-seg__b { height: 4px; flex: 1; border-radius: 2px; background: var(--neutral-150); }
.lh-seg__b--done { background: var(--accent-bright); }
.lh-seg__b--cur { background: var(--accent); }
.lh-fb { display: flex; align-items: flex-start; gap: 9px; }
.lh-fd { width: 8px; height: 8px; border-radius: 50%; flex: none; margin-top: 6px; }
.lh-fd--pos { background: var(--pos-solid); }
.lh-fd--warn { background: var(--warn-solid); }
.lh-fd--info { background: var(--info-solid); }
.lh-fd--neu { background: var(--neutral-sig); }
.lh-fb__t { font-size: var(--fs-xs); color: var(--text-body); line-height: 1.5; }
.lh-status {
display: inline-flex; align-items: center; gap: 6px;
font-size: var(--fs-xs); font-weight: 600;
padding: 4px 10px; border-radius: 100px; white-space: nowrap;
}
.lh-sd { width: 6px; height: 6px; border-radius: 50%; flex: none; }
.lh-sd--pos { background: var(--pos-solid); }
.lh-sd--info { background: var(--info-solid); }
.lh-sd--neu { background: var(--neutral-sig); }
.lh-status--review { background: var(--info-tint); color: var(--info-text); }
.lh-status--done { background: var(--pos-tint); color: var(--pos-text); }
.lh-status--draft { background: var(--paper-2); color: var(--text-muted); }
/* ── (4) 성장 small-multiples ── */
.lh-tabs { display: inline-flex; gap: 2px; background: var(--paper-2); border-radius: var(--radius-sm); padding: 3px; }
.lh-tab {
font: 600 var(--fs-xs)/1 var(--font-sans);
color: var(--text-muted);
background: none; border: none; cursor: pointer;
padding: 6px 12px; border-radius: var(--radius-sm);
transition: color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
}
.lh-tab:hover { color: var(--text-body); }
.lh-tab.is-on { color: var(--accent-deep); background: var(--surface); box-shadow: var(--shadow-sm); }
.lh-tab:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus-ring); }
.lh-growth { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); }
.lh-spark {
background: var(--surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
padding: var(--sp-5);
}
.lh-spark__label { font-size: var(--fs-sm); font-weight: 600; color: var(--text-strong); letter-spacing: -0.01em; }
.lh-spark__now { display: flex; align-items: baseline; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
.lh-spark__state {
font-size: 22px; font-weight: 700; color: var(--text-strong);
letter-spacing: -0.02em;
}
.lh-delta { font-size: var(--fs-xs); font-weight: 600; display: inline-flex; align-items: center; gap: 4px; }
.lh-delta--up { color: var(--pos-text); }
.lh-delta--warn { color: var(--warn-text); }
.lh-delta--flat { color: var(--text-muted); }
.lh-chart { margin-top: var(--sp-4); width: 100%; height: 56px; display: block; }
.lh-chart__line {
/* 진입 시 draw 모션(절제). reduced-motion 시 0ms. */
stroke-dasharray: 1;
stroke-dashoffset: 0;
}
.lh-axis { display: flex; justify-content: space-between; margin-top: 8px; font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
/* ── 반응형 ── */
@media (max-width: 920px) {
.lh-growth { grid-template-columns: 1fr; }
.lh-thead { display: none; }
.lh-trow { grid-template-columns: 1fr 1fr; row-gap: var(--sp-3); padding: var(--sp-5); }
.lh-cta { padding: var(--sp-5) var(--sp-6); }
.lh-cta__actions { align-items: stretch; width: 100%; }
}
`;