G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
709
apps/web/src/pages/session-review/AlliancePulseCard.tsx
Normal file
709
apps/web/src/pages/session-review/AlliancePulseCard.tsx
Normal file
|
|
@ -0,0 +1,709 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Badge, Button, Card, Icon, Kicker } from "../../components/ui";
|
||||
import { ApiError } from "../../lib/api";
|
||||
import {
|
||||
alliancePulseApi,
|
||||
type AllianceDimension,
|
||||
type AllianceMeasurement,
|
||||
type AlliancePerspective,
|
||||
type AlliancePulse,
|
||||
type AllianceScores,
|
||||
} from "./alliancePulseApi";
|
||||
import "./alliance-pulse.css";
|
||||
|
||||
interface PulseTurn {
|
||||
id: string;
|
||||
ts: string;
|
||||
speaker: string;
|
||||
who: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface AlliancePulseCardProps {
|
||||
sessionId: string;
|
||||
turns: PulseTurn[];
|
||||
isSupervisorView: boolean;
|
||||
onJumpToTurn: (turnId: string) => void;
|
||||
}
|
||||
|
||||
type ScoreDraft = Record<AllianceDimension, number | null>;
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
|
||||
const DIMENSIONS: AllianceDimension[] = ["goal", "task", "bond"];
|
||||
|
||||
const DIMENSION_COPY: Record<
|
||||
AllianceDimension,
|
||||
{ label: string; short: string; prompt: string }
|
||||
> = {
|
||||
goal: {
|
||||
label: "목표 합의",
|
||||
short: "목표",
|
||||
prompt: "이번 회기에서 무엇을 다루려는지 서로 이해했나요?",
|
||||
},
|
||||
task: {
|
||||
label: "과업 합의",
|
||||
short: "과업",
|
||||
prompt: "목표를 위해 사용한 질문과 활동이 도움이 됐나요?",
|
||||
},
|
||||
bond: {
|
||||
label: "정서적 유대",
|
||||
short: "유대",
|
||||
prompt: "내담자가 존중받고 안전하다고 느꼈을까요?",
|
||||
},
|
||||
};
|
||||
|
||||
const CORE_PERSPECTIVES: AlliancePerspective[] = [
|
||||
"learner_self_report",
|
||||
"client_agent_report",
|
||||
"independent_observer",
|
||||
];
|
||||
|
||||
const PERSPECTIVE_COPY: Record<
|
||||
AlliancePerspective,
|
||||
{ label: string; provenance: string }
|
||||
> = {
|
||||
learner_self_report: { label: "내 판단", provenance: "잠긴 자기평가" },
|
||||
client_agent_report: { label: "내담자 관점", provenance: "AI 역할 추론" },
|
||||
independent_observer: { label: "관찰자 관점", provenance: "AI 축어록 추론" },
|
||||
supervisor_human: { label: "교수자 판정", provenance: "교수자 근거 판정" },
|
||||
};
|
||||
|
||||
const SCALE = [
|
||||
{ value: 0, number: "1", label: "전혀 아니다" },
|
||||
{ value: 0.25, number: "2", label: "조금 아니다" },
|
||||
{ value: 0.5, number: "3", label: "보통이다" },
|
||||
{ value: 0.75, number: "4", label: "대체로 그렇다" },
|
||||
{ value: 1, number: "5", label: "매우 그렇다" },
|
||||
] as const;
|
||||
|
||||
const EMPTY_DRAFT: ScoreDraft = { goal: null, task: null, bond: null };
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
function scoreText(value: number | null): string {
|
||||
if (value == null || !Number.isFinite(value)) return "근거 없음";
|
||||
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}점`;
|
||||
}
|
||||
|
||||
function scoreDescriptor(value: number | null): string {
|
||||
if (value == null || !Number.isFinite(value)) return "판단 불가";
|
||||
if (value >= 0.8) return "강하게 형성됨";
|
||||
if (value >= 0.6) return "대체로 형성됨";
|
||||
if (value >= 0.4) return "혼재함";
|
||||
if (value >= 0.2) return "약한 편";
|
||||
return "거의 형성되지 않음";
|
||||
}
|
||||
|
||||
function pulseStatusCopy(pulse: AlliancePulse): {
|
||||
label: string;
|
||||
tone: "neutral" | "accent" | "warn" | "crit" | "info";
|
||||
body: string;
|
||||
} {
|
||||
if (pulse.status === "awaiting_agents" || pulse.status === "processing") {
|
||||
return {
|
||||
label: "관점 분석 중",
|
||||
tone: "info",
|
||||
body: "자기평가는 잠겼습니다. 내담자와 관찰자 관점이 모두 준비된 뒤 함께 공개합니다.",
|
||||
};
|
||||
}
|
||||
if (pulse.status === "degraded") {
|
||||
return {
|
||||
label: "일부 근거 부족",
|
||||
tone: "warn",
|
||||
body: "확인 가능한 관점만 표시합니다. 생성되지 않은 점수를 임의로 채우지 않습니다.",
|
||||
};
|
||||
}
|
||||
if (pulse.status === "error" || pulse.error_code) {
|
||||
return {
|
||||
label: "분석 보류",
|
||||
tone: "crit",
|
||||
body: "자기평가는 보존됐지만 AI 관점은 준비되지 않았습니다. 점수를 추정해 표시하지 않습니다.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "관점 비교 준비됨",
|
||||
tone: "accent",
|
||||
body: "세 축을 평균내지 않고 관점별 차이와 발화 근거를 확인합니다.",
|
||||
};
|
||||
}
|
||||
|
||||
function isCompleteDraft(draft: ScoreDraft): draft is AllianceScores {
|
||||
return DIMENSIONS.every((dimension) => draft[dimension] != null);
|
||||
}
|
||||
|
||||
function latestPostPulse(items: AlliancePulse[]): AlliancePulse | null {
|
||||
const postItems = items.filter((item) => item.checkpoint === "post");
|
||||
return postItems.length > 0 ? postItems[postItems.length - 1] : null;
|
||||
}
|
||||
|
||||
function selectedMeasurement(
|
||||
pulse: AlliancePulse,
|
||||
dimension: AllianceDimension,
|
||||
perspective: AlliancePerspective,
|
||||
): AllianceMeasurement | null {
|
||||
const candidates = pulse.measurements.filter(
|
||||
(item) => item.dimension === dimension && item.perspective === perspective,
|
||||
);
|
||||
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
|
||||
}
|
||||
|
||||
function AxisAssessmentControl({
|
||||
dimension,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
prefix,
|
||||
}: {
|
||||
dimension: AllianceDimension;
|
||||
value: number | null;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
prefix: string;
|
||||
}) {
|
||||
const copy = DIMENSION_COPY[dimension];
|
||||
return (
|
||||
<fieldset className="ap-axis" disabled={disabled}>
|
||||
<legend>
|
||||
<span>{copy.label}</span>
|
||||
<small>{copy.prompt}</small>
|
||||
</legend>
|
||||
<div className="ap-scale" role="radiogroup" aria-label={`${copy.label} 평가`}>
|
||||
{SCALE.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`ap-scale__option ${value === option.value ? "is-selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`${prefix}-${dimension}`}
|
||||
value={option.value}
|
||||
aria-label={`${option.number} ${option.label}`}
|
||||
checked={value === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
/>
|
||||
<span aria-hidden="true">{option.number}</span>
|
||||
<small>{option.label}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidencePicker({
|
||||
turns,
|
||||
selected,
|
||||
onChange,
|
||||
scope,
|
||||
}: {
|
||||
turns: PulseTurn[];
|
||||
selected: string[];
|
||||
onChange: (turnIds: string[]) => void;
|
||||
scope: "learner" | "supervisor";
|
||||
}) {
|
||||
const eligible = useMemo(() => turns.slice(-12), [turns]);
|
||||
const summaryLabel =
|
||||
scope === "learner" ? "내 판단의 근거 장면 선택" : "교수자 판정 근거 장면 선택";
|
||||
|
||||
const toggle = (turnId: string) => {
|
||||
onChange(
|
||||
selected.includes(turnId)
|
||||
? selected.filter((item) => item !== turnId)
|
||||
: [...selected, turnId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<details className="ap-evidence-picker">
|
||||
<summary>
|
||||
{summaryLabel}
|
||||
<span>{selected.length}개 선택</span>
|
||||
</summary>
|
||||
{eligible.length > 0 ? (
|
||||
<div className="ap-evidence-picker__list">
|
||||
{eligible.map((turn) => (
|
||||
<label key={turn.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(turn.id)}
|
||||
onChange={() => toggle(turn.id)}
|
||||
/>
|
||||
<span className="ap-evidence-picker__time">{turn.ts}</span>
|
||||
<span>
|
||||
<b>{turn.who}</b>
|
||||
{turn.text}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>선택할 수 있는 저장 발화가 없습니다.</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function MeasurementEvidence({
|
||||
measurement,
|
||||
onJumpToTurn,
|
||||
}: {
|
||||
measurement: AllianceMeasurement;
|
||||
onJumpToTurn: (turnId: string) => void;
|
||||
}) {
|
||||
const evidence = measurement.evidence ?? [];
|
||||
if (!measurement.rationale && evidence.length === 0) return null;
|
||||
return (
|
||||
<details className="ap-measurement-evidence">
|
||||
<summary>근거 {evidence.length}개</summary>
|
||||
{measurement.rationale ? <p>{measurement.rationale}</p> : null}
|
||||
{evidence.length > 0 ? (
|
||||
<div className="ap-measurement-evidence__turns">
|
||||
{evidence.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${measurement.measurement_id}-${item.turn_id}-${item.seq}`}
|
||||
onClick={() => onJumpToTurn(item.turn_id)}
|
||||
>
|
||||
<span>{item.seq}번째 발화</span>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function PerspectiveCell({
|
||||
pulse,
|
||||
dimension,
|
||||
perspective,
|
||||
onJumpToTurn,
|
||||
}: {
|
||||
pulse: AlliancePulse;
|
||||
dimension: AllianceDimension;
|
||||
perspective: AlliancePerspective;
|
||||
onJumpToTurn: (turnId: string) => void;
|
||||
}) {
|
||||
const measurement =
|
||||
perspective === "learner_self_report"
|
||||
? null
|
||||
: selectedMeasurement(pulse, dimension, perspective);
|
||||
const value =
|
||||
perspective === "learner_self_report"
|
||||
? (pulse.self_scores?.[dimension] ?? null)
|
||||
: (measurement?.value ?? null);
|
||||
const failed = measurement != null && measurement.status !== "ready";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ap-perspective-cell ${failed || value == null ? "is-unavailable" : ""}`}
|
||||
>
|
||||
<span className="ap-perspective-cell__label">
|
||||
<b>{PERSPECTIVE_COPY[perspective].label}</b>
|
||||
<small>{PERSPECTIVE_COPY[perspective].provenance}</small>
|
||||
</span>
|
||||
<strong>{scoreText(value)}</strong>
|
||||
<span>{scoreDescriptor(value)}</span>
|
||||
{measurement ? (
|
||||
<MeasurementEvidence measurement={measurement} onJumpToTurn={onJumpToTurn} />
|
||||
) : (
|
||||
<small>먼저 기록한 자기평가</small>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
|
||||
const terminalWithoutReveal =
|
||||
pulse.status === "error" ||
|
||||
pulse.status === "degraded" ||
|
||||
Boolean(pulse.error_code);
|
||||
return (
|
||||
<div className="ap-waiting" role="status" aria-live="polite">
|
||||
<div className="ap-waiting__lock">
|
||||
<Icon name="shield" size={18} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div>
|
||||
<b>
|
||||
{terminalWithoutReveal
|
||||
? "내 판단은 보존됐습니다"
|
||||
: "내 판단이 잠겼습니다"}
|
||||
</b>
|
||||
<p>
|
||||
{terminalWithoutReveal
|
||||
? "공개 가능한 AI 근거를 확정하지 못했습니다. 준비되지 않은 점수는 추정해 표시하지 않습니다."
|
||||
: "AI 관점은 아직 화면에 표시하지 않습니다. 두 관점이 준비되면 한 번에 공개합니다."}
|
||||
</p>
|
||||
</div>
|
||||
{terminalWithoutReveal ? null : (
|
||||
<div className="ap-waiting__skeleton" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
<dl className="ap-locked-scores">
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<div key={dimension}>
|
||||
<dt>{DIMENSION_COPY[dimension].short}</dt>
|
||||
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonView({
|
||||
pulse,
|
||||
onJumpToTurn,
|
||||
}: {
|
||||
pulse: AlliancePulse;
|
||||
onJumpToTurn: (turnId: string) => void;
|
||||
}) {
|
||||
const supervisorExists = pulse.measurements.some(
|
||||
(measurement) => measurement.perspective === "supervisor_human",
|
||||
);
|
||||
const perspectives = supervisorExists
|
||||
? [...CORE_PERSPECTIVES, "supervisor_human" as const]
|
||||
: CORE_PERSPECTIVES;
|
||||
|
||||
return (
|
||||
<div className="ap-comparison" aria-label="치료 동맹 관점 비교">
|
||||
<div
|
||||
className={`ap-comparison__head ${supervisorExists ? "has-supervisor" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>독립 축</span>
|
||||
{perspectives.map((perspective) => (
|
||||
<span key={perspective}>
|
||||
<b>{PERSPECTIVE_COPY[perspective].label}</b>
|
||||
<small>{PERSPECTIVE_COPY[perspective].provenance}</small>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<section
|
||||
className={`ap-comparison__row ${supervisorExists ? "has-supervisor" : ""}`}
|
||||
key={dimension}
|
||||
aria-labelledby={`ap-dimension-${dimension}`}
|
||||
>
|
||||
<div className="ap-comparison__axis">
|
||||
<b id={`ap-dimension-${dimension}`}>{DIMENSION_COPY[dimension].label}</b>
|
||||
<span>{DIMENSION_COPY[dimension].prompt}</span>
|
||||
</div>
|
||||
{perspectives.map((perspective) => (
|
||||
<PerspectiveCell
|
||||
key={perspective}
|
||||
pulse={pulse}
|
||||
dimension={dimension}
|
||||
perspective={perspective}
|
||||
onJumpToTurn={onJumpToTurn}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
<p className="ap-comparison__note">
|
||||
총점과 평균은 만들지 않습니다. 축별 차이는 다음 회기에서 확인할 학습 질문으로 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SupervisorRatingForm({
|
||||
sessionId,
|
||||
pulse,
|
||||
turns,
|
||||
onRecorded,
|
||||
}: {
|
||||
sessionId: string;
|
||||
pulse: AlliancePulse;
|
||||
turns: PulseTurn[];
|
||||
onRecorded: () => Promise<void>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<ScoreDraft>(EMPTY_DRAFT);
|
||||
const [evidenceTurnIds, setEvidenceTurnIds] = useState<string[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [recorded, setRecorded] = useState(false);
|
||||
const hasExisting = pulse.measurements.some(
|
||||
(measurement) => measurement.perspective === "supervisor_human",
|
||||
);
|
||||
|
||||
const submit = async () => {
|
||||
if (
|
||||
!isCompleteDraft(draft) ||
|
||||
evidenceTurnIds.length === 0 ||
|
||||
!note.trim()
|
||||
) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await alliancePulseApi.addSupervisorRating(sessionId, pulse.pulse_id, {
|
||||
scores: draft,
|
||||
evidence_turn_ids: evidenceTurnIds,
|
||||
note: note.trim(),
|
||||
});
|
||||
setRecorded(true);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setEvidenceTurnIds([]);
|
||||
setNote("");
|
||||
await onRecorded();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "교수자 동맹 판정을 저장하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<details className="ap-supervisor" open={!hasExisting}>
|
||||
<summary>{hasExisting ? "교수자 판정 추가" : "교수자 판정 기록"}</summary>
|
||||
<div className="ap-supervisor__body">
|
||||
<p>
|
||||
세 축을 각각 판정하고 근거 발화를 연결합니다. 새 판정은 기존 기록을 덮어쓰지 않습니다.
|
||||
</p>
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<AxisAssessmentControl
|
||||
key={dimension}
|
||||
dimension={dimension}
|
||||
value={draft[dimension]}
|
||||
prefix="supervisor-alliance"
|
||||
disabled={saving}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, [dimension]: value }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<EvidencePicker
|
||||
turns={turns}
|
||||
selected={evidenceTurnIds}
|
||||
onChange={setEvidenceTurnIds}
|
||||
scope="supervisor"
|
||||
/>
|
||||
<label className="ap-note-field">
|
||||
<span>판정 메모</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={note}
|
||||
maxLength={2000}
|
||||
placeholder="관점 차이를 해석하고 다음 지도에서 확인할 점을 남깁니다."
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="ap-inline-error" role="alert">{error}</p> : null}
|
||||
{recorded ? (
|
||||
<p className="ap-inline-success" role="status">교수자 판정을 원장에 추가했습니다.</p>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
saving ||
|
||||
!isCompleteDraft(draft) ||
|
||||
evidenceTurnIds.length === 0 ||
|
||||
!note.trim()
|
||||
}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{saving ? "판정 저장 중" : "근거와 함께 판정 추가"}
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlliancePulseCard({
|
||||
sessionId,
|
||||
turns,
|
||||
isSupervisorView,
|
||||
onJumpToTurn,
|
||||
}: AlliancePulseCardProps) {
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
const [pulse, setPulse] = useState<AlliancePulse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<ScoreDraft>(EMPTY_DRAFT);
|
||||
const [evidenceTurnIds, setEvidenceTurnIds] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
try {
|
||||
const response = await alliancePulseApi.list(sessionId, signal);
|
||||
setPulse(latestPostPulse(response.items ?? []));
|
||||
setLoadState("ready");
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setLoadState("error");
|
||||
setError(
|
||||
err instanceof Error ? err.message : "치료 동맹 펄스를 불러오지 못했습니다.",
|
||||
);
|
||||
}
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pulse || (pulse.status !== "awaiting_agents" && pulse.status !== "processing")) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => void load(), POLL_INTERVAL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load, pulse]);
|
||||
|
||||
const submitSelfAssessment = async () => {
|
||||
if (!isCompleteDraft(draft) || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const scores: AllianceScores = draft;
|
||||
const created = await alliancePulseApi.create(sessionId, {
|
||||
checkpoint: "post",
|
||||
scores,
|
||||
evidence_turn_ids: evidenceTurnIds,
|
||||
});
|
||||
setPulse({
|
||||
pulse_id: created.pulse_id,
|
||||
checkpoint: "post",
|
||||
status: created.status,
|
||||
learner_locked_at: new Date().toISOString(),
|
||||
revealed_at: null,
|
||||
error_code: null,
|
||||
self_scores: scores,
|
||||
measurements: [],
|
||||
});
|
||||
setLoadState("ready");
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
await load();
|
||||
setError("이미 잠긴 자기평가가 있어 저장된 기록을 불러왔습니다.");
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "자기평가를 잠그지 못했습니다.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revealReady = Boolean(
|
||||
pulse?.learner_locked_at && pulse?.revealed_at,
|
||||
);
|
||||
const status = pulse ? pulseStatusCopy(pulse) : null;
|
||||
const learnerEvidenceRequired = turns.length > 0;
|
||||
const canSubmit =
|
||||
isCompleteDraft(draft) &&
|
||||
(!learnerEvidenceRequired || evidenceTurnIds.length > 0) &&
|
||||
!submitting;
|
||||
|
||||
let content: ReactNode;
|
||||
if (loadState === "loading") {
|
||||
content = (
|
||||
<div className="ap-loading" role="status" aria-label="치료 동맹 펄스 불러오는 중">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
} else if (loadState === "error" && !pulse) {
|
||||
content = (
|
||||
<div className="ap-load-error" role="alert">
|
||||
<p>{error ?? "치료 동맹 펄스를 불러오지 못했습니다."}</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => void load()}>
|
||||
다시 불러오기
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else if (!pulse && isSupervisorView) {
|
||||
content = (
|
||||
<div className="ap-empty">
|
||||
<b>학습자 자기평가 대기</b>
|
||||
<p>학습자가 세 축을 먼저 잠근 뒤에만 AI 관점과 교수자 판정을 연결할 수 있습니다.</p>
|
||||
</div>
|
||||
);
|
||||
} else if (!pulse) {
|
||||
content = (
|
||||
<div className="ap-self-form">
|
||||
<div className="ap-self-form__intro">
|
||||
<b>AI 관점을 보기 전에 먼저 스스로 판단합니다</b>
|
||||
<p>세 축을 따로 평가하세요. 제출하면 값은 잠기며 이후 관점에 맞춰 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<AxisAssessmentControl
|
||||
key={dimension}
|
||||
dimension={dimension}
|
||||
value={draft[dimension]}
|
||||
prefix="learner-alliance"
|
||||
disabled={submitting}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, [dimension]: value }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<EvidencePicker
|
||||
turns={turns}
|
||||
selected={evidenceTurnIds}
|
||||
onChange={setEvidenceTurnIds}
|
||||
scope="learner"
|
||||
/>
|
||||
{error ? <p className="ap-inline-error" role="alert">{error}</p> : null}
|
||||
<div className="ap-self-form__commit">
|
||||
<span>
|
||||
<Icon name="shield" size={15} strokeWidth={1.8} />
|
||||
제출 뒤에는 AI 관점이 준비될 때까지 내 판단만 보입니다.
|
||||
</span>
|
||||
<Button size="sm" disabled={!canSubmit} onClick={() => void submitSelfAssessment()}>
|
||||
{submitting ? "판단 잠그는 중" : "내 판단 잠그고 관점 비교"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (!revealReady) {
|
||||
content = <LockedWaitingState pulse={pulse} />;
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
<ComparisonView pulse={pulse} onJumpToTurn={onJumpToTurn} />
|
||||
{isSupervisorView ? (
|
||||
<SupervisorRatingForm
|
||||
sessionId={sessionId}
|
||||
pulse={pulse}
|
||||
turns={turns}
|
||||
onRecorded={() => load()}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="sr-card ap-card" aria-busy={loadState === "loading"}>
|
||||
<div className="ap-card__head">
|
||||
<div>
|
||||
<Kicker dot={false}>치료 동맹 펄스</Kicker>
|
||||
<h2>목표, 과업, 유대를 따로 봅니다</h2>
|
||||
</div>
|
||||
{status ? <Badge tone={status.tone}>{status.label}</Badge> : null}
|
||||
</div>
|
||||
{status ? <p className="ap-card__status-copy">{status.body}</p> : null}
|
||||
{content}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
795
apps/web/src/pages/session-review/alliance-pulse.css
Normal file
795
apps/web/src/pages/session-review/alliance-pulse.css
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
/* G1 치료 동맹 펄스. 기존 Vignette 임상 워크벤치 토큰을 그대로 따른다. */
|
||||
|
||||
.ap-card {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5);
|
||||
overflow: hidden;
|
||||
border-color: color-mix(in srgb, var(--accent) 22%, var(--border-subtle));
|
||||
background: color-mix(in srgb, var(--bg-surface) 94%, var(--accent-tint));
|
||||
}
|
||||
|
||||
.ap-card__head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.ap-card__head h2 {
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: clamp(1.15rem, 1.7vw, var(--fs-h2));
|
||||
font-weight: 720;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
|
||||
.ap-card__status-copy {
|
||||
max-width: 72ch;
|
||||
margin: -6px 0 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ap-self-form,
|
||||
.ap-waiting,
|
||||
.ap-comparison,
|
||||
.ap-supervisor,
|
||||
.ap-empty,
|
||||
.ap-load-error {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface-2);
|
||||
box-shadow: var(--glass-inset-shadow);
|
||||
}
|
||||
|
||||
.ap-self-form {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.ap-self-form__intro {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ap-self-form__intro b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
.ap-self-form__intro p,
|
||||
.ap-empty p,
|
||||
.ap-load-error p,
|
||||
.ap-waiting p,
|
||||
.ap-supervisor__body > p {
|
||||
margin: 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ap-axis {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.75fr) minmax(360px, 1.8fr);
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
margin: 0;
|
||||
padding: var(--sp-3) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
|
||||
.ap-axis legend {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.ap-axis legend > span {
|
||||
align-self: end;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.ap-axis legend > small {
|
||||
grid-column: 1;
|
||||
align-self: start;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ap-scale {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(68px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ap-scale__option {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 3px;
|
||||
padding: 8px 5px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-body);
|
||||
background: var(--bg-surface);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-base) var(--ease-out),
|
||||
background-color var(--dur-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.ap-scale__option:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 44%, var(--border-subtle));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ap-scale__option:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.ap-scale__option.is-selected {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-accent);
|
||||
background: var(--accent-tint);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.ap-scale__option input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ap-scale__option:has(input:focus-visible) {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ap-scale__option > span {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.ap-scale__option > small {
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ap-evidence-picker {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.ap-evidence-picker > summary,
|
||||
.ap-supervisor > summary,
|
||||
.ap-measurement-evidence > summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 690;
|
||||
}
|
||||
|
||||
.ap-evidence-picker > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: 11px 13px;
|
||||
}
|
||||
|
||||
.ap-evidence-picker > summary span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.ap-evidence-picker > summary:focus-visible,
|
||||
.ap-supervisor > summary:focus-visible,
|
||||
.ap-measurement-evidence > summary:focus-visible,
|
||||
.ap-measurement-evidence__turns button:focus-visible {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ap-evidence-picker__list {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.ap-evidence-picker__list label {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-body);
|
||||
background: var(--bg-surface-2);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ap-evidence-picker__list label:has(input:checked) {
|
||||
color: var(--text-strong);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.ap-evidence-picker__list input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.ap-evidence-picker__list b {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.ap-evidence-picker__time {
|
||||
padding-top: 1px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
}
|
||||
|
||||
.ap-self-form__commit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.ap-self-form__commit > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ap-waiting {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(170px, 0.45fr);
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.ap-waiting__lock {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--text-accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.ap-waiting b,
|
||||
.ap-empty b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
.ap-waiting__skeleton,
|
||||
.ap-loading {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ap-waiting__skeleton span,
|
||||
.ap-loading span {
|
||||
height: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--neutral-150);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.ap-waiting__skeleton span:nth-child(2),
|
||||
.ap-loading span:nth-child(2) {
|
||||
width: 72%;
|
||||
}
|
||||
|
||||
.ap-waiting__skeleton span:nth-child(3),
|
||||
.ap-loading span:nth-child(3) {
|
||||
width: 86%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.ap-waiting__skeleton span,
|
||||
.ap-loading span {
|
||||
animation: ap-breathe 1.8s var(--ease-in-out) infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ap-breathe {
|
||||
from { opacity: 0.42; transform: scaleX(0.94); }
|
||||
to { opacity: 0.84; transform: scaleX(1); }
|
||||
}
|
||||
|
||||
.ap-locked-scores {
|
||||
grid-column: 2 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.ap-locked-scores > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.ap-locked-scores dt {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.ap-locked-scores dd {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 730;
|
||||
}
|
||||
|
||||
.ap-comparison {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.ap-comparison__head,
|
||||
.ap-comparison__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(172px, 0.8fr) repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.ap-comparison__head.has-supervisor,
|
||||
.ap-comparison__row.has-supervisor {
|
||||
grid-template-columns: minmax(172px, 0.8fr) repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ap-comparison__head {
|
||||
margin-bottom: 1px;
|
||||
color: var(--text-muted);
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.ap-comparison__head > span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 9px 11px;
|
||||
background: var(--bg-surface-2);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.ap-comparison__head b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.ap-comparison__head small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ap-comparison__row {
|
||||
margin-top: 1px;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.ap-comparison__axis,
|
||||
.ap-perspective-cell {
|
||||
min-width: 0;
|
||||
padding: 13px 11px;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.ap-comparison__axis {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, var(--bg-surface) 80%, var(--accent-tint));
|
||||
}
|
||||
|
||||
.ap-comparison__axis b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.ap-comparison__axis span {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ap-perspective-cell {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ap-perspective-cell__label {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ap-perspective-cell__label b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.ap-perspective-cell__label small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ap-perspective-cell > strong {
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-h3);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.ap-perspective-cell > span,
|
||||
.ap-perspective-cell > small {
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ap-perspective-cell.is-unavailable > strong,
|
||||
.ap-perspective-cell.is-unavailable > span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ap-measurement-evidence {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ap-measurement-evidence > summary {
|
||||
color: var(--text-accent);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.ap-measurement-evidence > p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ap-measurement-evidence__turns {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.ap-measurement-evidence__turns button {
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-body);
|
||||
background: var(--bg-surface-2);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.ap-measurement-evidence__turns button:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ap-measurement-evidence__turns button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.ap-measurement-evidence__turns button span {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-accent);
|
||||
font-family: var(--font-num);
|
||||
}
|
||||
|
||||
.ap-comparison__note {
|
||||
margin: var(--sp-3) 2px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ap-supervisor {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap-supervisor > summary {
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.ap-supervisor[open] > summary {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.ap-supervisor__body {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.ap-note-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.ap-note-field textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-strong);
|
||||
background: var(--bg-surface-2);
|
||||
font: inherit;
|
||||
font-weight: 450;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.ap-note-field textarea::placeholder {
|
||||
color: var(--neutral-400);
|
||||
}
|
||||
|
||||
.ap-note-field textarea:focus-visible {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.ap-loading {
|
||||
min-height: 130px;
|
||||
align-content: center;
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
|
||||
.ap-empty,
|
||||
.ap-load-error {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 7px;
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.ap-inline-error,
|
||||
.ap-inline-success {
|
||||
margin: 0;
|
||||
padding: 9px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ap-inline-error {
|
||||
color: var(--crit-text);
|
||||
background: var(--crit-tint);
|
||||
}
|
||||
|
||||
.ap-inline-success {
|
||||
color: var(--pos-text);
|
||||
background: var(--pos-tint);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.ap-axis {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.ap-axis legend > small,
|
||||
.ap-scale {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.ap-scale {
|
||||
grid-row: auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ap-comparison__head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ap-comparison__row,
|
||||
.ap-comparison__row.has-supervisor {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.ap-comparison__axis {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ap-comparison__row.has-supervisor .ap-perspective-cell:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ap-perspective-cell__label {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.ap-card {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.ap-card__head {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.ap-card__head .vg-badge {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.ap-self-form {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.ap-scale {
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ap-scale__option {
|
||||
min-height: 52px;
|
||||
padding: 7px 2px;
|
||||
}
|
||||
|
||||
.ap-scale__option > small {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
.ap-self-form__commit {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ap-self-form__commit .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ap-waiting {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ap-waiting__skeleton {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ap-locked-scores {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ap-comparison {
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.ap-comparison__row,
|
||||
.ap-comparison__row.has-supervisor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ap-comparison__axis,
|
||||
.ap-comparison__row.has-supervisor .ap-perspective-cell:last-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.ap-perspective-cell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, auto) minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
column-gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ap-perspective-cell__label {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ap-perspective-cell > strong {
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
.ap-perspective-cell .ap-measurement-evidence,
|
||||
.ap-perspective-cell > small {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ap-evidence-picker__list label {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ap-evidence-picker__time {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.ap-scale {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ap-scale__option {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.ap-scale__option:nth-child(4) {
|
||||
grid-column: 2 / span 2;
|
||||
}
|
||||
|
||||
.ap-scale__option:nth-child(5) {
|
||||
grid-column: 4 / span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ap-scale__option,
|
||||
.ap-measurement-evidence__turns button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
90
apps/web/src/pages/session-review/alliancePulseApi.ts
Normal file
90
apps/web/src/pages/session-review/alliancePulseApi.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { api, supplementalApi } from "../../lib/api";
|
||||
|
||||
export type AllianceDimension = "goal" | "task" | "bond";
|
||||
export type AllianceCheckpoint = "pre" | "mid" | "post";
|
||||
export type AlliancePerspective =
|
||||
| "learner_self_report"
|
||||
| "client_agent_report"
|
||||
| "independent_observer"
|
||||
| "supervisor_human";
|
||||
|
||||
export type AllianceScores = Record<AllianceDimension, number>;
|
||||
|
||||
export interface AllianceEvidence {
|
||||
turn_id: string;
|
||||
seq: number;
|
||||
speaker: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AllianceMeasurement {
|
||||
measurement_id: string;
|
||||
dimension: AllianceDimension;
|
||||
perspective: AlliancePerspective;
|
||||
source_kind: string;
|
||||
value: number | null;
|
||||
confidence: number | null;
|
||||
status: string;
|
||||
error_code: string | null;
|
||||
rationale: string | null;
|
||||
evidence: AllianceEvidence[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AlliancePulse {
|
||||
pulse_id: string;
|
||||
checkpoint: AllianceCheckpoint;
|
||||
status: string;
|
||||
learner_locked_at: string | null;
|
||||
revealed_at: string | null;
|
||||
error_code: string | null;
|
||||
self_scores: AllianceScores | null;
|
||||
measurements: AllianceMeasurement[];
|
||||
}
|
||||
|
||||
export interface AlliancePulseListResponse {
|
||||
items: AlliancePulse[];
|
||||
}
|
||||
|
||||
export interface CreateAlliancePulseRequest {
|
||||
checkpoint: AllianceCheckpoint;
|
||||
scores: AllianceScores;
|
||||
evidence_turn_ids: string[];
|
||||
}
|
||||
|
||||
export interface CreateAlliancePulseResponse {
|
||||
pulse_id: string;
|
||||
status: "awaiting_agents" | string;
|
||||
}
|
||||
|
||||
export interface SupervisorAllianceRatingRequest {
|
||||
scores: AllianceScores;
|
||||
evidence_turn_ids: string[];
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface SupervisorAllianceRatingResponse {
|
||||
status: "recorded" | string;
|
||||
}
|
||||
|
||||
function pulsePath(sessionId: string): string {
|
||||
return `/sessions/${encodeURIComponent(sessionId)}/alliance-pulses`;
|
||||
}
|
||||
|
||||
export const alliancePulseApi = {
|
||||
list: (sessionId: string, signal?: AbortSignal) =>
|
||||
supplementalApi.get<AlliancePulseListResponse>(pulsePath(sessionId), {
|
||||
signal,
|
||||
}),
|
||||
create: (sessionId: string, body: CreateAlliancePulseRequest) =>
|
||||
api.post<CreateAlliancePulseResponse>(pulsePath(sessionId), body),
|
||||
addSupervisorRating: (
|
||||
sessionId: string,
|
||||
pulseId: string,
|
||||
body: SupervisorAllianceRatingRequest,
|
||||
) =>
|
||||
api.post<SupervisorAllianceRatingResponse>(
|
||||
`${pulsePath(sessionId)}/${encodeURIComponent(pulseId)}/supervisor-rating`,
|
||||
body,
|
||||
),
|
||||
};
|
||||
1089
apps/web/src/pages/session-review/calibration-transfer.css
Normal file
1089
apps/web/src/pages/session-review/calibration-transfer.css
Normal file
File diff suppressed because it is too large
Load diff
120
apps/web/src/pages/session-review/calibrationTransferApi.ts
Normal file
120
apps/web/src/pages/session-review/calibrationTransferApi.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { api, supplementalApi } from "../../lib/api";
|
||||
import type { components } from "../../lib/api.gen";
|
||||
|
||||
type ApiSchema<Name extends keyof components["schemas"]> =
|
||||
components["schemas"][Name];
|
||||
|
||||
/** G5 wire DTO는 생성된 FastAPI OpenAPI 계약을 그대로 사용한다. */
|
||||
export type CalibrationConfidenceInterval = ApiSchema<"ConfidenceInterval">;
|
||||
export type CalibrationPair = ApiSchema<"CalibrationPair">;
|
||||
export type CompetencyCalibrationAssessment =
|
||||
ApiSchema<"CompetencyCalibrationAssessment">;
|
||||
export type MetacognitivePrescription = ApiSchema<"MetacognitivePrescription">;
|
||||
export type PredictionRevisionItem = ApiSchema<"PredictionRevisionItem">;
|
||||
export type PredictionLockItem = ApiSchema<"PredictionLockItem">;
|
||||
export type PerformanceObservationItem = ApiSchema<"PerformanceObservationItem">;
|
||||
export type PredictionHistoryItem = ApiSchema<"PredictionHistoryItem">;
|
||||
export type CalibrationAssessmentItem = ApiSchema<"CalibrationAssessmentItem">;
|
||||
export type TransferAssessment = ApiSchema<"TransferAssessment">;
|
||||
export type TransferTrialItem = ApiSchema<"TransferTrialItem">;
|
||||
export type TransferAssessmentItem = ApiSchema<"TransferAssessmentItem">;
|
||||
export type SyntheticSubgroupResult = ApiSchema<"SyntheticSubgroupResult">;
|
||||
export type SubgroupDriftReport = ApiSchema<"SubgroupDriftReport">;
|
||||
export type DriftReportItem = ApiSchema<"DriftReportItem">;
|
||||
export type TransferSuiteItem = ApiSchema<"TransferSuiteItem">;
|
||||
export type TeacherReviewItem = ApiSchema<"TeacherReviewItem">;
|
||||
export type CalibrationTransferReadModelResponse =
|
||||
ApiSchema<"CalibrationTransferReadModelResponse">;
|
||||
export type PredictionRevisionRequest = ApiSchema<"PredictionRevisionRequest">;
|
||||
export type PredictionRevisionResponse = ApiSchema<"PredictionRevisionResponse">;
|
||||
export type PredictionLockRequest = ApiSchema<"PredictionLockRequest">;
|
||||
export type PredictionLockResponse = ApiSchema<"PredictionLockResponse">;
|
||||
export type TeacherCorrectionResponse = ApiSchema<"TeacherReviewResponse">;
|
||||
export type ActualTransferAssessment = ApiSchema<"ActualTransferAssessment">;
|
||||
export type ActualTransferExecutionRequest =
|
||||
ApiSchema<"ActualTransferExecutionRequest">;
|
||||
export type ActualTransferExecutionResponse =
|
||||
ApiSchema<"ActualTransferExecutionResponse">;
|
||||
|
||||
export type CalibrationBias = CompetencyCalibrationAssessment["bias"];
|
||||
export type CalibrationImprovement =
|
||||
CompetencyCalibrationAssessment["improvement"];
|
||||
export type PerformanceStatus = PerformanceObservationItem["status"];
|
||||
export type RelationshipStyle = TransferTrialItem["relationship_style"];
|
||||
export type DriftStatus = SubgroupDriftReport["status"];
|
||||
|
||||
/** 교수자 UI는 원판정 쓰기를 열지 않고 append-only 교정만 허용한다. */
|
||||
export type TeacherCorrectionRequest = Omit<
|
||||
ApiSchema<"TeacherReviewRequest">,
|
||||
"disposition" | "correction_payload"
|
||||
> & {
|
||||
disposition: "corrected";
|
||||
correction_payload: { teacher_note: string };
|
||||
};
|
||||
|
||||
interface TeacherSessionRef {
|
||||
session_id: string;
|
||||
learner_id: string;
|
||||
}
|
||||
|
||||
interface TeacherDashboardLookup {
|
||||
pending_reviews?: TeacherSessionRef[];
|
||||
recent_sessions?: TeacherSessionRef[];
|
||||
}
|
||||
|
||||
function learnerPath(learnerId?: string): string {
|
||||
return learnerId
|
||||
? `/calibration/learners/${encodeURIComponent(learnerId)}`
|
||||
: "/calibration/learners/me";
|
||||
}
|
||||
|
||||
export class CalibrationLearnerContextError extends Error {
|
||||
constructor() {
|
||||
super("이 회기의 학습자 캘리브레이션 원장을 찾지 못했습니다.");
|
||||
this.name = "CalibrationLearnerContextError";
|
||||
}
|
||||
}
|
||||
|
||||
export const calibrationTransferApi = {
|
||||
getForLearner: (signal?: AbortSignal) =>
|
||||
supplementalApi.get<CalibrationTransferReadModelResponse>(learnerPath(), {
|
||||
signal,
|
||||
}),
|
||||
|
||||
getForTeacherSession: async (sessionId: string, signal?: AbortSignal) => {
|
||||
const dashboard = await supplementalApi.get<TeacherDashboardLookup>(
|
||||
"/teacher/dashboard",
|
||||
{ signal },
|
||||
);
|
||||
const session = [
|
||||
...(dashboard.pending_reviews ?? []),
|
||||
...(dashboard.recent_sessions ?? []),
|
||||
].find((item) => item.session_id === sessionId);
|
||||
if (!session) throw new CalibrationLearnerContextError();
|
||||
return supplementalApi.get<CalibrationTransferReadModelResponse>(
|
||||
learnerPath(session.learner_id),
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
|
||||
appendPredictionRevision: (body: PredictionRevisionRequest) =>
|
||||
api.post<PredictionRevisionResponse>(
|
||||
"/calibration/predictions/revisions",
|
||||
body,
|
||||
),
|
||||
|
||||
lockPrediction: (historyId: string, body: PredictionLockRequest) =>
|
||||
api.post<PredictionLockResponse>(
|
||||
`/calibration/predictions/${encodeURIComponent(historyId)}/lock`,
|
||||
body,
|
||||
),
|
||||
|
||||
appendTeacherCorrection: (body: TeacherCorrectionRequest) =>
|
||||
api.post<TeacherCorrectionResponse>("/calibration/reviews", body),
|
||||
|
||||
appendActualTransferExecution: (body: ActualTransferExecutionRequest) =>
|
||||
api.post<ActualTransferExecutionResponse>(
|
||||
"/calibration/transfer-executions",
|
||||
body,
|
||||
),
|
||||
};
|
||||
1168
apps/web/src/pages/session-review/deliberate-practice.css
Normal file
1168
apps/web/src/pages/session-review/deliberate-practice.css
Normal file
File diff suppressed because it is too large
Load diff
124
apps/web/src/pages/session-review/deliberatePracticeApi.ts
Normal file
124
apps/web/src/pages/session-review/deliberatePracticeApi.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import {
|
||||
api,
|
||||
ApiError,
|
||||
apiFetch,
|
||||
apiUrl,
|
||||
supplementalApi,
|
||||
} from "../../lib/api";
|
||||
import type { components, paths } from "../../lib/api.gen";
|
||||
|
||||
type ApiSchema<Name extends keyof components["schemas"]> =
|
||||
components["schemas"][Name];
|
||||
|
||||
export type DeliberatePracticeReadModel =
|
||||
ApiSchema<"DeliberatePracticeReadModelResponse">;
|
||||
export type PracticePrescriptionItem = ApiSchema<"PracticePrescriptionItem">;
|
||||
export type PracticeEpisodeItem = ApiSchema<"PracticeEpisodeItem">;
|
||||
export type PracticeAttemptItem = ApiSchema<"PracticeAttemptItem">;
|
||||
export type PracticeEvidenceRef = ApiSchema<"PracticeEvidenceRef">;
|
||||
export type PracticeAttemptSubmissionRequest =
|
||||
ApiSchema<"PracticeAttemptSubmissionRequest">;
|
||||
export type PracticeAttemptSubmissionResponse =
|
||||
ApiSchema<"PracticeAttemptSubmissionResponse">;
|
||||
type ObserveCompletedPracticeSessionOperation = NonNullable<
|
||||
paths["/practice/{prescription_id}/attempts/from-session/{practice_session_id}"]["post"]
|
||||
>;
|
||||
type ObserveCompletedPracticeSessionPath =
|
||||
ObserveCompletedPracticeSessionOperation["parameters"]["path"];
|
||||
export type ObserveCompletedPracticeSessionResponse =
|
||||
ObserveCompletedPracticeSessionOperation["responses"][201]["content"]["application/json"];
|
||||
export type PracticeTeacherCorrectionRequest =
|
||||
ApiSchema<"PracticeTeacherCorrectionRequest">;
|
||||
export type PracticeTeacherCorrectionResponse =
|
||||
ApiSchema<"PracticeTeacherCorrectionResponse">;
|
||||
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
|
||||
|
||||
function learnerPracticePath(learnerId?: string): string {
|
||||
return learnerId
|
||||
? `/practice/learners/${encodeURIComponent(learnerId)}`
|
||||
: "/practice/learners/me";
|
||||
}
|
||||
|
||||
function dashboardSessions(dashboard: TeacherDashboardResponse) {
|
||||
return [
|
||||
...(dashboard.pending_reviews ?? []),
|
||||
...(dashboard.recent_sessions ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
async function getTeacherDashboard(signal?: AbortSignal) {
|
||||
const response = await fetch(apiUrl("/teacher/dashboard"), {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
let body: unknown;
|
||||
let detail =
|
||||
response.statusText || "교수자 학습자 맥락을 불러오지 못했습니다.";
|
||||
try {
|
||||
body = await response.json();
|
||||
const candidate = (body as { detail?: unknown }).detail;
|
||||
if (typeof candidate === "string") detail = candidate;
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
// 이 보조 조회의 실패는 전체 회기 리뷰의 인증 성공을 뒤집지 않는다.
|
||||
// 후속 역할 보호 API는 공통 client를 사용하므로 실제 세션 만료는 그대로 전파된다.
|
||||
throw new ApiError(response.status, detail, body);
|
||||
}
|
||||
return (await response.json()) as TeacherDashboardResponse;
|
||||
}
|
||||
|
||||
export class PracticeLearnerContextError extends Error {
|
||||
constructor() {
|
||||
super("이 회기의 학습자 연습 원장을 찾지 못했습니다.");
|
||||
this.name = "PracticeLearnerContextError";
|
||||
}
|
||||
}
|
||||
|
||||
export const deliberatePracticeApi = {
|
||||
getForLearner: (signal?: AbortSignal) =>
|
||||
supplementalApi.get<DeliberatePracticeReadModel>(learnerPracticePath(), {
|
||||
signal,
|
||||
}),
|
||||
|
||||
getForTeacherSession: async (sessionId: string, signal?: AbortSignal) => {
|
||||
const dashboard = await getTeacherDashboard(signal);
|
||||
const session = dashboardSessions(dashboard).find(
|
||||
(item) => item.session_id === sessionId,
|
||||
);
|
||||
if (!session) throw new PracticeLearnerContextError();
|
||||
return supplementalApi.get<DeliberatePracticeReadModel>(
|
||||
learnerPracticePath(session.learner_id),
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
|
||||
submitAttempt: (
|
||||
prescriptionId: string,
|
||||
body: PracticeAttemptSubmissionRequest,
|
||||
) =>
|
||||
api.post<PracticeAttemptSubmissionResponse>(
|
||||
`/practice/${encodeURIComponent(prescriptionId)}/attempts`,
|
||||
body,
|
||||
),
|
||||
|
||||
observeCompletedSession: (
|
||||
prescriptionId: ObserveCompletedPracticeSessionPath["prescription_id"],
|
||||
practiceSessionId: ObserveCompletedPracticeSessionPath["practice_session_id"],
|
||||
) =>
|
||||
api.post<ObserveCompletedPracticeSessionResponse>(
|
||||
`/practice/${encodeURIComponent(prescriptionId)}/attempts/from-session/${encodeURIComponent(practiceSessionId)}`,
|
||||
),
|
||||
|
||||
appendCorrection: (
|
||||
attemptRecordId: string,
|
||||
body: PracticeTeacherCorrectionRequest,
|
||||
) =>
|
||||
apiFetch<PracticeTeacherCorrectionResponse>(
|
||||
`/practice/attempts/${encodeURIComponent(attemptRecordId)}/correction`,
|
||||
{ method: "PATCH", body },
|
||||
),
|
||||
};
|
||||
815
apps/web/src/pages/session-review/multimodal-alliance.css
Normal file
815
apps/web/src/pages/session-review/multimodal-alliance.css
Normal file
|
|
@ -0,0 +1,815 @@
|
|||
.mma-card {
|
||||
display: grid;
|
||||
gap: var(--sp-5);
|
||||
padding: var(--sp-5);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.mma-header,
|
||||
.mma-section-head,
|
||||
.mma-practice-cta,
|
||||
.mma-event-inspector {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.mma-header h2 {
|
||||
max-width: 760px;
|
||||
margin: 7px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: clamp(1.3rem, 2.1vw, 1.7rem);
|
||||
font-weight: 720;
|
||||
line-height: 1.3;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.mma-header p,
|
||||
.mma-boundary-copy,
|
||||
.mma-state-copy p,
|
||||
.mma-practice-cta p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.mma-header__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mma-clock,
|
||||
.mma-axis-board,
|
||||
.mma-privacy {
|
||||
min-width: 0;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.mma-section-head {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mma-section-head h3,
|
||||
.mma-state-copy h2,
|
||||
.mma-axis h4,
|
||||
.mma-event-inspector h4 {
|
||||
margin: 3px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-weight: 690;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.mma-section-head h3 {
|
||||
font-size: var(--fs-h3);
|
||||
}
|
||||
|
||||
.mma-eyebrow,
|
||||
.mma-axis__title > span,
|
||||
.mma-measurement__label,
|
||||
.mma-fusion > span,
|
||||
.mma-clock__lane-label,
|
||||
.mma-no-total {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-kicker);
|
||||
font-weight: 720;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.mma-clock__privacy-note {
|
||||
margin: var(--sp-3) 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.mma-clock__viewport {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 3px 0 var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
scrollbar-color: var(--neutral-200) transparent;
|
||||
}
|
||||
|
||||
.mma-clock__viewport:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.mma-clock__canvas {
|
||||
position: relative;
|
||||
min-width: 720px;
|
||||
padding: 30px 12px 4px 108px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--bg-surface);
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in srgb, var(--border-subtle) 48%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to right, color-mix(in srgb, var(--border-strong) 60%, transparent) 1px, transparent 1px);
|
||||
background-size: calc((100% - 120px) / 20) 100%, calc((100% - 120px) / 4) 100%;
|
||||
background-position: 108px 0, 108px 0;
|
||||
}
|
||||
|
||||
.mma-clock__ticks {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
left: 108px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mma-clock__ticks span {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.mma-clock__ticks span:first-child {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.mma-clock__ticks span:last-child {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.mma-clock__lane {
|
||||
position: relative;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.mma-clock__lane + .mma-clock__lane {
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-subtle) 72%, transparent);
|
||||
}
|
||||
|
||||
.mma-clock__lane-label {
|
||||
position: absolute;
|
||||
right: calc(100% + 12px);
|
||||
top: 50%;
|
||||
width: 92px;
|
||||
transform: translateY(-50%);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mma-clock__track {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mma-word {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
min-width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.mma-word.is-client {
|
||||
background: var(--clay);
|
||||
}
|
||||
|
||||
.mma-word.is-learner {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.mma-event {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
min-width: 24px;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 3px;
|
||||
color: var(--text-body);
|
||||
background: var(--bg-surface);
|
||||
font: 680 11px/1 var(--font-sans);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mma-event:hover,
|
||||
.mma-event.is-selected {
|
||||
color: var(--text-accent);
|
||||
border-color: var(--border-focus);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.mma-event.is-overlap,
|
||||
.mma-event.is-interruption {
|
||||
color: var(--warn-text);
|
||||
background: var(--warn-tint);
|
||||
}
|
||||
|
||||
.mma-event:focus-visible {
|
||||
z-index: 3;
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mma-playhead {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 25px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
background: var(--info-solid);
|
||||
pointer-events: none;
|
||||
transition: left var(--dur-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.mma-playhead::before {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--info-solid);
|
||||
content: "";
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.mma-event-inspector {
|
||||
align-items: center;
|
||||
margin-top: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.mma-event-inspector > div {
|
||||
flex: 0 0 190px;
|
||||
}
|
||||
|
||||
.mma-event-inspector span,
|
||||
.mma-event-inspector small {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.mma-event-inspector h4 {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.mma-event-inspector p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.mma-event-inspector small {
|
||||
flex: 0 0 180px;
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mma-clock__empty,
|
||||
.mma-state-copy {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.mma-clock__empty p {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.mma-axis-board {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.mma-axis-board__head,
|
||||
.mma-axis {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 0.55fr) minmax(320px, 1.4fr) minmax(210px, 0.8fr);
|
||||
gap: var(--sp-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mma-axis-board__head {
|
||||
padding: 0 var(--sp-3);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-kicker);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mma-axis {
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.mma-axis__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mma-axis__title h4 {
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
.mma-axis__modalities {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mma-measurement,
|
||||
.mma-fusion {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.mma-measurement__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.mma-measurement strong,
|
||||
.mma-fusion strong {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-h3);
|
||||
}
|
||||
|
||||
.mma-measurement small,
|
||||
.mma-fusion small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.mma-measurement.is-unavailable strong {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
.mma-measurement details {
|
||||
margin-top: var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.mma-measurement summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mma-measurement dl {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 7px 0 0;
|
||||
}
|
||||
|
||||
.mma-measurement dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mma-measurement dt,
|
||||
.mma-measurement dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mma-fusion {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.mma-fusion.is-fused {
|
||||
background: var(--info-tint);
|
||||
}
|
||||
|
||||
.mma-fusion.is-text-only {
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.mma-fusion p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mma-no-total {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.mma-privacy {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.mma-boundary-copy {
|
||||
max-width: 850px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mma-privacy-ledger {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mma-privacy-ledger > div {
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.mma-privacy-ledger dt {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.mma-privacy-ledger dd {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.mma-consent-form,
|
||||
.mma-privacy-actions,
|
||||
.mma-delete-fieldset {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.mma-consent-form,
|
||||
.mma-privacy-actions {
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.mma-consent-form > label:first-child {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 150px;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.mma-consent-form select {
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-strong);
|
||||
background: var(--bg-surface-2);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mma-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mma-check input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.mma-consent-form .vg-btn,
|
||||
.mma-privacy-actions > .vg-btn,
|
||||
.mma-delete-fieldset .vg-btn {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.mma-raw-access strong,
|
||||
.mma-practice-cta strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.mma-raw-access p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.mma-raw-asset {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.mma-scene-player {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.mma-scene-player__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.mma-scene-player__head > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mma-scene-player__head span {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.mma-scene-player audio {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
.mma-scene-player audio:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.mma-playback-status.is-error {
|
||||
color: var(--crit-text);
|
||||
}
|
||||
|
||||
.mma-delete-fieldset {
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.mma-delete-fieldset legend {
|
||||
padding: 0 5px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mma-delete-fieldset .vg-btn {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.mma-deletion-history {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mma-deletion-history > div {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.mma-deletion-history small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mma-action-message {
|
||||
margin: 0;
|
||||
padding: 9px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-body);
|
||||
background: var(--bg-surface);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.mma-action-message.is-success {
|
||||
color: var(--pos-text);
|
||||
background: var(--pos-tint);
|
||||
}
|
||||
|
||||
.mma-action-message.is-error {
|
||||
color: var(--crit-text);
|
||||
background: var(--crit-tint);
|
||||
}
|
||||
|
||||
.mma-practice-cta {
|
||||
align-items: center;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.mma-practice-cta > div {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.mma-practice-cta p {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.mma-card.is-loading > p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.mma-card.is-error .mma-state-copy {
|
||||
color: var(--crit-text);
|
||||
}
|
||||
|
||||
.mma-state-copy {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mma-state-copy h2 {
|
||||
font-size: var(--fs-h3);
|
||||
}
|
||||
|
||||
.mma-state-copy .vg-btn {
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.mma-axis-board__head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mma-axis {
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mma-fusion {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.mma-event-inspector {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mma-event-inspector > div,
|
||||
.mma-event-inspector small {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.mma-event-inspector p {
|
||||
flex: 1 1 100%;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.mma-event-inspector small {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.mma-card {
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.mma-header,
|
||||
.mma-section-head,
|
||||
.mma-practice-cta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mma-header__badges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mma-clock,
|
||||
.mma-axis-board,
|
||||
.mma-privacy {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.mma-axis {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mma-axis__title,
|
||||
.mma-fusion {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.mma-axis__title {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: flex-start;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mma-privacy-ledger {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mma-delete-fieldset {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mma-delete-fieldset .vg-btn {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.mma-practice-cta .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mma-scene-player__head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mma-scene-player__head .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.mma-card {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.mma-axis__modalities,
|
||||
.mma-privacy-ledger {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mma-consent-form > label:first-child,
|
||||
.mma-deletion-history > div {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mma-deletion-history small {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.mma-event-inspector small {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mma-playhead {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
385
apps/web/src/pages/session-review/multimodalAllianceApi.ts
Normal file
385
apps/web/src/pages/session-review/multimodalAllianceApi.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import { api, apiUrl, supplementalApi } from "../../lib/api";
|
||||
import type { components } from "../../lib/api.gen";
|
||||
|
||||
type ApiSchema<Name extends keyof components["schemas"]> =
|
||||
components["schemas"][Name];
|
||||
|
||||
type WireMetadata = ApiSchema<"MultimodalSessionMetadataResponse">;
|
||||
type WireRawAudio = ApiSchema<"RawAudioAccessResponse">;
|
||||
export type MultimodalConsentRequest = ApiSchema<"MultimodalConsentRequest">;
|
||||
export type MultimodalConsentResponse = ApiSchema<"MultimodalConsentResponse">;
|
||||
export type MultimodalWithdrawalRequest =
|
||||
ApiSchema<"MultimodalWithdrawalRequest">;
|
||||
export type MultimodalDeletionRequest =
|
||||
ApiSchema<"MultimodalDeletionRequest">;
|
||||
export type MultimodalDeletionResponse =
|
||||
ApiSchema<"MultimodalDeletionRequestResponse">;
|
||||
|
||||
export type AllianceAxis = "goal" | "task" | "bond";
|
||||
export type AllianceModality = "text" | "voice";
|
||||
export type MeasurementStatus = "ready" | "missing" | "error";
|
||||
export type ConsentStatus = "granted" | "withdrawn" | "not_granted";
|
||||
export type VoiceEventType =
|
||||
| "silence"
|
||||
| "overlap"
|
||||
| "interruption"
|
||||
| "prosody"
|
||||
| "pace"
|
||||
| "audio_quality";
|
||||
|
||||
export interface MultimodalConsentSnapshot {
|
||||
consent_snapshot_id: string;
|
||||
sequence_no: number;
|
||||
consent_status: ConsentStatus;
|
||||
retain_audio: boolean;
|
||||
retain_derived_features: boolean;
|
||||
transcript_retained: boolean;
|
||||
retention_days: number | null;
|
||||
policy_version: string;
|
||||
reason_code: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MultimodalTimeline {
|
||||
timeline_id: string;
|
||||
audio_duration_ms: number;
|
||||
clock_version: string;
|
||||
word_count: number;
|
||||
event_count: number;
|
||||
created_at: string;
|
||||
derived_features_available: boolean;
|
||||
}
|
||||
|
||||
export interface MultimodalWordTimestamp {
|
||||
timeline_id: string;
|
||||
word_index: number;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
speaker: "learner" | "client";
|
||||
token_hash: string;
|
||||
}
|
||||
|
||||
export interface MultimodalVoiceEvent {
|
||||
timeline_id: string;
|
||||
event_id: string;
|
||||
event_type: VoiceEventType;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
actor: "learner" | "client" | "both" | "channel";
|
||||
observed_feature: string;
|
||||
uncertainty: number;
|
||||
source: "observed_audio_runtime" | "stt_word_timestamps";
|
||||
claim_scope: "interaction_signal";
|
||||
clinical_claim_allowed: false;
|
||||
}
|
||||
|
||||
export interface MultimodalMeasurement {
|
||||
measurement_id: string;
|
||||
axis: AllianceAxis;
|
||||
modality: AllianceModality;
|
||||
status: MeasurementStatus;
|
||||
value: number | null;
|
||||
confidence: number | null;
|
||||
uncertainty: number;
|
||||
evidence_refs: string[];
|
||||
model_run_id: string | null;
|
||||
instrument_id: string;
|
||||
instrument_version: string;
|
||||
model_name: string;
|
||||
prompt_version: string;
|
||||
source_kind: string;
|
||||
error_code: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MultimodalFusionDecision {
|
||||
fusion_record_id: string;
|
||||
axis: AllianceAxis;
|
||||
status: MeasurementStatus;
|
||||
value: number | null;
|
||||
uncertainty: number;
|
||||
modalities_used: AllianceModality[];
|
||||
measurement_ids: string[];
|
||||
fusion_applied: boolean;
|
||||
calibration_id: string | null;
|
||||
benchmark_version: string | null;
|
||||
incremental_gain: number | null;
|
||||
counterevidence: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MultimodalDeletionRecord {
|
||||
deletion_request_id: string;
|
||||
scopes: Array<"audio" | "derived_features">;
|
||||
request_reason: string;
|
||||
requested_at: string;
|
||||
completed_scopes: Array<"audio" | "derived_features">;
|
||||
}
|
||||
|
||||
export interface RawAudioAsset {
|
||||
audio_asset_id: string;
|
||||
session_id: string;
|
||||
media_type: string;
|
||||
byte_size: number;
|
||||
duration_ms: number;
|
||||
retained_until: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MultimodalAllianceReadModel {
|
||||
session_id: string;
|
||||
learner_id: string;
|
||||
clinical_claim_allowed: false;
|
||||
consent_snapshots: MultimodalConsentSnapshot[];
|
||||
timelines: MultimodalTimeline[];
|
||||
word_timestamps: MultimodalWordTimestamp[];
|
||||
voice_events: MultimodalVoiceEvent[];
|
||||
measurements: MultimodalMeasurement[];
|
||||
fusion_decisions: MultimodalFusionDecision[];
|
||||
deletion_requests: MultimodalDeletionRecord[];
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function objectRows(value: unknown): Row[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(item): item is Row => Boolean(item) && typeof item === "object",
|
||||
);
|
||||
}
|
||||
|
||||
function text(row: Row, key: string, fallback = ""): string {
|
||||
const value = row[key];
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function numberValue(row: Row, key: string, fallback = 0): number {
|
||||
const value = row[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function optionalNumber(row: Row, key: string): number | null {
|
||||
const value = row[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bool(row: Row, key: string): boolean {
|
||||
return row[key] === true;
|
||||
}
|
||||
|
||||
function stringArray(row: Row, key: string): string[] {
|
||||
const value = row[key];
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function isAllianceAxis(value: string): value is AllianceAxis {
|
||||
return value === "goal" || value === "task" || value === "bond";
|
||||
}
|
||||
|
||||
function isModality(value: string): value is AllianceModality {
|
||||
return value === "text" || value === "voice";
|
||||
}
|
||||
|
||||
function measurementStatus(value: string): MeasurementStatus {
|
||||
return value === "ready" || value === "error" ? value : "missing";
|
||||
}
|
||||
|
||||
function consentStatus(value: string): ConsentStatus {
|
||||
if (value === "granted" || value === "withdrawn") return value;
|
||||
return "not_granted";
|
||||
}
|
||||
|
||||
function voiceEventType(value: string): VoiceEventType {
|
||||
const allowed: VoiceEventType[] = [
|
||||
"silence",
|
||||
"overlap",
|
||||
"interruption",
|
||||
"prosody",
|
||||
"pace",
|
||||
"audio_quality",
|
||||
];
|
||||
return allowed.includes(value as VoiceEventType)
|
||||
? (value as VoiceEventType)
|
||||
: "audio_quality";
|
||||
}
|
||||
|
||||
function normalizeMetadata(payload: WireMetadata): MultimodalAllianceReadModel {
|
||||
const consentSnapshots = objectRows(payload.consent_snapshots).map((row) => ({
|
||||
consent_snapshot_id: text(row, "consent_snapshot_id"),
|
||||
sequence_no: numberValue(row, "sequence_no"),
|
||||
consent_status: consentStatus(text(row, "consent_status")),
|
||||
retain_audio: bool(row, "retain_audio"),
|
||||
retain_derived_features: bool(row, "retain_derived_features"),
|
||||
transcript_retained: bool(row, "transcript_retained"),
|
||||
retention_days: optionalNumber(row, "retention_days"),
|
||||
policy_version: text(row, "policy_version"),
|
||||
reason_code: optionalText(row, "reason_code"),
|
||||
created_at: text(row, "created_at"),
|
||||
}));
|
||||
const timelines = objectRows(payload.timelines).map((row) => ({
|
||||
timeline_id: text(row, "timeline_id"),
|
||||
audio_duration_ms: numberValue(row, "audio_duration_ms"),
|
||||
clock_version: text(row, "clock_version"),
|
||||
word_count: numberValue(row, "word_count"),
|
||||
event_count: numberValue(row, "event_count"),
|
||||
created_at: text(row, "created_at"),
|
||||
derived_features_available: bool(row, "derived_features_available"),
|
||||
}));
|
||||
const words = objectRows(payload.word_timestamps)
|
||||
.map((row) => ({
|
||||
timeline_id: text(row, "timeline_id"),
|
||||
word_index: numberValue(row, "word_index"),
|
||||
start_ms: numberValue(row, "start_ms"),
|
||||
end_ms: numberValue(row, "end_ms"),
|
||||
speaker: text(row, "speaker") === "learner" ? "learner" as const : "client" as const,
|
||||
token_hash: text(row, "token_hash"),
|
||||
}))
|
||||
.sort((a, b) => a.word_index - b.word_index);
|
||||
const events = objectRows(payload.voice_events).map((row) => ({
|
||||
timeline_id: text(row, "timeline_id"),
|
||||
event_id: text(row, "event_id"),
|
||||
event_type: voiceEventType(text(row, "event_type")),
|
||||
start_ms: numberValue(row, "start_ms"),
|
||||
end_ms: numberValue(row, "end_ms"),
|
||||
actor: (["learner", "client", "both", "channel"].includes(text(row, "actor"))
|
||||
? text(row, "actor")
|
||||
: "channel") as MultimodalVoiceEvent["actor"],
|
||||
observed_feature: text(row, "observed_feature"),
|
||||
uncertainty: numberValue(row, "uncertainty", 1),
|
||||
source: text(row, "source") === "stt_word_timestamps"
|
||||
? "stt_word_timestamps" as const
|
||||
: "observed_audio_runtime" as const,
|
||||
claim_scope: "interaction_signal" as const,
|
||||
clinical_claim_allowed: false as const,
|
||||
}));
|
||||
const measurements = objectRows(payload.measurements).flatMap((row) => {
|
||||
const axis = text(row, "axis");
|
||||
const modality = text(row, "modality");
|
||||
if (!isAllianceAxis(axis) || !isModality(modality)) return [];
|
||||
return [{
|
||||
measurement_id: text(row, "measurement_id"),
|
||||
axis,
|
||||
modality,
|
||||
status: measurementStatus(text(row, "status")),
|
||||
value: optionalNumber(row, "value"),
|
||||
confidence: optionalNumber(row, "confidence"),
|
||||
uncertainty: numberValue(row, "uncertainty", 1),
|
||||
evidence_refs: stringArray(row, "evidence_refs"),
|
||||
model_run_id: optionalText(row, "model_run_id"),
|
||||
instrument_id: text(row, "instrument_id"),
|
||||
instrument_version: text(row, "instrument_version"),
|
||||
model_name: text(row, "model_name"),
|
||||
prompt_version: text(row, "prompt_version"),
|
||||
source_kind: text(row, "source_kind"),
|
||||
error_code: optionalText(row, "error_code"),
|
||||
created_at: text(row, "created_at"),
|
||||
}];
|
||||
});
|
||||
const fusions = objectRows(payload.fusion_decisions).flatMap((row) => {
|
||||
const axis = text(row, "axis");
|
||||
if (!isAllianceAxis(axis)) return [];
|
||||
return [{
|
||||
fusion_record_id: text(row, "fusion_record_id"),
|
||||
axis,
|
||||
status: measurementStatus(text(row, "status")),
|
||||
value: optionalNumber(row, "value"),
|
||||
uncertainty: numberValue(row, "uncertainty", 1),
|
||||
modalities_used: stringArray(row, "modalities_used").filter(isModality),
|
||||
measurement_ids: stringArray(row, "measurement_ids"),
|
||||
fusion_applied: bool(row, "fusion_applied"),
|
||||
calibration_id: optionalText(row, "calibration_id"),
|
||||
benchmark_version: optionalText(row, "benchmark_version"),
|
||||
incremental_gain: optionalNumber(row, "incremental_gain"),
|
||||
counterevidence: stringArray(row, "counterevidence"),
|
||||
created_at: text(row, "created_at"),
|
||||
}];
|
||||
});
|
||||
const deletionRequests = objectRows(payload.deletion_requests).map((row) => ({
|
||||
deletion_request_id: text(row, "deletion_request_id"),
|
||||
scopes: stringArray(row, "scopes").filter(
|
||||
(scope): scope is "audio" | "derived_features" =>
|
||||
scope === "audio" || scope === "derived_features",
|
||||
),
|
||||
request_reason: text(row, "request_reason"),
|
||||
requested_at: text(row, "requested_at"),
|
||||
completed_scopes: stringArray(row, "completed_scopes").filter(
|
||||
(scope): scope is "audio" | "derived_features" =>
|
||||
scope === "audio" || scope === "derived_features",
|
||||
),
|
||||
}));
|
||||
|
||||
return {
|
||||
session_id: payload.session_id,
|
||||
learner_id: payload.learner_id,
|
||||
clinical_claim_allowed: false,
|
||||
consent_snapshots: consentSnapshots,
|
||||
timelines,
|
||||
word_timestamps: words,
|
||||
voice_events: events,
|
||||
measurements,
|
||||
fusion_decisions: fusions,
|
||||
deletion_requests: deletionRequests,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRawAudio(payload: WireRawAudio): RawAudioAsset[] {
|
||||
return objectRows(payload.items).map((row) => ({
|
||||
audio_asset_id: text(row, "audio_asset_id"),
|
||||
session_id: text(row, "session_id"),
|
||||
media_type: text(row, "media_type"),
|
||||
byte_size: numberValue(row, "byte_size"),
|
||||
duration_ms: numberValue(row, "duration_ms"),
|
||||
retained_until: text(row, "retained_until"),
|
||||
created_at: text(row, "created_at"),
|
||||
}));
|
||||
}
|
||||
|
||||
function basePath(sessionId: string): string {
|
||||
return `/sessions/${encodeURIComponent(sessionId)}/multimodal-alliance`;
|
||||
}
|
||||
|
||||
export function rawAudioPlaybackUrl(
|
||||
sessionId: string,
|
||||
audioAssetId: string,
|
||||
): string {
|
||||
return apiUrl(
|
||||
`${basePath(sessionId)}/raw-audio/${encodeURIComponent(audioAssetId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export const multimodalAllianceApi = {
|
||||
getMetadata: (sessionId: string, signal?: AbortSignal) =>
|
||||
supplementalApi
|
||||
.get<WireMetadata>(basePath(sessionId), { signal })
|
||||
.then(normalizeMetadata),
|
||||
getRawAudio: (sessionId: string, signal?: AbortSignal) =>
|
||||
supplementalApi
|
||||
.get<WireRawAudio>(`${basePath(sessionId)}/raw-audio`, { signal })
|
||||
.then(normalizeRawAudio),
|
||||
saveConsent: (sessionId: string, body: MultimodalConsentRequest) =>
|
||||
api.post<MultimodalConsentResponse>(`${basePath(sessionId)}/consent`, body),
|
||||
withdraw: (sessionId: string, body: MultimodalWithdrawalRequest) =>
|
||||
api.post<MultimodalConsentResponse>(`${basePath(sessionId)}/withdraw`, body),
|
||||
requestDeletion: (sessionId: string, body: MultimodalDeletionRequest) =>
|
||||
api.post<MultimodalDeletionResponse>(
|
||||
`${basePath(sessionId)}/deletion-requests`,
|
||||
body,
|
||||
),
|
||||
};
|
||||
1307
apps/web/src/pages/session-review/outcome-trajectory.css
Normal file
1307
apps/web/src/pages/session-review/outcome-trajectory.css
Normal file
File diff suppressed because it is too large
Load diff
49
apps/web/src/pages/session-review/outcomeTrajectoryApi.ts
Normal file
49
apps/web/src/pages/session-review/outcomeTrajectoryApi.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { api, supplementalApi } from "../../lib/api";
|
||||
import type { components } from "../../lib/api.gen";
|
||||
|
||||
type ApiSchema<Name extends keyof components["schemas"]> =
|
||||
components["schemas"][Name];
|
||||
|
||||
/** Wire DTO는 생성된 OpenAPI 계약을 그대로 사용한다. */
|
||||
export type OutcomeAxisValues = ApiSchema<"OutcomeAxisValues">;
|
||||
export type OutcomeAxis = keyof OutcomeAxisValues;
|
||||
export type TrajectoryStatus = ApiSchema<"AxisTrajectoryAssessment">["status"];
|
||||
export type ObservationStatus = ApiSchema<"OutcomeObservationResponse">["status"];
|
||||
export type RelationshipEventType = ApiSchema<"RelationshipMemoryProjection">["event_type"];
|
||||
|
||||
export type SyntheticExpectedDistribution =
|
||||
ApiSchema<"SyntheticExpectedDistribution">;
|
||||
export type SyntheticExpectedArc = ApiSchema<"ExpectedArcLabelResponse">;
|
||||
export type OutcomeAxisObservation = ApiSchema<"OutcomeObservationResponse">;
|
||||
export type SafetySignalReference = ApiSchema<"SafetySignalReference">;
|
||||
export type AxisTrajectoryAssessment = ApiSchema<"AxisTrajectoryAssessment">;
|
||||
export type SessionTrajectoryAssessment = ApiSchema<"SessionTrajectoryAssessment">;
|
||||
export type LongitudinalOutcomeAssessment =
|
||||
ApiSchema<"LongitudinalOutcomeAssessment">;
|
||||
export type RelationshipMemoryProjection =
|
||||
ApiSchema<"RelationshipMemoryProjection">;
|
||||
export type OutcomeTrajectoryResponse = ApiSchema<"OutcomeTrajectoryResponse">;
|
||||
export type OutcomeObservationSubmissionRequest =
|
||||
ApiSchema<"OutcomeObservationSubmissionRequest">;
|
||||
export type OutcomeObservationSubmissionResponse =
|
||||
ApiSchema<"OutcomeObservationSubmissionResponse">;
|
||||
|
||||
function trajectoryPath(sessionId: string): string {
|
||||
return `/sessions/${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
||||
export const outcomeTrajectoryApi = {
|
||||
get: (sessionId: string, signal?: AbortSignal) =>
|
||||
supplementalApi.get<OutcomeTrajectoryResponse>(
|
||||
`${trajectoryPath(sessionId)}/outcome-trajectory`,
|
||||
{ signal },
|
||||
),
|
||||
submitObservation: (
|
||||
sessionId: string,
|
||||
body: OutcomeObservationSubmissionRequest,
|
||||
) =>
|
||||
api.post<OutcomeObservationSubmissionResponse>(
|
||||
`${trajectoryPath(sessionId)}/outcome-observations`,
|
||||
body,
|
||||
),
|
||||
};
|
||||
775
apps/web/src/pages/session-review/rupture-repair.css
Normal file
775
apps/web/src/pages/session-review/rupture-repair.css
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
/* G3 균열·수선 원장. 기존 Vignette 임상 워크벤치 토큰을 보존해 확장한다. */
|
||||
|
||||
.rr-card {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--sp-5);
|
||||
padding: var(--sp-5);
|
||||
overflow: hidden;
|
||||
border-color: color-mix(in srgb, var(--info-solid) 18%, var(--border-subtle));
|
||||
background: color-mix(in srgb, var(--bg-surface) 97%, var(--info-tint));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.rr-card__head {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.rr-card__head h2,
|
||||
.rr-card--state h2 {
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: clamp(1.18rem, 1.7vw, var(--fs-h2));
|
||||
font-weight: 730;
|
||||
letter-spacing: -0.018em;
|
||||
line-height: 1.26;
|
||||
}
|
||||
|
||||
.rr-card__head p,
|
||||
.rr-card--state p {
|
||||
max-width: 72ch;
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.rr-card__flags {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rr-card--state {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.rr-card--state > div:last-child {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.rr-card--error {
|
||||
border-color: color-mix(in srgb, var(--warn-solid) 34%, var(--border-subtle));
|
||||
}
|
||||
|
||||
.rr-contract {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid color-mix(in srgb, var(--info-solid) 24%, var(--border-subtle));
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--info-tint) 70%, var(--bg-surface-2));
|
||||
box-shadow: var(--glass-inset-shadow);
|
||||
}
|
||||
|
||||
.rr-contract strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.rr-contract p {
|
||||
margin: 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rr-card code {
|
||||
max-width: 100%;
|
||||
padding: 4px 7px;
|
||||
overflow-wrap: anywhere;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--info-text);
|
||||
background: color-mix(in srgb, var(--info-tint) 76%, var(--bg-surface));
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.rr-episodes {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
|
||||
.rr-episode {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--glass-inset-shadow);
|
||||
}
|
||||
|
||||
.rr-episode__head {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.rr-episode__eyebrow {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.035em;
|
||||
}
|
||||
|
||||
.rr-episode__head h3 {
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: clamp(1.08rem, 1.4vw, 1.32rem);
|
||||
font-weight: 740;
|
||||
letter-spacing: -0.016em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rr-episode__badges {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rr-current {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rr-current > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
.rr-current > div + div {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rr-current small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.rr-current strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 730;
|
||||
}
|
||||
|
||||
.rr-provenance {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
gap: var(--sp-3);
|
||||
align-items: stretch;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle));
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--accent-tint) 50%, var(--bg-surface-2));
|
||||
}
|
||||
|
||||
.rr-provenance__node {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
align-items: start;
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 16%, var(--border-subtle));
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.rr-provenance__step {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--accent-contrast);
|
||||
background: var(--accent);
|
||||
font-family: var(--font-num);
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.rr-provenance__node small {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: 9px;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.rr-provenance__node strong {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.rr-provenance__node p {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.rr-provenance__node > code {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.rr-provenance__connector {
|
||||
align-self: center;
|
||||
color: var(--text-accent);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.rr-provenance__human {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid color-mix(in srgb, var(--accent) 16%, var(--border-subtle));
|
||||
}
|
||||
|
||||
.rr-provenance__human > span:last-child {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.rr-evidence-grid {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.rr-evidence-grid > section {
|
||||
min-width: 0;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.rr-section-head,
|
||||
.rr-safety__head,
|
||||
.rr-practice__head,
|
||||
.rr-correction__intro {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rr-section-head h4,
|
||||
.rr-safety h4,
|
||||
.rr-correction h4 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 730;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.rr-section-head > span {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.rr-evidence-list,
|
||||
.rr-counterevidence,
|
||||
.rr-safety__items {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: var(--sp-3) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.rr-evidence-list button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-body);
|
||||
background: var(--bg-surface);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-base) var(--ease-out),
|
||||
background-color var(--dur-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.rr-evidence-list button:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--border-subtle));
|
||||
background: var(--accent-tint);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.rr-evidence-list button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.rr-evidence-list button span {
|
||||
color: var(--text-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.rr-evidence-list button small {
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.rr-counterevidence li {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rr-counterevidence li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.62em;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--warn-solid) 62%, var(--border-subtle));
|
||||
border-radius: 50%;
|
||||
background: var(--warn-tint);
|
||||
}
|
||||
|
||||
.rr-muted {
|
||||
margin: var(--sp-3) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rr-safety {
|
||||
min-width: 0;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid color-mix(in srgb, var(--crit-solid) 24%, var(--border-subtle));
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--crit-tint) 46%, var(--bg-surface-2));
|
||||
}
|
||||
|
||||
.rr-safety__head p,
|
||||
.rr-practice__head p,
|
||||
.rr-correction__intro p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.rr-safety__items li {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--crit-solid) 18%, var(--border-subtle));
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.rr-safety__items li > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-safety__items strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.rr-safety__items li > div span {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rr-safety__items button {
|
||||
padding: 4px 0;
|
||||
border: 0;
|
||||
color: var(--text-accent);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rr-practice {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid color-mix(in srgb, var(--pos-solid) 24%, var(--border-subtle));
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--pos-tint) 44%, var(--bg-surface-2));
|
||||
}
|
||||
|
||||
.rr-practice legend,
|
||||
.rr-correction__evidence legend {
|
||||
padding: 0 6px;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.rr-practice__items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.rr-practice__items label,
|
||||
.rr-correction__evidence label {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-body);
|
||||
background: var(--bg-surface);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rr-practice input,
|
||||
.rr-correction__evidence input {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 1px 0 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.rr-practice__items label:has(input:checked) {
|
||||
border-color: color-mix(in srgb, var(--pos-solid) 44%, var(--border-subtle));
|
||||
color: var(--text-strong);
|
||||
background: color-mix(in srgb, var(--pos-tint) 58%, var(--bg-surface));
|
||||
}
|
||||
|
||||
.rr-correction {
|
||||
min-width: 0;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid color-mix(in srgb, var(--info-solid) 24%, var(--border-subtle));
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.rr-correction > summary {
|
||||
width: fit-content;
|
||||
color: var(--text-accent);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 730;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rr-correction form {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-4);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rr-correction__intro code {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.rr-correction__grid {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rr-correction__grid > label,
|
||||
.rr-correction__field {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rr-correction__grid label > span,
|
||||
.rr-correction__field > span {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rr-correction select,
|
||||
.rr-correction textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-strong);
|
||||
background: var(--bg-surface);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.rr-correction textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rr-correction input[type="range"] {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.rr-correction__evidence {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.rr-correction__evidence legend {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.rr-correction__evidence label > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-correction__evidence b {
|
||||
color: var(--text-strong);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rr-correction__evidence small {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--text-body);
|
||||
line-height: 1.45;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.rr-correction__actions {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rr-correction__message {
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.rr-correction__message--success {
|
||||
color: var(--pos-text);
|
||||
}
|
||||
|
||||
.rr-correction__message--error {
|
||||
color: var(--crit-text);
|
||||
}
|
||||
|
||||
.rr-correction summary:focus-visible,
|
||||
.rr-evidence-list button:focus-visible,
|
||||
.rr-safety__items button:focus-visible,
|
||||
.rr-practice input:focus-visible,
|
||||
.rr-correction input:focus-visible,
|
||||
.rr-correction select:focus-visible,
|
||||
.rr-correction textarea:focus-visible {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rr-skeleton {
|
||||
min-height: 98px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-surface-2) 20%,
|
||||
color-mix(in srgb, var(--bg-surface-2) 72%, var(--border-subtle)) 50%,
|
||||
var(--bg-surface-2) 80%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: rr-shimmer 1.45s linear infinite;
|
||||
}
|
||||
|
||||
.rr-skeleton--heading {
|
||||
width: min(560px, 84%);
|
||||
min-height: 62px;
|
||||
}
|
||||
|
||||
.rr-skeleton--rail {
|
||||
min-height: 126px;
|
||||
}
|
||||
|
||||
.rr-skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
@keyframes rr-shimmer {
|
||||
to {
|
||||
background-position: -220% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.rr-card {
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.rr-card__head,
|
||||
.rr-episode__head,
|
||||
.rr-section-head,
|
||||
.rr-safety__head,
|
||||
.rr-practice__head,
|
||||
.rr-correction__intro {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rr-card__flags,
|
||||
.rr-episode__badges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.rr-contract {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rr-contract code {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.rr-episode {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.rr-current {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rr-current > div + div {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.rr-provenance,
|
||||
.rr-evidence-grid,
|
||||
.rr-practice__items,
|
||||
.rr-correction__grid,
|
||||
.rr-correction__evidence,
|
||||
.rr-skeleton-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rr-provenance__connector {
|
||||
justify-self: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rr-safety__items li {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.rr-safety__items li > button,
|
||||
.rr-safety__items li > code {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.rr-correction__actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rr-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.rr-evidence-list button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.rr-evidence-list button:hover,
|
||||
.rr-evidence-list button:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
39
apps/web/src/pages/session-review/ruptureRepairApi.ts
Normal file
39
apps/web/src/pages/session-review/ruptureRepairApi.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { api, supplementalApi } from "../../lib/api";
|
||||
import type { components } from "../../lib/api.gen";
|
||||
|
||||
type ApiSchema<Name extends keyof components["schemas"]> =
|
||||
components["schemas"][Name];
|
||||
|
||||
/** Wire DTO는 generated OpenAPI 계약을 그대로 사용한다. */
|
||||
export type RuptureObservation = ApiSchema<"RuptureObservationResponse">;
|
||||
export type RuptureReconciliation =
|
||||
ApiSchema<"RuptureReconciliationResponse">;
|
||||
export type RuptureSafetyReference =
|
||||
ApiSchema<"RuptureSafetyReferenceResponse">;
|
||||
export type RuptureEpisode = ApiSchema<"RuptureEpisodeResponse">;
|
||||
export type RuptureRepairReadModel =
|
||||
ApiSchema<"RuptureRepairReadModelResponse">;
|
||||
export type HumanRuptureCorrectionRequest =
|
||||
ApiSchema<"HumanRuptureCorrectionRequest">;
|
||||
export type HumanRuptureCorrectionResponse =
|
||||
ApiSchema<"HumanRuptureCorrectionResponse">;
|
||||
|
||||
function rupturePath(sessionId: string): string {
|
||||
return `/sessions/${encodeURIComponent(sessionId)}/ruptures`;
|
||||
}
|
||||
|
||||
export const ruptureRepairApi = {
|
||||
get: (sessionId: string, signal?: AbortSignal) =>
|
||||
supplementalApi.get<RuptureRepairReadModel>(rupturePath(sessionId), {
|
||||
signal,
|
||||
}),
|
||||
correct: (
|
||||
sessionId: string,
|
||||
episodeId: string,
|
||||
body: HumanRuptureCorrectionRequest,
|
||||
) =>
|
||||
api.post<HumanRuptureCorrectionResponse>(
|
||||
`${rupturePath(sessionId)}/${encodeURIComponent(episodeId)}/corrections`,
|
||||
body,
|
||||
),
|
||||
};
|
||||
|
|
@ -101,6 +101,11 @@
|
|||
grid-template-columns: repeat(2, minmax(130px, auto));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.sr-practice-result-jump {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
.sr-stat {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
|
|
@ -134,6 +139,72 @@
|
|||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
/* 실행 인계로 시작한 회기는 리뷰에서도 출처와 다음 행동을 한 번만 유지한다. */
|
||||
.sr-practice-return {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(300px, 0.9fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border-subtle));
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--accent-tint) 82%, var(--bg-surface));
|
||||
}
|
||||
.sr-practice-return__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.sr-practice-return__copy h2 {
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-h3);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sr-practice-return__copy p {
|
||||
max-width: 64ch;
|
||||
margin: 7px 0 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.sr-practice-return__facts {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
}
|
||||
.sr-practice-return__facts > div {
|
||||
min-width: 0;
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
.sr-practice-return__facts dt {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
.sr-practice-return__facts dd {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sr-practice-return > .vg-btn {
|
||||
min-height: 44px;
|
||||
justify-self: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-practice-return.is-error {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
border-color: color-mix(in srgb, var(--crit-text) 45%, var(--border-subtle));
|
||||
background: var(--crit-tint);
|
||||
}
|
||||
|
||||
/* ── 레이아웃 코어 (2026-07-15 재설계 → 같은 날 전 폭 탭 전환) ─────────
|
||||
리뷰 요약은 상단 전폭 밴드(.sr-overview--band)로 한 번 읽고 지나간다
|
||||
(sticky 도킹/스크롤 추종 제거). 본문은 모든 폭에서 상단 가로 탭이
|
||||
|
|
@ -206,16 +277,17 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.sr-card--worksheet {
|
||||
.sr-worksheet-panel {
|
||||
grid-area: worksheet;
|
||||
min-width: 0;
|
||||
}
|
||||
.sr-worksheet-panel > .sr-card--worksheet {
|
||||
grid-area: auto;
|
||||
}
|
||||
.sr-card--transcript {
|
||||
grid-area: transcript;
|
||||
align-self: start;
|
||||
}
|
||||
.sr-session-id {
|
||||
grid-area: session;
|
||||
}
|
||||
.sr-card {
|
||||
min-width: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
|
@ -246,6 +318,7 @@
|
|||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
/* D6: 비활성 탭이 배경과 붙어 보이지 않으므로 탭 사이마다 얇은 구분선을 세워
|
||||
"여러 개 중 하나"임을 드러낸다. 가운데 탭이 활성일 때도 구분이 남도록 항상 그리고,
|
||||
|
|
@ -1331,7 +1404,7 @@
|
|||
/* Filled state: only a real client quote earns the high-contrast stage card. */
|
||||
.sr-feedback--filled {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, rgba(30, 39, 36, .95), rgba(30, 39, 36, .82));
|
||||
background: var(--bg-stage);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
/* D1·D2: 장식 점과 영문용 넓은 자간을 빼고 한글 섹션 라벨로 맞춘다 */
|
||||
|
|
@ -1356,7 +1429,7 @@
|
|||
line-height: 1.65;
|
||||
}
|
||||
.sr-feedback--filled .sr-feedback__quote {
|
||||
color: #d7ddda;
|
||||
color: var(--text-on-stage);
|
||||
}
|
||||
.sr-feedback__src {
|
||||
display: flex;
|
||||
|
|
@ -1370,8 +1443,8 @@
|
|||
font-size: 11px;
|
||||
}
|
||||
.sr-feedback--filled .sr-feedback__src {
|
||||
border-top-color: rgba(255, 255, 255, 0.12);
|
||||
color: #8a9794;
|
||||
border-top-color: color-mix(in srgb, var(--text-on-stage) 12%, transparent);
|
||||
color: var(--text-muted-on-stage);
|
||||
}
|
||||
|
||||
.sr-points {
|
||||
|
|
@ -1437,28 +1510,10 @@
|
|||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
/* 배경 잎사귀 이미지 위에 그냥 얹히면 대비가 부족해 읽히지 않는다.
|
||||
내용만큼만 차지하는 불투명 칩으로 감싸 본문 대비를 확보한다. */
|
||||
.sr-session-id {
|
||||
min-width: 0;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-body);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .sr-head,
|
||||
[data-theme="dark"] .sr-card,
|
||||
[data-theme="dark"] .sr-overview {
|
||||
background: linear-gradient(180deg, rgba(27, 42, 38, 0.92), rgba(18, 31, 28, 0.96));
|
||||
background: var(--surface-gradient);
|
||||
border-color: rgba(203, 227, 220, 0.13);
|
||||
box-shadow:
|
||||
0 16px 42px rgba(3, 9, 8, 0.24),
|
||||
|
|
@ -1510,12 +1565,37 @@
|
|||
[data-theme="dark"] .sr-turn--active .sr-turn__said {
|
||||
background: rgba(125, 162, 148, 0.14);
|
||||
}
|
||||
[data-theme="dark"] .sr-practice-return {
|
||||
border-color: rgba(125, 162, 148, 0.32);
|
||||
background: rgba(32, 50, 45, 0.88);
|
||||
}
|
||||
[data-theme="dark"] .sr-practice-return__facts > div {
|
||||
border-color: rgba(203, 227, 220, 0.13);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
[data-theme="dark"] .sr-practice-return.is-error {
|
||||
border-color: rgba(224, 139, 130, 0.42);
|
||||
background: rgba(93, 42, 42, 0.3);
|
||||
}
|
||||
[data-theme="dark"] .sr-feedback--filled {
|
||||
border-color: rgba(203, 227, 220, 0.12);
|
||||
background: linear-gradient(135deg, rgba(12, 24, 21, 0.98), rgba(30, 44, 40, 0.92));
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.sr-practice-return {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr);
|
||||
}
|
||||
.sr-practice-return > .vg-btn {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
.sr-practice-return.is-error {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
.sr-practice-return.is-error > .vg-btn {
|
||||
grid-column: auto;
|
||||
}
|
||||
.sr-overview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
|
@ -1541,6 +1621,17 @@
|
|||
.sr-head__stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.sr-practice-return,
|
||||
.sr-practice-return.is-error {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
.sr-practice-return > .vg-btn,
|
||||
.sr-practice-return.is-error > .vg-btn {
|
||||
grid-column: auto;
|
||||
justify-self: start;
|
||||
min-height: 44px;
|
||||
}
|
||||
.sr-cols,
|
||||
.sr-cols--supervisor,
|
||||
.sr-root--empty .sr-cols,
|
||||
|
|
@ -1582,6 +1673,19 @@
|
|||
}
|
||||
.sr-ws-toolbar .vg-btn {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
.sr-actions .vg-btn,
|
||||
.sr-prepost__actions .vg-btn,
|
||||
.sr-teacher-review__actions .vg-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
.sr-ws-evidence,
|
||||
.sr-point__at {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.sr-chart__plot {
|
||||
height: 156px;
|
||||
|
|
@ -1614,6 +1718,12 @@
|
|||
.sr-head__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sr-practice-return__facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sr-practice-return > .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.sr-stat {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
|
@ -1631,7 +1741,7 @@
|
|||
}
|
||||
.sr-actions .vg-btn {
|
||||
flex-basis: 100%;
|
||||
min-height: 38px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.sr-teacher-review__actions {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -1660,7 +1770,7 @@
|
|||
}
|
||||
.sr-chip-toggle {
|
||||
flex: 1;
|
||||
min-height: 34px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.sr-turn {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -1699,28 +1809,23 @@
|
|||
}
|
||||
|
||||
.sr-cols--tab-transcript {
|
||||
grid-template-areas:
|
||||
"transcript"
|
||||
"session";
|
||||
grid-template-areas: "transcript";
|
||||
}
|
||||
|
||||
.sr-cols--tab-insights {
|
||||
grid-template-areas:
|
||||
"insight"
|
||||
"feedback"
|
||||
"session";
|
||||
"feedback";
|
||||
}
|
||||
|
||||
.sr-cols--tab-worksheet {
|
||||
grid-template-areas:
|
||||
"worksheet"
|
||||
"session";
|
||||
grid-template-areas: "worksheet";
|
||||
}
|
||||
|
||||
.sr-cols:not(.sr-cols--tab-transcript) .sr-card--transcript,
|
||||
.sr-cols:not(.sr-cols--tab-insights) .sr-left,
|
||||
.sr-cols:not(.sr-cols--tab-insights) .sr-right,
|
||||
.sr-cols:not(.sr-cols--tab-worksheet) .sr-card--worksheet {
|
||||
.sr-cols:not(.sr-cols--tab-worksheet) .sr-worksheet-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue