vignette/apps/web/src/pages/Professor.tsx
2026-06-28 12:18:20 +09:00

1638 lines
46 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
import {
ApiError,
personaReviewApi,
teacherApi,
type PersonaReviewAction,
type PersonaReviewStatus,
type PersonaReviewSummary,
type TeacherLearnerGrowth,
type TeacherGrowthPoint,
type TeacherSafetyAlert,
type TeacherDashboardResponse,
} from "../lib/api";
type LoadState = "loading" | "ready" | "error";
function formatDateTime(value: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function formatScore(value: number | null | undefined): string {
if (typeof value !== "number" || Number.isNaN(value)) return "평가 부족";
return `${Math.round(value * 100)}%`;
}
function formatDelta(value: number | null | undefined): string {
if (typeof value !== "number" || Number.isNaN(value)) return "변화 부족";
const sign = value > 0 ? "+" : "";
return `${sign}${Math.round(value * 100)}%p`;
}
function trendLabel(value: string): string {
if (value === "up") return "상승";
if (value === "down") return "하락";
if (value === "flat") return "유지";
return "평가 부족";
}
function sessionOpenLabel(status: string): string {
return status === "ended" ? "상세 리뷰" : "진행 기록";
}
function sessionReviewStatusLabel(status: string | null | undefined): string {
if (status === "closed") return "검토 완료";
if (status === "viewed") return "메모 저장";
return "검토 대기";
}
function sessionReviewTone(status: string | null | undefined): "accent" | "neutral" | "warn" {
if (status === "closed") return "accent";
if (status === "viewed") return "warn";
return "neutral";
}
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
if (status === "review") return "검수 대기";
if (status === "draft") return "수정 대기";
if (status === "approved") return "승인됨";
return "보관됨";
}
function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" | "warn" {
if (status === "review") return "accent";
if (status === "draft") return "warn";
return "neutral";
}
function EmptyState({ title, desc }: { title: string; desc: string }) {
return (
<div className="pf-empty">
<b>{title}</b>
<span>{desc}</span>
</div>
);
}
export default function Professor() {
const navigate = useNavigate();
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [error, setError] = useState<string | null>(null);
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
const [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const loadDashboard = useCallback(async () => {
setLoadState("loading");
setError(null);
try {
const next = await teacherApi.dashboard();
setDashboard(next);
setUpdatedAt(new Date());
setLoadState("ready");
} catch (err) {
setError(err instanceof Error ? err.message : "교수자 대시보드를 불러오지 못했습니다.");
setLoadState("error");
}
}, []);
const loadPersonaReviews = useCallback(async () => {
setPersonaReviewLoading(true);
setPersonaReviewError(null);
try {
const next = await personaReviewApi.list();
setPersonaReviews(next);
} catch (err) {
// 404(검수 엔드포인트 부재/검수 데이터 없음)는 장애가 아니므로
// 빨간 에러 배너 대신 정상 빈 상태로만 표시한다.
if (err instanceof ApiError && err.status === 404) {
setPersonaReviews([]);
} else {
// 진짜 장애(5xx/네트워크)일 때만 정돈된 에러 카드를 노출한다.
setPersonaReviewError("검수 데이터를 불러오지 못했습니다.");
}
} finally {
setPersonaReviewLoading(false);
}
}, []);
const decidePersonaReview = useCallback(
async (personaId: string, action: PersonaReviewAction) => {
setPersonaDecisionBusy(`${personaId}:${action}`);
setPersonaReviewError(null);
try {
const next = await personaReviewApi.decide(personaId, action);
setPersonaReviews((current) => {
if (next.status === "draft" || next.status === "review") {
return current.map((item) => (item.persona_id === next.persona_id ? next : item));
}
return current.filter((item) => item.persona_id !== next.persona_id);
});
} catch (err) {
console.warn("[professor] failed to decide persona review", err);
setPersonaReviewError("페르소나 검수 결정을 저장하지 못했습니다.");
} finally {
setPersonaDecisionBusy(null);
}
},
[],
);
useEffect(() => {
void loadDashboard();
void loadPersonaReviews();
}, [loadDashboard, loadPersonaReviews]);
const kpis = useMemo(
() => [
{
label: "검토 대기",
value: dashboard?.pending_reviews?.length ?? 0,
hint: "종료 회기",
icon: "review" as const,
emphasis: "primary" as const,
},
{
label: "위기 알림",
value: dashboard?.safety_alerts?.length ?? 0,
hint: "109 확인",
icon: "alert" as const,
emphasis: "warn" as const,
},
{
label: "페르소나 검수",
value: personaReviews.length,
hint: "승인 대기",
icon: "check" as const,
},
{
label: "최근 세션",
value: dashboard?.recent_sessions?.length ?? 0,
hint: "종료 · 진행",
icon: "play" as const,
},
{
label: "전체 학생",
value: dashboard?.total_learners ?? 0,
hint: "담당 코호트",
icon: "users" as const,
},
{
label: "활성 세션",
value: dashboard?.active_sessions ?? 0,
hint: "진행 중",
icon: "play" as const,
},
],
[dashboard, personaReviews.length],
);
const pendingCount = dashboard?.pending_reviews?.length ?? 0;
const hasPending = pendingCount > 0;
const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0);
const personaReviewCount = personaReviews.length;
const safetyAlerts = dashboard?.safety_alerts ?? [];
const learnerGrowth = dashboard?.learner_growth ?? [];
const pendingReviews = dashboard?.pending_reviews ?? [];
const recentSessions = dashboard?.recent_sessions ?? [];
const firstPendingReview = pendingReviews[0];
return (
<AppShell navRole="teacher" wide>
<style>{PF_CSS}</style>
<main className="pf-root">
<header className="pf-head">
<div>
<Kicker> </Kicker>
<h1>
{hasPending ? `${pendingCount}건의 리뷰가 대기 중입니다.` : "검토할 실제 회기가 없습니다."}
</h1>
<p>
. ,
.
</p>
</div>
<div className="pf-head__actions">
{firstPendingReview ? (
<Button
leading={<Icon name="review" size={16} />}
onClick={() => navigate(`/teach/session/${firstPendingReview.session_id}/review`)}
>
</Button>
) : null}
<Button
variant="secondary"
leading={<Icon name="review" size={16} />}
onClick={() => {
void loadDashboard();
void loadPersonaReviews();
}}
disabled={loadState === "loading" || personaReviewLoading}
>
{loadState === "loading" || personaReviewLoading ? "확인 중" : "새로고침"}
</Button>
</div>
</header>
<section className="pf-signal-strip" aria-label="교수자 검토 요약">
<section className={`pf-triage ${hasPending ? "is-active" : ""}`} role="status">
<Dot tone={loadState === "error" ? "crit" : "accent"} size={9} />
<div className="pf-triage__copy">
<Kicker> </Kicker>
<b>{dashboard?.message ?? "학습 세션을 확인하는 중입니다."}</b>
<span>
{dashboard ? `${dashboard.cohort_label} · 실제 기록` : "확인 중"}
{updatedAt ? ` · ${updatedAt.toLocaleTimeString("ko-KR")} 갱신` : ""}
</span>
</div>
<div className="pf-triage__meta">
<span>
<b>{pendingCount}</b>
<small> </small>
</span>
<span>
<b>{totalSessions}</b>
<small> </small>
</span>
</div>
</section>
<section className="pf-kpis" aria-label="교수자 요약">
{kpis.map((kpi) => {
const emphasis = "emphasis" in kpi ? kpi.emphasis : null;
return (
<div
className={`pf-kpi ${emphasis ? `pf-kpi--${emphasis}` : ""}`}
key={kpi.label}
>
<span className="pf-kpi__ic" aria-hidden="true">
<Icon name={kpi.icon} size={15} strokeWidth={1.9} />
</span>
<span className="pf-kpi__lab">{kpi.label}</span>
<b>{kpi.value}</b>
<small>{kpi.hint}</small>
</div>
);
})}
</section>
</section>
{error ? (
<section className="pf-error" role="alert">
<Icon name="alert" size={18} />
{error}
</section>
) : null}
<section className="pf-section pf-section--growth">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>· </h2>
</div>
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}>
{learnerGrowth.length}
</Badge>
</div>
<Card className="pf-panel pf-growth-panel">
{learnerGrowth.length > 0 ? (
<div className="pf-growth-list">
{learnerGrowth.map((learner) => (
<GrowthCard learner={learner} key={learner.learner_id} />
))}
</div>
) : (
<EmptyState
title={loadState === "loading" ? "성장 지표 계산 중" : "표시할 성장 이력 없음"}
desc="학습자 회기와 턴별 평가가 쌓이면 적절성·라포·기법 사용 추이를 표시합니다."
/>
)}
</Card>
</section>
<section className="pf-workspace">
<div className="pf-queue-stack">
<section className="pf-section pf-section--pending">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone="accent">{pendingReviews.length}</Badge>
</div>
<Card className="pf-panel">
{pendingReviews.length > 0 ? (
<div className="pf-list">
{pendingReviews.map((session) => (
<button
type="button"
className="pf-session pf-session--action"
key={session.session_id}
data-pending-review-row="true"
aria-label={`${session.learner_label} ${session.persona_code} 회기 상세 검토`}
onClick={() => navigate(`/teach/session/${session.session_id}/review`)}
>
<span className="pf-session__dot" aria-hidden="true" />
<div className="pf-session__main">
<b>{session.learner_label}</b>
<span>
{session.persona_code} · {session.stage} · {session.turn_count}
</span>
<code>{session.session_id}</code>
</div>
<div className="pf-session__meta">
<span>{formatDateTime(session.ended_at ?? null)}</span>
<Badge tone={sessionReviewTone(session.review_status)}>
{sessionReviewStatusLabel(session.review_status)}
</Badge>
</div>
<span className="pf-session__open">
<Icon name="review" size={13} />
</span>
</button>
))}
</div>
) : (
<EmptyState
title="검토할 실제 회기가 없습니다"
desc="종료된 회기를 검토 완료로 닫으면 이 목록에서 사라집니다."
/>
)}
</Card>
</section>
<section className="pf-section pf-section--safety">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>109 </h2>
</div>
<Badge tone={safetyAlerts.length > 0 ? "warn" : "neutral"}>
{safetyAlerts.length}
</Badge>
</div>
<Card className="pf-panel">
{safetyAlerts.length > 0 ? (
<div className="pf-alerts">
{safetyAlerts.map((alert) => (
<SafetyAlertRow alert={alert} key={alert.id} />
))}
</div>
) : (
<EmptyState
title="현재 위기 알림 없음"
desc="실제 위기 신호가 감지되면 이 목록에 109 확인 큐로 표시됩니다."
/>
)}
</Card>
</section>
<section className="pf-section pf-section--persona">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone={personaReviewCount > 0 ? "accent" : "neutral"}>
{personaReviewCount}
</Badge>
</div>
<Card className="pf-panel">
{personaReviewError ? (
<div className="pf-empty" role="alert">
<Icon name="alert" size={18} />
<b>{personaReviewError}</b>
<span> .</span>
<Button
size="sm"
variant="secondary"
leading={<Icon name="review" size={14} />}
onClick={() => void loadPersonaReviews()}
disabled={personaReviewLoading}
>
{personaReviewLoading ? "확인 중" : "다시 시도"}
</Button>
</div>
) : personaReviews.length > 0 ? (
<div className="pf-personas">
{personaReviews.map((persona) => {
return (
<article
className="pf-persona"
key={`${persona.persona_id}:${persona.version}`}
data-persona-review-row="true"
>
<div className="pf-persona__main">
<span className="pf-persona__code">{persona.code}</span>
<div>
<b>{persona.display_name}</b>
<span>
v{persona.version} · {persona.difficulty} ·{" "}
{persona.theory_target.join(" · ") || "공통"}
</span>
</div>
</div>
<p>{persona.source_provenance || "출처 정보 없음"}</p>
<div className="pf-persona__meta">
<Badge tone={personaReviewTone(persona.status)}>
{personaReviewStatusLabel(persona.status)}
</Badge>
<span>{formatDateTime(persona.created_at ?? null)}</span>
</div>
<div className="pf-persona__actions">
{persona.status === "review" ? (
<Button
size="sm"
variant="primary"
leading={<Icon name="check" size={14} />}
onClick={() => void decidePersonaReview(persona.persona_id, "approve")}
disabled={personaDecisionBusy === `${persona.persona_id}:approve`}
>
</Button>
) : null}
<Button
size="sm"
variant="secondary"
leading={<Icon name="review" size={14} />}
onClick={() =>
navigate(`/teach/personas?draft=${encodeURIComponent(persona.persona_id)}`)
}
>
</Button>
</div>
</article>
);
})}
</div>
) : (
<EmptyState
title={personaReviewLoading ? "페르소나 검수 큐 확인 중" : "검수 대기 페르소나 없음"}
desc="draft 또는 review 상태의 페르소나가 등록되면 스튜디오에서 검수합니다."
/>
)}
</Card>
</section>
<section className="pf-section pf-section--draft">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone="accent"> </Badge>
</div>
<Card className="pf-panel pf-studio-card">
<div className="pf-studio-card__copy">
<b> </b>
<p>
, , ·, ,
.
</p>
<div className="pf-studio-card__meta">
<span> {personaReviewCount}</span>
<span> </span>
</div>
</div>
<div className="pf-studio-card__actions">
<Button
variant="secondary"
leading={<Icon name="review" size={16} />}
onClick={() => navigate("/teach/personas")}
>
</Button>
</div>
</Card>
</section>
</div>
<section className="pf-section pf-section--recent">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone="neutral">{recentSessions.length}</Badge>
</div>
<Card className="pf-panel">
{recentSessions.length > 0 ? (
<div className="pf-recent-list">
<div className="pf-recent-head" aria-hidden="true">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{recentSessions.map((session) => {
const openLabel = sessionOpenLabel(session.status);
return (
<button
type="button"
className="pf-recent-row pf-recent-row--action"
key={session.session_id}
data-recent-session-row="true"
aria-label={`${session.learner_label} ${session.persona_code} ${openLabel}`}
onClick={() => navigate(`/teach/session/${session.session_id}/review`)}
>
<div className="pf-recent__learner">
<b>{session.learner_label}</b>
<code>{session.session_id}</code>
</div>
<span className="pf-recent__cell" data-label="페르소나">
{session.persona_code}
</span>
<span className="pf-recent__cell" data-label="상태">
<Badge tone={session.status === "ended" ? "neutral" : "accent"}>
{session.status === "ended" ? "종료" : "진행 중"}
</Badge>
{session.status === "ended" ? (
<small className="pf-recent__review-state">
{sessionReviewStatusLabel(session.review_status)}
</small>
) : null}
</span>
<span className="pf-recent__cell" data-label="단계">
{session.stage}
</span>
<span className="pf-recent__cell tabular" data-label="턴">
{session.turn_count}
</span>
<span className="pf-recent__cell tabular" data-label="시작">
{formatDateTime(session.started_at)}
</span>
<span className="pf-recent__cell tabular" data-label="종료">
{formatDateTime(session.ended_at ?? null)}
</span>
<span className="pf-recent__cell pf-recent__cell--open" data-label="열기">
<span className="pf-recent__open">
<Icon name="review" size={13} />
{openLabel}
</span>
</span>
</button>
);
})}
</div>
) : (
<EmptyState
title="세션 로그 없음"
desc="현재 표시할 학습 세션이 없습니다."
/>
)}
</Card>
</section>
</section>
</main>
</AppShell>
);
}
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
const points = learner.points ?? [];
const topTechniques = learner.top_techniques ?? [];
const recentPoints = points.slice(-3).reverse();
return (
<article className={`pf-growth-card trend-${learner.trend}`}>
<div className="pf-growth-card__top">
<div className="pf-growth-card__id">
<b>{learner.learner_label}</b>
<span>
{learner.ended_sessions}/{learner.sessions} · {formatDateTime(learner.latest_at)}
</span>
</div>
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
{trendLabel(learner.trend)}
</Badge>
</div>
<div className="pf-growth-card__metrics" aria-label="학습자 성장 요약">
<span>
<small> </small>
<b>{formatScore(learner.latest_score)}</b>
</span>
<span>
<small></small>
<b>{formatDelta(learner.score_delta)}</b>
</span>
<span>
<small> </small>
<b>{formatScore(learner.avg_rapport == null ? null : (learner.avg_rapport + 1) / 2)}</b>
</span>
</div>
<div className="pf-growth-bars" aria-label="회기별 적절성 추이">
{points.map((point) => (
<GrowthBar point={point} key={point.session_id} />
))}
</div>
<div className="pf-growth-card__tags">
{topTechniques.length > 0 ? (
topTechniques.map((tag) => <span key={tag}>{tag}</span>)
) : (
<span> </span>
)}
</div>
<div className="pf-growth-card__points">
{recentPoints.length > 0 ? (
recentPoints.map((point) => (
<div className="pf-growth-point" key={point.session_id}>
<b>
{point.persona_code} · {point.session_no}
</b>
<span>
{formatScore(point.score)} · {point.technique_count} · {point.watch_count}
</span>
</div>
))
) : (
<div className="pf-growth-point">
<b> </b>
<span> .</span>
</div>
)}
</div>
</article>
);
}
function GrowthBar({ point }: { point: TeacherGrowthPoint }) {
const hasScore = typeof point.score === "number" && !Number.isNaN(point.score);
const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10;
return (
<span
className={`pf-growth-bar ${hasScore ? "" : "is-empty"}`}
title={`${point.persona_code} ${point.session_no}회기 · ${formatScore(point.score)}`}
>
<i style={{ height: `${height}%` }} />
<small>{point.session_no}</small>
</span>
);
}
function SafetyAlertRow({ alert }: { alert: TeacherSafetyAlert }) {
return (
<article className="pf-alert">
<span className="pf-alert__ic" aria-hidden="true">
<Icon name="alert" size={16} />
</span>
<div className="pf-alert__main">
<b>{alert.learner_label}</b>
<span>
{alert.persona_code || "세션"} · {alert.ko_risk_level} ·{" "}
{formatDateTime(alert.created_at)}
</span>
<code>{alert.session_id}</code>
</div>
<div className="pf-alert__resource">
<span>{alert.resource_title}</span>
<b>{alert.resource_number}</b>
</div>
</article>
);
}
const PF_CSS = `
.pf-root{
max-width:1280px;
margin:0 auto;
display:flex;
flex-direction:column;
gap:16px;
}
.pf-head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:var(--sp-4);
flex-wrap:wrap;
}
.pf-head__actions{
display:flex;
align-items:center;
justify-content:flex-end;
gap:8px;
flex-wrap:wrap;
}
.pf-head h1{
margin:6px 0 0;
color:var(--text-strong);
font-size:var(--fs-h2);
line-height:1.28;
letter-spacing:0;
}
.pf-head p{
margin:6px 0 0;
max-width:760px;
color:var(--text-body);
font-size:var(--fs-xs);
line-height:1.55;
}
.pf-signal-strip{
order:1;
display:grid;
grid-template-columns:1fr;
gap:0;
min-width:0;
}
.pf-triage{
display:grid;
grid-template-columns:auto minmax(0,1fr) auto;
align-items:center;
gap:12px;
min-width:0;
padding:14px 16px;
border:1px solid var(--hair);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-triage.is-active{
background:color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface));
}
.pf-triage__copy{
min-width:0;
}
.pf-triage__copy .vg-kicker{
margin-bottom:4px;
}
.pf-triage__copy b{
display:block;
color:var(--text-strong);
font-size:var(--fs-body);
line-height:1.35;
}
.pf-triage__copy span,
.pf-triage__meta small{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.pf-triage__meta{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:0;
min-width:180px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 86%,transparent);
overflow:hidden;
}
.pf-triage__meta span{
display:grid;
align-content:center;
gap:2px;
padding:9px 12px;
}
.pf-triage__meta span + span{
border-left:1px solid var(--hair);
}
.pf-triage__meta b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:17px;
line-height:1;
}
.pf-error{
display:flex;
align-items:center;
gap:10px;
padding:12px 14px;
border-radius:var(--radius);
background:var(--crit-tint);
color:var(--crit-text);
font-size:var(--fs-sm);
}
.pf-kpis{
display:grid;
grid-template-columns:repeat(6,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius);
overflow:hidden;
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-kpi{
position:relative;
min-width:0;
padding:14px 16px;
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:4px 10px;
}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;}
.pf-kpi--primary,
.pf-kpi--warn{
background:color-mix(in srgb,var(--accent-tint) 32%,var(--bg-surface));
}
.pf-kpi--warn{
background:color-mix(in srgb,var(--warn-tint) 36%,var(--bg-surface));
}
.pf-kpi--primary .pf-kpi__ic{
color:var(--text-on-accent);
background:var(--accent);
}
.pf-kpi--warn .pf-kpi__ic{
color:var(--warn-text);
background:var(--warn-tint);
}
.pf-kpi__ic{
grid-column:2;
grid-row:1 / span 3;
width:30px;
height:30px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--accent);
background:var(--accent-tint);
}
.pf-kpi__lab{
display:block;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
}
.pf-kpi b{
display:block;
color:var(--text-strong);
font-family:var(--font-num);
font-size:24px;
line-height:1;
}
.pf-kpi small{
color:var(--text-muted);
font-size:12px;
line-height:1.35;
}
.pf-workspace{
order:2;
display:grid;
grid-template-columns:minmax(320px,390px) minmax(0,1fr);
align-items:start;
gap:14px;
min-width:0;
}
.pf-queue-stack{
display:flex;
flex-direction:column;
gap:14px;
min-width:0;
}
.pf-section{
display:flex;
flex-direction:column;
gap:10px;
min-width:0;
}
.pf-section__head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:12px;
}
.pf-section__head h2{
margin:4px 0 0;
color:var(--text-strong);
font-size:var(--fs-body);
font-weight:700;
line-height:1.3;
letter-spacing:0;
}
.pf-panel{
padding:0;
overflow:hidden;
}
.pf-studio-card{
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:14px;
align-items:center;
padding:14px;
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
.pf-studio-card__copy{
min-width:0;
display:grid;
gap:7px;
}
.pf-studio-card__copy b{
color:var(--text-strong);
font-size:var(--fs-body);
}
.pf-studio-card__copy p{
margin:0;
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.55;
}
.pf-studio-card__meta{
display:flex;
flex-wrap:wrap;
gap:6px;
}
.pf-studio-card__meta span{
padding:4px 7px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--text-body);
background:var(--bg-surface-2);
font-size:11px;
line-height:1.2;
}
.pf-studio-card__actions{
display:flex;
justify-content:flex-end;
}
.pf-section--growth{
order:3;
min-width:0;
}
.pf-growth-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
.pf-growth-list{
max-height:min(420px,44vh);
overflow:auto;
scrollbar-gutter:stable;
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:12px;
padding:12px;
}
.pf-growth-card{
min-width:0;
display:flex;
flex-direction:column;
gap:12px;
padding:13px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface) 82%,var(--bg-surface-2));
box-shadow:none;
}
.pf-growth-card__top{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
min-width:0;
}
.pf-growth-card__id{
min-width:0;
display:grid;
gap:3px;
}
.pf-growth-card__id b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__id span,
.pf-growth-card__metrics small,
.pf-growth-point span,
.pf-recent__review-state{
color:var(--text-muted);
font-size:12px;
line-height:1.4;
}
.pf-recent__review-state{
display:block;
margin-top:3px;
line-height:1.2;
}
.pf-growth-card__metrics{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:8px;
}
.pf-growth-card__metrics span{
min-width:0;
display:grid;
gap:3px;
padding:9px 10px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.pf-growth-card__metrics b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:15px;
line-height:1.15;
white-space:nowrap;
}
.pf-growth-bars{
height:82px;
display:flex;
align-items:flex-end;
gap:6px;
padding:8px 8px 6px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface-2) 82%,transparent);
}
.pf-growth-bar{
flex:1 1 0;
min-width:14px;
height:100%;
display:grid;
grid-template-rows:minmax(0,1fr) 14px;
gap:4px;
align-items:end;
}
.pf-growth-bar i{
display:block;
width:100%;
min-height:6px;
border-radius:6px 6px 3px 3px;
background:linear-gradient(180deg,var(--accent),var(--accent-deep));
}
.pf-growth-bar.is-empty i{
background:repeating-linear-gradient(135deg,var(--paper-2),var(--paper-2) 3px,var(--hair) 3px,var(--hair) 6px);
}
.pf-growth-bar small{
color:var(--text-muted);
font-family:var(--font-num);
font-size:10px;
text-align:center;
line-height:1;
}
.pf-growth-card__tags{
min-height:26px;
display:flex;
flex-wrap:wrap;
gap:6px;
align-content:flex-start;
}
.pf-growth-card__tags span{
max-width:100%;
padding:4px 7px;
border:1px solid var(--paper-2);
border-radius:999px;
color:var(--text-body);
background:var(--bg-surface-2);
font-size:11px;
line-height:1.2;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__points{
display:grid;
gap:7px;
}
.pf-growth-point{
min-width:0;
display:grid;
grid-template-columns:minmax(86px,.5fr) minmax(0,1fr);
gap:8px;
align-items:center;
}
.pf-growth-point b{
color:var(--text-strong);
font-size:12px;
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-point span{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-empty{
min-height:118px;
display:grid;
place-items:center;
gap:6px;
padding:var(--sp-5);
text-align:center;
}
.pf-empty b{
color:var(--text-strong);
font-size:var(--fs-body);
}
.pf-empty span{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.pf-list{
max-height:min(320px,42vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-personas{
max-height:min(320px,42vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-persona{
display:grid;
grid-template-columns:minmax(0,1fr);
gap:8px;
align-items:start;
padding:12px;
border-top:1px solid var(--paper-2);
}
.pf-persona:first-child{border-top:0;}
.pf-persona__main{
min-width:0;
display:grid;
grid-template-columns:40px minmax(0,1fr);
gap:10px;
align-items:center;
}
.pf-persona__code{
width:40px;
height:32px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--accent-deep);
background:var(--accent-tint);
font-family:var(--font-num);
font-weight:800;
font-size:12px;
}
.pf-persona__main b{
display:block;
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona__main span,
.pf-persona p,
.pf-persona__meta span{
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.45;
}
.pf-persona p{
margin:0;
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona__meta{
display:flex;
align-items:center;
gap:10px;
justify-content:space-between;
white-space:nowrap;
}
.pf-persona__actions{
display:flex;
justify-content:flex-end;
gap:8px;
min-width:0;
}
.pf-persona__actions .vg-btn{
min-width:72px;
padding-inline:10px;
}
.pf-alerts{
max-height:min(300px,38vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-alert{
display:grid;
grid-template-columns:auto minmax(0,1fr) auto;
gap:10px;
align-items:center;
padding:12px;
border-top:1px solid var(--paper-2);
background:color-mix(in srgb,var(--warn-tint) 34%,transparent);
}
.pf-alert:first-child{border-top:0;}
.pf-alert__ic{
width:30px;
height:30px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--warn-text);
background:var(--warn-tint);
}
.pf-alert__main{
min-width:0;
display:grid;
gap:3px;
}
.pf-alert__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
}
.pf-alert__main span,
.pf-alert__main code,
.pf-alert__resource span{
color:var(--text-muted);
font-size:12px;
}
.pf-alert__main code{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-alert__resource{
display:grid;
gap:2px;
justify-items:end;
min-width:86px;
}
.pf-alert__resource b{
color:var(--warn-text);
font-family:var(--font-num);
font-size:18px;
}
.pf-session{
width:100%;
font:inherit;
text-align:left;
background:transparent;
color:inherit;
display:grid;
grid-template-columns:8px minmax(0,1fr) auto;
gap:8px 10px;
align-items:start;
padding:12px;
border:0;
border-top:1px solid var(--paper-2);
cursor:default;
}
.pf-session:first-child{border-top:0;}
.pf-session--action{
cursor:pointer;
}
.pf-session--action:hover{
background:var(--bg-surface-2);
}
.pf-session--action:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-session__dot{
width:8px;
height:8px;
border-radius:50%;
background:var(--accent);
}
.pf-session__main{
min-width:0;
display:flex;
flex-direction:column;
gap:3px;
}
.pf-session__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
}
.pf-session__main span,.pf-session__main code{
color:var(--text-muted);
font-size:var(--fs-xs);
overflow-wrap:anywhere;
}
.pf-session__main code,.pf-recent__learner code{
font-family:var(--font-num);
overflow:hidden;
text-overflow:ellipsis;
}
.pf-session__meta{
grid-column:2 / 4;
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
color:var(--text-muted);
font-family:var(--font-num);
font-size:var(--fs-xs);
white-space:normal;
}
.pf-session__open{
grid-column:3;
grid-row:1;
display:inline-flex;
align-items:center;
gap:5px;
align-self:center;
justify-self:end;
min-height:30px;
padding:0 9px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-size:var(--fs-xs);
font-weight:760;
white-space:nowrap;
}
.pf-session--action:hover .pf-session__open{
border-color:var(--accent);
}
.pf-recent-list{
max-height:min(620px,calc(100vh - 220px));
overflow:auto;
scrollbar-gutter:stable;
min-width:0;
}
.pf-recent-head,
.pf-recent-row{
display:grid;
grid-template-columns:
minmax(128px,1.25fr)
minmax(58px,.55fr)
minmax(66px,.55fr)
minmax(82px,.85fr)
minmax(32px,.35fr)
minmax(70px,.6fr)
minmax(66px,.55fr)
minmax(86px,.65fr);
align-items:center;
gap:8px;
min-width:0;
}
.pf-recent-head{
position:sticky;
top:0;
z-index:1;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
padding:9px 12px;
border-bottom:1px solid var(--hair);
background:var(--bg-surface);
white-space:nowrap;
}
.pf-recent-row{
width:100%;
font:inherit;
text-align:left;
color:inherit;
background:transparent;
padding:10px 12px;
border:0;
border-top:1px solid var(--paper-2);
}
.pf-recent-row--action{
cursor:pointer;
}
.pf-recent-row--action:hover{
background:var(--bg-surface-2);
}
.pf-recent-row--action:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-recent-head + .pf-recent-row{
border-top:0;
}
.pf-recent__learner,
.pf-recent__cell{
min-width:0;
color:var(--text-body);
font-size:var(--fs-sm);
overflow-wrap:anywhere;
}
.pf-recent__cell::before{
display:none;
}
.pf-recent__learner{
min-width:0;
display:flex;
flex-direction:column;
gap:3px;
}
.pf-recent__learner b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow-wrap:anywhere;
}
.pf-recent__learner code{
max-width:100%;
color:var(--text-muted);
font-size:11px;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.pf-recent__cell .vg-badge{
justify-self:start;
}
.pf-recent__cell--open{
display:flex;
justify-content:flex-end;
}
.pf-recent__open{
display:inline-flex;
align-items:center;
justify-content:center;
gap:4px;
min-height:30px;
padding:0 7px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-size:var(--fs-xs);
font-weight:760;
white-space:nowrap;
}
.pf-recent-row--action:hover .pf-recent__open{
border-color:var(--accent);
}
@media (max-width:1100px){
.pf-signal-strip,
.pf-workspace{
grid-template-columns:1fr;
}
.pf-triage{
grid-template-columns:auto minmax(0,max-content) auto;
justify-content:start;
}
.pf-kpis{
grid-template-columns:repeat(4,minmax(0,1fr));
}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;}
.pf-growth-list{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-list,
.pf-personas{
max-height:360px;
}
.pf-recent-list{
max-height:460px;
}
}
@media (max-width:860px){
.pf-head__actions{
width:100%;
justify-content:flex-start;
}
.pf-triage{
grid-template-columns:auto minmax(0,1fr);
}
.pf-triage__meta{
grid-column:1 / -1;
width:100%;
}
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi:nth-child(n+2){border-left:0;}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-growth-list{
grid-template-columns:1fr;
}
.pf-recent-list{
display:flex;
flex-direction:column;
gap:10px;
padding:10px;
background:var(--bg-surface-2);
overflow-x:hidden;
}
.pf-recent-head{
display:none;
}
.pf-recent-row{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px 12px;
padding:12px;
border:1px solid var(--paper-2);
border-radius:var(--radius);
background:var(--bg-surface);
}
.pf-recent__learner{
grid-column:1 / -1;
padding-bottom:10px;
border-bottom:1px solid var(--paper-2);
}
.pf-recent__learner code{
white-space:normal;
overflow-wrap:anywhere;
}
.pf-recent__cell{
display:grid;
grid-template-columns:minmax(74px,.4fr) minmax(0,1fr);
gap:8px;
align-items:center;
}
.pf-recent__cell--open{
justify-content:stretch;
}
.pf-recent__cell::before{
display:block;
content:attr(data-label);
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
line-height:1.35;
}
.pf-session{
grid-template-columns:8px minmax(0,1fr);
}
.pf-session__meta{
grid-column:2;
justify-content:flex-start;
}
.pf-session__open{
grid-column:2;
grid-row:auto;
justify-self:start;
}
}
@media (max-width:520px){
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi,
.pf-kpi + .pf-kpi{border-left:0;}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-persona__actions{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-persona__actions .vg-btn{
width:100%;
}
.pf-growth-list{
padding:10px;
}
.pf-growth-card__metrics{
grid-template-columns:1fr;
}
.pf-growth-point{
grid-template-columns:1fr;
gap:2px;
}
.pf-growth-point b,
.pf-growth-point span{
white-space:normal;
}
.pf-alert{
grid-template-columns:auto minmax(0,1fr);
}
.pf-alert__resource{
grid-column:2;
justify-items:start;
}
.pf-recent-list{
padding:8px;
}
.pf-recent-row{
grid-template-columns:1fr;
gap:10px;
}
.pf-recent__cell{
grid-template-columns:minmax(64px,.32fr) minmax(0,1fr);
}
.pf-recent__cell--open .pf-recent__open{
justify-self:start;
}
}
`;