대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -1,7 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "../components/shell/AppShell";
import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
import { teacherApi, type TeacherDashboardResponse } from "../lib/api";
import {
ApiError,
personaReviewApi,
teacherApi,
type PersonaReviewAction,
type PersonaReviewStatus,
type PersonaReviewSummary,
type TeacherDashboardResponse,
} from "../lib/api";
type LoadState = "loading" | "ready" | "error";
@ -17,6 +25,19 @@ function formatDateTime(value: string | null): string {
});
}
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">
@ -28,8 +49,12 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
export default function Professor() {
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 [personaReviewBusy, setPersonaReviewBusy] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const loadDashboard = useCallback(async () => {
@ -46,9 +71,53 @@ export default function Professor() {
}
}, []);
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);
}
}, []);
useEffect(() => {
void loadDashboard();
}, [loadDashboard]);
void loadPersonaReviews();
}, [loadDashboard, loadPersonaReviews]);
const decidePersonaReview = useCallback(
async (personaId: string, action: PersonaReviewAction) => {
setPersonaReviewBusy(`${personaId}:${action}`);
setPersonaReviewError(null);
try {
const updated = await personaReviewApi.decide(personaId, action);
setPersonaReviews((current) => {
if (updated.status === "approved" || updated.status === "archived") {
return current.filter((item) => item.persona_id !== updated.persona_id);
}
return current.map((item) => (item.persona_id === updated.persona_id ? updated : item));
});
} catch (err) {
setPersonaReviewError(
err instanceof Error ? err.message : "페르소나 검수 결정을 저장하지 못했습니다.",
);
} finally {
setPersonaReviewBusy(null);
}
},
[],
);
const kpis = useMemo(
() => [
@ -82,6 +151,7 @@ export default function Professor() {
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;
return (
<AppShell>
@ -101,31 +171,51 @@ export default function Professor() {
<Button
variant="secondary"
leading={<Icon name="review" size={16} />}
onClick={() => void loadDashboard()}
disabled={loadState === "loading"}
onClick={() => {
void loadDashboard();
void loadPersonaReviews();
}}
disabled={loadState === "loading" || personaReviewLoading}
>
{loadState === "loading" || personaReviewLoading ? "확인 중" : "새로고침"}
</Button>
</header>
<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} · 실제 기록` : "확인 중"}</span>
</div>
<div className="pf-triage__meta">
<span>
<b>{pendingCount}</b>
<small> </small>
</span>
<span>
<b>{totalSessions}</b>
<small> </small>
</span>
<time>{updatedAt ? updatedAt.toLocaleTimeString("ko-KR") : ""}</time>
</div>
<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) => (
<div className="pf-kpi" 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 ? (
@ -135,20 +225,97 @@ export default function Professor() {
</section>
) : null}
<section className="pf-kpis" aria-label="교수자 요약">
{kpis.map((kpi) => (
<div className="pf-kpi" 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>
<section className="pf-workspace">
<div className="pf-queue-stack">
<section className="pf-section">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
))}
</section>
<Badge tone={personaReviewCount > 0 ? "accent" : "neutral"}>
{personaReviewCount}
</Badge>
</div>
<section className="pf-section">
<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) => {
const approveKey = `${persona.persona_id}:approve`;
const rejectKey = `${persona.persona_id}:reject`;
const busy = personaReviewBusy?.startsWith(`${persona.persona_id}:`) ?? false;
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)}</span>
</div>
<div className="pf-persona__actions">
<Button
size="sm"
variant="secondary"
leading={<Icon name="x" size={14} />}
onClick={() => void decidePersonaReview(persona.persona_id, "reject")}
disabled={busy}
>
{personaReviewBusy === rejectKey ? "저장 중" : "반려"}
</Button>
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void decidePersonaReview(persona.persona_id, "approve")}
disabled={busy}
>
{personaReviewBusy === approveKey ? "저장 중" : "승인"}
</Button>
</div>
</article>
);
})}
</div>
) : (
<EmptyState
title={personaReviewLoading ? "페르소나 검수 큐 확인 중" : "검수 대기 페르소나 없음"}
desc="draft 또는 review 상태의 페르소나가 등록되면 이 목록에서 승인하거나 반려합니다."
/>
)}
</Card>
</section>
<section className="pf-section">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
@ -184,9 +351,10 @@ export default function Professor() {
/>
)}
</Card>
</section>
</section>
</div>
<section className="pf-section">
<section className="pf-section pf-section--recent">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
@ -197,42 +365,48 @@ export default function Professor() {
<Card className="pf-panel">
{dashboard && dashboard.recent_sessions.length > 0 ? (
<div className="pf-tablewrap">
<table className="pf-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{dashboard.recent_sessions.map((session) => (
<tr key={session.session_id}>
<td>
<span className="pf-learner">
{session.learner_label}
<code>{session.session_id}</code>
</span>
</td>
<td>{session.persona_code}</td>
<td>
<Badge tone={session.status === "ended" ? "neutral" : "accent"}>
{session.status === "ended" ? "종료" : "진행 중"}
</Badge>
</td>
<td>{session.stage}</td>
<td className="tabular">{session.turn_count}</td>
<td className="tabular">{formatDateTime(session.started_at)}</td>
<td className="tabular">{formatDateTime(session.ended_at)}</td>
</tr>
))}
</tbody>
</table>
<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>
</div>
{dashboard.recent_sessions.map((session) => (
<article
className="pf-recent-row"
key={session.session_id}
data-recent-session-row="true"
>
<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>
</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)}
</span>
</article>
))}
</div>
) : (
<EmptyState
@ -241,6 +415,7 @@ export default function Professor() {
/>
)}
</Card>
</section>
</section>
</main>
</AppShell>
@ -253,99 +428,97 @@ const PF_CSS = `
margin:0 auto;
display:flex;
flex-direction:column;
gap:var(--sp-6);
gap:18px;
}
.pf-head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:var(--sp-5);
gap:var(--sp-4);
flex-wrap:wrap;
}
.pf-head h1{
margin:8px 0 0;
margin:6px 0 0;
color:var(--text-strong);
font-size:var(--fs-h1);
line-height:1.24;
font-size:var(--fs-h2);
line-height:1.28;
letter-spacing:0;
}
.pf-head p{
margin:10px 0 0;
max-width:760px;
margin:6px 0 0;
max-width:680px;
color:var(--text-body);
font-size:var(--fs-sm);
line-height:1.65;
font-size:var(--fs-xs);
line-height:1.55;
}
.pf-signal-strip{
display:grid;
grid-template-columns:minmax(0,1.45fr) minmax(300px,.75fr);
gap:12px;
min-width:0;
}
.pf-triage{
display:grid;
grid-template-columns:auto minmax(0,1fr) auto;
align-items:center;
gap:var(--sp-4);
padding:var(--sp-5);
gap:12px;
min-width:0;
padding:14px 16px;
border:1px solid var(--hair);
border-radius:var(--radius-lg);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-triage.is-active{
background:linear-gradient(135deg,var(--bg-surface) 0%,var(--accent-tint) 100%);
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:6px;
margin-bottom:4px;
}
.pf-triage__copy b{
display:block;
color:var(--text-strong);
font-size:var(--fs-lead);
line-height:1.4;
font-size:var(--fs-body);
line-height:1.35;
}
.pf-triage__copy span,
.pf-triage__meta small,
.pf-triage__meta time{
.pf-triage__meta small{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.pf-triage__meta{
display:flex;
align-items:stretch;
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:0;
min-width:max-content;
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,
.pf-triage__meta time{
min-width:96px;
.pf-triage__meta span{
display:grid;
align-content:center;
gap:2px;
padding:10px 14px;
padding:9px 12px;
}
.pf-triage__meta span + span,
.pf-triage__meta time{
.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:18px;
font-size:17px;
line-height:1;
}
.pf-triage__meta time{
font-family:var(--font-num);
white-space:nowrap;
text-align:right;
}
.pf-error{
display:flex;
align-items:center;
gap:var(--sp-3);
padding:var(--sp-4);
gap:10px;
padding:12px 14px;
border-radius:var(--radius);
background:var(--crit-tint);
color:var(--crit-text);
@ -353,9 +526,9 @@ const PF_CSS = `
}
.pf-kpis{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
grid-template-columns:repeat(2,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius-lg);
border-radius:var(--radius);
overflow:hidden;
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
@ -363,17 +536,18 @@ const PF_CSS = `
.pf-kpi{
position:relative;
min-width:0;
padding:var(--sp-5);
padding:11px 12px;
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:6px var(--sp-3);
gap:4px 10px;
}
.pf-kpi + .pf-kpi{border-left:1px solid var(--hair);}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-kpi__ic{
grid-column:2;
grid-row:1 / span 3;
width:34px;
height:34px;
width:30px;
height:30px;
display:grid;
place-items:center;
border-radius:var(--radius);
@ -390,7 +564,7 @@ const PF_CSS = `
display:block;
color:var(--text-strong);
font-family:var(--font-num);
font-size:30px;
font-size:23px;
line-height:1;
}
.pf-kpi small{
@ -398,21 +572,36 @@ const PF_CSS = `
font-size:12px;
line-height:1.35;
}
.pf-workspace{
display:grid;
grid-template-columns:minmax(330px,380px) 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:var(--sp-4);
gap:10px;
min-width:0;
}
.pf-section__head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:var(--sp-4);
gap:12px;
}
.pf-section__head h2{
margin:6px 0 0;
margin:4px 0 0;
color:var(--text-strong);
font-size:var(--fs-h3);
font-size:var(--fs-body);
font-weight:700;
line-height:1.3;
letter-spacing:0;
}
@ -421,11 +610,11 @@ const PF_CSS = `
overflow:hidden;
}
.pf-empty{
min-height:156px;
min-height:118px;
display:grid;
place-items:center;
gap:6px;
padding:var(--sp-6);
padding:var(--sp-5);
text-align:center;
}
.pf-empty b{
@ -434,21 +623,96 @@ const PF_CSS = `
}
.pf-empty span{
color:var(--text-muted);
font-size:var(--fs-sm);
font-size:var(--fs-xs);
}
.pf-list{
max-height:min(430px,52vh);
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-session{
display:grid;
grid-template-columns:8px minmax(0,1fr) auto;
gap:var(--sp-4);
align-items:center;
padding:var(--sp-4) var(--sp-5);
grid-template-columns:8px minmax(0,1fr);
gap:8px 10px;
align-items:start;
padding:12px;
border-top:1px solid var(--paper-2);
}
.pf-session:first-child{border-top:0;}
@ -471,62 +735,112 @@ const PF_CSS = `
.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-learner code{
.pf-session__main code,.pf-recent__learner code{
font-family:var(--font-num);
overflow:hidden;
text-overflow:ellipsis;
}
.pf-session__meta{
grid-column:2;
display:flex;
align-items:center;
gap:var(--sp-3);
justify-content:space-between;
gap:8px;
color:var(--text-muted);
font-family:var(--font-num);
font-size:var(--fs-xs);
white-space:nowrap;
white-space:normal;
}
.pf-tablewrap{
max-height:min(480px,58vh);
.pf-recent-list{
max-height:min(620px,calc(100vh - 220px));
overflow:auto;
scrollbar-gutter:stable;
min-width:0;
}
.pf-table{
width:100%;
border-collapse:collapse;
min-width:760px;
.pf-recent-head,
.pf-recent-row{
display:grid;
grid-template-columns:minmax(155px,1.35fr) 82px 78px minmax(110px,.95fr) 42px 86px 86px;
align-items:center;
gap:9px;
min-width:0;
}
.pf-table th{
.pf-recent-head{
position:sticky;
top:0;
z-index:1;
text-align:left;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
padding:var(--sp-4) var(--sp-5) var(--sp-3);
padding:9px 12px;
border-bottom:1px solid var(--hair);
background:var(--bg-surface);
white-space:nowrap;
}
.pf-table td{
.pf-recent-row{
padding:10px 12px;
border-top:1px solid var(--paper-2);
}
.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);
padding:var(--sp-4) var(--sp-5);
border-bottom:1px solid var(--paper-2);
vertical-align:middle;
overflow-wrap:anywhere;
}
.pf-table tr:last-child td{border-bottom:0;}
.pf-learner{
.pf-recent__cell::before{
display:none;
}
.pf-recent__learner{
min-width:0;
display:flex;
flex-direction:column;
gap:3px;
}
.pf-learner code{
max-width:240px;
.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;
}
@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(even),
.pf-kpi + .pf-kpi{border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:0;}
.pf-list,
.pf-personas{
max-height:360px;
}
.pf-recent-list{
max-height:460px;
}
}
@media (max-width:860px){
.pf-triage{
@ -536,34 +850,76 @@ const PF_CSS = `
grid-column:1 / -1;
width:100%;
}
.pf-triage__meta span,
.pf-triage__meta time{
min-width:0;
flex:1;
text-align:left;
}
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi:nth-child(odd){border-left:0;}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-list{max-height:380px;}
.pf-tablewrap{max-height:420px;}
.pf-session{grid-template-columns:1fr;}
.pf-session__meta{justify-content:space-between;white-space:normal;}
.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::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__meta{justify-content:flex-start;}
}
@media (max-width:520px){
.pf-kpis{grid-template-columns:1fr;}
.pf-kpi + .pf-kpi{border-left:0;border-top:1px solid var(--hair);}
.pf-triage__meta{
.pf-kpi,
.pf-kpi:nth-child(even),
.pf-kpi + .pf-kpi{border-left:0;}
.pf-kpi + .pf-kpi{border-top:1px solid var(--hair);}
.pf-persona__actions{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-triage__meta time{
grid-column:1 / -1;
border-left:0;
border-top:1px solid var(--hair);
.pf-persona__actions .vg-btn{
width:100%;
}
.pf-triage__meta span:nth-child(2){
border-left:1px solid var(--hair);
.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);
}
}
`;