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,583 @@
/* =====================================================================
Professor (/teach). .
설계: DESIGN_CONCEPT §6.5 awareness가 triage.
1) 헤드라인: ("3명에게 개입이 필요합니다")
2) (triage, ): + crit dot. .
3) 회기: 코멘트 .
4) 테이블: sparkline + + / .
5) 영역: 가로 ( ) + small-multiples + .
accent=- AuthProvider body[data-role=instructor] (teacherinstructor).
철칙: border-left 0 · 0( inline SVG/Icon) · 0 ·
/ · weight/tint/kicker/dot · red는 .
스타일: Login <style>{PF_CSS}</style> (pf- ).
===================================================================== */
import { useMemo, useState } from "react";
import { AppShell } from "../components/shell/AppShell";
import { Badge, Button, Dot, Icon, Kicker } from "../components/ui";
import { formatDelta, trendOf } from "../lib/format";
import { MiniMultiple, SparkLine, TrendArrow } from "./professor/charts";
import {
COHORT_BARS,
COHORT_META,
COHORT_TRENDS,
LEARNERS,
PENDING_REVIEWS,
TRIAGE,
type CompetencyCell,
type LearnerRow,
} from "./professor/mock";
type SortKey = "risk" | "name";
const STATE_LABEL: Record<LearnerRow["state"], string> = {
crit: "개입",
warn: "관찰",
ok: "양호",
};
/** 역량 셀 — 숫자 + 추세 화살표(색만 시맨틱). 절대 점수 박스 강조 금지. */
function MetricCell({ cell }: { cell: CompetencyCell }) {
const trend = trendOf(cell.delta, 1.5);
return (
<span className="pf-metric">
<span className="pf-metric__v tabular">{cell.value}</span>
<TrendArrow trend={trend} delta={cell.delta} />
</span>
);
}
export default function Professor() {
// 개입 큐: 펼쳐서 슈퍼바이저 코멘트를 그 자리에서 작성(교수 핵심 동선).
const [openId, setOpenId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [sent, setSent] = useState<Record<string, boolean>>({});
// 학습자 테이블 정렬(위험도 ↔ 이름).
const [sortKey, setSortKey] = useState<SortKey>("risk");
const [showAll, setShowAll] = useState(false);
const sortedLearners = useMemo(() => {
const rows = [...LEARNERS];
if (sortKey === "risk") rows.sort((a, b) => b.risk - a.risk);
else rows.sort((a, b) => a.name.localeCompare(b.name, "ko"));
return rows;
}, [sortKey]);
const visibleLearners = showAll ? sortedLearners : sortedLearners.slice(0, 7);
const hiddenCount = COHORT_META.total - visibleLearners.length;
const toggleRow = (id: string) => setOpenId((cur) => (cur === id ? null : id));
const submitComment = (id: string) => {
const text = (drafts[id] ?? "").trim();
if (!text) return;
// mock — 실제로는 POST /sessions/{id}/comment. 여기선 보낸 상태만 표시.
setSent((s) => ({ ...s, [id]: true }));
};
return (
<AppShell>
<style>{PF_CSS}</style>
<div className="pf-root">
{/* ── 페이지 헤드라인 (한 화면 한 메시지) ── */}
<header className="pf-head">
<Kicker>
{COHORT_META.course} · {COHORT_META.total} · {COHORT_META.week}
</Kicker>
<h1 className="pf-head__title">
, <em>{COHORT_META.needIntervention} </em>.
</h1>
<p className="pf-head__sub">
· . {COHORT_META.stable}
.
</p>
</header>
{/* ── 1. 개입 큐 (triage, 최우선 풀폭) ── */}
<section className="pf-block">
<div className="pf-sechead">
<div className="pf-sechead__lt">
<Kicker> </Kicker>
<p className="pf-sechead__desc">
.
.
</p>
</div>
</div>
<div className="pf-queue">
{TRIAGE.map((t) => {
const open = openId === t.id;
const wasSent = sent[t.id];
return (
<div key={t.id} className={"pf-qitem" + (open ? " is-open" : "")}>
<button
type="button"
className="pf-qrow"
aria-expanded={open}
onClick={() => toggleRow(t.id)}
>
<span className="pf-qrow__dot" aria-hidden="true">
<Dot tone="crit" size={8} />
</span>
<span className="pf-qrow__who">
<span className="pf-qrow__nm">{t.name}</span>
<span className="pf-qrow__yr">{t.meta}</span>
</span>
<span className="pf-qrow__why">
<b>{t.reasonLead}</b>
{t.trail ? <span className="pf-qrow__trail tabular"> {t.trail}</span> : null}
</span>
<span className="pf-qrow__last">
{t.lastPracticed}
<span className="pf-qrow__em">{t.signal}</span>
</span>
<span className="pf-qrow__go" aria-hidden="true">
<Icon name={open ? "chevron-left" : "chevron-right"} size={18} strokeWidth={2} />
</span>
</button>
{open ? (
<div className="pf-qpanel">
<div className="pf-qpanel__meta">
<span className="pf-qpanel__mk">
{t.existingComments > 0 ? (
<Badge tone="info"> {t.existingComments}</Badge>
) : (
<Badge tone="neutral"> </Badge>
)}
</span>
<a className="pf-qpanel__link" href={`#learner-${t.id}`}>
<Icon name="chevron-right" size={14} strokeWidth={2} />
</a>
</div>
{wasSent ? (
<div className="pf-qpanel__done">
<Icon name="check" size={16} strokeWidth={2} />
. .
</div>
) : (
<div className="pf-compose">
<label className="pf-compose__lab" htmlFor={`cmt-${t.id}`}>
</label>
<textarea
id={`cmt-${t.id}`}
className="pf-compose__ta"
rows={3}
placeholder={`${t.name} 학습자에게 남길 코멘트… (예: 위기 신호 직후 침묵을 견디는 연습을 권합니다)`}
value={drafts[t.id] ?? ""}
onChange={(e) =>
setDrafts((d) => ({ ...d, [t.id]: e.target.value }))
}
/>
<div className="pf-compose__act">
<span className="pf-compose__hint">
.
</span>
<Button
size="sm"
variant="primary"
disabled={!(drafts[t.id] ?? "").trim()}
onClick={() => submitComment(t.id)}
>
</Button>
</div>
</div>
)}
</div>
) : null}
</div>
);
})}
</div>
</section>
{/* ── 2. 검수 대기 회기 (코멘트 미작성, 시간순) ── */}
<section className="pf-block">
<div className="pf-sechead">
<div className="pf-sechead__lt">
<Kicker> </Kicker>
<p className="pf-sechead__desc">
. AI가
.
</p>
</div>
<Badge tone="accent">{PENDING_REVIEWS.length} </Badge>
</div>
<ul className="pf-review">
{PENDING_REVIEWS.map((r) => (
<li key={r.id} className="pf-review__row">
<a className="pf-review__link" href={`#review-${r.id}`}>
<span className="pf-review__who">
<span className="pf-review__nm">{r.learner}</span>
<span className="pf-review__case">{r.caseLabel}</span>
</span>
<span className="pf-review__sess tabular">{r.sessionNo}</span>
<span className="pf-review__flag">
{r.flagged > 0 ? (
<Badge tone="warn"> {r.flagged}</Badge>
) : (
<span className="pf-review__noflag"> </span>
)}
</span>
<span className="pf-review__time tabular">{r.endedAt}</span>
<span className="pf-review__go" aria-hidden="true">
<Icon name="chevron-right" size={16} strokeWidth={2} />
</span>
</a>
</li>
))}
</ul>
</section>
{/* ── 3. 담당 학습자 현황 (테이블 + sparkline) ── */}
<section className="pf-block">
<div className="pf-sechead">
<div className="pf-sechead__lt">
<Kicker> </Kicker>
<p className="pf-sechead__desc">
· .
</p>
</div>
<div className="pf-sechead__rt">
<div className="pf-sort" role="group" aria-label="정렬 기준">
<button
type="button"
className={"pf-sort__btn" + (sortKey === "risk" ? " is-on" : "")}
onClick={() => setSortKey("risk")}
>
</button>
<button
type="button"
className={"pf-sort__btn" + (sortKey === "name" ? " is-on" : "")}
onClick={() => setSortKey("name")}
>
</button>
</div>
</div>
</div>
<div className="pf-tablewrap">
<table className="pf-tbl">
<thead>
<tr>
<th></th>
<th className="pf-tbl__n"></th>
<th className="pf-tbl__n"></th>
<th className="pf-tbl__n"></th>
<th className="pf-tbl__n"></th>
<th className="pf-tbl__n"></th>
<th> ( 8)</th>
<th> </th>
<th></th>
</tr>
</thead>
<tbody>
{visibleLearners.map((l) => (
<tr key={l.id} className="pf-tbl__row">
<td>
<a className="pf-tbl__name" href={`#learner-${l.id}`}>
{l.name}
<span className="pf-tbl__id tabular">{l.id}</span>
</a>
</td>
<td className="pf-tbl__n tabular">{l.sessions}</td>
<td className="pf-tbl__m"><MetricCell cell={l.rapport} /></td>
<td className="pf-tbl__m"><MetricCell cell={l.technique} /></td>
<td className="pf-tbl__m"><MetricCell cell={l.intervention} /></td>
<td className="pf-tbl__m"><MetricCell cell={l.empathy} /></td>
<td className="pf-tbl__spark">
<SparkLine values={l.trend} />
</td>
<td className="pf-tbl__last tabular">{l.lastPracticed}</td>
<td className="pf-tbl__state">
<span className={"pf-statetag pf-statetag--" + l.state}>
<Dot
tone={l.state === "crit" ? "crit" : l.state === "warn" ? "warn" : "muted"}
size={7}
/>
{STATE_LABEL[l.state]}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pf-tbl__foot">
{COHORT_META.total} {visibleLearners.length}
{hiddenCount > 0 ? (
<>
{" · "}
<button type="button" className="pf-link" onClick={() => setShowAll(true)}>
{hiddenCount}
</button>
</>
) : (
<>
{" · "}
<button type="button" className="pf-link" onClick={() => setShowAll(false)}>
</button>
</>
)}
</div>
</section>
{/* ── 4. 반 전체 취약 영역 (가로 막대 + small-multiples + 인사이트) ── */}
<section className="pf-block">
<div className="pf-sechead">
<div className="pf-sechead__lt">
<Kicker> </Kicker>
<p className="pf-sechead__desc">
. .
</p>
</div>
</div>
<div className="pf-compare">
{/* 좌: 가로 막대(최저만 강조) + small-multiples 추세 */}
<div className="pf-compare__main">
<div className="pf-hbars">
{COHORT_BARS.map((b) => (
<div className="pf-hbar" key={b.label}>
<div className="pf-hbar__top">
<span className="pf-hbar__lb">{b.label}</span>
{b.weakest ? (
<span className="pf-hbar__tag">
<Icon name="alert" size={12} strokeWidth={2} />
</span>
) : null}
<span className="pf-hbar__vv tabular">{b.avg}</span>
</div>
<div className="pf-hbar__track">
<span
className={"pf-hbar__fill" + (b.weakest ? " is-weak" : "")}
style={{ width: `${b.avg}%` }}
/>
</div>
{b.note ? <p className="pf-hbar__note">{b.note}</p> : null}
</div>
))}
</div>
<div className="pf-mm__group" aria-label="반 평균 역량 추이(최근 6주)">
<span className="pf-mm__caption"> · 6 · 0100</span>
<div className="pf-mm__grid">
{COHORT_TRENDS.map((t, i) => (
<MiniMultiple
key={t.label}
label={t.label}
values={t.values}
delta={t.delta}
showAxis={i === 0}
/>
))}
</div>
</div>
</div>
{/* 우: 코칭 인사이트 콜아웃 (accent tint, 좌측바 없음 → tint+kicker+dot) */}
<aside className="pf-insight">
<span className="pf-insight__kicker">
<span className="pf-insight__dot" aria-hidden="true" />
</span>
<h3 className="pf-insight__h3">
<em> </em> .
</h3>
<p className="pf-insight__p">
19 1.8 . ··
.
?
</p>
<div className="pf-insight__act">
<Button
variant="primary"
trailing={<Icon name="chevron-right" size={16} strokeWidth={2} />}
>
</Button>
</div>
<p className="pf-insight__meta tabular">
{COHORT_BARS.find((b) => b.weakest)?.avg ?? 0} ·
{formatDelta(-2)}
</p>
</aside>
</div>
</section>
</div>
</AppShell>
);
}
/* =====================================================================
Login (<style>{CSS}>) .
. border-left 0 · 0 · ( 8/12, 6).
===================================================================== */
const PF_CSS = `
.pf-root{ max-width: var(--maxw); margin:0 auto; }
/* 헤드라인 */
.pf-head{ margin-bottom: var(--sp-7); }
.pf-head .vg-kicker{ margin-bottom: var(--sp-3); }
.pf-head__title{ font-size: var(--fs-h1); font-weight:700; letter-spacing:-0.02em; color:var(--text-strong); line-height:1.3; }
.pf-head__title em{ font-style:normal; color:var(--accent); }
.pf-head__sub{ font-size: var(--fs-lead); color:var(--text-muted); margin-top: var(--sp-2); line-height:1.55; }
/* 블록/섹션헤드 */
.pf-block{ margin-bottom: var(--sp-8); }
.pf-sechead{ display:flex; align-items:flex-end; justify-content:space-between; gap: var(--sp-4); margin-bottom: var(--sp-4); }
.pf-sechead__lt .vg-kicker{ margin-bottom: 6px; }
.pf-sechead__desc{ font-size: var(--fs-xs); color: var(--text-muted); max-width: 560px; line-height:1.5; }
.pf-sechead__rt{ display:flex; align-items:center; gap: var(--sp-2); flex:none; }
.pf-link{ background:none; border:none; padding:0; cursor:pointer; color:var(--accent); font-weight:600; font-size: var(--fs-xs); font-family:inherit; }
.pf-link:hover{ color: var(--accent-deep); }
/* 정렬 토글 */
.pf-sort{ display:inline-flex; gap:2px; background:var(--bg-surface-2); border:1px solid var(--border-subtle); border-radius: var(--radius); padding:2px; }
.pf-sort__btn{ border:none; background:none; cursor:pointer; font-family: var(--font-num); font-size: var(--fs-xs); font-weight:600; color:var(--text-muted); padding:5px 11px; border-radius: var(--radius-sm); transition: color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out); }
.pf-sort__btn:hover{ color: var(--text-body); }
.pf-sort__btn.is-on{ background: var(--bg-surface); color: var(--accent); box-shadow: var(--shadow-sm); }
/* ── 1. 개입 큐 ── */
.pf-queue{ background: var(--bg-surface); border:1px solid var(--border-subtle); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow:hidden; }
.pf-qitem{ border-top:1px solid var(--paper-2); }
.pf-qitem:first-child{ border-top:none; }
.pf-qrow{ width:100%; display:grid; grid-template-columns: 18px 150px 1fr 150px 22px; align-items:center; column-gap: var(--sp-4); padding: var(--sp-5); background:none; border:none; cursor:pointer; text-align:left; font-family:inherit; transition: background var(--dur-base) var(--ease-out); }
.pf-qrow:hover{ background: var(--accent-tint); }
.pf-qitem.is-open > .pf-qrow{ background: var(--accent-tint); }
.pf-qrow__dot{ display:inline-flex; }
.pf-qrow__who{ display:flex; flex-direction:column; gap:2px; }
.pf-qrow__nm{ font-size: var(--fs-body); font-weight:700; color: var(--text-strong); letter-spacing:-0.01em; }
.pf-qrow__yr{ font-size: var(--fs-xs); color: var(--text-muted); }
.pf-qrow__why{ font-size: var(--fs-sm); color: var(--text-body); line-height:1.5; }
.pf-qrow__why b{ font-weight:600; color: var(--text-strong); }
.pf-qrow__trail{ font-family: var(--font-num); font-variant-numeric: tabular-nums; color: var(--crit-text); font-weight:600; }
.pf-qrow__last{ font-size: var(--fs-xs); color: var(--text-muted); text-align:right; line-height:1.4; }
.pf-qrow__em{ display:block; color: var(--warn-text); font-weight:600; margin-top:2px; }
.pf-qrow__go{ display:flex; justify-content:flex-end; color: var(--text-muted); transition: color var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out); }
.pf-qrow:hover .pf-qrow__go{ color: var(--accent); transform: translateX(2px); }
.pf-qpanel{ padding: 0 var(--sp-5) var(--sp-5) calc(18px + var(--sp-4) + var(--sp-5)); animation: pf-fade var(--dur-base) var(--ease-out); }
.pf-qpanel__meta{ display:flex; align-items:center; gap: var(--sp-4); margin-bottom: var(--sp-3); }
.pf-qpanel__link{ display:inline-flex; align-items:center; gap:4px; font-size: var(--fs-xs); font-weight:600; color: var(--accent); text-decoration:none; }
.pf-qpanel__link:hover{ color: var(--accent-deep); }
.pf-qpanel__done{ display:inline-flex; align-items:center; gap:8px; font-size: var(--fs-sm); color: var(--pos-text); background: var(--pos-tint); border-radius: var(--radius); padding:10px 14px; }
.pf-compose{ display:flex; flex-direction:column; gap:8px; }
.pf-compose__lab{ font-size: var(--fs-sm); font-weight:600; color: var(--text-strong); }
.pf-compose__ta{ width:100%; resize:vertical; min-height:64px; background: var(--bg-surface-2); color: var(--text-strong); border:1px solid var(--border-strong); border-radius: var(--radius-sm); padding:10px 12px; font: var(--fs-sm)/1.55 var(--font-sans); transition: border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out); }
.pf-compose__ta::placeholder{ color: var(--neutral-400); }
.pf-compose__ta:focus{ outline:none; border-color: var(--border-focus); box-shadow:0 0 0 3px var(--focus-ring); }
.pf-compose__act{ display:flex; align-items:center; justify-content:space-between; gap: var(--sp-4); }
.pf-compose__hint{ font-size: var(--fs-xs); color: var(--text-muted); }
/* ── 2. 검수 대기 회기 ── */
.pf-review{ list-style:none; background: var(--bg-surface); border:1px solid var(--border-subtle); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow:hidden; }
.pf-review__row{ border-top:1px solid var(--paper-2); }
.pf-review__row:first-child{ border-top:none; }
.pf-review__link{ display:grid; grid-template-columns: 1fr 80px 130px 110px 20px; align-items:center; column-gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); text-decoration:none; transition: background var(--dur-base) var(--ease-out); }
.pf-review__link:hover{ background: var(--accent-tint); }
.pf-review__who{ display:flex; flex-direction:column; gap:2px; }
.pf-review__nm{ font-size: var(--fs-sm); font-weight:600; color: var(--text-strong); }
.pf-review__case{ font-size: var(--fs-xs); color: var(--text-muted); }
.pf-review__sess{ font-family: var(--font-num); font-size: var(--fs-xs); color: var(--text-body); text-align:right; }
.pf-review__flag{ display:flex; }
.pf-review__noflag{ font-size: var(--fs-xs); color: var(--text-muted); }
.pf-review__time{ font-family: var(--font-num); font-size: var(--fs-xs); color: var(--text-muted); text-align:right; }
.pf-review__go{ display:flex; justify-content:flex-end; color: var(--text-muted); transition: color var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out); }
.pf-review__link:hover .pf-review__go{ color: var(--accent); transform: translateX(2px); }
/* ── 3. 학습자 테이블 ── */
.pf-tablewrap{ overflow-x:auto; }
.pf-tbl{ width:100%; border-collapse:collapse; font-size: var(--fs-sm); }
.pf-tbl thead th{ font-size: var(--fs-xs); font-weight:600; color: var(--text-muted); letter-spacing:0.02em; text-align:left; padding:0 var(--sp-3) var(--sp-3); border-bottom:1px solid var(--border-strong); white-space:nowrap; }
.pf-tbl thead th.pf-tbl__n{ text-align:right; }
.pf-tbl tbody td{ padding: var(--sp-4) var(--sp-3); border-bottom:1px solid var(--paper-2); vertical-align:middle; }
.pf-tbl__row{ transition: background var(--dur-base) var(--ease-out); }
.pf-tbl__row:hover{ background: var(--accent-tint); }
.pf-tbl__name{ display:inline-flex; flex-direction:column; gap:1px; font-weight:600; color: var(--text-strong); text-decoration:none; white-space:nowrap; }
.pf-tbl__name:hover{ color: var(--accent); }
.pf-tbl__id{ font-size: 11.5px; font-weight:400; color: var(--text-muted); font-family: var(--font-num); }
.pf-tbl__n{ text-align:right; font-family: var(--font-num); font-variant-numeric: tabular-nums; color: var(--text-body); }
.pf-tbl__m{ text-align:right; }
.pf-metric{ display:inline-flex; align-items:center; gap:3px; justify-content:flex-end; font-family: var(--font-num); font-variant-numeric: tabular-nums; color: var(--text-strong); }
.pf-metric__v{ font-weight:600; }
.pf-tbl__spark{ width:80px; }
.pf-tbl__last{ color: var(--text-muted); font-family: var(--font-num); font-variant-numeric: tabular-nums; font-size: var(--fs-xs); white-space:nowrap; }
.pf-tbl__state{ white-space:nowrap; }
.pf-statetag{ display:inline-flex; align-items:center; gap:7px; font-size: var(--fs-sm); }
.pf-statetag--ok{ color: var(--text-muted); }
.pf-statetag--warn{ color: var(--warn-text); font-weight:500; }
.pf-statetag--crit{ color: var(--crit-text); font-weight:600; }
.pf-tbl__foot{ margin-top: var(--sp-4); font-size: var(--fs-xs); color: var(--text-muted); }
/* 추세 화살표 (charts.tsx 공통) */
.pf-ar{ display:inline-flex; align-items:center; }
.pf-ar--up{ color: var(--pos-solid); }
.pf-ar--down{ color: var(--warn-solid); }
.pf-ar--flat{ color: var(--text-muted); }
/* ── 4. 비교(7:5 비대칭) ── */
.pf-compare{ display:grid; grid-template-columns: 1.4fr 1fr; gap: var(--sp-7); align-items:start; }
.pf-compare__main{ display:flex; flex-direction:column; gap: var(--sp-7); }
.pf-hbars{ display:flex; flex-direction:column; gap: var(--sp-5); }
.pf-hbar__top{ display:flex; align-items:baseline; gap: var(--sp-3); margin-bottom:8px; }
.pf-hbar__lb{ font-size: var(--fs-sm); color: var(--text-strong); font-weight:500; }
.pf-hbar__tag{ display:inline-flex; align-items:center; gap:5px; font-size: var(--fs-xs); font-weight:600; color: var(--warn-text); }
.pf-hbar__tag svg{ color: var(--warn-solid); }
.pf-hbar__vv{ margin-left:auto; font-family: var(--font-num); font-variant-numeric: tabular-nums; font-size: var(--fs-xs); color: var(--text-body); font-weight:600; }
.pf-hbar__track{ height:8px; background: var(--paper-2); border-radius:4px; overflow:hidden; }
.pf-hbar__fill{ display:block; height:100%; border-radius:4px; background: var(--accent); transition: width var(--dur-slow) var(--ease-out); }
.pf-hbar__fill.is-weak{ background: var(--warn-solid); }
.pf-hbar__note{ font-size: var(--fs-xs); color: var(--text-muted); margin-top:7px; line-height:1.5; }
/* small-multiples */
.pf-mm__group{ border-top:1px solid var(--hair); padding-top: var(--sp-5); }
.pf-mm__caption{ display:block; font-size: var(--fs-xs); color: var(--text-muted); margin-bottom: var(--sp-3); }
.pf-mm__grid{ display:grid; grid-template-columns: repeat(2, 1fr); gap: var(--sp-4); }
.pf-mm{ background: var(--bg-surface); border:1px solid var(--border-subtle); border-radius: var(--radius); padding: var(--sp-4); }
.pf-mm__top{ margin-bottom:8px; }
.pf-mm__label{ font-size: var(--fs-xs); color: var(--text-body); font-weight:500; }
.pf-mm__chart{ width:100%; }
.pf-mm__foot{ display:flex; align-items:baseline; gap:5px; margin-top:6px; }
.pf-mm__val{ font-family: var(--font-num); font-variant-numeric: tabular-nums; font-size: var(--fs-h3); font-weight:700; color: var(--text-strong); letter-spacing:-0.02em; }
/* 인사이트 콜아웃 */
.pf-insight{ background: var(--accent-tint); border-radius: var(--radius-lg); padding: var(--sp-6); }
.pf-insight__kicker{ display:inline-flex; align-items:center; gap:8px; font-family: var(--font-num); font-size: var(--fs-kicker); font-weight:600; letter-spacing:0.04em; color: var(--accent-deep); margin-bottom: var(--sp-3); }
.pf-insight__dot{ width:7px; height:7px; border-radius:50%; background: var(--accent); flex:none; }
.pf-insight__h3{ font-size: var(--fs-lead); font-weight:600; color: var(--text-strong); line-height:1.5; letter-spacing:-0.01em; margin-bottom: var(--sp-3); }
.pf-insight__h3 em{ font-style:normal; color: var(--accent-deep); }
.pf-insight__p{ font-size: var(--fs-sm); color: var(--text-body); line-height:1.65; }
.pf-insight__act{ margin-top: var(--sp-5); }
.pf-insight__meta{ margin-top: var(--sp-4); font-size: var(--fs-xs); color: var(--text-muted); font-family: var(--font-num); }
@keyframes pf-fade{ from{ opacity:0; transform: translateY(-4px); } to{ opacity:1; transform:none; } }
@media (prefers-reduced-motion: reduce){
.pf-qpanel{ animation:none; }
.pf-hbar__fill, .pf-qrow__go, .pf-review__go, .pf-tbl__row, .pf-qrow, .pf-review__link{ transition:none; }
}
@media (max-width: 980px){
.pf-compare{ grid-template-columns: 1fr; }
.pf-qrow{ grid-template-columns: 18px 1fr 22px; row-gap:8px; }
.pf-qrow__why{ grid-column: 2 / 4; }
.pf-qrow__last{ grid-column: 2 / 4; text-align:left; }
}
@media (max-width: 640px){
.pf-mm__grid{ grid-template-columns: 1fr; }
}
`;