전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영
- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리 (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가. RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset 루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉 마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤 스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등. - 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규 학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual), 저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence + 결정론 파생 폴백). - 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의 CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect. - 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest 421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과, layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드 142~144차 노트.
This commit is contained in:
parent
0ae94499f2
commit
4511383cd9
55 changed files with 9333 additions and 573 deletions
|
|
@ -1090,8 +1090,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const services = health?.services ?? [];
|
||||
const users = usersData?.users ?? [];
|
||||
// 렌더마다 새 배열을 만들면 TanStack core row model이 재계산되며
|
||||
// _autoResetPageIndex 상태 갱신 루프(무한 동기 렌더 프리즈)를 유발한다 — 반드시 메모화.
|
||||
const services = useMemo(() => health?.services ?? [], [health]);
|
||||
const users = useMemo(() => usersData?.users ?? [], [usersData]);
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
ok: services.filter((service) => service.status === "ok").length,
|
||||
|
|
@ -1447,6 +1449,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (user) => user.user_id,
|
||||
// 페이지네이션 미사용(수동 40행 슬라이스). row model 재계산이 pageIndex 리셋
|
||||
// setState를 큐잉해 무한 렌더 루프를 만들던 경로를 차단한다(결함 로그 #19).
|
||||
autoResetPageIndex: false,
|
||||
});
|
||||
const visibleUserRows = userTable
|
||||
.getRowModel()
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
sessionSortTime,
|
||||
sessionStatusLabel,
|
||||
stageProgressValue,
|
||||
starterPersona,
|
||||
theoryLine,
|
||||
trendLabel,
|
||||
truncateText,
|
||||
|
|
@ -98,7 +99,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
|
||||
if (personaResult.status === "fulfilled") {
|
||||
const personaRows = personaResult.value;
|
||||
const firstUsableCode = personaRows.find(isUsablePersona)?.code ?? "";
|
||||
// 결정 D3: 회기 기록이 0건인 신규 학습자는 난도 오름차순(기초 우선) 첫
|
||||
// 사용 가능 페르소나를 기본 선택한다. 기록이 있거나 기록 조회에 실패하면
|
||||
// 기존 규칙(API 순서 첫 사용 가능 페르소나)을 유지한다.
|
||||
const hasNoSessionRecords =
|
||||
sessionResult.status === "fulfilled" &&
|
||||
(sessionResult.value.sessions ?? []).length === 0;
|
||||
const firstUsableCode = hasNoSessionRecords
|
||||
? (starterPersona(personaRows)?.code ?? "")
|
||||
: (personaRows.find(isUsablePersona)?.code ?? "");
|
||||
setPersonas(personaRows);
|
||||
setSelectedCode((cur) =>
|
||||
personaRows.some(
|
||||
|
|
@ -1285,7 +1294,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="lh-dashboard-side" aria-label="최근 기록 패널">
|
||||
<aside
|
||||
className="lh-dashboard-side"
|
||||
aria-label={
|
||||
dashTab === "growth" ? "성장 지표 패널" : "최근 기록 패널"
|
||||
}
|
||||
>
|
||||
<article
|
||||
className={surfaceClassName("lh-panel lh-panel--records")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ const EMPTY_FORM: OnboardingForm = {
|
|||
privacy_accepted: false,
|
||||
};
|
||||
|
||||
// 서버 OnboardingRequest 검증과 동일 규칙: 숫자·하이픈·공백·괄호·점, 선행 +, 숫자 9~15자리.
|
||||
function isValidPhone(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
const digits = trimmed.replace(/\D/g, "");
|
||||
return /^\+?[0-9()\-\s.]+$/.test(trimmed) && digits.length >= 9 && digits.length <= 15;
|
||||
}
|
||||
|
||||
function profileToForm(profile: UserProfileResponse | null): OnboardingForm {
|
||||
return {
|
||||
...EMPTY_FORM,
|
||||
|
|
@ -121,6 +128,10 @@ export default function Onboarding() {
|
|||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || submitting) return;
|
||||
if (!isValidPhone(form.phone)) {
|
||||
setError("연락처는 숫자 9~15자리의 전화번호 형식으로 입력해 주세요 (예: 010-1234-5678).");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ export default function PendingApproval() {
|
|||
const navigate = useNavigate();
|
||||
const { user, logout, refresh } = useAuth();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<string | null>(null);
|
||||
|
||||
const checkAgain = async () => {
|
||||
if (checking) return;
|
||||
setChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
const nextUser = await refresh();
|
||||
if (nextUser?.accountStatus === "approved") {
|
||||
|
|
@ -21,6 +23,16 @@ export default function PendingApproval() {
|
|||
nextUser.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(nextUser.role),
|
||||
{ replace: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const at = new Date().toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (nextUser) {
|
||||
setCheckResult(`${at} 기준 아직 승인 전입니다. 잠시 후 다시 확인해 주세요.`);
|
||||
} else {
|
||||
setCheckResult(`${at} 상태 확인에 실패했습니다. 네트워크 연결 후 다시 시도해 주세요.`);
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
|
|
@ -51,14 +63,17 @@ export default function PendingApproval() {
|
|||
<span aria-hidden="true" />
|
||||
<div>
|
||||
<b>접속 권한 확인 중</b>
|
||||
<small>승인되면 새로고침 후 온보딩 또는 역할 홈으로 이동합니다.</small>
|
||||
<small>
|
||||
{checkResult ??
|
||||
"승인되면 새로고침 후 온보딩 또는 역할 홈으로 이동합니다."}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pa-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
leading={<Icon name="settings" size={16} />}
|
||||
leading={<Icon name="refresh" size={16} />}
|
||||
onClick={() => void checkAgain()}
|
||||
disabled={checking}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2595,120 +2595,6 @@ export default function PersonaStudio() {
|
|||
className="ps-layout ps-authoring-layout"
|
||||
data-authoring-step={authoringStep}
|
||||
>
|
||||
<aside className="ps-rail" aria-label="페르소나 목록">
|
||||
<div className={surfaceClassName("ps-rail__head")}>
|
||||
<div>
|
||||
<Kicker>카탈로그</Kicker>
|
||||
<b>{catalog.length + reviews.length}개</b>
|
||||
</div>
|
||||
<Badge tone={loadState === "ready" ? "accent" : "neutral"}>
|
||||
{loadState === "ready" ? suggestedCode : "..."}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={surfaceClassName("ps-workflow")}
|
||||
aria-label="저작 단계"
|
||||
>
|
||||
<Kicker>저작 흐름</Kicker>
|
||||
{AUTHORING_STEPS.map((step, index) => (
|
||||
<div
|
||||
className={`ps-workflow-step ${
|
||||
index < authoringStepIndex
|
||||
? "is-done"
|
||||
: index === authoringStepIndex
|
||||
? "is-current"
|
||||
: ""
|
||||
}`}
|
||||
key={step.label}
|
||||
>
|
||||
<span>{index + 1}</span>
|
||||
<div>
|
||||
<b>{step.label}</b>
|
||||
<small>{step.desc}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ps-list">
|
||||
{reviews.length > 0 ? (
|
||||
reviews.map((persona) => (
|
||||
<button
|
||||
type="button"
|
||||
className={surfaceClassName(
|
||||
`ps-list-row ${editingId === persona.persona_id ? "is-active" : ""}`,
|
||||
{ variant: "interactive", flat: true },
|
||||
)}
|
||||
key={`${persona.persona_id}:${persona.version}`}
|
||||
onClick={() => void loadDraft(persona.persona_id)}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<span className="ps-list-row__code">{persona.code}</span>
|
||||
<span className="ps-list-row__body">
|
||||
<b>{persona.display_name}</b>
|
||||
<small>
|
||||
v{persona.version} ·{" "}
|
||||
{personaReviewStatusLabel(persona.status)}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
title="검수 큐 없음"
|
||||
desc="저장된 초안이 여기에 표시됩니다."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={surfaceClassName("ps-approved")}>
|
||||
<Kicker>공개 페르소나</Kicker>
|
||||
{catalog.length > 0 ? (
|
||||
catalog.map((persona) => (
|
||||
<article
|
||||
className="ps-approved-row"
|
||||
key={`${persona.code}:${persona.version ?? "approved"}`}
|
||||
data-approved-persona-row="true"
|
||||
>
|
||||
<span className="ps-approved-row__code">
|
||||
{persona.code}
|
||||
</span>
|
||||
<div className="ps-approved-row__body">
|
||||
<b>{persona.display_name}</b>
|
||||
<small>
|
||||
v{persona.version ?? 1} ·{" "}
|
||||
{persona.source === "database"
|
||||
? "DB 공개본"
|
||||
: persona.source}
|
||||
</small>
|
||||
</div>
|
||||
<div className="ps-approved-row__actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void reviseApprovedPersona(persona)}
|
||||
disabled={busy !== null || !persona.persona_id}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => void archiveApprovedPersona(persona)}
|
||||
disabled={busy !== null || !persona.persona_id}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<span className="ps-muted">공개 목록 없음</span>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="ps-editor">
|
||||
<div className={surfaceClassName("ps-editor__top")}>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { AppShell } from "../components/shell/AppShell";
|
||||
import {
|
||||
Badge,
|
||||
|
|
@ -1003,6 +1004,16 @@ function LearnerOverviewTable({
|
|||
const [expandedLearnerId, setExpandedLearnerId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
// 세로 가상화 스크롤 컨테이너(.pf-learner-table) 참조.
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: learners.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
// 접힌 행 기준 추정 높이. 실제 높이(펼침 포함)는 measureElement가 보정한다.
|
||||
estimateSize: () => 62,
|
||||
overscan: 8,
|
||||
getItemKey: (index) => learners[index]?.learner_id ?? index,
|
||||
});
|
||||
const sortOptions: Array<{ key: LearnerSort; label: string }> = [
|
||||
{ key: "latest", label: "최근순" },
|
||||
{ key: "sessions", label: "회기순" },
|
||||
|
|
@ -1019,6 +1030,12 @@ function LearnerOverviewTable({
|
|||
}
|
||||
}, [expandedLearnerId, learners]);
|
||||
|
||||
const toggleExpanded = useCallback((learnerId: string) => {
|
||||
setExpandedLearnerId((current) =>
|
||||
current === learnerId ? null : learnerId,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="pf-section pf-section--learner-overview">
|
||||
<div className="pf-section__head">
|
||||
|
|
@ -1071,7 +1088,11 @@ function LearnerOverviewTable({
|
|||
desc="검색어를 줄이거나 다른 학습자 이름, ID, 기법 태그로 다시 확인합니다."
|
||||
/>
|
||||
) : (
|
||||
<div className="pf-learner-table" data-learner-overview-table="true">
|
||||
<div
|
||||
className="pf-learner-table"
|
||||
data-learner-overview-table="true"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div className="pf-learner-table__head" aria-hidden="true">
|
||||
<span>학습자</span>
|
||||
<span>회기</span>
|
||||
|
|
@ -1083,156 +1104,32 @@ function LearnerOverviewTable({
|
|||
<span>펼침</span>
|
||||
<span>상세</span>
|
||||
</div>
|
||||
{learners.map((learner) => {
|
||||
const isExpanded = expandedLearnerId === learner.learner_id;
|
||||
const expandId = `learner-expanded-${learner.learner_id}`;
|
||||
const topTechniques = learner.top_techniques ?? [];
|
||||
return (
|
||||
<div
|
||||
className={`pf-learner-record ${isExpanded ? "is-expanded" : ""}`}
|
||||
key={learner.learner_id}
|
||||
>
|
||||
<article
|
||||
className="pf-learner-row"
|
||||
data-learner-overview-row="true"
|
||||
{/* 세로 가상화 본문 — 전체 높이를 확보하고 화면 주변 행만 절대 배치로 렌더한다. */}
|
||||
<div
|
||||
className="pf-learner-table__body"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const learner = learners[virtualRow.index];
|
||||
const isExpanded = expandedLearnerId === learner.learner_id;
|
||||
return (
|
||||
<div
|
||||
className={`pf-learner-record ${isExpanded ? "is-expanded" : ""}`}
|
||||
key={learner.learner_id}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||
>
|
||||
<div className="pf-learner-row__id">
|
||||
<button
|
||||
type="button"
|
||||
className="pf-learner-name-button"
|
||||
onClick={() => onOpen(learner.learner_id)}
|
||||
>
|
||||
{learner.learner_label}
|
||||
</button>
|
||||
<span>{learner.learner_id}</span>
|
||||
</div>
|
||||
<div className="pf-learner-row__metric" data-label="회기">
|
||||
<b>
|
||||
{learner.ended_sessions}/{learner.sessions}
|
||||
</b>
|
||||
<span>{formatDateTime(learner.latest_at)}</span>
|
||||
</div>
|
||||
<div
|
||||
className="pf-learner-row__metric"
|
||||
data-label="최근 적절성"
|
||||
>
|
||||
<b>{formatScore(learner.latest_score)}</b>
|
||||
</div>
|
||||
<div className="pf-learner-row__metric" data-label="변화">
|
||||
<b>{formatDelta(learner.score_delta)}</b>
|
||||
</div>
|
||||
<div
|
||||
className="pf-learner-row__metric"
|
||||
data-label="평균 라포"
|
||||
>
|
||||
<b>{formatRapport(learner.avg_rapport)}</b>
|
||||
</div>
|
||||
<div className="pf-learner-row__trend" data-label="추이">
|
||||
<Badge
|
||||
tone={
|
||||
learner.trend === "down"
|
||||
? "warn"
|
||||
: learner.trend === "up"
|
||||
? "accent"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{trendLabel(learner.trend)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
className="pf-learner-row__tag-summary"
|
||||
data-label="기법 요약"
|
||||
>
|
||||
<b>{topTechniques[0] ?? "기법 부족"}</b>
|
||||
{topTechniques.length > 1 ? (
|
||||
<span>외 {topTechniques.length - 1}개</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="pf-learner-row__expand">
|
||||
<button
|
||||
type="button"
|
||||
className="pf-icon-action"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={expandId}
|
||||
aria-label={`${learner.learner_label} 요약 ${isExpanded ? "접기" : "펼치기"}`}
|
||||
onClick={() =>
|
||||
setExpandedLearnerId(
|
||||
isExpanded ? null : learner.learner_id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={isExpanded ? "arrow-up" : "arrow-down"}
|
||||
size={14}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="pf-learner-row__action">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="chevron-right" size={14} />}
|
||||
aria-label={`${learner.learner_label} 상세 보기`}
|
||||
onClick={() => onOpen(learner.learner_id)}
|
||||
>
|
||||
상세 보기
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
{isExpanded ? (
|
||||
<div
|
||||
className="pf-learner-row-detail"
|
||||
id={expandId}
|
||||
data-learner-expanded-row="true"
|
||||
>
|
||||
<div className="pf-learner-detail-item pf-learner-detail-item--trend">
|
||||
<span>회기별 추이</span>
|
||||
{(learner.points ?? []).length > 0 ? (
|
||||
<div
|
||||
className="pf-learner-spark"
|
||||
aria-label={`${learner.learner_label} 회기별 추이`}
|
||||
>
|
||||
{(learner.points ?? []).map((point) => (
|
||||
<GrowthBar point={point} key={point.session_id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<small>저장된 점수 없음</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="pf-learner-detail-item">
|
||||
<span>점수 변화</span>
|
||||
<b>
|
||||
{formatScore(learner.first_score)} →{" "}
|
||||
{formatScore(learner.latest_score)}
|
||||
</b>
|
||||
<small>{formatDelta(learner.score_delta)}</small>
|
||||
</div>
|
||||
<div className="pf-learner-detail-item">
|
||||
<span>최근 기록</span>
|
||||
<b>{formatDateTime(learner.latest_at)}</b>
|
||||
<small>
|
||||
완료 {learner.ended_sessions} / 전체{" "}
|
||||
{learner.sessions}
|
||||
</small>
|
||||
</div>
|
||||
<div className="pf-learner-detail-item pf-learner-detail-item--tags">
|
||||
<span>기법 태그</span>
|
||||
<div className="pf-learner-row__tags">
|
||||
{topTechniques.slice(0, 5).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
{topTechniques.length === 0 ? (
|
||||
<span>기법 부족</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<LearnerOverviewRecord
|
||||
learner={learner}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={toggleExpanded}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
|
@ -1240,6 +1137,143 @@ function LearnerOverviewTable({
|
|||
);
|
||||
}
|
||||
|
||||
// 학습자 개요 테이블의 한 행(요약 article + 펼침 상세). 가상화 래퍼(.pf-learner-record)
|
||||
// 안에서 렌더되며, 펼침 시 높이 변화는 measureElement의 ResizeObserver가 재측정한다.
|
||||
function LearnerOverviewRecord({
|
||||
learner,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onOpen,
|
||||
}: {
|
||||
learner: TeacherLearnerGrowth;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: (learnerId: string) => void;
|
||||
onOpen: (learnerId: string) => void;
|
||||
}) {
|
||||
const expandId = `learner-expanded-${learner.learner_id}`;
|
||||
const topTechniques = learner.top_techniques ?? [];
|
||||
return (
|
||||
<>
|
||||
<article className="pf-learner-row" data-learner-overview-row="true">
|
||||
<div className="pf-learner-row__id">
|
||||
<button
|
||||
type="button"
|
||||
className="pf-learner-name-button"
|
||||
onClick={() => onOpen(learner.learner_id)}
|
||||
>
|
||||
{learner.learner_label}
|
||||
</button>
|
||||
<span>{learner.learner_id}</span>
|
||||
</div>
|
||||
<div className="pf-learner-row__metric" data-label="회기">
|
||||
<b>
|
||||
{learner.ended_sessions}/{learner.sessions}
|
||||
</b>
|
||||
<span>{formatDateTime(learner.latest_at)}</span>
|
||||
</div>
|
||||
<div className="pf-learner-row__metric" data-label="최근 적절성">
|
||||
<b>{formatScore(learner.latest_score)}</b>
|
||||
</div>
|
||||
<div className="pf-learner-row__metric" data-label="변화">
|
||||
<b>{formatDelta(learner.score_delta)}</b>
|
||||
</div>
|
||||
<div className="pf-learner-row__metric" data-label="평균 라포">
|
||||
<b>{formatRapport(learner.avg_rapport)}</b>
|
||||
</div>
|
||||
<div className="pf-learner-row__trend" data-label="추이">
|
||||
<Badge
|
||||
tone={
|
||||
learner.trend === "down"
|
||||
? "warn"
|
||||
: learner.trend === "up"
|
||||
? "accent"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{trendLabel(learner.trend)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="pf-learner-row__tag-summary" data-label="기법 요약">
|
||||
<b>{topTechniques[0] ?? "기법 부족"}</b>
|
||||
{topTechniques.length > 1 ? (
|
||||
<span>외 {topTechniques.length - 1}개</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="pf-learner-row__expand">
|
||||
<button
|
||||
type="button"
|
||||
className="pf-icon-action"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={expandId}
|
||||
aria-label={`${learner.learner_label} 요약 ${isExpanded ? "접기" : "펼치기"}`}
|
||||
onClick={() => onToggleExpand(learner.learner_id)}
|
||||
>
|
||||
<Icon name={isExpanded ? "arrow-up" : "arrow-down"} size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="pf-learner-row__action">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="chevron-right" size={14} />}
|
||||
aria-label={`${learner.learner_label} 상세 보기`}
|
||||
onClick={() => onOpen(learner.learner_id)}
|
||||
>
|
||||
상세 보기
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
{isExpanded ? (
|
||||
<div
|
||||
className="pf-learner-row-detail"
|
||||
id={expandId}
|
||||
data-learner-expanded-row="true"
|
||||
>
|
||||
<div className="pf-learner-detail-item pf-learner-detail-item--trend">
|
||||
<span>회기별 추이</span>
|
||||
{(learner.points ?? []).length > 0 ? (
|
||||
<div
|
||||
className="pf-learner-spark"
|
||||
aria-label={`${learner.learner_label} 회기별 추이`}
|
||||
>
|
||||
{(learner.points ?? []).map((point) => (
|
||||
<GrowthBar point={point} key={point.session_id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<small>저장된 점수 없음</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="pf-learner-detail-item">
|
||||
<span>점수 변화</span>
|
||||
<b>
|
||||
{formatScore(learner.first_score)} →{" "}
|
||||
{formatScore(learner.latest_score)}
|
||||
</b>
|
||||
<small>{formatDelta(learner.score_delta)}</small>
|
||||
</div>
|
||||
<div className="pf-learner-detail-item">
|
||||
<span>최근 기록</span>
|
||||
<b>{formatDateTime(learner.latest_at)}</b>
|
||||
<small>
|
||||
완료 {learner.ended_sessions} / 전체 {learner.sessions}
|
||||
</small>
|
||||
</div>
|
||||
<div className="pf-learner-detail-item pf-learner-detail-item--tags">
|
||||
<span>기법 태그</span>
|
||||
<div className="pf-learner-row__tags">
|
||||
{topTechniques.slice(0, 5).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
{topTechniques.length === 0 ? <span>기법 부족</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LearnerAnalysisPanel({
|
||||
analysis,
|
||||
onOpenSession,
|
||||
|
|
|
|||
|
|
@ -3403,12 +3403,12 @@ export default function Session() {
|
|||
</span>
|
||||
<div>
|
||||
<h2 id="sx-end-dialog-title">
|
||||
{timeUp ? "회기 시간이 끝났어요" : "회기를 종료할까?"}
|
||||
{timeUp ? "회기 시간이 끝났어요" : "회기를 종료할까요?"}
|
||||
</h2>
|
||||
<p id="sx-end-dialog-desc">
|
||||
{timeUp
|
||||
? "실제 상담처럼 시간이 회기를 마무리합니다. 마지막 인사를 나누고 싶다면 잠시 계속할 수 있어요. 종료하면 축어록을 저장하고 리뷰로 이동합니다."
|
||||
: "종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동한다."}
|
||||
: "종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동합니다."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { roleLabel, useAuth } from "../lib/auth";
|
|||
import {
|
||||
adminApi,
|
||||
adminEngineApi,
|
||||
apiUrl,
|
||||
userApi,
|
||||
type AdminHealthResponse,
|
||||
type AdminEngineConfigResponse,
|
||||
|
|
@ -108,6 +109,18 @@ function formatTicketDate(value?: number | null): string {
|
|||
}).format(new Date(value * 1000));
|
||||
}
|
||||
|
||||
/** 동의 시각 표기 — 감사 추적을 위해 연도까지 포함한다. */
|
||||
function formatConsentDate(value?: number | null): string {
|
||||
if (!value) return "-";
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value * 1000));
|
||||
}
|
||||
|
||||
function SettingsLoadingState({
|
||||
children,
|
||||
testId,
|
||||
|
|
@ -189,11 +202,13 @@ function completeNotificationPreferences(
|
|||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const { user, refresh, acceptConsent, withdrawConsent } = useAuth();
|
||||
const role = user?.role ?? "learner";
|
||||
const isAdmin = role === "admin";
|
||||
const [dark, setDark] = useTheme();
|
||||
const [activeSection, setActiveSection] = useState("account");
|
||||
// goSection의 스무스 스크롤 동안 IntersectionObserver가 활성 섹션을 되돌리지 않게 억제한다.
|
||||
const spySuppressUntilRef = useRef(0);
|
||||
|
||||
const [profile, setProfile] = useState<UserProfileResponse | null>(null);
|
||||
const [displayName, setDisplayName] = useState(user?.name ?? "");
|
||||
|
|
@ -221,6 +236,15 @@ export default function Settings() {
|
|||
const affiliationDirtyRef = useRef(false);
|
||||
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
|
||||
const loadSettingsRequestRef = useRef(0);
|
||||
// 아바타 업로드 — 온보딩과 같은 계약(3MB, PNG/JPEG/WebP)을 설정에서도 쓴다.
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState("");
|
||||
// 실습 동의 관리 — 철회는 인라인 2단계 확인을 거친다.
|
||||
const [consentConfirming, setConsentConfirming] = useState(false);
|
||||
const [consentBusy, setConsentBusy] = useState(false);
|
||||
const [consentWithdrawn, setConsentWithdrawn] = useState(false);
|
||||
const [consentError, setConsentError] = useState("");
|
||||
|
||||
const flashSaved = (key: string) => {
|
||||
setSavedKey(key);
|
||||
|
|
@ -312,6 +336,7 @@ export default function Settings() {
|
|||
const base: NavItem[] = [
|
||||
{ id: "account", label: "계정", icon: "users" },
|
||||
{ id: "support", label: "지원 요청", icon: "review" },
|
||||
{ id: "consent", label: "동의", icon: "check" },
|
||||
{ id: "appearance", label: "테마", icon: "settings" },
|
||||
{ id: "notify", label: "알림", icon: "info" },
|
||||
{ id: "voice", label: "음성", icon: "mic" },
|
||||
|
|
@ -323,18 +348,34 @@ export default function Settings() {
|
|||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
|
||||
if (visible?.target.id) {
|
||||
setActiveSection(visible.target.id.replace(/^set-/, ""));
|
||||
// 활성 규칙: 뷰포트 상단 18~38% 밴드에 걸친 섹션 중 가장 아래(=방금 지나친 헤딩).
|
||||
// 비율 우세 방식은 scroll-margin 정렬 지점이 밴드 하단 근처인 모바일에서
|
||||
// 직전 섹션이 계속 이겨 마지막 섹션이 활성화되지 않는다(결함 로그 #17).
|
||||
const pickActiveSection = () => {
|
||||
const bandTop = window.innerHeight * 0.18;
|
||||
const bandBottom = window.innerHeight * 0.38;
|
||||
let winner: { id: string; top: number } | null = null;
|
||||
for (const item of navItems) {
|
||||
const section = document.getElementById(`set-${item.id}`);
|
||||
if (!section) continue;
|
||||
const rect = section.getBoundingClientRect();
|
||||
if (rect.top > bandBottom || rect.bottom < bandTop) continue;
|
||||
if (!winner || rect.top > winner.top) {
|
||||
winner = { id: item.id, top: rect.top };
|
||||
}
|
||||
}
|
||||
if (winner) setActiveSection(winner.id);
|
||||
};
|
||||
const observer = new IntersectionObserver(
|
||||
() => {
|
||||
if (Date.now() < spySuppressUntilRef.current) return;
|
||||
pickActiveSection();
|
||||
},
|
||||
{
|
||||
rootMargin: "-18% 0px -62% 0px",
|
||||
threshold: [0.2, 0.55],
|
||||
// 0 임계값이 없으면 긴 섹션(모바일)은 밴드 대비 비율이 0.2에 못 미쳐
|
||||
// 콜백이 아예 오지 않는다(결함 로그 #17).
|
||||
threshold: [0, 0.2, 0.55],
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -343,7 +384,22 @@ export default function Settings() {
|
|||
if (section) observer.observe(section);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
// 마지막 섹션은 짧은 뷰포트에서 활성 밴드(18~38%)에 도달하지 못한다.
|
||||
// 스크롤 컨테이너가 바닥에 닿으면 마지막 섹션을 활성으로 처리한다(결함 로그 #17).
|
||||
const scroller = document.querySelector(".vg-main");
|
||||
const onScroll = () => {
|
||||
if (!scroller || Date.now() < spySuppressUntilRef.current) return;
|
||||
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 4) {
|
||||
const last = navItems[navItems.length - 1];
|
||||
if (last) setActiveSection(last.id);
|
||||
}
|
||||
};
|
||||
scroller?.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
scroller?.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [navItems]);
|
||||
|
||||
const visibleNotifications = useMemo(
|
||||
|
|
@ -380,6 +436,7 @@ export default function Settings() {
|
|||
);
|
||||
|
||||
const goSection = (id: string) => {
|
||||
spySuppressUntilRef.current = Date.now() + 900;
|
||||
setActiveSection(id);
|
||||
document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
|
@ -406,6 +463,58 @@ export default function Settings() {
|
|||
flashSaved("account");
|
||||
};
|
||||
|
||||
/** 아바타 업로드 → PATCH /users/me 저장 → 톱바·레일 아바타 갱신. */
|
||||
const uploadAvatar = async (file: File | null) => {
|
||||
if (!file || uploadingAvatar) return;
|
||||
setUploadingAvatar(true);
|
||||
setAvatarError("");
|
||||
try {
|
||||
const uploaded = await userApi.uploadAvatar(file);
|
||||
const next = await userApi.updateMe({ avatar_url: uploaded.avatar_url });
|
||||
setProfile(next);
|
||||
await refresh();
|
||||
flashSaved("account");
|
||||
} catch (err) {
|
||||
console.warn("[settings] avatar upload failed", err);
|
||||
setAvatarError("아바타 업로드에 실패했습니다. PNG, JPG, WebP 파일을 3MB 이하로 올려 주세요.");
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 실습 동의 철회 확정 — DELETE /auth/consent, learner 전용. */
|
||||
const confirmWithdrawConsent = async () => {
|
||||
if (consentBusy) return;
|
||||
setConsentBusy(true);
|
||||
setConsentError("");
|
||||
try {
|
||||
await withdrawConsent();
|
||||
setConsentWithdrawn(true);
|
||||
setConsentConfirming(false);
|
||||
} catch (err) {
|
||||
console.warn("[settings] consent withdraw failed", err);
|
||||
setConsentError(err instanceof Error ? err.message : "실습 동의 철회에 실패했습니다.");
|
||||
} finally {
|
||||
setConsentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 실습 동의 재동의 — POST /auth/consent, 회기 시작 화면과 같은 계약. */
|
||||
const reacceptConsent = async () => {
|
||||
if (consentBusy) return;
|
||||
setConsentBusy(true);
|
||||
setConsentError("");
|
||||
try {
|
||||
await acceptConsent();
|
||||
setConsentWithdrawn(false);
|
||||
} catch (err) {
|
||||
console.warn("[settings] consent accept failed", err);
|
||||
setConsentError(err instanceof Error ? err.message : "실습 동의 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setConsentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePreferences = async (key: string) => {
|
||||
if (!preferencesReady || !preferences) return;
|
||||
const notifications = completeNotificationPreferences(preferences.notifications);
|
||||
|
|
@ -453,6 +562,10 @@ export default function Settings() {
|
|||
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
|
||||
const accountEmail = profile?.email ?? user?.email ?? "";
|
||||
const initials = accountName.trim().slice(0, 1).toUpperCase();
|
||||
// 프로필이 먼저 도착하면 프로필 값을, 아니면 auth 스토어의 절대화된 URL을 쓴다.
|
||||
const avatarSrc = profile?.avatar_url ? apiUrl(profile.avatar_url) : user?.avatarUrl ?? "";
|
||||
const isLearner = role === "learner";
|
||||
const consentAt = user?.consentAt ?? null;
|
||||
const engineHasRequiredFields = engineConfig
|
||||
? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0
|
||||
: false;
|
||||
|
|
@ -490,7 +603,7 @@ export default function Settings() {
|
|||
|
||||
<div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약">
|
||||
<div className="vg-set__rail-avatar" aria-hidden="true">
|
||||
{initials}
|
||||
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
|
||||
</div>
|
||||
<div className="vg-set__rail-copy">
|
||||
<div className="vg-set__rail-name">{accountName}</div>
|
||||
|
|
@ -541,7 +654,7 @@ export default function Settings() {
|
|||
|
||||
<div className={surfaceClassName("vg-set__profile", { variant: "inset", flat: true })}>
|
||||
<div className="vg-set__avatar" aria-hidden="true">
|
||||
{initials}
|
||||
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
|
||||
</div>
|
||||
<div className="vg-set__profile-meta">
|
||||
<div className="n">{accountName}</div>
|
||||
|
|
@ -551,8 +664,25 @@ export default function Settings() {
|
|||
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vg-set__avatar-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={loading || uploadingAvatar}
|
||||
>
|
||||
{uploadingAvatar ? "업로드 중" : avatarSrc ? "아바타 변경" : "아바타 올리기"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{avatarError ? (
|
||||
<div className="vg-set__state vg-set__state--warn" role="alert">
|
||||
<span className="vg-set__state-dot" aria-hidden="true" />
|
||||
<span>{avatarError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="vg-set__field-grid vg-set__field-grid--account">
|
||||
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
|
||||
<div className="vg-set__field-copy">
|
||||
|
|
@ -626,6 +756,21 @@ export default function Settings() {
|
|||
저장
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 숨은 아바타 file input — 계정 텍스트 입력들의 DOM 순서(0=표시 이름)를
|
||||
흔들지 않도록 섹션 마지막에 둔다. 버튼이 ref 로 클릭을 위임한다. */}
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="vg-set__avatar-input"
|
||||
aria-label="아바타 이미지"
|
||||
disabled={loading || uploadingAvatar}
|
||||
onChange={(event) => {
|
||||
void uploadAvatar(event.currentTarget.files?.[0] ?? null);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel id="set-support" className="vg-set__group vg-set__group--wide">
|
||||
|
|
@ -819,6 +964,118 @@ export default function Settings() {
|
|||
</Panel>
|
||||
) : null}
|
||||
|
||||
<Panel id="set-consent" className="vg-set__group vg-set__group--compact">
|
||||
<div className="vg-set__group-head">
|
||||
<div>
|
||||
<div className="vg-set__group-title">동의</div>
|
||||
<div className="vg-set__group-desc">실습 동의와 약관 동의 이력을 확인하고 관리합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__opt-grid">
|
||||
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
|
||||
<div className="vg-set__opt-text">
|
||||
<div className="l">실습 동의</div>
|
||||
<div className="h" data-testid="settings-consent-practice">
|
||||
{!isLearner
|
||||
? "학습자 전용 항목입니다. 실습 동의는 학습자 계정에서만 관리합니다."
|
||||
: consentAt != null
|
||||
? `동의 시각 ${formatConsentDate(consentAt)}`
|
||||
: "미동의 상태입니다."}
|
||||
</div>
|
||||
</div>
|
||||
{!isLearner ? (
|
||||
<Badge tone="neutral">학습자 전용</Badge>
|
||||
) : consentAt != null && !consentConfirming ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setConsentConfirming(true)}
|
||||
disabled={consentBusy}
|
||||
>
|
||||
실습 동의 철회
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
|
||||
<div className="vg-set__opt-text">
|
||||
<div className="l">이용약관</div>
|
||||
<div className="h">
|
||||
{profileReady && profile
|
||||
? `버전 ${profile.terms_version || "-"} · 동의 ${formatConsentDate(profile.terms_agreed_at)}`
|
||||
: "동의 이력을 확인하는 중입니다."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
|
||||
<div className="vg-set__opt-text">
|
||||
<div className="l">개인정보 처리방침</div>
|
||||
<div className="h">
|
||||
{profileReady && profile
|
||||
? `버전 ${profile.privacy_version || "-"} · 동의 ${formatConsentDate(profile.privacy_agreed_at)}`
|
||||
: "동의 이력을 확인하는 중입니다."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{consentError ? (
|
||||
<div className="vg-set__state vg-set__state--warn" role="alert">
|
||||
<span className="vg-set__state-dot" aria-hidden="true" />
|
||||
<span>{consentError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLearner && consentAt != null && consentConfirming ? (
|
||||
<div className={surfaceClassName("vg-set__consent-block", { variant: "inset", flat: true })}>
|
||||
<span>정말 철회할까요? 철회하면 새 회기 시작과 음성 연습이 즉시 차단됩니다.</span>
|
||||
<div className="vg-set__consent-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => void confirmWithdrawConsent()}
|
||||
disabled={consentBusy}
|
||||
>
|
||||
{consentBusy ? "철회 중" : "철회 확정"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConsentConfirming(false)}
|
||||
disabled={consentBusy}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLearner && consentAt == null ? (
|
||||
<div className={surfaceClassName("vg-set__consent-block", { variant: "inset", flat: true })}>
|
||||
{consentWithdrawn ? (
|
||||
<span role="status">
|
||||
새 회기 시작과 음성 연습이 차단됩니다. 아래 버튼 또는 회기 시작 화면에서
|
||||
다시 동의할 수 있습니다.
|
||||
</span>
|
||||
) : (
|
||||
<span>실습 동의가 없어 새 회기 시작과 음성 연습이 차단된 상태입니다.</span>
|
||||
)}
|
||||
<div className="vg-set__consent-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void reacceptConsent()}
|
||||
disabled={consentBusy}
|
||||
>
|
||||
{consentBusy ? "저장 중" : "다시 동의"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel id="set-appearance" className="vg-set__group vg-set__group--compact">
|
||||
<div className="vg-set__group-head">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -838,7 +838,7 @@
|
|||
}
|
||||
|
||||
.ad-user-sort__indicator {
|
||||
color: var(--accent);
|
||||
color: var(--text-accent);
|
||||
font-family: var(--font-num);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
@ -980,7 +980,7 @@
|
|||
.ad-approval-head h2 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-lg);
|
||||
font-size: var(--fs-h3);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
|
|
@ -1278,14 +1278,31 @@
|
|||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
/* 티켓 필터 툴바.
|
||||
기존에는 기본값이 7트랙 한 줄(minmax(220px,...) + 132px*3 + 140px + 버튼 2개)이라
|
||||
최소 954px를 요구했다. 그런데 폭 1180~1257px 구간은 셸 사이드바(262px)가 펼쳐지면서
|
||||
이 툴바의 실제 컨테이너가 876~950px밖에 안 된다. 그래서 1180px에서 마지막
|
||||
"초기화" 버튼이 left=1142 / right=1216 으로 뷰포트(1180) 밖 36px까지 밀려나 잘렸다.
|
||||
(1179px 이하에서는 max-width:1179px 규칙이 3트랙으로 접어줘서 문제가 없었다.)
|
||||
해결: 기본값을 4트랙 2줄로 낮추고 모든 유연 트랙을 minmax(0, ...)로 두어
|
||||
컨테이너가 좁아져도 트랙이 끝까지 줄어들 수 있게 한다(구조적으로 넘칠 수 없음). */
|
||||
.ad-ticket-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(132px, 0.45fr)) minmax(140px, 0.5fr) max-content max-content;
|
||||
grid-template-columns: minmax(0, 1.4fr) repeat(3, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* 컨테이너가 954px 이상 확보되는 폭(뷰포트 1320px~, 컨테이너 1016px~)에서만
|
||||
7트랙 한 줄로 편다. 최소치를 0으로 둬서 이 구간에서도 넘침이 불가능하다. */
|
||||
@media (min-width: 1320px) {
|
||||
.ad-ticket-filter {
|
||||
grid-template-columns:
|
||||
minmax(0, 1.6fr) repeat(3, minmax(0, 1fr)) minmax(0, 1.1fr) max-content max-content;
|
||||
}
|
||||
}
|
||||
|
||||
.ad-ticket-filter input,
|
||||
.ad-ticket-filter select {
|
||||
min-width: 0;
|
||||
|
|
@ -1469,13 +1486,10 @@
|
|||
font-size: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1179px) {
|
||||
.ad-ticket-filter {
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(132px, 0.6fr) minmax(132px, 0.6fr);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* max-width:1179px 의 .ad-ticket-filter 3트랙 규칙을 제거했다.
|
||||
1179px(컨테이너 875px)와 1180px(컨테이너 876px)은 실제 사용 가능한 폭이 1px 차이인데
|
||||
트랙 수만 3 -> 7 로 튀면서 1180px에서만 넘침이 생겼다. 이제 981~1319px 전 구간이
|
||||
위의 기본 4트랙을 공유하므로 경계에서 레이아웃이 튀지 않는다. */
|
||||
@media (max-width: 980px) {
|
||||
.ad-kpis,
|
||||
.ad-flow-grid,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
gap: var(--sp-5);
|
||||
align-items: end;
|
||||
padding-bottom: var(--sp-5);
|
||||
border-bottom: 1px solid var(--line-subtle);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.axl__head h1,
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
.axl__stats span {
|
||||
min-width: 104px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
padding-bottom: var(--sp-7);
|
||||
border-bottom: 1px solid var(--line-subtle);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.axl__persona-head {
|
||||
|
|
@ -111,7 +111,7 @@
|
|||
.axl__persona-title dl > div {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface) 82%, transparent);
|
||||
}
|
||||
|
|
@ -148,7 +148,7 @@
|
|||
align-content: start;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
|
||||
.ap__head h1{font-size:var(--fs-xl);margin:0 0 6px;}
|
||||
.ap__head h1{font-size:var(--fs-h1);margin:0 0 6px;}
|
||||
.ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;}
|
||||
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,rgba(238,244,242,.96),rgba(246,240,236,.92));margin-bottom:32px;}
|
||||
.ap__hero .vg-avatar__persona{color:#1c2a2a;}
|
||||
|
|
|
|||
|
|
@ -1748,6 +1748,20 @@
|
|||
padding:4px 7px;
|
||||
}
|
||||
}
|
||||
/* 초소형 폭(320~420px, iPhone SE급) 전용 밴드.
|
||||
기존 최소 브레이크포인트가 460px이라 이 구간이 비어 있었다.
|
||||
320px에서 페르소나 카드는 78vw(=249.6px)이고 마크 38px + 좌우 패딩 48px + 갭 11px를
|
||||
빼면 이름 칸이 150.6px밖에 안 남는다. 그래서
|
||||
"하린(가명) · 고3 · 진로갈등(부모기대 vs 본인욕구)"가 3줄(57px)을 요구하는데
|
||||
기본 -webkit-line-clamp:2(38px)에 걸려 마지막 줄 19px이 통째로 사라졌다
|
||||
(실측: clientHeight 38 / scrollHeight 57).
|
||||
→ 이 구간만 3줄까지 허용해 이름이 온전히 보이게 한다. 그보다 더 길어져도
|
||||
-webkit-box + line-clamp를 유지하므로 세로로 깎이지 않고 말줄임(…)으로만 잘린다. */
|
||||
@media (max-width:420px){
|
||||
.lh-persona__name{
|
||||
-webkit-line-clamp:3;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.lh-skel__box,
|
||||
.lh-skel__line{animation:none;}
|
||||
|
|
@ -1764,8 +1778,8 @@
|
|||
text-align: left;
|
||||
padding: 9px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.09));
|
||||
background: var(--bg-inset, rgba(0, 0, 0, 0.12));
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface-2);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -1781,7 +1795,7 @@
|
|||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.lh-rapport-list {
|
||||
display: grid;
|
||||
|
|
@ -1806,13 +1820,13 @@
|
|||
margin-left: 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 620;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.lh-rapport-row__bar {
|
||||
display: block;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-inset, rgba(0, 0, 0, 0.16));
|
||||
background: var(--neutral-100);
|
||||
overflow: hidden;
|
||||
}
|
||||
.lh-rapport-row__bar i {
|
||||
|
|
@ -1840,10 +1854,10 @@
|
|||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.1));
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 11.5px;
|
||||
font-weight: 680;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.lh-milestone.is-done {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
|
|
|
|||
|
|
@ -14,12 +14,36 @@ import {
|
|||
DIFFICULTY_LABEL,
|
||||
PENDING_PERSONA_AVATAR_APPEARANCE,
|
||||
demographicText,
|
||||
isUsablePersona,
|
||||
personaAvatarAppearance,
|
||||
personaBaselineExpression,
|
||||
personaMeta,
|
||||
shortPersonaName,
|
||||
} from "../../lib/personaViewModel";
|
||||
|
||||
// 결정 D3: 난도 오름차순 정렬 순서 — 알 수 없는 값은 뒤로 보낸다.
|
||||
const DIFFICULTY_ORDER: Record<string, number> = {
|
||||
easy: 0,
|
||||
moderate: 1,
|
||||
hard: 2,
|
||||
};
|
||||
const UNKNOWN_DIFFICULTY_ORDER = 99;
|
||||
|
||||
/** 회기 기록이 없는 신규 학습자에게 권하는 기초 우선 페르소나(난도 오름차순, 동률이면 code 순). */
|
||||
export function starterPersona(
|
||||
personas: PersonaSummary[],
|
||||
): PersonaSummary | null {
|
||||
const usable = personas.filter(isUsablePersona);
|
||||
usable.sort((a, b) => {
|
||||
const rank =
|
||||
(DIFFICULTY_ORDER[a.difficulty] ?? UNKNOWN_DIFFICULTY_ORDER) -
|
||||
(DIFFICULTY_ORDER[b.difficulty] ?? UNKNOWN_DIFFICULTY_ORDER);
|
||||
if (rank !== 0) return rank;
|
||||
return a.code.localeCompare(b.code);
|
||||
});
|
||||
return usable[0] ?? null;
|
||||
}
|
||||
|
||||
export type HistoryFilter = "all" | "active" | "review" | "archived";
|
||||
|
||||
export const HISTORY_FILTERS: Array<{
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@
|
|||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-bright);
|
||||
/* --accent-bright 는 dot/진행바 fill 용 밝은 값이라 12px 글자로 쓰면 3.29:1 이다. */
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.lg-kicker .d {
|
||||
|
|
@ -285,9 +286,12 @@
|
|||
margin-top: var(--sp-3);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(154, 94, 20, 0.28);
|
||||
border: 1px solid rgba(236, 180, 91, 0.16);
|
||||
color: #f2b75f;
|
||||
/* 2026-07-27: 기존 rgba(154,94,20,.28) + #f2b75f 조합은 다크 패널 전제였다.
|
||||
라이트 패널 위에서는 탄색 배경에 밝은 앰버 글자가 얹혀 1.19:1까지 떨어져
|
||||
사실상 읽을 수 없었다. 테마별로 스왑되는 warn 의미 토큰으로 교체한다. */
|
||||
background: var(--warn-tint);
|
||||
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
|
||||
color: var(--warn-text);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,10 +166,14 @@
|
|||
border-radius:var(--radius);
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
/* 약관·개인정보 동의는 법적 확인 조작이다. 라벨 박스가 실제 클릭 타깃인데
|
||||
높이가 19px 라 WCAG 2.2 SC 2.5.8(24px)에 미달했다. 최소 높이를 올린다. */
|
||||
.ob-checks label{
|
||||
grid-template-columns:auto minmax(0,1fr);
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
min-height:28px;
|
||||
cursor:pointer;
|
||||
}
|
||||
.ob-checks input{
|
||||
width:18px;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@
|
|||
gap:16px;
|
||||
align-items:start;
|
||||
}
|
||||
.ps-rail,
|
||||
.ps-inspector{
|
||||
position:sticky;
|
||||
top:76px;
|
||||
|
|
@ -45,9 +44,7 @@
|
|||
gap:14px;
|
||||
min-width:0;
|
||||
}
|
||||
.ps-rail__head,
|
||||
.ps-editor__top,
|
||||
.ps-workflow,
|
||||
.ps-inspector__block{
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius);
|
||||
|
|
@ -55,14 +52,12 @@
|
|||
padding:16px;
|
||||
box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.ps-rail__head,
|
||||
.ps-editor__top{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
}
|
||||
.ps-rail__head b,
|
||||
.ps-editor__top h2{
|
||||
display:block;
|
||||
margin:4px 0 0;
|
||||
|
|
@ -73,167 +68,15 @@
|
|||
line-height:1.25;
|
||||
letter-spacing:0;
|
||||
}
|
||||
.ps-list,
|
||||
.ps-approved,
|
||||
.ps-review-cards,
|
||||
.ps-check{
|
||||
display:grid;
|
||||
gap:8px;
|
||||
}
|
||||
.ps-list-row{
|
||||
width:100%;
|
||||
display:grid;
|
||||
grid-template-columns:42px minmax(0,1fr);
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
padding:10px;
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
color:inherit;
|
||||
text-align:left;
|
||||
cursor:pointer;
|
||||
}
|
||||
.ps-list-row:hover,
|
||||
.ps-list-row.is-active{
|
||||
border-color:var(--accent);
|
||||
background:var(--accent-tint);
|
||||
}
|
||||
.ps-list-row:disabled{
|
||||
opacity:.55;
|
||||
cursor:not-allowed;
|
||||
}
|
||||
.ps-list-row__code{
|
||||
display:grid;
|
||||
place-items:center;
|
||||
min-width:38px;
|
||||
min-height:34px;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius-sm);
|
||||
color:var(--accent-deep);
|
||||
background:var(--bg-surface-2);
|
||||
font-weight:760;
|
||||
font-family:var(--font-num);
|
||||
}
|
||||
.ps-list-row__body{
|
||||
min-width:0;
|
||||
}
|
||||
.ps-list-row__body b,
|
||||
.ps-list-row__body small{
|
||||
display:block;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.ps-list-row__body small,
|
||||
.ps-muted,
|
||||
.ps-approved-row span,
|
||||
.ps-review span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
}
|
||||
.ps-approved{
|
||||
padding:14px;
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
}
|
||||
.ps-workflow{
|
||||
display:grid;
|
||||
gap:10px;
|
||||
}
|
||||
.ps-workflow-step{
|
||||
display:grid;
|
||||
grid-template-columns:30px minmax(0,1fr);
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
padding:10px;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface-2);
|
||||
color:var(--text-muted);
|
||||
}
|
||||
.ps-workflow-step > span{
|
||||
display:grid;
|
||||
place-items:center;
|
||||
width:30px;
|
||||
height:30px;
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:50%;
|
||||
background:var(--bg-surface);
|
||||
color:var(--text-muted);
|
||||
font-family:var(--font-num);
|
||||
font-weight:760;
|
||||
}
|
||||
.ps-workflow-step b,
|
||||
.ps-workflow-step small{
|
||||
display:block;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.ps-workflow-step b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
}
|
||||
.ps-workflow-step small{
|
||||
margin-top:2px;
|
||||
font-size:11px;
|
||||
}
|
||||
.ps-workflow-step.is-current{
|
||||
border-color:var(--accent);
|
||||
background:var(--accent-tint);
|
||||
color:var(--accent-deep);
|
||||
}
|
||||
.ps-workflow-step.is-current > span,
|
||||
.ps-workflow-step.is-done > span{
|
||||
border-color:var(--accent);
|
||||
background:var(--accent);
|
||||
color:var(--text-on-accent);
|
||||
}
|
||||
.ps-approved-row{
|
||||
display:grid;
|
||||
grid-template-columns:38px minmax(0,1fr);
|
||||
gap:8px 10px;
|
||||
align-items:center;
|
||||
padding:8px 0;
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.ps-approved-row:first-of-type{border-top:0;}
|
||||
.ps-approved-row__code{
|
||||
font-family:var(--font-num);
|
||||
font-size:var(--fs-xs);
|
||||
color:var(--text-muted);
|
||||
}
|
||||
.ps-approved-row__body{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:2px;
|
||||
}
|
||||
.ps-approved-row b{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
font-size:var(--fs-xs);
|
||||
}
|
||||
.ps-approved-row small{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
color:var(--text-muted);
|
||||
font-size:11px;
|
||||
}
|
||||
.ps-approved-row__actions{
|
||||
grid-column:1 / -1;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:6px;
|
||||
}
|
||||
.ps-approved-row__actions .vg-btn{
|
||||
width:100%;
|
||||
}
|
||||
.ps-editor{
|
||||
display:grid;
|
||||
gap:14px;
|
||||
|
|
@ -493,8 +336,8 @@
|
|||
}
|
||||
.ps-prompt-section__head h3{
|
||||
margin:0;
|
||||
color:var(--text-heading);
|
||||
font-size:var(--fs-md);
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-body);
|
||||
line-height:1.25;
|
||||
}
|
||||
.ps-prompt-section__head p,
|
||||
|
|
@ -516,7 +359,7 @@
|
|||
gap:10px;
|
||||
align-items:start;
|
||||
padding:8px 0;
|
||||
border-top:1px solid var(--border-faint);
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.ps-prompt-row dt{
|
||||
color:var(--text-muted);
|
||||
|
|
@ -671,9 +514,6 @@
|
|||
.ps-layout{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.ps-rail{
|
||||
position:static;
|
||||
}
|
||||
.ps-inspector{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
|
|
@ -931,7 +771,7 @@
|
|||
min-height:58px;
|
||||
padding:9px 16px;
|
||||
border:0;
|
||||
border-top:1px solid var(--border-faint);
|
||||
border-top:1px solid var(--hair);
|
||||
background:transparent;
|
||||
color:var(--text-body);
|
||||
text-align:left;
|
||||
|
|
@ -959,7 +799,7 @@
|
|||
.ps-catalog-hero__count b{color:var(--text-strong);font-family:var(--font-num);font-size:54px;line-height:1;letter-spacing:-.06em;}
|
||||
.ps-catalog-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
|
||||
.ps-settings-list{display:grid;margin:18px 0 0;}
|
||||
.ps-settings-list > div{display:grid;grid-template-columns:minmax(130px,.4fr) minmax(0,1fr);gap:18px;padding:11px 0;border-top:1px solid var(--border-faint);}
|
||||
.ps-settings-list > div{display:grid;grid-template-columns:minmax(130px,.4fr) minmax(0,1fr);gap:18px;padding:11px 0;border-top:1px solid var(--hair);}
|
||||
.ps-settings-list dt{color:var(--text-muted);font-size:var(--fs-xs);font-weight:720;}
|
||||
.ps-settings-list dd{margin:0;color:var(--text-strong);font-size:var(--fs-sm);text-align:right;overflow-wrap:anywhere;}
|
||||
.ps-distribution{display:grid;gap:14px;margin-top:18px;}
|
||||
|
|
@ -967,7 +807,7 @@
|
|||
.ps-distribution span{color:var(--text-body);font-size:var(--fs-xs);}
|
||||
.ps-distribution b{color:var(--text-strong);font-family:var(--font-num);font-size:12px;}
|
||||
.ps-catalog-list{margin-top:16px;border-top:1px solid var(--border-subtle);}
|
||||
.ps-catalog-list button{width:100%;display:grid;grid-template-columns:48px minmax(0,1fr) minmax(70px,.25fr) 46px 20px;gap:12px;align-items:center;padding:12px 4px;border:0;border-bottom:1px solid var(--border-faint);background:transparent;color:var(--text-body);text-align:left;cursor:pointer;}
|
||||
.ps-catalog-list button{width:100%;display:grid;grid-template-columns:48px minmax(0,1fr) minmax(70px,.25fr) 46px 20px;gap:12px;align-items:center;padding:12px 4px;border:0;border-bottom:1px solid var(--hair);background:transparent;color:var(--text-body);text-align:left;cursor:pointer;}
|
||||
.ps-catalog-list button:hover{background:var(--bg-surface-2);}
|
||||
.ps-catalog-list button > span:nth-child(2){min-width:0;display:grid;gap:3px;}
|
||||
.ps-catalog-list button b,.ps-catalog-list button small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
|
|
@ -987,7 +827,7 @@
|
|||
.ps-persona-table{overflow:hidden;border:1px solid var(--border-subtle);border-radius:var(--radius);background:color-mix(in srgb,var(--bg-surface) 88%,transparent);}
|
||||
.ps-persona-table__head,.ps-persona-row{display:grid;grid-template-columns:minmax(300px,1.5fr) minmax(135px,.55fr) minmax(100px,.4fr) minmax(110px,.42fr) 54px minmax(190px,.72fr);gap:12px;align-items:center;}
|
||||
.ps-persona-table__head{padding:10px 14px;background:var(--bg-surface-2);color:var(--text-muted);font-size:10px;font-weight:780;}
|
||||
.ps-persona-row{min-height:70px;padding:10px 14px;border-top:1px solid var(--border-faint);}
|
||||
.ps-persona-row{min-height:70px;padding:10px 14px;border-top:1px solid var(--hair);}
|
||||
.ps-persona-row:hover{background:color-mix(in srgb,var(--bg-surface-2) 72%,transparent);}
|
||||
.ps-persona-row__identity{min-width:0;display:grid;grid-template-columns:44px minmax(0,1fr);gap:11px;align-items:center;padding:0;border:0;background:transparent;color:inherit;text-align:left;cursor:pointer;}
|
||||
.ps-persona-row__identity > span:last-child,.ps-persona-row__usage{min-width:0;display:grid;gap:3px;}
|
||||
|
|
@ -1008,7 +848,7 @@
|
|||
.ps-review-card__top{display:flex;align-items:center;justify-content:space-between;gap:10px;}
|
||||
.ps-review-card h3{margin:0;color:var(--text-strong);font-size:17px;}
|
||||
.ps-review-card > p{margin:0;color:var(--text-muted);font-size:var(--fs-xs);line-height:1.5;}
|
||||
.ps-review-card dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0;border-top:1px solid var(--border-faint);}
|
||||
.ps-review-card dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0;border-top:1px solid var(--hair);}
|
||||
.ps-review-card dl > div{display:grid;gap:4px;padding:10px 8px 0;}
|
||||
.ps-review-card dt{color:var(--text-muted);font-size:10px;}
|
||||
.ps-review-card dd{margin:0;color:var(--text-body);font-size:11px;overflow-wrap:anywhere;}
|
||||
|
|
@ -1024,13 +864,13 @@
|
|||
.ps-chip-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:20px;}
|
||||
.ps-chip-row span{padding:5px 8px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);color:var(--text-muted);font-size:11px;}
|
||||
.ps-detail-facts dl{display:grid;margin:0;}
|
||||
.ps-detail-facts dl > div{display:flex;justify-content:space-between;gap:16px;padding:10px 0;border-top:1px solid var(--border-faint);}
|
||||
.ps-detail-facts dl > div{display:flex;justify-content:space-between;gap:16px;padding:10px 0;border-top:1px solid var(--hair);}
|
||||
.ps-detail-facts dl > div:first-child{border-top:0;}
|
||||
.ps-detail-facts dt{color:var(--text-muted);font-size:var(--fs-xs);}
|
||||
.ps-detail-facts dd{margin:0;color:var(--text-strong);font-size:var(--fs-xs);font-weight:720;text-align:right;}
|
||||
.ps-learning-detail{display:grid;gap:14px;}
|
||||
.ps-point-list{display:grid;margin-top:14px;border-top:1px solid var(--border-subtle);}
|
||||
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--border-faint);color:var(--text-body);font-size:var(--fs-xs);}
|
||||
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--hair);color:var(--text-body);font-size:var(--fs-xs);}
|
||||
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
|
||||
.ps-point-list small{color:var(--text-muted);text-align:right;}
|
||||
.ps-stepper{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden;background:color-mix(in srgb,var(--bg-surface) 78%,transparent);}
|
||||
|
|
@ -1044,7 +884,6 @@
|
|||
.ps-stepper button.is-current{background:var(--accent-tint);color:var(--accent-deep);}
|
||||
.ps-stepper button.is-current > span:first-child,.ps-stepper button.is-done > span:first-child{border-color:var(--accent);background:var(--accent);color:var(--text-on-accent);}
|
||||
.ps-authoring-layout{grid-template-columns:minmax(0,1fr) minmax(260px,320px);}
|
||||
.ps-authoring-layout > .ps-rail{display:none;}
|
||||
.ps-authoring-layout[data-authoring-step="source"] .ps-tabs,
|
||||
.ps-authoring-layout[data-authoring-step="source"] .ps-edit-panel,
|
||||
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs,
|
||||
|
|
@ -1074,7 +913,7 @@
|
|||
.ps-kpi > b{font-size:30px;}
|
||||
.ps-performance__metrics{grid-template-columns:1fr;gap:18px;}
|
||||
.ps-review-summary__grid{grid-template-columns:1fr;}
|
||||
.ps-review-summary__grid > div{padding:12px 0;border-right:0;border-bottom:1px solid var(--border-faint);}
|
||||
.ps-review-summary__grid > div{padding:12px 0;border-right:0;border-bottom:1px solid var(--hair);}
|
||||
.ps-usage-table__head{display:none;}
|
||||
.ps-usage-row{grid-template-columns:minmax(0,1fr) repeat(2,58px);gap:8px;}
|
||||
.ps-usage-row strong:nth-last-child(-n+2){display:none;}
|
||||
|
|
@ -1094,14 +933,14 @@
|
|||
padding:14px;
|
||||
}
|
||||
.ps-persona-row__identity{grid-column:1 / -1;}
|
||||
.ps-persona-row__usage{grid-column:1 / -1;padding:9px 0;border-top:1px solid var(--border-faint);border-bottom:1px solid var(--border-faint);}
|
||||
.ps-persona-row__usage{grid-column:1 / -1;padding:9px 0;border-top:1px solid var(--hair);border-bottom:1px solid var(--hair);}
|
||||
.ps-persona-row > span:nth-child(3),
|
||||
.ps-persona-row > span:nth-child(4){justify-self:start;}
|
||||
.ps-version{justify-self:end;}
|
||||
.ps-persona-row__actions{grid-column:1 / -1;justify-content:stretch;}
|
||||
.ps-persona-row__actions .vg-btn{flex:1 1 0;}
|
||||
.ps-review-page__summary{grid-template-columns:1fr;}
|
||||
.ps-review-page__summary > div{border-right:0;border-bottom:1px solid var(--border-faint);}
|
||||
.ps-review-page__summary > div{border-right:0;border-bottom:1px solid var(--hair);}
|
||||
.ps-review-board{grid-template-columns:1fr;}
|
||||
.ps-drill-head{grid-template-columns:1fr;}
|
||||
.ps-drill-head__actions{justify-content:flex-start;}
|
||||
|
|
|
|||
|
|
@ -405,9 +405,26 @@
|
|||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
/* 세로 가상화 스크롤 컨테이너. height:auto + max-height라 행이 적으면
|
||||
내용 높이만 차지해 데드스페이스가 생기지 않는다. */
|
||||
max-height:64vh;
|
||||
overflow-x:auto;
|
||||
overflow-y:auto;
|
||||
scrollbar-gutter:stable;
|
||||
}
|
||||
/* 가상화 본문 — 전체 목록 높이를 확보하는 relative 캔버스.
|
||||
column flex 컨테이너가 인라인 height를 축소하지 않도록 flex:none이 필수다. */
|
||||
.pf-learner-table__body{
|
||||
position:relative;
|
||||
flex:none;
|
||||
min-width:1000px;
|
||||
}
|
||||
.pf-learner-table__body .pf-learner-record{
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
width:100%;
|
||||
}
|
||||
.pf-learner-table__head,
|
||||
.pf-learner-row{
|
||||
display:grid;
|
||||
|
|
@ -441,7 +458,8 @@
|
|||
min-width:1000px;
|
||||
border-top:1px solid var(--paper-2);
|
||||
}
|
||||
.pf-learner-table__head + .pf-learner-record{
|
||||
/* 가상화로 첫 행이 헤더의 형제가 아니게 되어 data-index 기준으로 상단 이중선을 막는다. */
|
||||
.pf-learner-record[data-index="0"]{
|
||||
border-top:0;
|
||||
}
|
||||
.pf-learner-row{
|
||||
|
|
@ -458,6 +476,15 @@
|
|||
.pf-learner-row__tags{
|
||||
min-width:0;
|
||||
}
|
||||
/* 암시적 컬럼(auto = minmax(auto,max-content))은 자식의 width:max-content 요청을
|
||||
그대로 수용해 셀 폭(261px)을 넘겨 288px까지 늘어났고, 그 결과 max-width:100%가
|
||||
확장된 트랙 기준으로 풀려 ellipsis가 걸리지 않고 옆 컬럼을 침범했다.
|
||||
트랙을 minmax(0,1fr)로 고정해야 셀 안에서 잘린다. (2026-07-27 겹침 회귀 수정) */
|
||||
.pf-learner-row__id,
|
||||
.pf-learner-row__metric,
|
||||
.pf-learner-row__tag-summary{
|
||||
grid-template-columns:minmax(0,1fr);
|
||||
}
|
||||
.pf-learner-row__id{
|
||||
display:grid;
|
||||
gap:3px;
|
||||
|
|
@ -465,7 +492,10 @@
|
|||
.pf-learner-name-button{
|
||||
width:max-content;
|
||||
max-width:100%;
|
||||
padding:0;
|
||||
/* 상세 진입의 주 조작이다. 줄높이만큼(19px)이면 WCAG 2.2 SC 2.5.8(24px)에
|
||||
미달해 세로 여백으로 타깃을 채운다. */
|
||||
min-height:24px;
|
||||
padding:3px 0;
|
||||
border:0;
|
||||
color:var(--text-strong);
|
||||
background:transparent;
|
||||
|
|
|
|||
|
|
@ -946,10 +946,10 @@
|
|||
background: var(--accent);
|
||||
}
|
||||
.sr-nonverbal--paralinguistic .sr-nonverbal__dot {
|
||||
background: var(--good-solid);
|
||||
background: var(--pos-solid);
|
||||
}
|
||||
.sr-nonverbal--prosody .sr-nonverbal__dot {
|
||||
background: var(--ink-soft);
|
||||
background: var(--ink-2);
|
||||
}
|
||||
.sr-nonverbal--audio_quality .sr-nonverbal__dot {
|
||||
background: var(--text-muted);
|
||||
|
|
@ -1389,7 +1389,7 @@
|
|||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
color: var(--text-accent);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
|
|
|
|||
|
|
@ -2637,7 +2637,13 @@
|
|||
.sx-prestart {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.82fr) minmax(360px, 1.72fr) minmax(260px, 0.98fr);
|
||||
/* 트랙 최소치를 0으로 둬 컨테이너가 좁아져도 트랙이 끝까지 축소되게 한다(구조적 넘침 불가).
|
||||
이전 minmax(220px,…) minmax(360px,…) minmax(260px,…) 는 최소 합 840px+gap 48px=888px 를
|
||||
요구했는데, 뷰포트 1041~1240px 구간은 셸 사이드바를 뺀 실제 컨테이너가 735~886px 뿐이라
|
||||
3열이 축소되지 못하고 우측 "진행 초점" 패널이 최대 195px 잘려 안 보였다.
|
||||
overflow-x:hidden 이라 스크롤도 안 되고 문서 오버플로도 0 이라 기존 게이트가 못 잡았다.
|
||||
아래 2열 전환(≤1260px)과 이 최소치 0 이 이중 안전망이다. (2026-07-27) */
|
||||
grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.72fr) minmax(0, 0.98fr);
|
||||
align-items: center;
|
||||
gap: var(--sp-5);
|
||||
max-width: none;
|
||||
|
|
@ -2794,13 +2800,16 @@
|
|||
line-height: 1.6;
|
||||
max-width: 58ch;
|
||||
}
|
||||
/* 사실 목록. repeat(3, 1fr) 고정이면 항목이 1개뿐인 상태(페르소나 로딩/폴백)에서도
|
||||
칸이 1/3 로 쪼개져 값이 세로로 길게 잘렸다(결함 #2). auto-fit 으로 바꿔 남는 트랙을
|
||||
접고, 항목 수·폭에 따라 1~3열로 스스로 접히게 한다. */
|
||||
.sx-prestart__facts {
|
||||
margin: var(--sp-4) 0 0;
|
||||
padding: var(--sp-4) 0;
|
||||
padding: var(--sp-3) 0;
|
||||
border-top: 1px solid var(--hair);
|
||||
border-bottom: 1px solid var(--hair);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 96px), 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__facts {
|
||||
|
|
@ -2814,16 +2823,27 @@
|
|||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* 값은 2줄 클램프로 잘라 쓰던 자리다. 폴백 문구("실제 연습 대상 정보를 확인하고
|
||||
있습니다.")가 320~421 등에서 113px 잘려 나가 클램프를 걷고 전문을 흘린다.
|
||||
대신 데스크톱 한 화면 제약은 아래 min-width:1181px 블록에서 사실 블록 자체의
|
||||
최대 높이 + 내부 스크롤로 지킨다. */
|
||||
.sx-prestart__facts dd {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 13.5px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 데스크톱은 문서 스크롤 없이 한 화면에 들어가야 하므로, 페르소나 요약이 아주 길 때만
|
||||
사실 블록 안에서 스크롤되게 상한을 둔다(잘라내지 않는다). */
|
||||
@media (min-width: 1181px) {
|
||||
.sx-prestart__facts {
|
||||
max-height: 152px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
.sx-prestart__chips {
|
||||
display: flex;
|
||||
|
|
@ -2847,9 +2867,12 @@
|
|||
background: var(--clay-tint);
|
||||
color: var(--clay-deep);
|
||||
}
|
||||
/* 아래 두 여백(이론모드 위 / 시작 버튼 위)은 dd 클램프를 걷어내면서 늘어난 세로
|
||||
12px 을 되돌려 받는 자리다. 프리스타트는 1280x900 에서 문서 스크롤 없이 들어가야
|
||||
한다(이 파일 상단 .vg-main:has(.sx-prestart--...) 규칙과 같은 제약). */
|
||||
.sx-theory {
|
||||
width: min(100%, 520px);
|
||||
margin: var(--sp-4) 0 0;
|
||||
margin: var(--sp-3) 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
|
|
@ -2913,12 +2936,16 @@
|
|||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
/* 상태 안내문. 예전에는 목표 fieldset 과의 간격을 줄이려고 margin-top 을 -12px 로
|
||||
당겨 썼는데, .sx-goals 는 마진이 0 으로 끝나는 블록이라 그 -12px 이 그대로
|
||||
fieldset 마지막 줄(목표 힌트) 위로 파고들었다. 320~1536 전 폭에서 88x12 겹침이
|
||||
재현돼(프리스타트 결함 #1) 음수 마진을 걷어내고 양수 간격으로 되돌린다. */
|
||||
.sx-prestart__note {
|
||||
max-width: 460px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
margin-top: calc(var(--sp-3) * -1);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
.sx-prestart__err {
|
||||
font-size: var(--fs-sm);
|
||||
|
|
@ -2956,7 +2983,7 @@
|
|||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--sp-5);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.sx-prestart__actions span {
|
||||
color: var(--text-muted);
|
||||
|
|
@ -3036,7 +3063,10 @@
|
|||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 1040px) {
|
||||
/* 3열이 실제로 들어가는 최소 컨테이너는 888px 이고, 그 폭은 뷰포트 1241px 부터 확보된다.
|
||||
기존 전환점 1040px 은 200px 짜리 사각지대(1041~1240)를 남겨 우측 패널이 잘렸다.
|
||||
안전 마진을 두고 1260px 까지 2열(사이드 패널은 하단 전체 폭)로 내린다. (2026-07-27) */
|
||||
@media (max-width: 1260px) {
|
||||
.sx-prestart {
|
||||
grid-template-columns: minmax(180px, 230px) minmax(0, 1fr);
|
||||
}
|
||||
|
|
@ -3448,6 +3478,14 @@
|
|||
.sx-page--active .sx-seg-block__label {
|
||||
display: none;
|
||||
}
|
||||
/* 절대배치 넛지가 좁은 폭에서 음성 건너뛰기 등 위쪽 컨트롤을 덮어 클릭을
|
||||
가로채던 문제(결함 로그 #18) — 모바일에서는 플로우 배치로 전환한다. */
|
||||
.sx-page--active .sx-coach-nudge {
|
||||
position: static;
|
||||
transform: none;
|
||||
align-self: center;
|
||||
margin: 0 auto 4px;
|
||||
}
|
||||
.sx-page--active .sx-segmented button {
|
||||
font-size: 12px;
|
||||
flex: 1 1 0;
|
||||
|
|
@ -3590,23 +3628,22 @@
|
|||
margin-top: 6px;
|
||||
font-size: 18px;
|
||||
}
|
||||
/* 2줄 클램프를 쓰던 자리. 본문 열이 좁아지는 320~421 에서 이 문장이 73px 잘려
|
||||
나갔다(결함 #3). 1180 이하는 어차피 세로 스크롤이 있는 구간이라 전문을 흘린다. */
|
||||
.sx-page--prestart .sx-prestart__desc {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__facts {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 132px), 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__facts dd {
|
||||
font-size: 11.5px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__chips {
|
||||
display: none;
|
||||
|
|
@ -3766,6 +3803,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* 프리스타트 아주 좁은 폭: 1180 이하 규칙이 아바타 열을 140px 로 고정해 두는데,
|
||||
460px 아래에서는 본문 열에 128px 밖에 남지 않아 설명문·목표 버튼·사실값이 전부
|
||||
짜부러졌다(결함 #2·#3 의 실제 원인). 이 구간만 세로 1열로 쌓는다. */
|
||||
@media (max-width: 460px) {
|
||||
.sx-page--prestart .sx-prestart {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__visual {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) and (max-height: 820px) {
|
||||
.sx-sessionbar {
|
||||
min-height: 38px;
|
||||
|
|
@ -4090,6 +4140,12 @@
|
|||
}
|
||||
|
||||
/* ── 시간 알람 바(10분 전 경고 · 시간 만료) — 강제 노출 배너 ── */
|
||||
/* 시간 알람 바가 있으면 페이지 그리드가 4자식이 된다. 3행 템플릿 그대로면
|
||||
타임바가 1fr 행을 차지하고 본 그리드가 min-content로 밀려 모바일에서
|
||||
컨텍스트 패널이 타임바를 덮는 겹침이 생긴다(결함 로그 #18). */
|
||||
.sx-page--active:has(> .sx-timebar) {
|
||||
grid-template-rows: min-content min-content minmax(0, 1fr) min-content;
|
||||
}
|
||||
.sx-timebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -591,6 +591,55 @@
|
|||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* 업로드된 아바타 이미지 — 원형 프레임을 그대로 채운다(이니셜 폴백 대체). */
|
||||
.vg-set__avatar img,
|
||||
.vg-set__rail-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
/* 아바타 업로드 컨트롤 — 숨은 file input + 버튼(온보딩과 같은 계약). */
|
||||
.vg-set__avatar-actions {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
.vg-set__avatar-input {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 동의 섹션 — 철회 확인·재동의 안내 블록 ── */
|
||||
.vg-set__consent-block {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
margin-top: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface-2);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-body);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.vg-set__consent-block > span {
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.vg-set__consent-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── 모델/엔진 메타 한 줄(읽기전용 상태 표기) ── */
|
||||
.vg-set__meta {
|
||||
|
|
@ -1032,9 +1081,48 @@
|
|||
.vg-set__range-control {
|
||||
min-width: 0;
|
||||
}
|
||||
/* WCAG 2.2 SC 2.5.8(타깃 최소 24px): 브라우저 기본 range 는 높이 16px 라 미달이다.
|
||||
thumb 을 24px 로 잡되 투명 테두리 + background-clip:content-box 로 실제로 칠하는
|
||||
원은 16px 로 남긴다. 시각 크기는 그대로, 포인터 적중 영역만 24px 로 넓어진다. */
|
||||
.vg-set__range-control input[type="range"] {
|
||||
accent-color: var(--accent);
|
||||
display: block;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vg-set__range-control input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
.vg-set__range-control input[type="range"]::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
.vg-set__range-control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: -10px;
|
||||
border: 4px solid transparent;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
.vg-set__range-control input[type="range"]::-moz-range-thumb {
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 4px solid transparent;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
.vg-set__meta {
|
||||
margin-top: var(--sp-2);
|
||||
|
|
@ -1281,15 +1369,18 @@ body[data-page="settings"] #set-support {
|
|||
body[data-page="settings"] #set-engine {
|
||||
order: 3;
|
||||
}
|
||||
body[data-page="settings"] #set-appearance {
|
||||
body[data-page="settings"] #set-consent {
|
||||
order: 4;
|
||||
}
|
||||
body[data-page="settings"] #set-voice {
|
||||
body[data-page="settings"] #set-appearance {
|
||||
order: 5;
|
||||
}
|
||||
body[data-page="settings"] #set-notify {
|
||||
body[data-page="settings"] #set-voice {
|
||||
order: 6;
|
||||
}
|
||||
body[data-page="settings"] #set-notify {
|
||||
order: 7;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__group {
|
||||
padding: 18px;
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -1404,48 +1495,92 @@ body[data-page="settings"] .vg-set__pill {
|
|||
background: var(--glass-surface-inset);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
body[data-page="settings"] .vg-set {
|
||||
grid-template-columns: 86px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
max-width: none;
|
||||
/* 721~1080px 구간을 상단 가로 탭으로 접는다.
|
||||
이 폭에서 좌측 레일 배치가 두 갈래로 깨져 서브네비 라벨 7개가 모두 폭 0 이 됐다.
|
||||
(1) 981~1080px: 공용 `@media (max-width:1080px) .vg-set__rail{display:grid;
|
||||
grid-template-columns:minmax(220px,280px) minmax(0,1fr)}` 가 이 페이지 레일까지
|
||||
덮어썼다. 레일 트랙은 244px 뿐이라 실측상 `220px 0px` 로 풀려 nav 폭 0,
|
||||
nav-item 폭 28px 로 찌그러졌다(981·1023~1025·1079~1081 재현).
|
||||
(2) 721~980px: 이전 980 미디어쿼리가 레일을 86px 아이콘 전용으로 만들며
|
||||
`.vg-set__nav-item span{display:none}` 으로 라벨을 통째로 숨겼다.
|
||||
두 경우 모두 라벨이 사라지므로, 이 프로젝트가 선호하는 가로 탭 전환으로 통일한다.
|
||||
244px+28px+820px = 1092px 를 요구하는 2열 배치는 1081px 이상에서만 쓴다. */
|
||||
@media (max-width: 1080px) {
|
||||
body[data-page="settings"] .vg-set__callout {
|
||||
margin: var(--sp-4) 24px 0;
|
||||
}
|
||||
body[data-page="settings"] .vg-set {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
max-width: none;
|
||||
padding: 0 24px 28px;
|
||||
}
|
||||
/* 공용 1080 미디어쿼리의 2열 grid 를 무효화해 레일 전체를 가로 탭 바로 되돌린다.
|
||||
이 구간에서는 아래 폼 카드와 같은 폭·라운드로 맞춰 탭이 카드 밖으로 삐져나오지 않게 한다. */
|
||||
body[data-page="settings"] .vg-set__rail {
|
||||
padding: 18px 10px;
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
min-height: auto;
|
||||
margin: 0 0 14px;
|
||||
padding: 14px 16px 10px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title {
|
||||
justify-content: center;
|
||||
padding: 0 0 16px;
|
||||
gap: 10px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title h1 {
|
||||
font-size: 19px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title p {
|
||||
display: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-mark {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title div,
|
||||
body[data-page="settings"] .vg-set__nav-item span,
|
||||
body[data-page="settings"] .vg-set__rail-status {
|
||||
display: none;
|
||||
}
|
||||
/* 탭이 많아 폭이 모자라면 가로 스크롤로 흘린다(라벨은 계속 노출). */
|
||||
body[data-page="settings"] .vg-set__nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 0 0 2px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item {
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
min-height: 38px;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item span {
|
||||
display: inline;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__forms {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ≤720px 는 위 1080 가로 탭 배치를 그대로 이어받고 여백·타이포만 더 좁힌다.
|
||||
display/position/overflow 같은 배치 선언은 1080 블록에 한 번만 두고 여기선 중복 제거. */
|
||||
@media (max-width: 720px) {
|
||||
body[data-page="settings"] .vg-set__callout {
|
||||
margin: 12px 12px 0;
|
||||
}
|
||||
body[data-page="settings"] .vg-set {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
padding: 0 16px 28px;
|
||||
}
|
||||
/* ≤720px 만 full-bleed 상단 바(모바일 헤더 패턴)로 되돌린다. */
|
||||
body[data-page="settings"] .vg-set__rail {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
min-height: auto;
|
||||
margin: 0 -16px 12px;
|
||||
padding: 13px 16px 10px;
|
||||
border-radius: 0;
|
||||
|
|
@ -1456,44 +1591,27 @@ body[data-page="settings"] .vg-set__pill {
|
|||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title div {
|
||||
display: block;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title p {
|
||||
display: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item {
|
||||
flex: 0 0 auto;
|
||||
min-height: 34px;
|
||||
gap: 5px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item span {
|
||||
display: inline;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__forms {
|
||||
gap: 10px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue