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:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
709
apps/web/src/pages/LearnerHome.tsx
Normal file
709
apps/web/src/pages/LearnerHome.tsx
Normal 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%; }
|
||||
}
|
||||
`;
|
||||
Loading…
Add table
Add a link
Reference in a new issue