전수 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:
Yun Chan 2026-07-27 14:25:24 +09:00
parent 0ae94499f2
commit 4511383cd9
55 changed files with 9333 additions and 573 deletions

View file

@ -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()

View file

@ -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")}
>

View file

@ -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 {

View file

@ -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}
>

View file

@ -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>

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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;

View file

@ -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;}

View file

@ -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);

View file

@ -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<{

View file

@ -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;
}

View file

@ -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;

View file

@ -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;}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}