vignette/apps/web/src/pages/LearnerHome.tsx
2026-06-27 17:22:38 +09:00

1220 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import { Button, Icon, Kicker } from "../components/ui";
import {
personaApi,
sessionApi,
type LearnerSessionSummary,
type PersonaSummary,
} from "../lib/api";
type LoadState = "loading" | "ready" | "error";
const DIFFICULTY_LABEL: Record<string, string> = {
easy: "기초",
moderate: "중간",
hard: "고난도",
};
function firstText(value: unknown): string | null {
if (typeof value !== "string") return null;
const text = value.trim();
return text || null;
}
function shortName(displayName: string): string {
return displayName.split("·")[0]?.replace(/\(.*?\)/g, "").trim() || displayName.trim();
}
function demographicsLine(persona: PersonaSummary | null): string {
if (!persona) return "내담자 정보를 불러오는 중";
const parts = [
firstText(persona.demographics.age_band),
firstText(persona.demographics.grade),
firstText(persona.demographics.job),
firstText(persona.demographics.status),
].filter(Boolean);
return parts.length ? parts.join(" · ") : "인적 정보 없음";
}
function theoryLine(persona: PersonaSummary | null): string {
if (!persona) return "-";
return persona.theory_target.length ? persona.theory_target.join(" · ") : "공통";
}
function difficultyLine(persona: PersonaSummary | null): string {
if (!persona) return "-";
return DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty;
}
function personaInitial(persona: PersonaSummary | null): string {
const name = persona ? shortName(persona.display_name) : "";
return (name || persona?.code || "V").slice(0, 1);
}
function isUsablePersona(persona: PersonaSummary): boolean {
return !persona.degraded && persona.source === "database";
}
function unavailablePersonaMessage(persona: PersonaSummary): string {
if (persona.degraded) {
return "카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.";
}
return "데이터베이스에서 확인된 내담자가 아니어서 현재 연습에 사용할 수 없습니다.";
}
function sessionStatusLabel(session: LearnerSessionSummary): string {
if (session.status === "active") return "진행 중";
if (session.review_ready) return "리뷰 가능";
return "종료";
}
function sessionDateLabel(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
export default function LearnerHome() {
const navigate = useNavigate();
const [personas, setPersonas] = useState<PersonaSummary[]>([]);
const [sessions, setSessions] = useState<LearnerSessionSummary[]>([]);
const [selectedCode, setSelectedCode] = useState("");
const [loadState, setLoadState] = useState<LoadState>("loading");
const [sessionLoadState, setSessionLoadState] = useState<LoadState>("loading");
useEffect(() => {
let alive = true;
(async () => {
setLoadState("loading");
setSessionLoadState("loading");
const [personaResult, sessionResult] = await Promise.allSettled([
personaApi.list(),
sessionApi.list(),
]);
if (!alive) return;
if (personaResult.status === "fulfilled") {
const personaRows = personaResult.value;
const firstUsableCode = personaRows.find(isUsablePersona)?.code ?? "";
setPersonas(personaRows);
setSelectedCode((cur) =>
personaRows.some((persona) => persona.code === cur && isUsablePersona(persona))
? cur
: firstUsableCode,
);
setLoadState("ready");
} else {
console.warn("[learner-home] failed to load personas", personaResult.reason);
setPersonas([]);
setLoadState("error");
}
if (sessionResult.status === "fulfilled") {
setSessions(sessionResult.value.sessions);
setSessionLoadState("ready");
} else {
console.warn("[learner-home] failed to load sessions", sessionResult.reason);
setSessions([]);
setSessionLoadState("error");
}
})();
return () => {
alive = false;
};
}, []);
const usablePersonas = useMemo(() => personas.filter(isUsablePersona), [personas]);
const selected = useMemo(
() => usablePersonas.find((p) => p.code === selectedCode) ?? usablePersonas[0] ?? null,
[selectedCode, usablePersonas],
);
const selectedSessions = useMemo(
() =>
sessions
.filter((session) => session.persona_code === selected?.code)
.sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at))
.slice(0, 4),
[selected?.code, sessions],
);
const activeCount = useMemo(
() => sessions.filter((session) => session.status === "active").length,
[sessions],
);
const reviewCount = useMemo(
() => sessions.filter((session) => session.status === "ended" && session.review_ready).length,
[sessions],
);
const startPractice = () => {
if (selected && isUsablePersona(selected)) navigate(`/learn/session/${selected.code}`);
};
const historyReady = sessionLoadState === "ready";
const historyStatus =
sessionLoadState === "ready"
? `${sessions.length}`
: sessionLoadState === "error"
? "기록 오류"
: "기록 확인 중";
const personaCatalogUnavailable = loadState === "ready" && !selected;
const hasSessionRecords = sessionLoadState === "ready" && sessions.length > 0;
const rootClassName = [
"lh-root",
hasSessionRecords ? "has-session-records" : "",
personaCatalogUnavailable ? "has-catalog-unavailable" : "",
]
.filter(Boolean)
.join(" ");
return (
<AppShell contextLabel="학습 공간" hideNav bleed>
<style>{LH_CSS}</style>
<main className={rootClassName}>
<header className="lh-head">
<div className="lh-head__copy">
<Kicker> </Kicker>
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
, , .
</p>
</div>
<div className="lh-head__status" aria-live="polite">
<Icon name="users" size={17} />
{loadState === "ready" ? `${usablePersonas.length}명 · ${historyStatus}` : "확인 중"}
</div>
</header>
<section className="lh-workspace" aria-label="학습자 상담 연습 공간">
<div className="lh-list-pane">
<div className="lh-pane-head">
<Kicker> </Kicker>
<span>{loadState === "ready" ? "내담자 목록" : "확인 중"}</span>
</div>
{loadState === "error" ? (
<div className="lh-error" role="alert">
<Icon name="alert" size={19} />
<div>
<b> .</b>
<p> .</p>
</div>
</div>
) : (
<div className="lh-personas" role="listbox" aria-label="연습 페르소나">
{loadState === "loading"
? Array.from({ length: 3 }).map((_, i) => (
<div className="lh-persona is-skel" key={i} aria-hidden="true">
<span className="lh-persona__mark lh-skel__box" />
<span className="lh-persona__body">
<span className="lh-skel__line lh-skel__line--name" />
<span className="lh-skel__line lh-skel__line--meta" />
<span className="lh-skel__line lh-skel__line--sum" />
<span className="lh-skel__line lh-skel__line--sum2" />
</span>
</div>
))
: personas.map((persona) => {
const usablePersona = isUsablePersona(persona);
const selectedPersona = usablePersona && persona.code === selected?.code;
const personaMeta =
(DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty) +
" · " +
theoryLine(persona);
return (
<button
key={persona.code}
type="button"
role="option"
aria-selected={selectedPersona}
aria-disabled={!usablePersona}
disabled={!usablePersona}
className={`lh-persona ${selectedPersona ? "is-selected" : ""}`}
onClick={() => {
if (usablePersona) setSelectedCode(persona.code);
}}
>
<span className="lh-persona__mark">{persona.code}</span>
<span className="lh-persona__body">
<span className="lh-persona__name">{persona.display_name}</span>
<span className="lh-persona__meta">
{usablePersona ? personaMeta : `${personaMeta} · 사용 불가`}
</span>
<span className="lh-persona__summary">
{usablePersona
? persona.presenting_summary
: unavailablePersonaMessage(persona)}
</span>
</span>
{selectedPersona ? (
<span className="lh-persona__check" aria-hidden="true">
<Icon name="check" size={15} strokeWidth={2.4} />
</span>
) : null}
</button>
);
})}
</div>
)}
</div>
<section className="lh-preview" aria-labelledby="lh-preview-title">
<div className="lh-preview__main">
<div className="lh-preview__hero">
<div className="lh-preview__identity">
<div className="lh-avatar" aria-hidden="true">
{personaCatalogUnavailable ? "!" : personaInitial(selected)}
</div>
<div className="lh-preview__copy">
<span className="lh-preview__code">
{personaCatalogUnavailable ? "catalog" : (selected?.code ?? "...")}
</span>
<h2 id="lh-preview-title">
{personaCatalogUnavailable
? "사용 가능한 내담자가 없습니다."
: (selected?.display_name ?? "내담자 정보를 불러오는 중")}
</h2>
<p>
{personaCatalogUnavailable
? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다."
: demographicsLine(selected)}
</p>
</div>
</div>
<div className="lh-actions">
<span className="lh-actions__note">
.
</span>
<Button
size="lg"
onClick={startPractice}
disabled={!selected}
trailing={<Icon name="chevron-right" size={18} />}
>
</Button>
</div>
</div>
<div className="lh-summary">
<Kicker>{personaCatalogUnavailable ? "카탈로그 상태" : "주호소"}</Kicker>
<p>
{personaCatalogUnavailable
? "현재 서버 카탈로그가 신뢰 가능한 상태로 확인되지 않아 연습 시작을 막았습니다. 카탈로그가 복구되면 선택할 수 있습니다."
: (selected?.presenting_summary ?? "내담자 정보를 확인하고 있습니다.")}
</p>
</div>
<dl className="lh-facts">
<div>
<dt></dt>
<dd>{difficultyLine(selected)}</dd>
</div>
<div>
<dt></dt>
<dd>{theoryLine(selected)}</dd>
</div>
<div>
<dt></dt>
<dd>{selected?.voice_preset ?? "기본"}</dd>
</div>
</dl>
</div>
<section
className={`lh-activity ${
historyReady && selectedSessions.length === 0 ? "is-empty" : ""
}`}
aria-label="연습 기록"
>
<div className="lh-activity__stats">
<span>
<b>{historyReady ? activeCount : ""}</b>
<small> </small>
</span>
<span>
<b>{historyReady ? reviewCount : ""}</b>
<small> </small>
</span>
<span>
<b>{historyReady ? sessions.length : ""}</b>
<small> </small>
</span>
</div>
<div className="lh-activity__recent">
<div className="lh-activity__head">
<Kicker> </Kicker>
<span>{selected ? `${selected.code} · 전체 ${historyReady ? sessions.length : ""}` : "-"}</span>
</div>
{sessionLoadState === "loading" ? (
<p> .</p>
) : sessionLoadState === "error" ? (
<div className="lh-activity__unavailable" role="status">
<Icon name="alert" size={16} />
<span> .</span>
</div>
) : personaCatalogUnavailable ? (
<div className="lh-activity__unavailable" role="status">
<Icon name="alert" size={16} />
<span> .</span>
</div>
) : selectedSessions.length > 0 ? (
<ul>
{selectedSessions.map((session) => (
<li key={session.session_id}>
<span>
<b>{sessionStatusLabel(session)}</b>
<small>{sessionDateLabel(session.started_at)}</small>
</span>
<em>
{session.turn_count} · {session.stage}
</em>
<span className="lh-session-actions">
{session.status === "active" ? (
<button
type="button"
className="lh-review-link"
onClick={() => navigate(`/learn/session/${session.session_id}`)}
>
</button>
) : (
<button
type="button"
className="lh-review-link"
onClick={() =>
navigate(`/learn/session/${session.session_id}/review`)
}
>
{session.review_ready ? "리뷰" : "기록"}
</button>
)}
<button
type="button"
className="lh-review-link lh-review-link--ghost"
onClick={() => navigate(`/learn/session/${session.persona_code}`)}
>
</button>
</span>
</li>
))}
</ul>
) : (
<div className="lh-activity__empty" role="status">
<Icon name="session" size={18} />
<div>
<b> .</b>
<p> .</p>
</div>
</div>
)}
</div>
</section>
</section>
</section>
</main>
</AppShell>
);
}
const LH_CSS = `
.lh-root{
width:min(100%,1480px);
min-height:calc(100dvh - var(--topbar-h));
margin:0 auto;
position:relative;
isolation:isolate;
display:grid;
grid-template-rows:auto minmax(0,1fr);
align-content:start;
gap:clamp(12px,1.6vw,20px);
padding:clamp(14px,2vw,28px);
background:var(--bg-app);
overflow:hidden;
}
.lh-root::before{
content:"";
position:absolute;
z-index:-1;
inset:-120px 0 auto auto;
width:min(720px,58vw);
height:520px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.07;
filter:saturate(.8);
pointer-events:none;
}
.lh-head{
display:flex;
align-items:center;
justify-content:space-between;
gap:var(--sp-4);
min-width:0;
}
.lh-head__copy{min-width:0;}
.lh-head__status{
flex:none;
min-height:34px;
display:inline-flex;
align-items:center;
gap:8px;
padding:0 12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
color:var(--text-body);
font-family:var(--font-num);
font-size:var(--fs-xs);
font-weight:750;
}
.lh-title{
margin:5px 0 0;
font-size:clamp(22px,2.4vw,30px);
font-weight:740;
letter-spacing:0;
line-height:1.18;
color:var(--text-strong);
}
.lh-sub{
margin:6px 0 0;
max-width:760px;
color:var(--text-body);
font-size:var(--fs-sm);
line-height:1.45;
}
.lh-workspace{
min-width:0;
display:grid;
grid-template-columns:minmax(300px,360px) minmax(0,1fr);
gap:clamp(14px,1.8vw,24px);
align-items:start;
}
.lh-list-pane,
.lh-preview{
min-width:0;
}
.lh-list-pane{
position:sticky;
top:calc(var(--topbar-h) + 18px);
max-height:calc(100dvh - var(--topbar-h) - 36px);
display:grid;
grid-template-rows:auto minmax(0,1fr);
padding:var(--sp-4);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
overflow:hidden;
}
.lh-list-pane::after{
content:"";
position:absolute;
inset:auto -48px -68px auto;
width:180px;
height:180px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.05;
pointer-events:none;
}
.lh-pane-head{
display:flex;
align-items:center;
justify-content:space-between;
gap:var(--sp-3);
margin-bottom:var(--sp-3);
}
.lh-pane-head span{
font-size:var(--fs-xs);
font-weight:700;
color:var(--text-muted);
font-family:var(--font-num);
white-space:nowrap;
}
.lh-personas{
min-height:0;
display:grid;
grid-auto-rows:auto;
align-content:start;
gap:8px;
overflow:auto;
scrollbar-gutter:stable;
padding-right:3px;
}
.lh-persona{
position:relative;
min-width:0;
min-height:92px;
display:grid;
grid-template-columns:44px minmax(0,1fr);
gap:11px;
align-items:start;
padding:12px 38px 12px 12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
color:var(--text-body);
text-align:left;
cursor:pointer;
transition:background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
}
.lh-persona:hover{border-color:var(--border-strong);}
.lh-persona:disabled{
cursor:not-allowed;
opacity:.68;
}
.lh-persona.is-selected{
border-color:var(--accent);
background:var(--accent-tint);
box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent);
}
.lh-persona__mark{
width:40px;
height:40px;
display:grid;
place-items:center;
color:var(--text-strong);
background:var(--bg-surface);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
font-family:var(--font-num);
font-size:12px;
font-weight:800;
}
.lh-persona__body{
min-width:0;
display:grid;
gap:5px;
}
.lh-persona__name{
color:var(--text-strong);
font-size:14.5px;
font-weight:760;
line-height:1.25;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:1;
-webkit-box-orient:vertical;
}
.lh-persona__meta{
color:var(--text-muted);
font-size:12px;
line-height:1.45;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.lh-persona__summary{
min-width:0;
color:var(--text-body);
font-size:12.5px;
line-height:1.45;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:2;
-webkit-box-orient:vertical;
}
.lh-persona__check{
position:absolute;
top:10px;
right:10px;
width:22px;
height:22px;
display:grid;
place-items:center;
border-radius:50%;
background:var(--accent);
color:var(--text-on-accent);
}
.lh-persona.is-skel{
pointer-events:none;
}
/* 실제 카드 구조(마크 + 이름/메타/요약 2줄)를 모사한 플레이스홀더 셰이머 */
.lh-skel__box,
.lh-skel__line{
background:linear-gradient(90deg,var(--bg-surface-2),var(--neutral-100),var(--bg-surface-2));
background-size:180% 100%;
animation:lhSkel 1.1s ease-in-out infinite;
}
.lh-skel__line{
height:11px;
border-radius:6px;
align-self:center;
}
.lh-skel__line--name{width:62%;height:13px;}
.lh-skel__line--meta{width:44%;height:10px;}
.lh-skel__line--sum{width:92%;}
.lh-skel__line--sum2{width:76%;}
.lh-preview{
display:grid;
grid-template-columns:minmax(0,1fr);
gap:clamp(14px,1.8vw,24px);
align-content:start;
align-items:start;
}
.lh-preview__main{
min-width:0;
position:relative;
display:grid;
gap:var(--sp-4);
align-content:start;
padding:var(--sp-5);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
overflow:hidden;
}
.lh-preview__main::after{
content:"";
position:absolute;
inset:auto -180px -210px auto;
width:520px;
height:360px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.08;
filter:saturate(.82);
pointer-events:none;
}
.lh-preview__main > *{
position:relative;
z-index:1;
}
.lh-preview__hero{
min-width:0;
display:grid;
grid-template-columns:minmax(0,1fr) minmax(220px,auto);
gap:var(--sp-4);
align-items:start;
}
@media (min-width:1400px) and (max-width:1559px){
.lh-preview__hero{
grid-template-columns:minmax(0,1fr);
}
.lh-actions{
justify-items:start;
}
.lh-actions__note{
text-align:left;
}
}
.lh-preview__identity{
min-width:0;
display:grid;
grid-template-columns:auto minmax(0,1fr);
align-items:start;
gap:var(--sp-4);
}
.lh-avatar{
width:64px;
height:64px;
display:grid;
place-items:center;
border-radius:50%;
background:var(--clay-tint);
color:var(--clay-deep);
font-size:26px;
font-weight:780;
box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--clay) 22%,transparent);
}
.lh-preview__copy{min-width:0;}
.lh-preview__code{
display:inline-flex;
align-items:center;
min-height:24px;
padding:0 9px;
border-radius:var(--radius-sm);
background:var(--accent-tint);
color:var(--accent-deep);
font-family:var(--font-num);
font-size:12px;
font-weight:800;
}
.lh-preview h2{
margin:8px 0 0;
color:var(--text-strong);
font-size:clamp(22px,2.4vw,30px);
line-height:1.2;
letter-spacing:0;
word-break:keep-all;
overflow-wrap:anywhere;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:2;
-webkit-box-orient:vertical;
}
.lh-preview__copy p{
margin:7px 0 0;
color:var(--text-muted);
font-size:var(--fs-sm);
line-height:1.45;
word-break:keep-all;
overflow-wrap:anywhere;
}
.lh-summary{
min-width:0;
padding:var(--sp-4);
border-radius:var(--radius);
background:
linear-gradient(180deg,color-mix(in srgb,var(--clay-tint) 48%,var(--bg-surface-2)),var(--bg-surface-2));
}
.lh-summary p{
margin:8px 0 0;
color:var(--text-body);
font-size:clamp(15px,1.2vw,18px);
line-height:1.52;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:4;
-webkit-box-orient:vertical;
}
.lh-facts{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:10px;
margin:0;
}
.lh-facts div{
min-width:0;
min-height:64px;
padding:12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
}
.lh-facts dt{
margin:0;
color:var(--text-muted);
font-size:12px;
font-weight:720;
}
.lh-facts dd{
margin:7px 0 0;
color:var(--text-strong);
font-size:14px;
font-weight:720;
line-height:1.3;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.lh-activity{
display:grid;
gap:var(--sp-4);
padding:var(--sp-5);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.lh-activity__stats{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:8px;
}
.lh-activity__stats span,
.lh-activity__recent{
min-width:0;
}
.lh-activity__stats span{
min-height:58px;
display:grid;
align-content:center;
gap:4px;
padding:10px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.lh-activity__stats b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:22px;
line-height:1;
}
.lh-activity__stats small{
color:var(--text-muted);
font-size:12px;
font-weight:720;
}
.lh-activity__recent{
min-height:0;
}
.lh-activity__head{
display:flex;
align-items:center;
justify-content:space-between;
gap:var(--sp-3);
margin-bottom:10px;
}
.lh-activity__head span{
color:var(--text-muted);
font-family:var(--font-num);
font-size:12px;
font-weight:800;
white-space:nowrap;
}
.lh-activity__recent ul{
list-style:none;
margin:0;
padding:0;
display:grid;
gap:8px;
}
.lh-activity__recent li{
min-width:0;
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:6px 10px;
align-items:center;
padding:10px 0;
border-top:1px solid var(--hair);
}
.lh-activity__recent li:first-child{border-top:0;padding-top:0;}
.lh-activity__recent li > span:first-child{
min-width:0;
display:grid;
gap:2px;
}
.lh-activity__recent b{
color:var(--text-strong);
font-size:13px;
}
.lh-activity__recent small,
.lh-activity__recent p{
color:var(--text-muted);
font-size:12px;
}
.lh-activity__recent p{margin:0;line-height:1.45;}
.lh-activity__recent em{
grid-column:1;
color:var(--text-body);
font-family:var(--font-num);
font-size:12px;
font-style:normal;
}
.lh-session-actions{
grid-column:2;
grid-row:1 / span 2;
display:flex;
align-items:center;
justify-content:flex-end;
gap:6px;
}
.lh-activity__unavailable{
display:flex;
align-items:center;
gap:8px;
color:var(--warn-text);
font-size:12px;
line-height:1.45;
}
.lh-activity__empty{
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:10px;
align-items:start;
padding:12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
color:var(--text-body);
}
.lh-activity__empty svg{
margin-top:2px;
color:var(--accent-deep);
}
.lh-activity__empty b{
display:block;
color:var(--text-strong);
font-size:13px;
}
.lh-activity__empty p{
margin:4px 0 0;
color:var(--text-muted);
font-size:12px;
line-height:1.45;
}
.lh-review-link{
flex:none;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
color:var(--accent-deep);
font-family:var(--font-sans);
font-size:12px;
font-weight:720;
min-height:30px;
padding:5px 9px;
cursor:pointer;
white-space:nowrap;
}
.lh-review-link:hover{background:var(--accent-tint);}
.lh-review-link--ghost{
color:var(--text-body);
}
.lh-review-link--ghost:hover{color:var(--accent-deep);}
.lh-actions{
min-width:0;
display:grid;
justify-items:end;
gap:10px;
}
.lh-actions__note{
display:block;
min-width:0;
max-width:260px;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
text-align:right;
word-break:keep-all;
text-wrap:balance;
}
.lh-error{
display:flex;
align-items:flex-start;
gap:var(--sp-3);
color:var(--crit-text);
background:var(--crit-tint);
border-radius:var(--radius);
padding:var(--sp-4);
}
.lh-error b{display:block;}
.lh-error p{margin:4px 0 0;font-size:var(--fs-sm);line-height:1.5;}
@keyframes lhSkel{0%{background-position:120% 0}100%{background-position:-80% 0}}
@media (min-width:1400px){
.lh-preview{
grid-template-columns:minmax(440px,1fr) minmax(340px,400px);
column-gap:var(--sp-5);
}
.lh-activity{
position:sticky;
top:calc(var(--topbar-h) + 18px);
}
.lh-activity__stats{
grid-template-columns:1fr;
}
}
@media (max-width:1080px){
.lh-workspace{
grid-template-columns:1fr;
}
.lh-list-pane{
position:relative;
top:auto;
max-height:none;
overflow:visible;
}
.lh-root::before{
width:560px;
height:420px;
opacity:.055;
}
.lh-personas{
grid-template-columns:repeat(2,minmax(0,1fr));
grid-auto-rows:auto;
overflow:visible;
padding-right:0;
}
.lh-preview{
grid-template-columns:1fr;
}
.lh-activity__stats{
grid-template-columns:repeat(3,minmax(0,1fr));
}
}
@media (max-width:760px){
.lh-root{
padding:12px;
gap:12px;
}
.lh-root::before,
.lh-preview__main::after,
.lh-list-pane::after{
display:none;
}
.lh-head{
align-items:flex-start;
}
.lh-workspace{
display:flex;
flex-direction:column;
gap:10px;
}
.lh-list-pane{
padding:12px;
}
.lh-pane-head{margin-bottom:10px;}
.lh-personas{
display:flex;
grid-template-columns:none;
gap:8px;
overflow-x:auto;
overflow-y:hidden;
padding-right:0;
padding-bottom:2px;
scrollbar-gutter:auto;
}
.lh-persona{
flex:0 0 min(82vw,320px);
min-height:64px;
grid-template-columns:38px minmax(0,1fr);
align-items:center;
gap:10px;
padding:10px 38px 10px 10px;
}
.lh-persona__mark{width:34px;height:34px;}
.lh-persona__name{font-size:13.5px;-webkit-line-clamp:1;}
.lh-persona__meta{font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lh-persona__summary{display:none;}
.lh-persona__check{top:50%;right:10px;transform:translateY(-50%);}
.lh-preview{
gap:12px;
}
.lh-preview__main,
.lh-activity{
padding:14px;
}
.lh-preview__hero{
grid-template-columns:1fr;
gap:12px;
}
.lh-preview__identity{grid-template-columns:54px minmax(0,1fr);gap:12px;}
.lh-avatar{width:54px;height:54px;font-size:22px;}
.lh-preview h2{margin-top:6px;font-size:20px;-webkit-line-clamp:2;}
.lh-preview__copy p{margin-top:5px;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lh-actions{
justify-items:stretch;
}
.lh-actions__note{
max-width:none;
text-align:left;
}
.lh-actions .vg-btn{
width:100%;
justify-content:center;
}
.lh-summary{padding:12px;}
.lh-summary p{margin-top:6px;font-size:14px;line-height:1.45;-webkit-line-clamp:2;}
.lh-facts{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;}
.lh-facts div{min-height:58px;padding:10px;}
.lh-facts dt{font-size:11px;}
.lh-facts dd{font-size:12px;}
.lh-activity{gap:12px;}
.lh-activity__stats{grid-template-columns:repeat(3,minmax(0,1fr));}
.lh-activity__stats span{
min-height:46px;
display:grid;
place-items:center;
gap:2px;
padding:8px;
}
.lh-activity__stats b{font-size:17px;}
.lh-activity__stats small{font-size:11px;}
.lh-session-actions{gap:4px;}
.lh-review-link{padding:4px 7px;}
}
@media (max-width:560px){
.lh-head__status{display:none;}
.lh-head .vg-kicker,
.lh-pane-head .vg-kicker,
.lh-summary .vg-kicker,
.lh-activity__head .vg-kicker{
font-size:11px;
gap:6px;
}
.lh-title{margin-top:3px;font-size:20px;line-height:1.18;}
.lh-sub{
margin-top:4px;
font-size:12.5px;
line-height:1.35;
}
.lh-head{gap:6px;}
.lh-pane-head{margin-bottom:5px;}
.lh-persona{
flex:0 0 min(78vw,300px);
min-height:56px;
width:min(78vw,300px);
grid-template-columns:30px minmax(0,1fr);
padding:7px 34px 7px 7px;
}
.lh-persona__mark{width:28px;height:28px;font-size:11px;}
.lh-persona__name{font-size:13px;}
.lh-persona__meta{font-size:11px;}
.lh-preview{gap:10px;}
.lh-preview__main{gap:10px;}
.lh-preview__identity{grid-template-columns:38px minmax(0,1fr);gap:9px;}
.lh-avatar{width:38px;height:38px;font-size:17px;}
.lh-preview h2{margin-top:4px;font-size:17px;line-height:1.2;}
.lh-preview__code{min-height:20px;font-size:10.5px;padding:0 8px;}
.lh-preview__copy p{margin-top:4px;font-size:11.5px;}
.lh-actions__note{display:none;}
.lh-summary{padding:10px;}
.lh-summary p{font-size:12.5px;line-height:1.38;-webkit-line-clamp:2;}
.lh-facts{grid-template-columns:1fr;gap:6px;}
.lh-facts div{
min-height:0;
display:flex;
align-items:center;
justify-content:space-between;
gap:6px;
padding:8px 10px;
}
.lh-facts dt{font-size:10.5px;}
.lh-facts dd{
margin-top:0;
font-size:11.5px;
white-space:nowrap;
overflow:hidden;
display:block;
text-overflow:ellipsis;
}
.lh-activity{padding:12px;gap:10px;}
.lh-activity__stats{gap:6px;}
.lh-activity__stats span{
min-height:40px;
padding:6px 4px;
}
.lh-activity__stats b{font-size:16px;}
.lh-activity__stats small{font-size:10.5px;}
.lh-activity__head{margin-bottom:6px;}
.lh-activity__empty{
padding:8px;
gap:8px;
}
.lh-activity__empty b{font-size:12px;}
.lh-activity__empty p{
margin-top:2px;
font-size:11px;
line-height:1.35;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:1;
-webkit-box-orient:vertical;
}
.lh-activity__recent li{
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:6px 10px;
}
.lh-activity__recent li em{display:none;}
.lh-session-actions{grid-column:2;grid-row:1 / span 2;flex-direction:column;align-items:stretch;}
.lh-actions .vg-btn{
min-height:40px;
font-size:14px;
padding:0 12px;
}
}
@media (prefers-reduced-motion:reduce){
.lh-skel__box,
.lh-skel__line{animation:none;}
}
`;