전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,504 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "../components/shell/AppShell";
import { Button, Field, Icon, Input, Kicker } from "../components/ui";
import {
adminApi,
adminEngineApi,
type AdminEngineConfigResponse,
type AdminHealthResponse,
type AdminUsageResponse,
} from "../lib/api";
import "./admin/admin-ai.css";
const WINDOW_OPTIONS = [7, 30, 90] as const;
const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const;
const ENGINE_MODE_LABEL: Record<string, string> = {
claude_cli: "Claude CLI 게이트웨이",
messages_api: "클라우드 연결",
claude_api: "Anthropic API",
openai: "OpenAI 호환",
solar: "Solar",
};
function countLabel(value: number): string {
return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
}
function costLabel(value: number, precise = false): string {
if (!Number.isFinite(value) || value <= 0) return "$0";
return `$${value.toFixed(precise || value < 0.01 ? 6 : 4)}`;
}
function rateLabel(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0%";
return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
}
function dateTimeLabel(value?: number | null): string {
if (!value || !Number.isFinite(value)) return "기록 없음";
return new Intl.DateTimeFormat("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value * 1000));
}
function dayLabel(value: string): string {
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ko-KR", { month: "numeric", day: "numeric" }).format(date);
}
function sourceLabel(usage: AdminUsageResponse): string {
return usage.durable && usage.source === "database" ? "운영 DB 원장" : "서버 런타임 임시 집계";
}
function budgetStatusLabel(status: AdminUsageResponse["budget"]["status"]): string {
if (status === "exceeded") return "예산 초과";
if (status === "warn") return "예산 주의";
if (status === "ok") return "예산 정상";
return "예산 경고 비활성";
}
function healthStatusLabel(status?: string): string {
if (status === "ok") return "정상";
if (status === "down") return "중단";
if (status === "degraded") return "제한 운영";
return "확인 불가";
}
export default function AdminAi() {
const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30);
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
const [health, setHealth] = useState<AdminHealthResponse | null>(null);
const [engine, setEngine] = useState<AdminEngineConfigResponse | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialLoad = useRef(true);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [usageResult, healthResult, engineResult] = await Promise.allSettled([
adminApi.usage(windowDays),
adminApi.health(),
adminEngineApi.get(),
]);
if (usageResult.status === "fulfilled") setUsage(usageResult.value);
if (healthResult.status === "fulfilled") setHealth(healthResult.value);
if (engineResult.status === "fulfilled") setEngine(engineResult.value);
const failures = [usageResult, healthResult, engineResult]
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.map((result) =>
result.reason instanceof Error ? result.reason.message : "일부 운영 정보를 불러오지 못했습니다.",
);
if (failures.length > 0) setError([...new Set(failures)].join(" · "));
} finally {
setLoading(false);
}
}, [windowDays]);
useEffect(() => {
if (initialLoad.current) {
initialLoad.current = false;
void load();
return;
}
let active = true;
setLoading(true);
setError(null);
void adminApi.usage(windowDays)
.then((nextUsage) => {
if (active) setUsage(nextUsage);
})
.catch((cause) => {
if (active) {
setError(cause instanceof Error ? cause.message : "AI 사용량을 불러오지 못했습니다.");
}
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [windowDays]);
const saveEngine = async () => {
if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return;
setSaving(true);
setSaved(false);
setError(null);
try {
const next = await adminEngineApi.update({
engine_mode: engine.engine_mode,
engine_url: engine.engine_url.trim(),
model: engine.model.trim(),
});
setEngine(next);
setSaved(true);
setHealth(await adminApi.health().catch(() => health));
window.setTimeout(() => setSaved(false), 2400);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다.");
} finally {
setSaving(false);
}
};
const daily = usage?.daily_cost ?? [];
const maxDailyCost = Math.max(0, ...daily.map((item) => item.cost_usd));
const totalTokens = (usage?.tokens_in ?? 0) + (usage?.tokens_out ?? 0);
const coverage = usage && usage.total_turns > 0 ? usage.metered_turns / usage.total_turns : 0;
const unmeteredTurns = usage ? Math.max(0, usage.total_turns - usage.metered_turns) : 0;
const costPerTurn = usage && usage.metered_turns > 0 ? usage.cost_usd / usage.metered_turns : 0;
const costPerThousandTokens = usage && totalTokens > 0 ? (usage.cost_usd / totalTokens) * 1000 : 0;
const engineService = health?.services.find((service) => service.key === "engine");
const cache = usage?.evaluator_cache;
const sortedBreakdown = useMemo(
() => [...(usage?.by_provider ?? [])].sort((a, b) => b.cost_usd - a.cost_usd),
[usage],
);
return (
<AppShell contextLabel="AI 운영" navRole="admin" wide>
<div className="aic" data-testid="admin-ai-page" aria-busy={loading}>
<header className="aic-head">
<div>
<Kicker> · AI CONTROL LEDGER</Kicker>
<h1>AI DB </h1>
<p> DB에 ·· .</p>
</div>
<div className="aic-head__actions">
<span className="aic-head__stamp">
<b>{dateTimeLabel(usage?.generated_at)}</b>
</span>
<Button
variant="secondary"
size="sm"
leading={<Icon name="arrow-flat" size={15} />}
onClick={() => void load()}
disabled={loading}
>
</Button>
</div>
</header>
{error ? (
<div className="aic-alert" role="alert">
<Icon name="alert" size={18} />
<span>{error}</span>
</div>
) : null}
<section className="aic-source" aria-label="계량 데이터 원천">
<span className={`aic-source__dot ${usage?.durable ? "is-live" : ""}`} aria-hidden="true" />
<div>
<b>{usage ? sourceLabel(usage) : "계량 원천 확인 중"}</b>
<span>
{usage?.durable
? "호출 완료 시점의 provider·model·token·cost 필드를 DB에서 다시 집계합니다."
: "DB 계량이 아니므로 서버 재시작 시 집계가 사라질 수 있습니다."}
</span>
</div>
<div className="aic-window" role="group" aria-label="계량 기간">
{WINDOW_OPTIONS.map((days) => (
<button
key={days}
type="button"
className={windowDays === days ? "is-active" : ""}
aria-pressed={windowDays === days}
onClick={() => setWindowDays(days)}
>
{days}
</button>
))}
</div>
</section>
<section className="aic-ledger" aria-label="AI 사용 핵심 지표">
<article>
<span> </span>
<b>{usage ? costLabel(usage.cost_usd) : "—"}</b>
<small>{windowDays} DB </small>
</article>
<article>
<span> </span>
<b>{usage ? countLabel(usage.metered_turns) : "—"}</b>
<small> {usage ? countLabel(usage.total_turns) : "—"}</small>
</article>
<article>
<span> </span>
<b>{usage ? countLabel(usage.tokens_in) : "—"}</b>
<small>·</small>
</article>
<article>
<span> </span>
<b>{usage ? countLabel(usage.tokens_out) : "—"}</b>
<small> </small>
</article>
<article>
<span> </span>
<b>{usage ? costLabel(costPerTurn, true) : "—"}</b>
<small>
1 {usage ? (totalTokens > 0 ? costLabel(costPerThousandTokens, true) : "계산 불가") : "—"}
</small>
</article>
</section>
<div className="aic-grid">
<div className="aic-column aic-column--main">
<section className="aic-panel aic-budget">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">SPEND CONTROL</span>
<h2> </h2>
</div>
<span className={`aic-status aic-status--${usage?.budget.status ?? "disabled"}`}>
{usage ? budgetStatusLabel(usage.budget.status) : "확인 중"}
</span>
</div>
<div className="aic-budget__split">
<div>
<div className="aic-metricline">
<span> </span>
<b>
{usage?.budget.status === "disabled"
? "한도 미설정"
: `${costLabel(usage?.cost_usd ?? 0)} / ${costLabel(usage?.budget.limit_usd ?? 0)}`}
</b>
</div>
<div className="aic-track" aria-label={`예산 사용률 ${rateLabel(usage?.budget.used_ratio ?? 0)}`}>
<span style={{ width: `${Math.min(100, (usage?.budget.used_ratio ?? 0) * 100)}%` }} />
</div>
<p>
{usage?.budget.status === "disabled"
? "서버의 ADMIN_USAGE_BUDGET_USD를 설정하면 80% 주의와 100% 초과 상태를 표시합니다."
: `잔여 ${costLabel(usage?.budget.remaining_usd ?? 0)} · ${rateLabel(usage?.budget.used_ratio ?? 0)} 사용`}
</p>
</div>
<div>
<div className="aic-metricline">
<span>DB </span>
<b>{rateLabel(coverage)}</b>
</div>
<div className="aic-track aic-track--coverage" aria-label={`DB 계량 커버리지 ${rateLabel(coverage)}`}>
<span style={{ width: `${Math.min(100, coverage * 100)}%` }} />
</div>
<p>
{unmeteredTurns > 0
? `${countLabel(unmeteredTurns)}개 턴은 provider·model·token·cost 계량 필드가 없습니다.`
: "조회 기간의 모든 완료 턴에 계량 정보가 연결되어 있습니다."}
</p>
</div>
</div>
</section>
<section className="aic-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">DAILY JOURNAL</span>
<h2> </h2>
</div>
<span className="aic-panel__meta">{daily.length} </span>
</div>
{daily.length > 0 ? (
<div className="aic-chart" role="img" aria-label={`${windowDays}일 일별 AI 비용 막대 차트`}>
{daily.map((item) => {
const height = maxDailyCost > 0 ? Math.max(3, (item.cost_usd / maxDailyCost) * 100) : 3;
return (
<div className="aic-chart__day" key={item.day} title={`${item.day} ${costLabel(item.cost_usd)}`}>
<span className="aic-chart__value">{costLabel(item.cost_usd)}</span>
<span className="aic-chart__rail">
<span className="aic-chart__bar" style={{ height: `${height}%` }} />
</span>
<span className="aic-chart__date">{dayLabel(item.day)}</span>
<small>{countLabel(item.turns)}</small>
</div>
);
})}
</div>
) : (
<div className="aic-empty"> AI .</div>
)}
</section>
<section className="aic-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">MODEL LEDGER</span>
<h2>Provider· </h2>
</div>
<span className="aic-panel__meta"> </span>
</div>
{sortedBreakdown.length > 0 ? (
<div className="aic-table-wrap">
<table className="aic-table">
<thead>
<tr>
<th scope="col">Provider / </th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"> </th>
</tr>
</thead>
<tbody>
{sortedBreakdown.map((row) => (
<tr key={`${row.provider}:${row.model}`}>
<td>
<b>{row.model}</b>
<span>{row.provider}</span>
</td>
<td>{countLabel(row.turns)}</td>
<td>{countLabel(row.tokens_in)}</td>
<td>{countLabel(row.tokens_out)}</td>
<td>{costLabel(row.cost_usd)}</td>
<td>{costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, true)}</td>
<td>
<span className="aic-share">
<span style={{ width: `${usage && usage.cost_usd > 0 ? (row.cost_usd / usage.cost_usd) * 100 : 0}%` }} />
</span>
{rateLabel(usage && usage.cost_usd > 0 ? row.cost_usd / usage.cost_usd : 0)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="aic-empty"> .</div>
)}
</section>
</div>
<div className="aic-column aic-column--side">
<section className="aic-panel aic-engine">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">RUNTIME ROUTE</span>
<h2>AI </h2>
</div>
<span className={`aic-runtime aic-runtime--${engineService?.status ?? "degraded"}`}>
{healthStatusLabel(engineService?.status)}
</span>
</div>
{engine ? (
<>
<div className="aic-mode" role="radiogroup" aria-label="AI 운영 방식">
{ENGINE_MODES.map((mode) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={engine.engine_mode === mode}
className={engine.engine_mode === mode ? "is-active" : ""}
onClick={() => setEngine({ ...engine, engine_mode: mode })}
>
{ENGINE_MODE_LABEL[mode]}
</button>
))}
</div>
<Field label="연결 주소" hint="응답 생성 게이트웨이 또는 API base URL입니다." htmlFor="aic-engine-url">
<Input
id="aic-engine-url"
value={engine.engine_url}
onChange={(event) => setEngine({ ...engine, engine_url: event.target.value })}
aria-label="AI 연결 주소"
/>
</Field>
<Field label="기본 모델" hint="개별 평가 모델 override가 없을 때 적용됩니다." htmlFor="aic-engine-model">
<Input
id="aic-engine-model"
value={engine.model}
onChange={(event) => setEngine({ ...engine, model: event.target.value })}
aria-label="AI 기본 모델"
/>
</Field>
<dl className="aic-config-meta">
<div><dt> </dt><dd>{engine.durable ? "DB 영구 저장" : "런타임 적용"}</dd></div>
<div><dt> </dt><dd>{engine.source}</dd></div>
<div><dt> </dt><dd>{dateTimeLabel(engine.updated_at)}</dd></div>
<div><dt></dt><dd>{engine.updated_by ?? "기록 없음"}</dd></div>
</dl>
<div className="aic-engine__foot">
<span>{saved ? <><Icon name="check" size={15} /> </> : engineService?.detail ?? "엔진 상태를 확인합니다."}</span>
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void saveEngine()}
disabled={saving || !engine.engine_url.trim() || !engine.model.trim()}
>
{saving ? "저장 중" : "운영 설정 저장"}
</Button>
</div>
</>
) : (
<div className="aic-empty"> AI .</div>
)}
</section>
<section className="aic-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">EVALUATOR CACHE</span>
<h2> </h2>
</div>
<span className={`aic-runtime ${cache?.enabled ? "aic-runtime--ok" : ""}`}>
{cache?.enabled ? "사용 중" : "비활성"}
</span>
</div>
<div className="aic-cache-score">
<b>{cache ? rateLabel(cache.hit_rate) : "—"}</b>
<span>hit-rate</span>
</div>
<dl className="aic-mini-grid">
<div><dt></dt><dd>{countLabel(cache?.requests ?? 0)}</dd></div>
<div><dt></dt><dd>{countLabel(cache?.hits ?? 0)}</dd></div>
<div><dt></dt><dd>{countLabel(cache?.misses ?? 0)}</dd></div>
<div><dt></dt><dd>{countLabel(cache?.stores ?? 0)}</dd></div>
<div><dt> </dt><dd>{countLabel(cache?.entries ?? 0)}</dd></div>
<div><dt></dt><dd>{countLabel(cache?.evictions ?? 0)}</dd></div>
</dl>
<p className="aic-note"> .</p>
</section>
<section className="aic-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">METERING INTEGRITY</span>
<h2>DB </h2>
</div>
</div>
<dl className="aic-integrity">
<div>
<dt></dt>
<dd>{usage ? sourceLabel(usage) : "확인 중"}</dd>
<span>{usage?.source ?? "—"}</span>
</div>
<div>
<dt></dt>
<dd>{usage?.durable ? "재시작 후 유지" : "런타임 한정"}</dd>
<span>{usage?.durable ? "durable=true" : "durable=false"}</span>
</div>
<div>
<dt> </dt>
<dd>{countLabel(unmeteredTurns)}</dd>
<span>{unmeteredTurns > 0 ? "감사 필요" : "누락 없음"}</span>
</div>
</dl>
</section>
</div>
</div>
</div>
</AppShell>
);
}

View file

@ -1,5 +1,5 @@
/* =====================================================================
AvatarPreview dev ( ).
AvatarPreview SVG dev ( ).
/dev/avatar-preview. · .
===================================================================== */
@ -16,7 +16,6 @@ const SEOYEON: AvatarPersona = {
outfitColor: "#7C8A92",
eyeColor: "#7A5A3C",
accentColor: "#6B5E7D",
rasterArtSet: "seoyeon-live2d-psd-v2",
realism: 0.4,
expressionBias: "sad",
};
@ -38,42 +37,29 @@ const PREVIEW_EXPRS: AvatarExpression[] = [
];
export default function AvatarPreview() {
const rig = typeof window === "undefined" ? "" : new URLSearchParams(window.location.search).get("rig");
const previewPersona: AvatarPersona = {
...SEOYEON,
rasterArtSet:
rig === "safe"
? "seoyeon"
: rig === "v3"
? "seoyeon-live2d-v3"
: rig === "psb"
? "seoyeon-live2d-psb"
: SEOYEON.rasterArtSet,
};
return (
<div
className="ap"
data-avatar-preview="true"
data-avatar-rig={rig === "safe" ? "safe" : rig === "v3" ? "v3" : rig === "psb" ? "psb" : "psd-v2"}
data-avatar-rig="svg-parameter"
>
<header className="ap__head">
<h1> (raster / Live2D식)</h1>
<p>imagegen gpt-image-2 + BiRefNet . ·· .</p>
<h1> (SVG )</h1>
<p> · ·· .</p>
</header>
<section className="ap__hero">
<ClientAvatar persona={previewPersona} state="speaking" affect="sad" size={220} />
<ClientAvatar persona={previewPersona} state="idle" affect="sad" size={220} />
<ClientAvatar persona={previewPersona} state="listening" affect="warm" size={220} />
<ClientAvatar persona={previewPersona} state="thinking" affect="anxious" size={220} />
<ClientAvatar persona={SEOYEON} state="speaking" affect="sad" size={220} />
<ClientAvatar persona={SEOYEON} state="idle" affect="sad" size={220} />
<ClientAvatar persona={SEOYEON} state="listening" affect="warm" size={220} />
<ClientAvatar persona={SEOYEON} state="thinking" affect="anxious" size={220} />
</section>
<section className="ap__grid">
{PREVIEW_EXPRS.map((expr) => (
<figure className="ap__cell" key={expr}>
<ClientAvatar
persona={previewPersona}
persona={SEOYEON}
state="idle"
affect={expr}
size={150}

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import { initialPathForUser, useAuth, type Role } from "../lib/auth";
import { apiUrl, authApi, type AuthConfigResponse } from "../lib/api";
import { LoginBrand } from "./login/LoginBrand";
import { LoginPanel, type LoginRoleOption } from "./login/LoginPanel";
import { AuthShell } from "../components/auth/AuthShell";
import "./login/login.css";
const OAUTH_NOT_CONFIGURED_MESSAGE =
@ -181,7 +182,7 @@ export default function Login() {
};
return (
<main className="lg-root">
<AuthShell className="lg-root" variant="room">
<LoginBrand allowedDomains={allowedDomains} oauthChecking={oauthChecking} />
<LoginPanel
oauth={{
@ -204,6 +205,6 @@ export default function Login() {
}}
error={{ message: loginError, reason: loginErrorReason }}
/>
</main>
</AuthShell>
);
}

View file

@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../components/ui";
import { surfaceClassName } from "../components/ui/Surface";
import { AuthShell } from "../components/auth/AuthShell";
import { roleHomePath, useAuth } from "../lib/auth";
import { apiUrl, userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api";
import "./onboarding.css";
@ -147,8 +149,8 @@ export default function Onboarding() {
return (
<>
<main className="ob-page">
<section className="ob-shell" aria-label="가입 정보 입력">
<AuthShell className="ob-page">
<section className={surfaceClassName("ob-shell")} aria-label="가입 정보 입력">
<header className="ob-head">
<p>Vignette </p>
<h1> .</h1>
@ -333,7 +335,7 @@ export default function Onboarding() {
</div>
</form>
</section>
</main>
</AuthShell>
</>
);
}

View file

@ -1,6 +1,8 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Icon } from "../components/ui";
import { surfaceClassName } from "../components/ui/Surface";
import { AuthShell } from "../components/auth/AuthShell";
import { roleHomePath, useAuth } from "../lib/auth";
import "./pending-approval.css";
@ -34,8 +36,8 @@ export default function PendingApproval() {
return (
<>
<main className="pa-page" aria-label="계정 승인 대기">
<section className="pa-panel">
<AuthShell className="pa-page" aria-label="계정 승인 대기">
<section className={surfaceClassName("pa-panel")}>
<div className="pa-mark" aria-hidden="true">
<Icon name="shield" size={30} strokeWidth={1.9} />
</div>
@ -73,7 +75,7 @@ export default function PendingApproval() {
</Button>
</div>
</section>
</main>
</AuthShell>
</>
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ import {
Input,
Kicker,
Panel,
surfaceClassName,
type IconName,
} from "../components/ui";
import { roleLabel, useAuth } from "../lib/auth";
@ -26,7 +27,7 @@ import {
} from "../lib/api";
import { readInitialTheme } from "../lib/theme";
import { Toggle } from "./settings/Toggle";
import { useTheme } from "./settings/useTheme";
import { useTheme } from "../lib/useTheme";
import "./settings/settings.css";
interface NavItem {
@ -219,13 +220,7 @@ export default function Settings() {
const displayNameDirtyRef = useRef(false);
const affiliationDirtyRef = useRef(false);
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
useEffect(() => {
document.body.setAttribute("data-page", "settings");
return () => {
document.body.removeAttribute("data-page");
};
}, []);
const loadSettingsRequestRef = useRef(0);
const flashSaved = (key: string) => {
setSavedKey(key);
@ -234,6 +229,8 @@ export default function Settings() {
};
const loadSettings = useCallback(async () => {
const requestId = loadSettingsRequestRef.current + 1;
loadSettingsRequestRef.current = requestId;
setLoading(true);
setError(null);
setProfileReady(false);
@ -246,16 +243,19 @@ export default function Settings() {
setEngineConfig(null);
engineConfigRef.current = null;
try {
let nextSupportTicketsError: string | null = null;
const [nextProfile, nextPrefs, nextTickets, presets, nextEngine] = await Promise.all([
userApi.me(),
userApi.preferences(),
userApi.supportTickets().catch((err) => {
setSupportTicketsError(err instanceof Error ? err.message : "지원 요청을 불러오지 못했습니다.");
nextSupportTicketsError =
err instanceof Error ? err.message : "지원 요청을 불러오지 못했습니다.";
return null;
}),
userApi.voicePresets(),
isAdmin ? adminEngineApi.get() : Promise.resolve(null),
]);
if (loadSettingsRequestRef.current !== requestId) return;
setProfile(nextProfile);
setProfileReady(true);
if (!displayNameDirtyRef.current) {
@ -269,6 +269,7 @@ export default function Settings() {
setPreferences(nextPrefs);
setPreferencesReady(true);
setSupportTickets(nextTickets);
setSupportTicketsError(nextSupportTicketsError);
setSupportTicketsReady(true);
setDark(nextPrefs.theme === "dark" || (nextPrefs.theme !== "light" && readInitialTheme() === "dark"));
setVoicePresets(presets);
@ -278,20 +279,35 @@ export default function Settings() {
}
if (isAdmin) {
void adminApi.health()
.then(setAdminHealth)
.catch(() => setAdminHealth(null));
.then((health) => {
if (loadSettingsRequestRef.current === requestId) setAdminHealth(health);
})
.catch(() => {
if (loadSettingsRequestRef.current === requestId) setAdminHealth(null);
});
}
} catch (err) {
if (loadSettingsRequestRef.current !== requestId) return;
setError(err instanceof Error ? err.message : "설정을 불러오지 못했습니다.");
} finally {
setLoading(false);
if (loadSettingsRequestRef.current === requestId) setLoading(false);
}
}, [isAdmin, setDark]);
useEffect(() => {
void loadSettings();
return () => {
loadSettingsRequestRef.current += 1;
};
}, [loadSettings]);
useEffect(() => {
document.body.dataset.page = "settings";
return () => {
delete document.body.dataset.page;
};
}, []);
const navItems: NavItem[] = useMemo(() => {
const base: NavItem[] = [
{ id: "account", label: "계정", icon: "users" },
@ -461,7 +477,7 @@ export default function Settings() {
) : null}
<div className="vg-set" aria-busy={loading}>
<aside className="vg-set__rail">
<aside className={surfaceClassName("vg-set__rail")}>
<div className="vg-set__rail-title">
<span className="vg-set__rail-mark" aria-hidden="true">
<Icon name="settings" size={24} strokeWidth={2} />
@ -472,7 +488,7 @@ export default function Settings() {
</div>
</div>
<div className="vg-set__rail-card" aria-label="계정 요약">
<div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약">
<div className="vg-set__rail-avatar" aria-hidden="true">
{initials}
</div>
@ -486,7 +502,7 @@ export default function Settings() {
</div>
</div>
<nav className="vg-set__nav" aria-label="설정 섹션">
<nav className={surfaceClassName("vg-set__nav", { variant: "inset", flat: true })} aria-label="설정 섹션">
<div className="vg-set__nav-kicker">SECTIONS</div>
{navItems.map((item) => (
<button
@ -523,7 +539,7 @@ export default function Settings() {
</div>
</div>
<div className="vg-set__profile">
<div className={surfaceClassName("vg-set__profile", { variant: "inset", flat: true })}>
<div className="vg-set__avatar" aria-hidden="true">
{initials}
</div>
@ -538,7 +554,7 @@ export default function Settings() {
</div>
<div className="vg-set__field-grid vg-set__field-grid--account">
<div className="vg-set__field">
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
@ -558,7 +574,7 @@ export default function Settings() {
</Field>
</div>
<div className="vg-set__field">
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
@ -573,7 +589,7 @@ export default function Settings() {
</Field>
</div>
<div className="vg-set__field vg-set__field--wide">
<div className={surfaceClassName("vg-set__field vg-set__field--wide", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
@ -636,7 +652,7 @@ export default function Settings() {
supportTickets.tickets.length > 0 ? (
<div className="vg-set__tickets" aria-label="내 지원 요청 목록">
{supportTickets.tickets.map((ticket) => (
<article className="vg-set__ticket" key={ticket.ticket_id}>
<article className={surfaceClassName("vg-set__ticket", { variant: "inset" })} key={ticket.ticket_id}>
<div className="vg-set__ticket-top">
<Badge tone={ticketStatusTone(ticket.status)}>
{TICKET_STATUS_LABEL[ticket.status]}
@ -651,12 +667,12 @@ export default function Settings() {
{ticket.assigned_group ? <span>{ticket.assigned_group}</span> : null}
</div>
{ticket.resolution_note ? (
<div className="vg-set__ticket-note">
<div className={surfaceClassName("vg-set__ticket-note", { variant: "inset", flat: true })}>
<b> </b>
<span>{ticket.resolution_note}</span>
</div>
) : (
<div className="vg-set__ticket-note vg-set__ticket-note--empty">
<div className={surfaceClassName("vg-set__ticket-note vg-set__ticket-note--empty", { variant: "inset", flat: true })}>
.
</div>
)}
@ -710,7 +726,7 @@ export default function Settings() {
{engineConfig ? (
<>
<div className="vg-set__engine-grid">
<div className="vg-set__control-block">
<div className={surfaceClassName("vg-set__control-block", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
@ -736,7 +752,7 @@ export default function Settings() {
</div>
<div className="vg-set__field-grid vg-set__field-grid--engine">
<div className="vg-set__field">
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
@ -754,7 +770,7 @@ export default function Settings() {
</Field>
</div>
<div className="vg-set__field">
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
@ -812,7 +828,7 @@ export default function Settings() {
</div>
{preferences ? (
<>
<div className="vg-set__opt">
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })}>
<div className="vg-set__opt-text">
<div className="l"> </div>
<div className="h"> .</div>
@ -866,7 +882,10 @@ export default function Settings() {
type="button"
role="radio"
aria-checked={selected}
className={"vg-set__voice" + (selected ? " is-on" : "")}
className={surfaceClassName(
"vg-set__voice" + (selected ? " is-on" : ""),
{ variant: "interactive", flat: true },
)}
onClick={() =>
setPreferences((cur) =>
cur ? { ...cur, voice_preset_id: preset.id } : cur,
@ -894,7 +913,7 @@ export default function Settings() {
})}
</div>
<div className="vg-set__range-row">
<div className={surfaceClassName("vg-set__range-row", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
@ -967,7 +986,7 @@ export default function Settings() {
<>
<div className="vg-set__opt-grid">
{visibleNotifications.map((item) => (
<div className="vg-set__opt" key={item.id}>
<div className={surfaceClassName("vg-set__opt", { variant: "inset", flat: true })} key={item.id}>
<div className="vg-set__opt-text">
<div className="l">{item.label}</div>
<div className="h">{item.hint}</div>

View file

@ -0,0 +1,766 @@
.aic {
width: 100%;
display: grid;
gap: 16px;
color: var(--text-body);
}
.aic-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
padding: 2px;
}
.aic-head h1 {
margin: 7px 0 0;
color: var(--text-strong);
font-size: clamp(1.65rem, 2.4vw, 2.2rem);
line-height: 1.12;
letter-spacing: -0.035em;
}
.aic-head p {
max-width: 720px;
margin: 8px 0 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.6;
}
.aic-head__actions {
display: flex;
align-items: center;
gap: 12px;
}
.aic-head__stamp {
color: var(--text-muted);
font-size: 12px;
text-align: right;
}
.aic-head__stamp b {
display: block;
margin-top: 2px;
color: var(--text-body);
font-family: var(--font-num);
font-weight: 650;
}
.aic-alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--crit-solid) 34%, var(--border-subtle));
border-radius: var(--radius);
background: var(--crit-tint);
color: var(--crit-text);
font-size: var(--fs-sm);
}
.aic-source {
display: grid;
grid-template-columns: 11px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 13px 14px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--glass-surface-inset);
box-shadow: var(--glass-inset-shadow);
backdrop-filter: var(--glass-blur);
}
.aic-source__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--warn-solid);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--warn-solid) 14%, transparent);
}
.aic-source__dot.is-live {
background: var(--pos-solid);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--pos-solid) 14%, transparent);
}
.aic-source b,
.aic-source span {
display: block;
}
.aic-source b {
color: var(--text-strong);
font-size: var(--fs-sm);
}
.aic-source div > span {
margin-top: 2px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.45;
}
.aic-window,
.aic-mode {
display: flex;
gap: 3px;
padding: 3px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
}
.aic-window button,
.aic-mode button {
min-height: 34px;
padding: 0 11px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font: 650 12px/1 var(--font-sans);
cursor: pointer;
}
.aic-window button:hover,
.aic-mode button:hover {
color: var(--text-strong);
background: var(--neutral-100);
}
.aic-window button.is-active,
.aic-mode button.is-active {
color: var(--text-on-accent);
background: var(--accent);
}
.aic-window button:focus-visible,
.aic-mode button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.aic-ledger {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface);
box-shadow: var(--shadow-sm);
}
.aic-ledger article {
min-width: 0;
padding: 14px;
}
.aic-ledger article + article {
border-left: 1px solid var(--border-subtle);
}
.aic-ledger span,
.aic-ledger small {
display: block;
color: var(--text-muted);
font-size: 11px;
}
.aic-ledger span {
font-weight: 700;
}
.aic-ledger b {
display: block;
margin: 8px 0 5px;
overflow: hidden;
color: var(--text-strong);
font-family: var(--font-num);
font-size: clamp(1.2rem, 2vw, 1.65rem);
line-height: 1;
text-overflow: ellipsis;
}
.aic-grid {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 0.85fr);
align-items: start;
gap: 16px;
}
.aic-column {
min-width: 0;
display: grid;
gap: 16px;
}
.aic-panel {
min-width: 0;
padding: 17px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--glass-surface-strong);
box-shadow: var(--glass-inset-shadow);
backdrop-filter: var(--glass-blur);
}
.aic-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 16px;
}
.aic-eyebrow {
display: block;
color: var(--accent-deep);
font: 750 10px/1.2 var(--font-num);
letter-spacing: 0.12em;
}
.aic-panel h2 {
margin: 5px 0 0;
color: var(--text-strong);
font-size: 1rem;
line-height: 1.3;
}
.aic-panel__meta {
color: var(--text-muted);
font: 600 11px/1.4 var(--font-num);
white-space: nowrap;
}
.aic-status,
.aic-runtime {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 999px;
background: var(--neutral-100);
color: var(--text-muted);
font-size: 11px;
font-weight: 750;
}
.aic-status--ok,
.aic-runtime--ok {
background: var(--pos-tint);
color: var(--pos-text);
}
.aic-status--warn,
.aic-status--disabled,
.aic-runtime--degraded {
background: var(--warn-tint);
color: var(--warn-text);
}
.aic-status--exceeded,
.aic-runtime--down {
background: var(--crit-tint);
color: var(--crit-text);
}
.aic-budget__split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 28px;
}
.aic-metricline {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
color: var(--text-muted);
font-size: 12px;
}
.aic-metricline b {
color: var(--text-strong);
font-family: var(--font-num);
font-size: 13px;
}
.aic-track {
height: 7px;
margin: 10px 0 8px;
overflow: hidden;
border-radius: 999px;
background: var(--neutral-100);
}
.aic-track > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--warn-solid);
}
.aic-track--coverage > span {
background: var(--accent-bright);
}
.aic-budget p,
.aic-note {
margin: 0;
color: var(--text-muted);
font-size: 11px;
line-height: 1.55;
}
.aic-chart {
display: grid;
grid-auto-columns: minmax(44px, 1fr);
grid-auto-flow: column;
align-items: end;
gap: 8px;
min-height: 205px;
overflow-x: auto;
padding: 4px 2px 8px;
}
.aic-chart__day {
min-width: 44px;
display: grid;
grid-template-rows: 18px 126px auto auto;
gap: 5px;
text-align: center;
}
.aic-chart__value,
.aic-chart__date,
.aic-chart__day small {
overflow: hidden;
color: var(--text-muted);
font: 600 9px/1.2 var(--font-num);
text-overflow: ellipsis;
white-space: nowrap;
}
.aic-chart__rail {
display: flex;
align-items: flex-end;
justify-content: center;
border-bottom: 1px solid var(--border-strong);
background-image: linear-gradient(to top, color-mix(in srgb, var(--border-subtle) 60%, transparent) 1px, transparent 1px);
background-size: 100% 25%;
}
.aic-chart__bar {
width: min(24px, 68%);
min-height: 3px;
border-radius: 3px 3px 0 0;
background: var(--accent-bright);
transition: height var(--dur-base) var(--ease-out);
}
.aic-chart__date {
color: var(--text-body);
}
.aic-empty {
display: grid;
min-height: 112px;
place-items: center;
padding: 18px;
border: 1px dashed var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: var(--fs-sm);
text-align: center;
}
.aic-table-wrap {
max-width: 100%;
overflow-x: auto;
}
.aic-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
font: 600 11px/1.4 var(--font-num);
}
.aic-table th,
.aic-table td {
padding: 10px 9px;
border-bottom: 1px solid var(--border-subtle);
text-align: right;
white-space: nowrap;
}
.aic-table th {
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
}
.aic-table th:first-child,
.aic-table td:first-child {
text-align: left;
}
.aic-table tbody tr:last-child td {
border-bottom: 0;
}
.aic-table td {
color: var(--text-body);
}
.aic-table td:first-child b,
.aic-table td:first-child span {
display: block;
}
.aic-table td:first-child b {
max-width: 220px;
overflow: hidden;
color: var(--text-strong);
font-family: var(--font-sans);
text-overflow: ellipsis;
}
.aic-table td:first-child span {
margin-top: 2px;
color: var(--text-muted);
font-size: 10px;
}
.aic-share {
display: inline-block;
width: 36px;
height: 4px;
margin-right: 6px;
overflow: hidden;
vertical-align: middle;
border-radius: 999px;
background: var(--neutral-100);
}
.aic-share > span {
display: block;
height: 100%;
background: var(--clay);
}
.aic-engine {
border-color: color-mix(in srgb, var(--accent) 34%, var(--border-subtle));
}
.aic-mode {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 14px;
}
.aic-mode button {
min-width: 0;
white-space: normal;
line-height: 1.25;
}
.aic-engine .vg-field + .vg-field {
margin-top: 13px;
}
.aic-engine .vg-field__label {
color: var(--text-strong);
font-size: 12px;
}
.aic-engine .vg-field__hint {
color: var(--text-muted);
font-size: 10px;
}
.aic-engine .vg-input {
width: 100%;
font-family: var(--font-num);
font-size: 12px;
}
.aic-config-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
margin: 16px 0 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
.aic-config-meta div {
min-width: 0;
padding: 9px;
}
.aic-config-meta div:nth-child(even) {
border-left: 1px solid var(--border-subtle);
}
.aic-config-meta div:nth-child(n + 3) {
border-top: 1px solid var(--border-subtle);
}
.aic-config-meta dt,
.aic-config-meta dd {
margin: 0;
}
.aic-config-meta dt {
color: var(--text-muted);
font-size: 10px;
}
.aic-config-meta dd {
margin-top: 3px;
overflow: hidden;
color: var(--text-body);
font: 600 10px/1.4 var(--font-num);
text-overflow: ellipsis;
white-space: nowrap;
}
.aic-engine__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
}
.aic-engine__foot > span {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
overflow: hidden;
color: var(--text-muted);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.aic-cache-score {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 14px;
}
.aic-cache-score b {
color: var(--text-strong);
font: 750 2rem/1 var(--font-num);
}
.aic-cache-score span {
color: var(--text-muted);
font-size: 11px;
}
.aic-mini-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin: 0 0 13px;
overflow: hidden;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--border-subtle);
}
.aic-mini-grid div {
padding: 9px;
background: var(--bg-surface);
}
.aic-mini-grid dt,
.aic-mini-grid dd {
margin: 0;
}
.aic-mini-grid dt {
color: var(--text-muted);
font-size: 9px;
}
.aic-mini-grid dd {
margin-top: 4px;
color: var(--text-strong);
font: 700 13px/1 var(--font-num);
}
.aic-integrity {
display: grid;
gap: 9px;
margin: 0;
}
.aic-integrity div {
display: grid;
grid-template-columns: minmax(70px, 0.55fr) minmax(0, 1fr);
gap: 2px 10px;
padding-bottom: 9px;
border-bottom: 1px solid var(--border-subtle);
}
.aic-integrity div:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.aic-integrity dt,
.aic-integrity dd {
margin: 0;
font-size: 11px;
}
.aic-integrity dt {
color: var(--text-muted);
}
.aic-integrity dd {
overflow: hidden;
color: var(--text-strong);
font-weight: 700;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.aic-integrity span {
grid-column: 2;
color: var(--text-muted);
font: 600 9px/1.3 var(--font-num);
text-align: right;
}
@media (max-width: 1180px) {
.aic-grid {
grid-template-columns: minmax(0, 1fr);
}
.aic-column--side {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.aic-engine {
grid-row: span 2;
}
}
@media (max-width: 900px) {
.aic-head {
align-items: flex-start;
flex-direction: column;
}
.aic-head__actions {
width: 100%;
justify-content: space-between;
}
.aic-source {
grid-template-columns: 11px minmax(0, 1fr);
}
.aic-window {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.aic-ledger {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.aic-ledger article + article {
border-left: 0;
}
.aic-ledger article:nth-child(even) {
border-left: 1px solid var(--border-subtle);
}
.aic-ledger article:nth-child(n + 3) {
border-top: 1px solid var(--border-subtle);
}
}
@media (max-width: 680px) {
.aic-head__stamp {
text-align: left;
}
.aic-budget__split,
.aic-column--side {
grid-template-columns: minmax(0, 1fr);
}
.aic-budget__split {
gap: 18px;
}
.aic-engine {
grid-row: auto;
}
.aic-ledger {
grid-template-columns: minmax(0, 1fr);
}
.aic-ledger article:nth-child(even) {
border-left: 0;
}
.aic-ledger article:nth-child(n + 2) {
border-top: 1px solid var(--border-subtle);
}
.aic-ledger article {
display: grid;
grid-template-columns: minmax(100px, 0.75fr) minmax(0, 1fr);
align-items: baseline;
gap: 4px 10px;
}
.aic-ledger b {
margin: 0;
text-align: right;
}
.aic-ledger small {
grid-column: 2;
text-align: right;
}
.aic-panel {
padding: 14px;
}
.aic-panel__head {
margin-bottom: 14px;
}
.aic-engine__foot {
align-items: stretch;
flex-direction: column;
}
.aic-engine__foot .vg-btn {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.aic-chart__bar {
transition: none;
}
}

View file

@ -8,19 +8,6 @@
gap: 16px;
}
.ad-root::before {
content: "";
position: absolute;
z-index: -1;
inset: -80px 0 auto auto;
width: min(560px, 52vw);
height: 420px;
background: var(--asset-warm-elements) center / cover no-repeat;
opacity: 0.045;
filter: saturate(0.75);
pointer-events: none;
}
.ad-blank-probe {
position: absolute;
width: 1px;
@ -651,15 +638,13 @@
}
.ad-user-create label,
.ad-users-toolbar label,
.ad-user__fields label {
.ad-users-toolbar label {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ad-user-create .ad-checkline,
.ad-user__fields .ad-checkline {
.ad-user-create .ad-checkline {
min-height: 36px;
flex-direction: row;
align-items: center;
@ -667,8 +652,7 @@
}
.ad-user-create span,
.ad-users-toolbar label span,
.ad-user__fields span {
.ad-users-toolbar label span {
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 650;
@ -677,8 +661,8 @@
.ad-user-create input,
.ad-user-create select,
.ad-users-toolbar input,
.ad-user__fields input,
.ad-user__fields select {
.ad-user-table input,
.ad-user-table select {
width: 100%;
min-width: 0;
height: 36px;
@ -689,8 +673,7 @@
padding: 0 10px;
font: var(--fs-sm) / 1.3 var(--font-sans);
}
.ad-user-create .ad-checkline input,
.ad-user__fields .ad-checkline input {
.ad-user-create .ad-checkline input {
width: 16px;
height: 16px;
flex: none;
@ -700,8 +683,8 @@
.ad-user-create input:focus,
.ad-user-create select:focus,
.ad-users-toolbar input:focus,
.ad-user__fields input:focus,
.ad-user__fields select:focus {
.ad-user-table input:focus,
.ad-user-table select:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--focus-ring);
@ -754,16 +737,237 @@
.ad-users {
flex: 1 1 auto;
max-height: min(760px, calc(100vh - 116px));
overflow: auto;
scrollbar-gutter: stable;
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid var(--hair);
border-radius: var(--radius);
background: var(--bg-surface);
box-shadow: none;
overflow: hidden;
}
.ad-user-table-scroll {
max-width: 100%;
max-height: min(760px, calc(100dvh - 220px));
overflow: auto;
scrollbar-gutter: stable both-edges;
overscroll-behavior: contain;
}
.ad-user-table-scroll:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: -2px;
}
.ad-user-table {
width: 100%;
min-width: 1440px;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
color: var(--text-body);
font-size: var(--fs-xs);
}
.ad-user-table th,
.ad-user-table td {
min-width: 0;
padding: 10px 12px;
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--glass-inset-border) 72%, transparent);
text-align: left;
vertical-align: middle;
}
.ad-user-table th {
position: sticky;
top: 0;
z-index: 3;
height: 42px;
padding-block: 6px;
background: color-mix(in srgb, var(--glass-canvas) 76%, var(--bg-surface));
color: var(--text-muted);
font-weight: 700;
white-space: nowrap;
-webkit-backdrop-filter: var(--glass-blur);
backdrop-filter: var(--glass-blur);
}
.ad-user-table tbody tr {
background: color-mix(in srgb, var(--bg-surface) 32%, transparent);
transition: background var(--dur-fast) var(--ease-out);
}
.ad-user-table tbody tr:hover {
background: color-mix(in srgb, var(--accent-tint) 38%, transparent);
}
.ad-user-table tbody tr:last-child td {
border-bottom: 0;
}
.ad-user-table th:nth-child(1) { width: 250px; }
.ad-user-table th:nth-child(2) { width: 140px; }
.ad-user-table th:nth-child(3) { width: 112px; }
.ad-user-table th:nth-child(4) { width: 145px; }
.ad-user-table th:nth-child(5) { width: 180px; }
.ad-user-table th:nth-child(6) { width: 155px; }
.ad-user-table th:nth-child(7) { width: 90px; }
.ad-user-table th:nth-child(8) { width: 160px; }
.ad-user-table th:nth-child(9) { width: 208px; }
.ad-user-sort {
width: 100%;
min-height: 30px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.ad-user-sort:hover,
.ad-user-sort:focus-visible {
color: var(--text-strong);
}
.ad-user-sort:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.ad-user-sort__indicator {
color: var(--accent);
font-family: var(--font-num);
font-size: 13px;
}
.ad-user-table input,
.ad-user-table select {
height: 32px;
font-size: 12.5px;
padding-inline: 9px;
}
.ad-user-table__identity {
min-width: 0;
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.ad-user-table__avatar {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--accent-tint);
color: var(--accent-deep);
font-weight: 750;
}
.ad-user-table__identity > div,
.ad-user-table__role,
.ad-user-table__status,
.ad-user-table__presence {
min-width: 0;
display: grid;
gap: 5px;
}
.ad-user-table__identity > div > span {
overflow: hidden;
color: var(--text-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ad-user-table__badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.ad-user-table__badges .vg-badge,
.ad-user-table__status .vg-badge {
width: max-content;
padding: 1px 7px;
font-size: 11px;
}
.ad-user-table__check {
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--text-body);
white-space: nowrap;
}
.ad-user-table__check input {
width: 16px;
height: 16px;
flex: none;
padding: 0;
}
.ad-user-table__number {
display: block;
color: var(--text-strong);
font-family: var(--font-num);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
text-align: right;
}
.ad-user-table__presence span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
font-weight: 650;
}
.ad-user-table__presence span::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--neutral-400);
}
.ad-user-table__presence span.is-online {
color: var(--pos-text);
}
.ad-user-table__presence span.is-online::before {
background: var(--pos-solid);
}
.ad-user-table__presence time {
color: var(--text-muted);
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ad-user-table-loading {
display: grid;
}
.ad-user-table-loading > div {
min-height: 58px;
display: grid;
grid-template-columns: 220px 1fr 120px 160px;
align-items: center;
gap: 14px;
padding: 10px 12px;
border-bottom: 1px solid var(--glass-inset-border);
}
.ad-approval-head {
@ -871,25 +1075,6 @@
white-space: nowrap;
}
.ad-user {
min-width: 0;
display: grid;
grid-template-columns: minmax(190px, 0.75fr) minmax(420px, 1.4fr) minmax(138px, 0.42fr) max-content;
align-items: center;
gap: 10px 12px;
padding: 9px 12px;
border-top: 1px solid var(--paper-2);
background: color-mix(in srgb, var(--bg-surface) 88%, var(--bg-surface-2));
}
.ad-user:hover {
background: var(--bg-surface-2);
}
.ad-user:first-child {
border-top: 0;
}
.ad-user__top,
.ad-user__id,
.ad-user__actions {
min-width: 0;
@ -897,12 +1082,6 @@
align-items: center;
}
.ad-user__top {
justify-content: space-between;
gap: 10px;
overflow: hidden;
}
.ad-user__id {
gap: 10px;
}
@ -934,47 +1113,6 @@
overflow-wrap: anywhere;
}
.ad-user__badges {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
.ad-user__fields {
display: grid;
grid-template-columns: minmax(130px, 1.1fr) 96px 104px 112px minmax(118px, 0.95fr) minmax(110px, 0.85fr);
gap: 8px;
min-width: 0;
}
.ad-user__fields span {
display: none;
}
.ad-user__fields input,
.ad-user__fields select {
height: 32px;
font-size: 12.5px;
padding-inline: 9px;
}
.ad-user__meta {
display: grid;
gap: 4px;
min-width: 0;
color: var(--text-muted);
font-family: var(--font-num);
font-size: var(--fs-xs);
}
.ad-user__meta span {
min-width: 0;
overflow-wrap: anywhere;
}
.ad-user__actions {
flex-wrap: nowrap;
gap: 8px;
@ -1336,24 +1474,6 @@
grid-template-columns: minmax(180px, 1fr) minmax(132px, 0.6fr) minmax(132px, 0.6fr);
}
.ad-user {
grid-template-columns: minmax(190px, 0.8fr) minmax(0, 1.2fr) max-content;
}
.ad-user__fields {
grid-template-columns: minmax(0, 1fr) 104px;
}
.ad-user__fields span {
display: block;
}
.ad-user__meta {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
}
}
@media (max-width: 980px) {
@ -1376,20 +1496,11 @@
grid-column: 1 / -1;
}
.ad-user {
grid-template-columns: 1fr;
align-items: start;
}
.ad-approval {
grid-template-columns: 1fr;
align-items: start;
}
.ad-user__actions {
justify-content: flex-start;
}
.ad-approval__actions {
justify-content: flex-start;
}
@ -1413,10 +1524,6 @@
}
@media (max-width: 700px) {
.ad-root::before {
display: none;
}
.ad-head {
align-items: flex-start;
flex-direction: column;
@ -1517,8 +1624,11 @@
}
.ad-users {
max-height: none;
overflow: visible;
overflow: hidden;
}
.ad-user-table-scroll {
max-height: min(68dvh, 680px);
}
}
@ -1531,21 +1641,6 @@
grid-column: 2;
}
.ad-user__top {
align-items: stretch;
flex-direction: column;
}
.ad-user__fields {
grid-template-columns: 1fr;
}
.ad-user__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
}
.ad-approval-metrics {
grid-template-columns: 1fr;
}
@ -1555,11 +1650,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ad-user__actions .vg-btn {
width: 100%;
justify-content: center;
}
.ad-approval__actions .vg-btn {
width: 100%;
}

View file

@ -0,0 +1,428 @@
import type {
AdminManagedUser,
AdminSupportTicket,
AdminTicketsResponse,
AdminUsersResponse,
} from "../../lib/api";
export interface AdminDataWarning {
key: string;
message: string;
detail: string;
}
function asRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object"
? (value as Record<string, unknown>)
: {};
}
function warn(
warnings: AdminDataWarning[],
key: string,
message: string,
detail: string,
): void {
warnings.push({ key, message, detail });
}
function stringField(
source: Record<string, unknown>,
field: string,
fallback: string,
warnings: AdminDataWarning[],
label: string,
): string {
const value = source[field];
if (typeof value === "string") return value;
if (value == null) {
warn(
warnings,
`${label}.${field}`,
`${label} 응답에서 ${field} 값이 비어 있어 기본값으로 표시합니다.`,
`${field}=null`,
);
return fallback;
}
warn(
warnings,
`${label}.${field}`,
`${label} 응답에서 ${field} 타입이 잘못되어 문자열로 변환했습니다.`,
`${field}=${typeof value}`,
);
return String(value);
}
function booleanField(
source: Record<string, unknown>,
field: string,
fallback: boolean,
warnings: AdminDataWarning[],
label: string,
): boolean {
const value = source[field];
if (typeof value === "boolean") return value;
if (value == null) {
warn(
warnings,
`${label}.${field}`,
`${label} 응답에서 ${field} 값이 비어 있어 ${fallback ? "true" : "false"}로 표시합니다.`,
`${field}=null`,
);
return fallback;
}
warn(
warnings,
`${label}.${field}`,
`${label} 응답에서 ${field} 타입이 잘못되어 bool 기본값으로 표시합니다.`,
`${field}=${typeof value}`,
);
return fallback;
}
function numberField(
source: Record<string, unknown>,
field: string,
fallback: number,
warnings: AdminDataWarning[],
label: string,
): number {
const value = source[field];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
warn(
warnings,
`${label}.${field}`,
`${label} 응답에서 ${field}가 문자열이라 숫자로 변환했습니다.`,
`${field}=string`,
);
return parsed;
}
}
warn(
warnings,
`${label}.${field}`,
`${label} 응답에서 ${field} 숫자 값이 없어 ${fallback}으로 표시합니다.`,
`${field}=${value == null ? "null" : typeof value}`,
);
return fallback;
}
function cohortIdsField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
label: string,
): string[] {
const value = source.cohort_ids;
if (!Array.isArray(value)) {
warn(
warnings,
`${label}.cohort_ids`,
`${label} 응답에서 cohort_ids가 배열이 아니라 빈 코호트로 표시합니다.`,
`cohort_ids=${value == null ? "null" : typeof value}`,
);
return [];
}
const normalized = value.filter(
(item): item is string => typeof item === "string",
);
if (normalized.length !== value.length) {
warn(
warnings,
`${label}.cohort_ids.items`,
`${label} 응답에서 cohort_ids 내부에 문자열이 아닌 값이 있어 제외했습니다.`,
`kept=${normalized.length}, raw=${value.length}`,
);
}
return normalized;
}
function numberRecordField(
source: Record<string, unknown>,
field: string,
warnings: AdminDataWarning[],
label: string,
): Record<string, number> {
const value = source[field];
if (value == null) {
warn(
warnings,
`${label}.${field}`,
`${label} 응답에서 ${field} 값이 비어 있어 빈 집계로 표시합니다.`,
`${field}=null`,
);
return {};
}
if (typeof value !== "object" || Array.isArray(value)) {
warn(
warnings,
`${label}.${field}`,
`${label} 응답에서 ${field} 타입이 잘못되어 빈 집계로 표시합니다.`,
`${field}=${typeof value}`,
);
return {};
}
const normalized: Record<string, number> = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (typeof raw === "number" && Number.isFinite(raw)) {
normalized[key] = raw;
continue;
}
const parsed = typeof raw === "string" ? Number(raw) : Number.NaN;
if (Number.isFinite(parsed)) {
normalized[key] = parsed;
warn(
warnings,
`${label}.${field}.${key}`,
`${label} 응답에서 ${field}.${key}가 문자열이라 숫자로 변환했습니다.`,
`${field}.${key}=string`,
);
continue;
}
warn(
warnings,
`${label}.${field}.${key}`,
`${label} 응답에서 ${field}.${key} 숫자 값이 없어 제외했습니다.`,
`${field}.${key}=${raw == null ? "null" : typeof raw}`,
);
}
return normalized;
}
function roleField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
label: string,
): AdminManagedUser["role"] {
const value = source.role;
if (value === "learner" || value === "teacher" || value === "admin") {
return value;
}
warn(
warnings,
`${label}.role`,
`${label} 응답에서 role 값이 계약과 달라 학습자로 표시합니다.`,
`role=${String(value)}`,
);
return "learner";
}
function accountStatusField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
label: string,
): AdminManagedUser["account_status"] {
const value = source.account_status;
if (value === "pending" || value === "approved" || value === "suspended") {
return value;
}
warn(
warnings,
`${label}.account_status`,
`${label} 응답에서 account_status 값이 계약과 달라 승인 대기로 표시합니다.`,
`account_status=${String(value)}`,
);
return "pending";
}
function userSourceField(
source: Record<string, unknown>,
parentSource: AdminUsersResponse["source"],
): AdminManagedUser["source"] {
const value = source.source;
return value === "server_session_registry" || value === "database"
? value
: parentSource;
}
export function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
response: AdminUsersResponse;
warnings: AdminDataWarning[];
} {
const warnings: AdminDataWarning[] = [];
const root = asRecord(rawResponse);
const source: AdminUsersResponse["source"] =
root.source === "server_session_registry"
? "server_session_registry"
: "database";
const durable =
typeof root.durable === "boolean" ? root.durable : source === "database";
const rawUsers = Array.isArray(root.users) ? root.users : [];
if (!Array.isArray(root.users)) {
warn(
warnings,
"admin.users",
"/admin/users 응답에서 users 배열이 없어 빈 목록으로 표시합니다.",
`users=${root.users == null ? "null" : typeof root.users}`,
);
}
const users = rawUsers.map((item, index): AdminManagedUser => {
const sourceRecord = asRecord(item);
const label = `users[${index}]`;
const userId = stringField(
sourceRecord,
"user_id",
`unknown-${index + 1}`,
warnings,
label,
);
return {
user_id: userId,
email: stringField(sourceRecord, "email", userId, warnings, label),
display_name: stringField(
sourceRecord,
"display_name",
userId,
warnings,
label,
),
role: roleField(sourceRecord, warnings, label),
admin_access: booleanField(
sourceRecord,
"admin_access",
false,
warnings,
label,
),
super_admin: booleanField(
sourceRecord,
"super_admin",
false,
warnings,
label,
),
account_status: accountStatusField(sourceRecord, warnings, label),
cohort_ids: cohortIdsField(sourceRecord, warnings, label),
affiliation: stringField(
sourceRecord,
"affiliation",
"",
warnings,
label,
),
active_sessions: numberField(
sourceRecord,
"active_sessions",
0,
warnings,
label,
),
created_at: numberField(sourceRecord, "created_at", 0, warnings, label),
last_seen_at: numberField(
sourceRecord,
"last_seen_at",
0,
warnings,
label,
),
source: userSourceField(sourceRecord, source),
};
});
return { response: { source, durable, users }, warnings };
}
export function normalizeAdminTicketsResponse(
rawResponse: AdminTicketsResponse,
): {
response: AdminTicketsResponse;
warnings: AdminDataWarning[];
} {
const warnings: AdminDataWarning[] = [];
const root = asRecord(rawResponse);
const source: AdminTicketsResponse["source"] =
root.source === "database" ? "database" : "unavailable";
const durable =
typeof root.durable === "boolean" ? root.durable : source === "database";
const rawTickets = Array.isArray(root.tickets) ? root.tickets : [];
if (!Array.isArray(root.tickets)) {
warn(
warnings,
"admin.tickets",
"/admin/tickets 응답에서 tickets 배열이 없어 빈 목록으로 표시합니다.",
`tickets=${root.tickets == null ? "null" : typeof root.tickets}`,
);
}
const summaryRoot = asRecord(root.summary);
if (
!root.summary ||
typeof root.summary !== "object" ||
Array.isArray(root.summary)
) {
warn(
warnings,
"admin.tickets.summary",
"/admin/tickets 응답에서 summary 객체가 없어 기본 집계로 표시합니다.",
`summary=${root.summary == null ? "null" : typeof root.summary}`,
);
}
return {
response: {
source,
durable,
generated_at: numberField(
root,
"generated_at",
0,
warnings,
"admin.tickets",
),
tickets: rawTickets as AdminSupportTicket[],
summary: {
total: numberField(
summaryRoot,
"total",
rawTickets.length,
warnings,
"admin.tickets.summary",
),
open_count: numberField(
summaryRoot,
"open_count",
0,
warnings,
"admin.tickets.summary",
),
high_priority_count: numberField(
summaryRoot,
"high_priority_count",
0,
warnings,
"admin.tickets.summary",
),
stale_count: numberField(
summaryRoot,
"stale_count",
0,
warnings,
"admin.tickets.summary",
),
by_category: numberRecordField(
summaryRoot,
"by_category",
warnings,
"admin.tickets.summary",
),
by_priority: numberRecordField(
summaryRoot,
"by_priority",
warnings,
"admin.tickets.summary",
),
by_status: numberRecordField(
summaryRoot,
"by_status",
warnings,
"admin.tickets.summary",
),
},
},
warnings,
};
}

View file

@ -73,6 +73,40 @@
grid-template-columns:repeat(4,minmax(0,1fr));
gap:10px;
}
/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 숨김(리뷰 화면 sr-tabs와 동일 패턴) */
.lh-tabs{
display:none;
gap:6px;
padding:4px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.lh-tabs button{
flex:1 1 0;
min-width:0;
padding:9px 12px;
border:1px solid transparent;
border-radius:calc(var(--radius) - 2px);
background:transparent;
color:var(--text-body);
font-size:var(--fs-sm);
font-weight:700;
cursor:pointer;
}
.lh-tabs button:hover{
color:var(--text-strong);
}
.lh-tabs button:focus-visible{
outline:2px solid var(--focus-ring);
outline-offset:1px;
}
.lh-tabs button.is-active{
background:var(--bg-surface);
border-color:var(--border-strong);
color:var(--text-strong);
box-shadow:var(--shadow-sm);
}
.lh-dashboard-side{
min-width:0;
display:grid;
@ -1483,10 +1517,33 @@
background:rgba(111,179,164,.13);
}
@keyframes lhSkel{0%{background-position:120% 0}100%{background-position:-80% 0}}
/* 중간 (1181~1420px): 우측 사이드는 유지하되 리캡+코치 2열은 세로 스택으로.
리캡 카드가 230px대로 붕괴해 세로 글자 줄바꿈이 생기는 문제 방지 (2026-07-14). */
@media (max-width:1420px){
.lh-work-cluster__primary{
grid-template-columns:1fr;
}
}
@media (max-width:1180px){
/* 1컬럼 세로 나열 대신 가로 탭으로 영역 전환(2026-07-15 소유자 피드백) */
.lh-tabs{
display:flex;
}
.lh-dashboard-grid{
grid-template-columns:1fr;
}
.lh-dashboard-grid:not(.lh-grid--tab-today) .lh-work-cluster{
display:none;
}
.lh-grid--tab-today .lh-dashboard-side{
display:none;
}
.lh-grid--tab-records .lh-panel--growth{
display:none;
}
.lh-grid--tab-growth .lh-panel--records{
display:none;
}
.lh-dashboard-hero,
.lh-dashboard-columns,
.lh-history-layout,
@ -1695,3 +1752,565 @@
.lh-skel__box,
.lh-skel__line{animation:none;}
}
/* ═══ 학습자 포탈 개선(2026-07-14): 보완할 부분 · 라포 누적 · 이정표 ═══ */
.lh-weakness__list {
display: grid;
gap: 8px;
}
.lh-weakness__list button {
display: grid;
gap: 3px;
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));
color: inherit;
cursor: pointer;
}
.lh-weakness__list button:hover {
border-color: var(--border-focus);
}
.lh-weakness__list b {
font-size: 12.5px;
font-weight: 760;
color: var(--text-strong);
}
.lh-weakness__list p {
margin: 0;
font-size: 11.5px;
line-height: 1.5;
color: var(--text-dim);
}
.lh-rapport-list {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.lh-rapport-row {
display: grid;
grid-template-columns: minmax(0, 88px) minmax(0, 1fr) 34px;
align-items: center;
gap: 8px;
}
.lh-rapport-row__name {
font-size: 12px;
font-weight: 700;
color: var(--text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lh-rapport-row__name small {
margin-left: 4px;
font-size: 10.5px;
font-weight: 620;
color: var(--text-dim);
}
.lh-rapport-row__bar {
display: block;
height: 5px;
border-radius: 3px;
background: var(--bg-inset, rgba(0, 0, 0, 0.16));
overflow: hidden;
}
.lh-rapport-row__bar i {
display: block;
height: 100%;
border-radius: 3px;
background: var(--accent);
transition: width 0.9s var(--ease-out);
}
.lh-rapport-row b {
font-size: 11px;
font-weight: 740;
font-variant-numeric: tabular-nums;
color: var(--text-body);
text-align: right;
}
.lh-milestones__list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lh-milestone {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.1));
font-size: 11.5px;
font-weight: 680;
color: var(--text-dim);
}
.lh-milestone.is-done {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--text-strong);
}
.lh-milestone.is-available {
color: var(--text-body);
}
/* 학습 대시보드 보태니컬 시안 적용
카드 광원은 좌상단, 황금 기운은 헤어라인과 가장자리에서만 아주 옅게 사용한다. */
.vg-shell--learner-dashboard .lh-root {
--lh-gold: #b5975d;
--lh-botanical-hair: color-mix(in srgb, var(--lh-gold) 34%, var(--border-subtle));
--lh-botanical-line: color-mix(in srgb, var(--lh-gold) 17%, var(--border-subtle));
gap: clamp(18px, 2vw, 28px);
}
.vg-shell--learner-dashboard .lh-title {
letter-spacing: -0.035em;
text-wrap: balance;
}
.vg-shell--learner-dashboard .lh-head__status {
border-color: var(--lh-botanical-hair);
background: var(--glass-surface-inset);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62);
backdrop-filter: blur(10px);
}
.vg-shell--learner-dashboard .lh-dashboard-status {
gap: 12px;
}
.vg-shell--learner-dashboard .lh-metric-card {
--lh-card-art: url("/design-elements/learner-dashboard/card-leaf-sprig-light.png");
position: relative;
isolation: isolate;
min-height: 132px;
align-content: center;
gap: 8px;
overflow: hidden;
padding: 18px 44% 18px 18px;
transition:
transform var(--dur-base) var(--ease-out),
border-color var(--dur-base) var(--ease-out),
box-shadow var(--dur-base) var(--ease-out);
}
.vg-shell--learner-dashboard .lh-metric-card:nth-child(2) {
--lh-card-art: url("/design-elements/learner-dashboard/card-folded-ribbon-light.png");
}
.vg-shell--learner-dashboard .lh-metric-card:nth-child(3) {
--lh-card-art: url("/design-elements/learner-dashboard/card-orbital-ring-light.png");
}
.vg-shell--learner-dashboard .lh-metric-card:nth-child(4) {
--lh-card-art: url("/design-elements/learner-dashboard/card-flowing-ribbon-light.png");
}
.vg-shell--learner-dashboard .lh-metric-card::after {
content: "";
position: absolute;
z-index: -1;
inset: 8px 0 4px 51%;
background: var(--lh-card-art) center / contain no-repeat;
opacity: 0.68;
filter: saturate(0.82) contrast(0.97);
pointer-events: none;
transition: transform var(--dur-slow) var(--ease-out), opacity var(--dur-base) var(--ease-out);
}
.vg-shell--learner-dashboard .lh-metric-card > * {
position: relative;
z-index: 1;
}
.vg-shell--learner-dashboard .lh-metric-card:hover {
transform: translateY(-2px);
}
.vg-shell--learner-dashboard .lh-metric-card:hover::after {
transform: translate3d(-3px, -2px, 0) scale(1.025);
opacity: 0.78;
}
.vg-shell--learner-dashboard .lh-metric-card span {
color: color-mix(in srgb, var(--text-body) 88%, var(--lh-gold));
}
.vg-shell--learner-dashboard .lh-metric-card b {
font-size: clamp(23px, 2.05vw, 30px);
letter-spacing: -0.035em;
}
.vg-shell--learner-dashboard .lh-metric-card p {
max-width: 19ch;
color: var(--text-body);
}
.vg-shell--learner-dashboard .lh-work-cluster {
position: relative;
overflow: hidden;
padding: clamp(18px, 2vw, 26px);
border-radius: var(--radius-lg);
}
.vg-shell--learner-dashboard .lh-work-cluster::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at 100% 0%, rgba(187, 156, 97, 0.06), transparent 22rem);
pointer-events: none;
}
.vg-shell--learner-dashboard .lh-work-cluster > * {
position: relative;
}
.vg-shell--learner-dashboard .lh-work-cluster__head {
border-bottom-color: var(--lh-botanical-line);
}
.vg-shell--learner-dashboard .lh-recap__progress i,
.vg-shell--learner-dashboard .lh-rapport-row__bar {
background: color-mix(in srgb, var(--lh-botanical-line) 72%, transparent);
}
.vg-shell--learner-dashboard .lh-coach-card {
position: relative;
isolation: isolate;
overflow: hidden;
}
.vg-shell--learner-dashboard .lh-coach-card::after {
content: "";
position: absolute;
z-index: -1;
top: -34px;
right: -54px;
width: 210px;
height: 160px;
background: url("/design-elements/learner-dashboard/card-paired-leaves-light.png") center / contain no-repeat;
opacity: 0.28;
pointer-events: none;
}
.vg-shell--learner-dashboard .lh-coach-card > div:first-child {
padding-right: clamp(24px, 24%, 92px);
}
.vg-shell--learner-dashboard .lh-coach-card__cta {
background:
linear-gradient(90deg, color-mix(in srgb, var(--accent) 42%, var(--glass-surface-inset)), color-mix(in srgb, var(--accent) 58%, var(--glass-surface-inset)));
border-color: color-mix(in srgb, var(--accent) 44%, var(--lh-gold));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28);
}
.vg-shell--learner-dashboard .lh-panel__head button,
.vg-shell--learner-dashboard .lh-panel__badge,
.vg-shell--learner-dashboard .lh-tabs {
border-color: var(--lh-botanical-line);
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-root {
--lh-gold: #bca579;
--lh-botanical-hair: color-mix(in srgb, var(--lh-gold) 27%, var(--border-subtle));
--lh-botanical-line: color-mix(in srgb, var(--lh-gold) 16%, var(--border-subtle));
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-head__status {
box-shadow: inset 0 1px 0 rgba(255, 245, 222, 0.055);
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-metric-card {
--lh-card-art: url("/design-elements/learner-dashboard/card-leaf-sprig.png");
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-metric-card:nth-child(2) {
--lh-card-art: url("/design-elements/learner-dashboard/card-folded-ribbon.png");
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-metric-card:nth-child(3) {
--lh-card-art: url("/design-elements/learner-dashboard/card-orbital-ring.png");
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-metric-card:nth-child(4) {
--lh-card-art: url("/design-elements/learner-dashboard/card-flowing-ribbon.png");
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-metric-card::after {
opacity: 0.56;
filter: saturate(0.76) brightness(0.92);
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-work-cluster::before {
background:
linear-gradient(105deg, rgba(203, 184, 139, 0.025), transparent 38%),
radial-gradient(circle at 100% 0%, rgba(187, 156, 97, 0.055), transparent 22rem);
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-coach-card::after {
background-image: url("/design-elements/learner-dashboard/card-paired-leaves.png");
opacity: 0.24;
}
@media (max-width: 1180px) {
.vg-shell--learner-dashboard .lh-metric-card {
min-height: 118px;
padding: 15px 38% 15px 15px;
}
.vg-shell--learner-dashboard .lh-metric-card::after {
inset: 6px 0 4px 55%;
}
}
@media (max-width: 720px) {
.vg-shell--learner-dashboard .lh-root {
gap: 16px;
}
.vg-shell--learner-dashboard .lh-dashboard-status {
gap: 8px;
}
.vg-shell--learner-dashboard .lh-metric-card {
min-height: 104px;
padding: 13px 34% 13px 13px;
}
.vg-shell--learner-dashboard .lh-metric-card::after {
inset: 12px 0 8px 56%;
opacity: 0.48;
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-metric-card::after {
opacity: 0.4;
}
.vg-shell--learner-dashboard .lh-metric-card b {
font-size: clamp(19px, 6vw, 24px);
}
.vg-shell--learner-dashboard .lh-metric-card p {
font-size: 11px;
}
.vg-shell--learner-dashboard .lh-work-cluster {
padding: 12px;
}
.vg-shell--learner-dashboard .lh-coach-card > div:first-child {
padding-right: 18%;
}
}
/* ── 보태니컬 시안 정합 2차: 1200px 기준 비율·세로 흐름·장식 크롭 ── */
.vg-shell--learner-dashboard .lh-tabs {
display: flex;
}
.vg-shell--learner-dashboard .lh-dashboard-grid:not(.lh-grid--tab-today) .lh-work-cluster {
display: none;
}
.vg-shell--learner-dashboard .lh-grid--tab-today .lh-dashboard-side {
display: none;
}
.vg-shell--learner-dashboard .lh-grid--tab-records .lh-panel--growth {
display: none;
}
.vg-shell--learner-dashboard .lh-grid--tab-growth .lh-panel--records {
display: none;
}
@media (min-width: 1181px) {
.vg-shell--learner-dashboard .lh-dashboard-grid {
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}
.vg-shell--learner-dashboard .lh-work-cluster__primary {
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
.vg-shell--learner-dashboard .lh-dashboard-side {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.vg-shell--learner-dashboard .lh-root {
max-width: 100%;
gap: 20px;
}
.vg-shell--learner-dashboard .lh-head {
min-height: 104px;
align-items: flex-start;
}
.vg-shell--learner-dashboard .lh-title {
margin-top: 6px;
font-size: clamp(29px, 2.7vw, 36px);
line-height: 1.14;
}
.vg-shell--learner-dashboard .lh-sub {
margin-top: 8px;
font-size: 14px;
}
.vg-shell--learner-dashboard .lh-dashboard-status {
gap: 14px;
}
.vg-shell--learner-dashboard .lh-metric-card {
--lh-art-right: -16px;
--lh-art-bottom: -24px;
--lh-art-width: 52%;
--lh-art-height: 112%;
min-height: 160px;
align-content: center;
padding: 22px 43% 22px 22px;
border-radius: 10px;
}
.vg-shell--learner-dashboard .lh-metric-card:nth-child(2) {
--lh-art-right: -20px;
--lh-art-bottom: -28px;
--lh-art-width: 57%;
--lh-art-height: 118%;
}
.vg-shell--learner-dashboard .lh-metric-card:nth-child(3) {
--lh-art-right: -24px;
--lh-art-bottom: -26px;
--lh-art-width: 58%;
--lh-art-height: 116%;
}
.vg-shell--learner-dashboard .lh-metric-card:nth-child(4) {
--lh-art-right: -26px;
--lh-art-bottom: -30px;
--lh-art-width: 62%;
--lh-art-height: 122%;
}
.vg-shell--learner-dashboard .lh-metric-card::after {
inset: auto 0 0 auto;
width: var(--lh-art-width);
height: var(--lh-art-height);
background-position: right var(--lh-art-right) bottom var(--lh-art-bottom);
}
.vg-shell--learner-dashboard .lh-metric-card b {
font-size: clamp(26px, 2.35vw, 32px);
}
.vg-shell--learner-dashboard .lh-work-cluster {
gap: 16px;
padding: 20px;
border-radius: 10px;
}
.vg-shell--learner-dashboard .lh-work-cluster__head {
min-height: 68px;
align-items: center;
padding-bottom: 16px;
}
.vg-shell--learner-dashboard .lh-work-cluster__head h2 {
font-size: clamp(22px, 2vw, 27px);
}
.vg-shell--learner-dashboard .lh-session-focus {
--lh-focus-art-right: -20px;
--lh-focus-art-bottom: -28px;
position: relative;
isolation: isolate;
overflow: hidden;
gap: 18px;
min-height: 466px;
padding: 28px;
border-radius: 9px;
}
.vg-shell--learner-dashboard .lh-session-focus::after {
content: "";
position: absolute;
z-index: 0;
right: var(--lh-focus-art-right);
bottom: var(--lh-focus-art-bottom);
width: clamp(190px, 29%, 270px);
height: clamp(210px, 58%, 290px);
background: url("/design-elements/learner-dashboard/card-leaf-sprig-light.png") right bottom / contain no-repeat;
opacity: 0.22;
pointer-events: none;
}
.vg-shell--learner-dashboard .lh-session-focus > * {
position: relative;
z-index: 1;
}
.vg-shell--learner-dashboard .lh-session-focus__head h2 {
font-size: clamp(30px, 3vw, 38px);
}
.vg-shell--learner-dashboard .lh-recap {
grid-template-columns: 224px minmax(0, 1fr);
gap: 30px;
padding-top: 18px;
}
.vg-shell--learner-dashboard .lh-recap__avatar {
position: relative;
isolation: isolate;
justify-self: center;
width: 216px;
min-height: 242px;
aspect-ratio: 0.9;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.vg-shell--learner-dashboard .lh-recap__avatar .vg-avatar {
position: relative;
z-index: 1;
}
.vg-shell--learner-dashboard .lh-recap__body {
align-content: start;
gap: 14px;
}
.vg-shell--learner-dashboard .lh-recap__facts div {
min-height: 62px;
padding: 10px 12px;
}
.vg-shell--learner-dashboard .lh-recap__progress i {
height: 9px;
}
.vg-shell--learner-dashboard .lh-recap__quote {
min-height: 82px;
padding: 12px 14px;
}
.vg-shell--learner-dashboard .lh-coach-card {
min-height: 300px;
gap: 14px;
padding: 28px;
border-radius: 9px;
}
.vg-shell--learner-dashboard .lh-coach-card::after {
top: auto;
right: 0;
bottom: 0;
width: 320px;
height: 240px;
background-position: right -42px bottom -28px;
opacity: 0.24;
}
.vg-shell--learner-dashboard .lh-coach-card > div:first-child {
padding-right: 34%;
}
.vg-shell--learner-dashboard .lh-coach-card h2 {
font-size: clamp(25px, 2.45vw, 32px);
}
.vg-shell--learner-dashboard .lh-coach-card > div:first-child p {
max-width: 64ch;
}
.vg-shell--learner-dashboard .lh-coach-hints {
position: relative;
z-index: 1;
}
.vg-shell--learner-dashboard .lh-coach-hints span {
min-height: 44px;
padding: 0 14px;
}
.vg-shell--learner-dashboard .lh-coach-card__cta {
position: relative;
z-index: 1;
min-height: 48px;
}
.vg-shell--learner-dashboard .lh-work-cluster__secondary {
margin-top: 2px;
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-root {
--text-strong: #eee8dc;
--text-body: #c3beb2;
--text-muted: #928f86;
--lh-botanical-hair: rgba(184, 160, 110, 0.31);
--lh-botanical-line: rgba(184, 160, 110, 0.22);
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-recap__avatar {
border: 0;
background: transparent;
box-shadow: none;
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-session-focus::after {
background-image: url("/design-elements/learner-dashboard/card-leaf-sprig.png");
opacity: 0.2;
}
@media (max-width: 1180px) {
.vg-shell--learner-dashboard .lh-head {
min-height: auto;
}
.vg-shell--learner-dashboard .lh-metric-card {
min-height: 126px;
}
.vg-shell--learner-dashboard .lh-session-focus {
min-height: auto;
}
.vg-shell--learner-dashboard .lh-coach-card {
min-height: auto;
}
}
@media (max-width: 720px) {
.vg-shell--learner-dashboard .lh-recap {
grid-template-columns: 1fr;
}
.vg-shell--learner-dashboard .lh-recap__avatar {
width: min(100%, 250px);
min-height: 210px;
}
.vg-shell--learner-dashboard .lh-coach-card > div:first-child {
padding-right: 20%;
}
}

View file

@ -0,0 +1,222 @@
import type {
AvatarAffect,
AvatarPersona,
AvatarState,
} from "../../components/avatar/ClientAvatar";
import type {
LearnerSessionSummary,
PersonaSummary,
SessionDetailResponse,
SessionDetailTurn,
} from "../../lib/api";
import { formatOptionalPercent } from "../../lib/format";
import {
DIFFICULTY_LABEL,
PENDING_PERSONA_AVATAR_APPEARANCE,
demographicText,
personaAvatarAppearance,
personaBaselineExpression,
personaMeta,
shortPersonaName,
} from "../../lib/personaViewModel";
export type HistoryFilter = "all" | "active" | "review" | "archived";
export const HISTORY_FILTERS: Array<{
value: HistoryFilter;
label: string;
desc: string;
}> = [
{ value: "all", label: "전체", desc: "모든 기록" },
{ value: "active", label: "진행 중", desc: "이어갈 회기" },
{ value: "review", label: "리뷰 필요", desc: "AI 피드백" },
{ value: "archived", label: "보관됨", desc: "읽기 전용" },
];
export function demographicsLine(persona: PersonaSummary | null): string {
if (!persona) return "내담자 정보를 불러오는 중";
const parts = [
demographicText(persona.demographics.age_band),
demographicText(persona.demographics.grade),
demographicText(persona.demographics.job),
demographicText(persona.demographics.status),
].filter(Boolean);
return parts.length ? parts.join(" · ") : "인적 정보 없음";
}
export function theoryLine(persona: PersonaSummary | null): string {
if (!persona) return "-";
return persona.theory_target.length
? persona.theory_target.join(" · ")
: "공통";
}
export function difficultyLine(persona: PersonaSummary | null): string {
if (!persona) return "-";
return DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty;
}
export function personaInitial(persona: PersonaSummary | null): string {
const name = persona ? shortPersonaName(persona.display_name) : "";
return (name || persona?.code || "V").slice(0, 1);
}
export function buildDashboardAvatar(
persona: PersonaSummary | null,
fallbackCode = "",
): AvatarPersona {
if (!persona) {
return {
code: fallbackCode || undefined,
label: "내담자 정보 확인 중",
...PENDING_PERSONA_AVATAR_APPEARANCE,
realism: 0.35,
};
}
return {
code: persona.code,
label: `${shortPersonaName(persona.display_name)} · ${personaMeta(persona)}`,
...personaAvatarAppearance(persona),
};
}
export function baselineExpressionFor(
persona: PersonaSummary | null,
): AvatarAffect {
return personaBaselineExpression(persona);
}
export function recapExpressionFor(
session: LearnerSessionSummary | null,
persona: PersonaSummary | null,
): AvatarAffect {
if (!session) return baselineExpressionFor(persona);
if (session.review_ready) return "confused";
if (session.stage === "정리") return "relief";
if (session.stage === "개입" && persona?.difficulty === "hard")
return "conflicted";
return baselineExpressionFor(persona);
}
export function recapAvatarState(
session: LearnerSessionSummary | null,
): AvatarState {
if (!session) return "idle";
return session.status === "active" ? "listening" : "idle";
}
export function truncateText(value: string, max = 118): string {
const text = value.replace(/\s+/g, " ").trim();
if (text.length <= max) return text;
return `${text.slice(0, max - 1)}`;
}
export function lastTurnText(
detail: SessionDetailResponse | null,
speaker: SessionDetailTurn["speaker"],
): string | null {
const turns = detail?.turns?.filter((turn) => turn.speaker === speaker) ?? [];
const last = turns[turns.length - 1];
return last?.text?.trim() || null;
}
export function recapCoachLine(
session: LearnerSessionSummary | null,
): string {
if (!session) return "첫 회기에서는 라포 형성과 개방 질문을 먼저 점검합니다.";
if (session.status === "active") {
return `${session.stage} 단계에서 멈췄습니다. 이어갈 때는 마지막 내담자 반응을 한 번 반영하고 다음 질문으로 넘어가세요.`;
}
if (session.review_ready) {
return "리뷰가 준비된 회기입니다. 새 연습 전에 상담자 반응과 내담자 상태 변화를 먼저 확인하세요.";
}
return "기록만 있는 회기입니다. 같은 내담자로 다시 연습할지, 다른 난도로 확장할지 결정하세요.";
}
export function sessionStatusLabel(session: LearnerSessionSummary): string {
if (session.archived) return "보관됨";
if (session.status === "active") return "진행 중";
if (session.review_ready) return "리뷰 가능";
return "종료";
}
export function historyBucket(
session: LearnerSessionSummary,
): HistoryFilter {
if (session.archived) return "archived";
if (session.status === "active") return "active";
if (session.review_ready) return "review";
return "all";
}
export function historyBucketLabel(session: LearnerSessionSummary): string {
const bucket = historyBucket(session);
if (bucket === "active") return "이어갈 회기";
if (bucket === "review") return "AI 피드백 대기";
return session.archived ? "보관 처리됨" : "종료 기록";
}
export function historyEmptyMessage(
filter: HistoryFilter,
query: string,
): string {
if (query.trim()) return "조건에 맞는 회기가 없습니다.";
if (filter === "active") return "이어갈 진행 회기가 없습니다.";
if (filter === "review") return "리뷰가 필요한 회기가 없습니다.";
if (filter === "archived") return "보관된 종료 회기가 없습니다.";
return "저장된 기존 회기가 없습니다.";
}
export function sessionDateLabel(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
export function stageProgressValue(
stage: LearnerSessionSummary["stage"] | null | undefined,
): number {
if (stage === "라포") return 25;
if (stage === "탐색") return 50;
if (stage === "개입") return 75;
if (stage === "정리") return 100;
return 0;
}
export function sessionSortTime(session: LearnerSessionSummary): number {
const value = session.ended_at ?? session.started_at;
const time = Date.parse(value);
return Number.isNaN(time) ? 0 : time;
}
export function averageLearnerTurns(
sessions: LearnerSessionSummary[],
): number | null {
if (!sessions.length) return null;
const total = sessions.reduce(
(sum, session) => sum + session.learner_turn_count,
0,
);
return Math.round(total / sessions.length);
}
export function scoreLabel(value: number | null | undefined): string {
return formatOptionalPercent(value, "평가 부족", "점");
}
export function rapportLabel(value: number | null | undefined): string {
if (value == null) return "평가 부족";
return value >= 0 ? `+${value.toFixed(2)}` : value.toFixed(2);
}
export function trendLabel(value: string | null | undefined): string {
if (value === "up") return "상승";
if (value === "down") return "점검 필요";
if (value === "flat") return "유지";
return "평가 부족";
}

View file

@ -1,4 +1,5 @@
import { Icon, type IconName } from "../../components/ui/Icon";
import { Surface, surfaceClassName } from "../../components/ui/Surface";
import type { Role } from "../../lib/auth";
export interface LoginRoleOption {
@ -53,7 +54,12 @@ function LoginProviderButton({
onClick: () => void;
}) {
return (
<button className={`lg-obtn ${variant}`} type="button" onClick={onClick} disabled={disabled}>
<button
className={surfaceClassName(`lg-obtn ${variant}`, { variant: "interactive", flat: true })}
type="button"
onClick={onClick}
disabled={disabled}
>
<span className="ic">
<Icon name={icon} size={19} strokeWidth={1.8} />
</span>
@ -92,7 +98,10 @@ function LoginDevAccess({ devAccess, error }: { devAccess: LoginDevAccessView; e
type="button"
role="radio"
aria-checked={devAccess.selected === opt.role}
className={`lg-roleopt ${devAccess.selected === opt.role ? "is-sel" : ""}`}
className={surfaceClassName(
`lg-roleopt ${devAccess.selected === opt.role ? "is-sel" : ""}`,
{ variant: "interactive", flat: true },
)}
onClick={() => devAccess.onSelect(opt.role)}
>
<span className={`d ${opt.dotClass}`} aria-hidden="true" />
@ -120,7 +129,7 @@ function LoginDevAccess({ devAccess, error }: { devAccess: LoginDevAccessView; e
export function LoginPanel({ oauth, devAccess, error }: LoginPanelProps) {
return (
<section className="lg-enter" aria-label="로그인">
<div className="lg-panel">
<Surface className="lg-panel">
<span className="lg-kicker">
<span className="d" aria-hidden="true" />
@ -163,7 +172,7 @@ export function LoginPanel({ oauth, devAccess, error }: LoginPanelProps) {
<p className="lg-note">
. , , .
</p>
</div>
</Surface>
</section>
);
}

View file

@ -1,18 +1,11 @@
.lg-root {
min-height: 100dvh;
width: 100vw;
max-width: none;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 500px);
background:
linear-gradient(
90deg,
rgba(7, 16, 14, 0.84) 0%,
rgba(10, 21, 19, 0.72) 48%,
rgba(10, 21, 19, 0.55) 74%,
rgba(10, 21, 19, 0.62) 100%
),
var(--asset-login-room) center / cover no-repeat;
color: #edf4f2;
color: var(--auth-hero-text);
overflow-x: hidden;
overflow-y: auto;
}
@ -21,7 +14,7 @@
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(3, 9, 8, 0.18), rgba(3, 9, 8, 0.42));
background: var(--auth-room-shade);
pointer-events: none;
}
@ -41,7 +34,7 @@
gap: var(--sp-7);
padding: var(--sp-7);
background: transparent;
color: #edf4f2;
color: var(--auth-hero-text);
overflow: hidden;
}
@ -58,7 +51,7 @@
font-size: 19px;
font-weight: 700;
letter-spacing: 0;
color: #edf4f2;
color: var(--auth-hero-text);
}
.lg-mark {
@ -101,7 +94,7 @@
position: relative;
z-index: 0;
padding: 0 4px;
background: linear-gradient(90deg, #59b5a6, #8ee4d6);
background: linear-gradient(90deg, #6b9488, #9fc0b5);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
@ -120,7 +113,7 @@
.lg-copy p {
margin: var(--sp-5) 0 0;
max-width: 560px;
color: rgba(237, 244, 242, 0.72);
color: var(--auth-hero-muted);
font-size: 17px;
line-height: 1.75;
}
@ -133,12 +126,12 @@
width: max-content;
max-width: min(430px, 100%);
padding: 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.14);
border: 1px solid var(--auth-hero-border);
border-radius: var(--radius-lg);
background: rgba(237, 244, 242, 0.08);
background: var(--auth-hero-surface);
backdrop-filter: blur(18px) saturate(1.08);
-webkit-backdrop-filter: blur(18px) saturate(1.08);
color: rgba(237, 244, 242, 0.66);
color: var(--auth-hero-muted);
font-size: 13px;
}
@ -150,9 +143,9 @@
}
.lg-policy b {
color: #edf4f2;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--auth-hero-text);
background: var(--auth-hero-chip);
border: 1px solid var(--auth-hero-border);
border-radius: 999px;
padding: 5px 10px;
font-weight: 650;
@ -170,14 +163,9 @@
.lg-panel {
width: 100%;
max-width: 430px;
background: rgba(11, 25, 22, 0.64);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: var(--radius-lg);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(24px) saturate(1.14);
-webkit-backdrop-filter: blur(24px) saturate(1.14);
padding: var(--sp-6);
color: #edf4f2;
color: var(--text-strong);
}
.lg-panel h2 {
@ -185,12 +173,12 @@
font-size: 28px;
line-height: 1.25;
letter-spacing: 0;
color: #f7fbf9;
color: var(--text-strong);
}
.lg-lead {
margin: 10px 0 0;
color: rgba(237, 244, 242, 0.76);
color: var(--text-body);
font-size: 14px;
line-height: 1.65;
}
@ -219,47 +207,44 @@
}
.lg-obtn.primary {
background: rgba(89, 181, 166, 0.2);
border: 1px solid rgba(114, 211, 197, 0.44);
color: #f7fbf9;
background: color-mix(in srgb, var(--accent-tint) 72%, var(--glass-surface-inset));
border-color: color-mix(in srgb, var(--accent) 44%, var(--glass-inset-border));
color: var(--text-strong);
}
.lg-obtn.primary:hover {
background: rgba(89, 181, 166, 0.28);
border-color: rgba(114, 211, 197, 0.62);
background: color-mix(in srgb, var(--accent-tint) 88%, var(--glass-surface-inset));
border-color: var(--accent);
}
.lg-obtn.secondary {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.13);
color: #edf4f2;
color: var(--text-strong);
}
.lg-obtn.secondary:hover {
border-color: rgba(114, 211, 197, 0.42);
background: rgba(255, 255, 255, 0.11);
border-color: color-mix(in srgb, var(--accent) 42%, var(--glass-inset-border));
}
.lg-obtn:disabled {
cursor: not-allowed;
opacity: 1;
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(237, 244, 242, 0.58);
background: var(--glass-surface-inset);
border-color: var(--glass-inset-border);
color: var(--text-muted);
}
.lg-obtn:disabled:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.1);
background: var(--glass-surface-inset);
border-color: var(--glass-inset-border);
}
.lg-obtn:disabled .ic {
background: rgba(255, 255, 255, 0.08);
color: rgba(237, 244, 242, 0.58);
background: var(--bg-surface-2);
color: var(--text-muted);
}
.lg-obtn:disabled .sub {
color: rgba(237, 244, 242, 0.45);
color: var(--text-muted);
}
.lg-obtn .ic {
@ -268,11 +253,12 @@
display: grid;
place-items: center;
border-radius: 8px;
background: rgba(255, 255, 255, 0.16);
background: var(--accent-tint);
color: var(--accent-deep);
}
.lg-obtn.secondary .ic {
background: rgba(7, 17, 15, 0.34);
background: var(--bg-surface-2);
}
.lg-obtn .txt {
@ -285,11 +271,11 @@
.lg-obtn .sub {
font-size: 12px;
font-weight: 550;
color: rgba(237, 244, 242, 0.58);
color: var(--text-muted);
}
.lg-obtn.primary .sub {
color: rgba(251, 250, 248, 0.74);
color: var(--text-body);
}
.lg-config {
@ -309,7 +295,7 @@
.lg-dev {
margin-top: var(--sp-6);
padding-top: var(--sp-5);
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid var(--glass-inset-border);
}
.lg-devhead {
@ -317,13 +303,13 @@
align-items: baseline;
justify-content: space-between;
gap: var(--sp-3);
color: #edf4f2;
color: var(--text-strong);
font-size: 13px;
font-weight: 700;
}
.lg-devhead small {
color: rgba(237, 244, 242, 0.58);
color: var(--text-muted);
font-weight: 600;
}
@ -341,17 +327,15 @@
align-items: center;
gap: 10px;
padding: 9px 11px;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.045);
color: #edf4f2;
color: var(--text-strong);
text-align: left;
cursor: pointer;
}
.lg-roleopt.is-sel {
border-color: rgba(114, 211, 197, 0.58);
background: rgba(89, 181, 166, 0.15);
border-color: color-mix(in srgb, var(--accent) 58%, var(--glass-inset-border));
background: color-mix(in srgb, var(--accent-tint) 64%, var(--glass-surface-inset));
}
.lg-roleopt .d {
@ -380,7 +364,7 @@
.lg-roleopt small {
display: block;
margin-top: 1px;
color: rgba(237, 244, 242, 0.58);
color: var(--text-muted);
font-size: 12px;
}
@ -391,7 +375,7 @@
border: 1px solid rgba(114, 211, 197, 0.58);
border-radius: var(--radius);
background: rgba(89, 181, 166, 0.15);
color: #8fe7d9;
color: var(--accent-deep);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 750;
@ -417,7 +401,7 @@
.lg-error small {
display: block;
margin-top: 4px;
color: rgba(237, 244, 242, 0.56);
color: var(--text-muted);
font-family: var(--font-num);
font-size: 11.5px;
overflow-wrap: anywhere;
@ -425,7 +409,7 @@
.lg-note {
margin: var(--sp-5) 0 0;
color: rgba(237, 244, 242, 0.52);
color: var(--text-muted);
font-size: 12.5px;
line-height: 1.6;
}

View file

@ -1,7 +1,6 @@
.ob-page{
min-height:100dvh;
width:100%;
background:var(--bg-app);
color:var(--text-body);
padding:clamp(20px,5vw,56px);
}
@ -11,6 +10,7 @@
margin:0 auto;
display:grid;
gap:var(--sp-6);
padding:clamp(22px,4vw,44px);
}
.ob-head{
display:grid;
@ -237,6 +237,7 @@
display:flex;
justify-content:flex-start;
}
@media (max-width:620px){
.ob-page{
padding:14px;

View file

@ -3,7 +3,6 @@
display:grid;
place-items:center;
padding:clamp(18px,5vw,56px);
background:var(--bg-app);
color:var(--text-body);
}
.pa-panel{
@ -84,6 +83,11 @@
flex-wrap:wrap;
gap:10px;
}
.pa-panel{
position:relative;
z-index:1;
}
@media (max-width:560px){
.pa-panel{
border-radius:var(--radius);

View file

@ -686,3 +686,433 @@
grid-template-columns:1fr;
}
}
/* 2026-07 persona workspace IA */
.ps-head > div:first-child{
min-width:0;
}
.ps-head > div:first-child > p{
margin:8px 0 0;
max-width:64ch;
color:var(--text-muted);
font-size:var(--fs-sm);
line-height:1.55;
text-wrap:pretty;
}
.ps-workspace-tabs{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
border-bottom:1px solid var(--border-subtle);
}
.ps-workspace-tabs button{
position:relative;
min-width:0;
min-height:66px;
display:grid;
align-content:center;
gap:4px;
padding:10px 18px 14px;
border:0;
background:transparent;
color:var(--text-muted);
text-align:left;
cursor:pointer;
transition:color .2s ease,background .2s ease;
}
.ps-workspace-tabs button::after{
content:"";
position:absolute;
right:18px;
bottom:-1px;
left:18px;
height:2px;
background:transparent;
transform:scaleX(.35);
transition:background .2s ease,transform .2s ease;
}
.ps-workspace-tabs button:hover{
color:var(--text-strong);
background:color-mix(in srgb,var(--bg-surface) 58%,transparent);
}
.ps-workspace-tabs button:focus-visible,
.ps-subtabs button:focus-visible,
.ps-stepper button:focus-visible,
.ps-usage-row:focus-visible,
.ps-persona-row__identity:focus-visible,
.ps-catalog-list button:focus-visible{
outline:2px solid var(--focus-ring);
outline-offset:2px;
}
.ps-workspace-tabs button.is-active{
color:var(--text-strong);
}
.ps-workspace-tabs button.is-active::after{
background:var(--accent);
transform:scaleX(1);
}
.ps-workspace-tabs span{
font-size:15px;
font-weight:780;
}
.ps-workspace-tabs small{
overflow:hidden;
color:var(--text-muted);
font-size:11px;
text-overflow:ellipsis;
white-space:nowrap;
}
.ps-overview,
.ps-catalog-page,
.ps-personas-page,
.ps-detail-page{
min-width:0;
display:grid;
gap:18px;
}
.ps-inline-note{
padding:11px 14px;
border-left:3px solid var(--warn-solid);
background:var(--warn-tint);
color:var(--warn-text);
font-size:var(--fs-sm);
}
.ps-kpi-grid{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
border-top:1px solid var(--border-subtle);
border-bottom:1px solid var(--border-subtle);
}
.ps-kpi{
min-width:0;
display:grid;
gap:7px;
padding:20px 22px 23px;
border-right:1px solid var(--border-subtle);
background:color-mix(in srgb,var(--bg-surface) 72%,transparent);
}
.ps-kpi:last-child{border-right:0;}
.ps-kpi > span{
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:740;
}
.ps-kpi > b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:clamp(28px,3vw,42px);
font-variant-numeric:tabular-nums;
letter-spacing:-.045em;
line-height:1;
}
.ps-kpi > small{
overflow:hidden;
color:var(--text-muted);
font-size:11px;
text-overflow:ellipsis;
white-space:nowrap;
}
.ps-dashboard-grid{
display:grid;
grid-template-columns:minmax(0,1.2fr) minmax(340px,.8fr);
gap:14px;
}
.ps-performance,
.ps-review-summary,
.ps-active-table,
.ps-catalog-block,
.ps-detail-primary,
.ps-detail-facts,
.ps-detail-wide{
min-width:0;
padding:20px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 88%,transparent);
box-shadow:var(--shadow-sm);
}
.ps-section-head{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:16px;
}
.ps-section-head h2{
margin:5px 0 0;
color:var(--text-strong);
font-size:19px;
line-height:1.3;
letter-spacing:-.02em;
}
.ps-section-head > span{
color:var(--text-muted);
font-size:11px;
}
.ps-performance__metrics{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:28px;
margin-top:28px;
}
.ps-performance__metrics > div{
display:grid;
grid-template-columns:minmax(0,1fr) auto;
align-items:end;
gap:8px 12px;
}
.ps-performance__metrics span{
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:730;
}
.ps-performance__metrics b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:24px;
font-variant-numeric:tabular-nums;
}
.ps-performance__metrics i,
.ps-distribution i{
grid-column:1 / -1;
height:5px;
overflow:hidden;
border-radius:5px;
background:var(--bg-surface-2);
}
.ps-performance__metrics i span,
.ps-distribution i span{
display:block;
height:100%;
border-radius:inherit;
background:var(--accent);
}
.ps-performance > p{
margin:22px 0 0;
max-width:68ch;
color:var(--text-muted);
font-size:11px;
line-height:1.55;
}
.ps-review-summary__grid{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
margin-top:22px;
border-top:1px solid var(--border-subtle);
}
.ps-review-summary__grid > div{
min-width:0;
display:grid;
gap:5px;
padding:18px 14px 4px;
border-right:1px solid var(--border-subtle);
}
.ps-review-summary__grid > div:first-child{padding-left:0;}
.ps-review-summary__grid > div:last-child{border-right:0;}
.ps-review-summary__grid span,.ps-review-summary__grid small{color:var(--text-muted);font-size:11px;}
.ps-review-summary__grid b{color:var(--text-strong);font-family:var(--font-num);font-size:28px;}
.ps-active-table{padding:0;overflow:hidden;}
.ps-active-table > .ps-section-head{padding:18px 20px;}
.ps-usage-table{border-top:1px solid var(--border-subtle);}
.ps-usage-table__head,
.ps-usage-row{
display:grid;
grid-template-columns:minmax(260px,1.4fr) repeat(4,minmax(90px,.45fr));
gap:12px;
align-items:center;
}
.ps-usage-table__head{
padding:9px 16px;
color:var(--text-muted);
background:var(--bg-surface-2);
font-size:10px;
font-weight:780;
}
.ps-usage-row{
width:100%;
min-height:58px;
padding:9px 16px;
border:0;
border-top:1px solid var(--border-faint);
background:transparent;
color:var(--text-body);
text-align:left;
cursor:pointer;
}
.ps-usage-row:hover{background:var(--bg-surface-2);}
.ps-usage-row > span{min-width:0;display:grid;gap:2px;}
.ps-usage-row b{color:var(--accent-deep);font-family:var(--font-num);font-size:11px;}
.ps-usage-row small{overflow:hidden;color:var(--text-body);font-weight:700;text-overflow:ellipsis;white-space:nowrap;}
.ps-usage-row strong{color:var(--text-body);font-family:var(--font-num);font-size:12px;font-variant-numeric:tabular-nums;}
.ps-catalog-hero{
display:grid;
grid-template-columns:minmax(0,1fr) minmax(190px,.28fr);
gap:32px;
align-items:end;
padding:28px 30px;
border-top:1px solid var(--border-strong);
border-bottom:1px solid var(--border-strong);
background:color-mix(in srgb,var(--bg-surface) 74%,transparent);
}
.ps-catalog-hero h2{margin:8px 0 0;max-width:28ch;color:var(--text-strong);font-size:clamp(24px,3vw,38px);line-height:1.16;letter-spacing:-.035em;text-wrap:balance;}
.ps-catalog-hero p{margin:12px 0 0;max-width:62ch;color:var(--text-muted);font-size:var(--fs-sm);line-height:1.6;}
.ps-catalog-hero__count{display:grid;gap:4px;padding-left:24px;border-left:1px solid var(--border-subtle);}
.ps-catalog-hero__count span,.ps-catalog-hero__count small{color:var(--text-muted);font-size:11px;}
.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 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;}
.ps-distribution > div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px 12px;align-items:center;}
.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: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;}
.ps-catalog-list button b{color:var(--text-strong);font-size:var(--fs-sm);}
.ps-catalog-list button small,.ps-catalog-list button > span:nth-child(3){color:var(--text-muted);font-size:11px;}
.ps-catalog-list button strong{font-family:var(--font-num);font-size:11px;}
.ps-code-box{display:grid;place-items:center;width:42px;min-height:34px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--bg-surface-2);color:var(--accent-deep);font-family:var(--font-num);font-size:11px;font-weight:800;}
.ps-subtabs{display:flex;align-items:center;gap:4px;border-bottom:1px solid var(--border-subtle);}
.ps-subtabs button{min-height:42px;padding:0 16px;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--text-muted);font-size:var(--fs-sm);font-weight:720;cursor:pointer;}
.ps-subtabs button.is-active{border-bottom-color:var(--accent);color:var(--text-strong);}
.ps-subtabs button b{margin-left:6px;font-family:var(--font-num);font-size:11px;}
.ps-table-tools{display:flex;align-items:end;justify-content:space-between;gap:14px;}
.ps-table-tools label{display:grid;gap:6px;min-width:min(420px,60%);}
.ps-table-tools label:last-child{min-width:190px;}
.ps-table-tools label > span{color:var(--text-muted);font-size:11px;font-weight:730;}
.ps-table-tools input,.ps-table-tools select{width:100%;min-height:40px;padding:0 12px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--bg-surface);color:var(--text-strong);}
.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: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;}
.ps-persona-row__identity b,.ps-persona-row__identity small,.ps-persona-row__usage small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ps-persona-row__identity b{color:var(--text-strong);font-size:var(--fs-sm);}
.ps-persona-row__identity small,.ps-persona-row__usage small{color:var(--text-muted);font-size:11px;}
.ps-persona-row__usage b{color:var(--text-body);font-family:var(--font-num);font-size:12px;}
.ps-version{color:var(--text-muted);font-family:var(--font-num);font-size:11px;}
.ps-persona-row__actions{display:flex;justify-content:flex-end;gap:5px;}
.ps-review-page{display:grid;gap:16px;}
.ps-review-page__summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border-top:1px solid var(--border-subtle);border-bottom:1px solid var(--border-subtle);}
.ps-review-page__summary > div{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:16px 20px;border-right:1px solid var(--border-subtle);}
.ps-review-page__summary > div:last-child{border-right:0;}
.ps-review-page__summary span{color:var(--text-muted);font-size:var(--fs-xs);font-weight:720;}
.ps-review-page__summary b{color:var(--text-strong);font-family:var(--font-num);font-size:28px;}
.ps-review-board{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;}
.ps-review-card{display:grid;gap:12px;padding:18px;border:1px solid var(--border-subtle);border-radius:var(--radius);background:color-mix(in srgb,var(--bg-surface) 88%,transparent);}
.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 > 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;}
.ps-review-card__actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap;}
.ps-drill-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:18px;align-items:start;padding:18px 0 20px;border-bottom:1px solid var(--border-subtle);}
.ps-drill-head h2{margin:5px 0 0;color:var(--text-strong);font-size:clamp(24px,3vw,34px);line-height:1.18;letter-spacing:-.035em;}
.ps-drill-head p{margin:7px 0 0;color:var(--text-muted);font-size:var(--fs-sm);}
.ps-drill-head__actions{display:flex;gap:7px;}
.ps-detail-grid{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(280px,.8fr);gap:14px;}
.ps-detail-wide{grid-column:1 / -1;}
.ps-detail-primary h3{margin:10px 0 0;color:var(--text-strong);font-size:24px;line-height:1.25;}
.ps-detail-primary > p{margin:14px 0 0;max-width:65ch;color:var(--text-body);font-size:var(--fs-body);line-height:1.7;}
.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: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 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);}
.ps-stepper button{min-width:0;display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:center;padding:13px 14px;border:0;border-right:1px solid var(--border-subtle);background:transparent;color:var(--text-muted);text-align:left;cursor:pointer;}
.ps-stepper button:last-child{border-right:0;}
.ps-stepper button > span:first-child{display:grid;place-items:center;width:30px;height:30px;border:1px solid var(--border-subtle);border-radius:50%;font-family:var(--font-num);font-size:11px;font-weight:800;}
.ps-stepper button > span:last-child{min-width:0;display:grid;gap:2px;}
.ps-stepper b,.ps-stepper small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ps-stepper b{color:var(--text-strong);font-size:var(--fs-xs);}
.ps-stepper small{font-size:10px;}
.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,
.ps-authoring-layout[data-authoring-step="generate"] .ps-edit-panel,
.ps-authoring-layout[data-authoring-step="edit"] .ps-source-panel,
.ps-authoring-layout[data-authoring-step="review"] .ps-source-panel{display:none;}
.ps-authoring-layout[data-authoring-step="source"] .ps-inspector__block:nth-child(n+2),
.ps-authoring-layout[data-authoring-step="generate"] .ps-inspector__block:nth-child(3){display:none;}
.ps-authoring-layout .ps-actions{padding-top:4px;}
@media (max-width:1180px){
.ps-kpi-grid{grid-template-columns:repeat(2,minmax(0,1fr));}
.ps-kpi:nth-child(2){border-right:0;}
.ps-kpi:nth-child(-n+2){border-bottom:1px solid var(--border-subtle);}
.ps-dashboard-grid,.ps-catalog-grid{grid-template-columns:1fr;}
.ps-persona-table{overflow-x:auto;}
.ps-persona-table__head,.ps-persona-row{min-width:980px;}
.ps-authoring-layout{grid-template-columns:1fr;}
.ps-authoring-layout .ps-inspector{grid-column:auto;grid-template-columns:repeat(2,minmax(0,1fr));}
}
@media (max-width:760px){
.ps-workspace-tabs button{min-height:52px;padding:8px 10px 11px;text-align:center;}
.ps-workspace-tabs button::after{right:8px;left:8px;}
.ps-workspace-tabs small{display:none;}
.ps-kpi-grid{grid-template-columns:repeat(2,minmax(0,1fr));}
.ps-kpi{padding:15px 14px 17px;}
.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-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;}
.ps-catalog-hero{grid-template-columns:1fr;padding:22px 18px;}
.ps-catalog-hero__count{padding:16px 0 0;border-top:1px solid var(--border-subtle);border-left:0;}
.ps-catalog-list button{grid-template-columns:44px minmax(0,1fr) 42px 18px;}
.ps-catalog-list button > span:nth-child(3){display:none;}
.ps-table-tools{display:grid;}
.ps-table-tools label,.ps-table-tools label:last-child{min-width:0;width:100%;}
.ps-persona-table{overflow:visible;}
.ps-persona-table__head{display:none;}
.ps-persona-row{
min-width:0;
grid-template-columns:minmax(0,1fr) auto;
gap:10px 12px;
align-items:center;
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 > 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-board{grid-template-columns:1fr;}
.ps-drill-head{grid-template-columns:1fr;}
.ps-drill-head__actions{justify-content:flex-start;}
.ps-detail-grid{grid-template-columns:1fr;}
.ps-detail-wide{grid-column:auto;}
.ps-stepper{grid-template-columns:repeat(4,minmax(54px,1fr));overflow-x:auto;}
.ps-stepper button{grid-template-columns:1fr;justify-items:center;padding:10px 7px;text-align:center;}
.ps-stepper button > span:last-child small{display:none;}
.ps-authoring-layout .ps-inspector{grid-template-columns:1fr;}
.ps-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));}
.ps-actions .vg-btn{width:100%;}
.ps-point-list > div{grid-template-columns:minmax(0,1fr) 64px;}
.ps-point-list small{grid-column:1 / -1;text-align:left;}
}

View file

@ -0,0 +1,552 @@
import type {
PersonaDraftDetail,
PersonaDraftPayload,
PersonaReviewSummary,
PersonaSummary,
} from "../../lib/api";
import { formatOptionalPercent } from "../../lib/format";
export type JsonRecord = Record<string, unknown>;
export type Difficulty = "easy" | "moderate" | "hard";
export interface PersonaStudioPayload {
code: string;
display_name: string;
difficulty: Difficulty;
theory_target: string[];
demographics: JsonRecord;
presenting: JsonRecord;
history: JsonRecord;
big5: Record<string, number>;
resistance: Record<string, number>;
speech_style: JsonRecord;
affect_baseline: Record<string, number>;
ccd: JsonRecord;
dsm5_dimensional: JsonRecord;
triggers: JsonRecord;
source_provenance: string;
is_synthetic: boolean;
submit_for_review: boolean;
}
export interface ValidationResult {
errors: string[];
warnings: string[];
}
export interface PromptPreviewRow {
label: string;
value: string;
}
export interface PromptPreviewSection {
title: string;
desc?: string;
rows?: PromptPreviewRow[];
items?: string[];
body?: string;
}
const EMPTY_STUDIO_DRAFT: PersonaStudioPayload = {
code: "",
display_name: "",
difficulty: "moderate",
theory_target: ["humanistic"],
demographics: {
age_band: "",
sex: "",
role: "",
context: "",
},
presenting: {
complaint: "",
surface: "",
first_session_opening: "",
},
history: {
family: "",
school_or_work: "",
relationships: "",
precipitant: "",
strengths: "",
},
big5: {
O: 0.5,
C: 0.5,
E: 0.5,
A: 0.5,
N: 0.5,
},
resistance: {
base_resistance: 0.5,
unlock_rate: 0.1,
decay_floor: 0.05,
silence_prob: 0.15,
deflection_prob: 0.25,
},
speech_style: {
register: "polite",
avg_sentence_len: "medium",
fillers: [],
honorific: true,
verbal_tics: [],
nonverbal_cues: [],
},
affect_baseline: {
negative_affect: 0.45,
hopelessness: 0.2,
anhedonia: 0.2,
sleep: 0.2,
anxiety: 0.35,
suicide_ideation_stage: 1,
},
ccd: {
core_belief: "",
intermediate_belief: "",
automatic_thought: [],
coping_strategy: "",
compensatory: "",
},
dsm5_dimensional: {
anxiety: 0.35,
depression: 0.2,
somatic: 0.1,
note: "",
},
triggers: {
sore_spots: [],
forbidden: [],
reaction: "",
taboo_terms: [],
session_scenarios: [],
prompt_contract: "",
},
source_provenance: "clinical draft",
is_synthetic: true,
submit_for_review: false,
};
export function cloneEmptyDraft(): PersonaStudioPayload {
return JSON.parse(JSON.stringify(EMPTY_STUDIO_DRAFT)) as PersonaStudioPayload;
}
function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
export function objectValue(value: unknown): JsonRecord {
return isRecord(value) ? value : {};
}
export function stringValue(section: JsonRecord, key: string): string {
const value = section[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean")
return String(value);
return "";
}
export function listValue(section: JsonRecord, key: string): string[] {
const value = section[key];
if (Array.isArray(value)) {
return value.map((item) => String(item)).filter((item) => item.trim());
}
if (typeof value === "string" && value.trim()) return [value.trim()];
return [];
}
export function editableListValue(section: JsonRecord, key: string): string[] {
const value = section[key];
if (Array.isArray(value)) return value.map((item) => String(item));
if (typeof value === "string" && value.trim()) return [value.trim()];
return [];
}
export function numberValue(
section: Record<string, number>,
key: string,
fallback: number,
): number {
const value = section[key];
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
export function normalizeCode(raw: string): string {
return raw.trim().toUpperCase();
}
export function nextPersonaCode(
catalog: PersonaSummary[],
reviews: PersonaReviewSummary[],
): string {
const used = new Set<string>();
for (const persona of catalog) used.add(normalizeCode(persona.code));
for (const persona of reviews) used.add(normalizeCode(persona.code));
let next = 1;
while (used.has(`P${next}`)) next += 1;
return `P${next}`;
}
export function draftDetailToStudioPayload(
detail: PersonaDraftDetail,
): PersonaStudioPayload {
const extended = detail as PersonaDraftDetail & { triggers?: JsonRecord };
return {
code: detail.code,
display_name: detail.display_name,
difficulty:
detail.difficulty === "easy" || detail.difficulty === "hard"
? detail.difficulty
: "moderate",
theory_target: detail.theory_target,
demographics: objectValue(detail.demographics),
presenting: objectValue(detail.presenting),
history: objectValue(detail.history),
big5: { ...detail.big5 },
resistance: { ...detail.resistance },
speech_style: objectValue(detail.speech_style),
affect_baseline: { ...detail.affect_baseline },
ccd: objectValue(detail.ccd),
dsm5_dimensional: objectValue(detail.dsm5_dimensional),
triggers: objectValue(extended.triggers),
source_provenance: detail.source_provenance,
is_synthetic: detail.is_synthetic,
submit_for_review: detail.status === "review",
};
}
export function draftPayloadToStudioPayload(
payload: PersonaDraftPayload,
): PersonaStudioPayload {
const extended = payload as PersonaDraftPayload & { triggers?: JsonRecord };
return {
code: payload.code,
display_name: payload.display_name,
difficulty: payload.difficulty,
theory_target: payload.theory_target ?? ["humanistic"],
demographics: objectValue(payload.demographics),
presenting: objectValue(payload.presenting),
history: objectValue(payload.history),
big5: { O: 0.5, C: 0.5, E: 0.5, A: 0.5, N: 0.5, ...(payload.big5 ?? {}) },
resistance: {
base_resistance: 0.5,
unlock_rate: 0.1,
decay_floor: 0.05,
silence_prob: 0.15,
deflection_prob: 0.25,
...(payload.resistance ?? {}),
},
speech_style: objectValue(payload.speech_style),
affect_baseline: {
negative_affect: 0.45,
hopelessness: 0.2,
anhedonia: 0.2,
sleep: 0.2,
anxiety: 0.35,
suicide_ideation_stage: 1,
...(payload.affect_baseline ?? {}),
},
ccd: objectValue(payload.ccd),
dsm5_dimensional: objectValue(payload.dsm5_dimensional),
triggers: objectValue(extended.triggers),
source_provenance: payload.source_provenance,
is_synthetic: payload.is_synthetic,
submit_for_review: false,
};
}
export function payloadForApi(
draft: PersonaStudioPayload,
submitForReview: boolean,
): PersonaDraftPayload & { triggers: JsonRecord } {
const ccd = {
...draft.ccd,
automatic_thought: listValue(draft.ccd, "automatic_thought"),
};
const speechStyle = {
...draft.speech_style,
fillers: listValue(draft.speech_style, "fillers"),
verbal_tics: listValue(draft.speech_style, "verbal_tics"),
nonverbal_cues: listValue(draft.speech_style, "nonverbal_cues"),
};
const triggers = {
...draft.triggers,
sore_spots: listValue(draft.triggers, "sore_spots"),
forbidden: listValue(draft.triggers, "forbidden"),
taboo_terms: listValue(draft.triggers, "taboo_terms"),
session_scenarios: listValue(draft.triggers, "session_scenarios"),
};
return {
code: normalizeCode(draft.code),
display_name: draft.display_name.trim(),
difficulty: draft.difficulty,
theory_target: draft.theory_target,
demographics: draft.demographics,
presenting: draft.presenting,
history: draft.history,
big5: draft.big5,
resistance: draft.resistance,
speech_style: speechStyle,
affect_baseline: draft.affect_baseline,
ccd,
dsm5_dimensional: draft.dsm5_dimensional,
triggers,
source_provenance: draft.source_provenance.trim(),
is_synthetic: draft.is_synthetic,
submit_for_review: submitForReview,
};
}
function previewText(value: string): string {
return value.trim() || "-";
}
function previewList(section: JsonRecord, key: string): string[] {
return listValue(section, key);
}
function previewListText(section: JsonRecord, key: string): string {
const items = previewList(section, key);
return items.length ? items.join("\n") : "-";
}
function scaleLabel(value: number): string {
return formatOptionalPercent(value);
}
export function buildPromptPreviewSections(
draft: PersonaStudioPayload,
): PromptPreviewSection[] {
const demographics = draft.demographics;
const presenting = draft.presenting;
const history = draft.history;
const speech = draft.speech_style;
const ccd = draft.ccd;
const dsm5 = draft.dsm5_dimensional;
const triggers = draft.triggers;
return [
{
title: `L1 페르소나 카드: ${previewText(draft.display_name)}`,
desc: "학습자에게 노출될 식별자와 첫 장면을 검토한다.",
rows: [
{ label: "코드", value: previewText(draft.code) },
{ label: "난이도", value: draft.difficulty },
{
label: "이론",
value: draft.theory_target.length
? draft.theory_target.join(", ")
: "-",
},
{ label: "출처", value: previewText(draft.source_provenance) },
{
label: "합성 여부",
value: draft.is_synthetic ? "합성/작성 초안" : "근거 기반 초안",
},
],
},
{
title: "인적 범주와 표층 호소",
rows: [
{
label: "연령대",
value: previewText(stringValue(demographics, "age_band")),
},
{
label: "성별/역할",
value:
[
stringValue(demographics, "sex"),
stringValue(demographics, "role"),
]
.filter(Boolean)
.join(" / ") || "-",
},
{
label: "상황",
value: previewText(stringValue(demographics, "context")),
},
{
label: "주호소",
value: previewText(stringValue(presenting, "complaint")),
},
{
label: "표면화 방식",
value: previewText(stringValue(presenting, "surface")),
},
{
label: "첫 회기 입장 발화",
value: previewText(stringValue(presenting, "first_session_opening")),
},
],
},
{
title: "임상 배경과 내부 사례개념화",
desc: "내담자가 직접 설명하지 않는 내부 모델이다.",
rows: [
{ label: "가족", value: previewText(stringValue(history, "family")) },
{
label: "학교/직장",
value: previewText(stringValue(history, "school_or_work")),
},
{
label: "관계",
value: previewText(stringValue(history, "relationships")),
},
{
label: "촉발 사건",
value: previewText(stringValue(history, "precipitant")),
},
{
label: "강점",
value: previewText(stringValue(history, "strengths")),
},
{
label: "핵심 신념",
value: previewText(stringValue(ccd, "core_belief")),
},
{
label: "중간 신념",
value: previewText(stringValue(ccd, "intermediate_belief")),
},
{ label: "자동사고", value: previewListText(ccd, "automatic_thought") },
{
label: "대처 전략",
value: previewText(stringValue(ccd, "coping_strategy")),
},
{
label: "보상 행동",
value: previewText(stringValue(ccd, "compensatory")),
},
],
},
{
title: "말투와 비언어 단서",
rows: [
{
label: "말투 register",
value: previewText(stringValue(speech, "register")),
},
{
label: "문장 길이",
value: previewText(stringValue(speech, "avg_sentence_len")),
},
{
label: "존대",
value:
stringValue(speech, "honorific") === "false"
? "사용 안 함"
: "사용",
},
{ label: "군말", value: previewListText(speech, "fillers") },
{ label: "말버릇", value: previewListText(speech, "verbal_tics") },
{
label: "비언어 단서",
value: previewListText(speech, "nonverbal_cues"),
},
],
},
{
title: "내부 수치 파라미터",
desc: "진단이 아니라 시뮬레이션 반응 강도와 평가 입력값이다.",
rows: [
{
label: "Big5 O/C/E/A/N",
value: ["O", "C", "E", "A", "N"]
.map(
(key) => `${key} ${scaleLabel(numberValue(draft.big5, key, 0))}`,
)
.join(" · "),
},
{
label: "저항",
value: `기본 ${scaleLabel(numberValue(draft.resistance, "base_resistance", 0))}, 해제 ${scaleLabel(numberValue(draft.resistance, "unlock_rate", 0))}, 바닥 ${scaleLabel(numberValue(draft.resistance, "decay_floor", 0))}`,
},
{
label: "침묵/회피",
value: `침묵 ${scaleLabel(numberValue(draft.resistance, "silence_prob", 0))}, 회피 ${scaleLabel(numberValue(draft.resistance, "deflection_prob", 0))}`,
},
{
label: "정서 기저선",
value: `부정정서 ${scaleLabel(numberValue(draft.affect_baseline, "negative_affect", 0))}, 불안 ${scaleLabel(numberValue(draft.affect_baseline, "anxiety", 0))}, 무망감 ${scaleLabel(numberValue(draft.affect_baseline, "hopelessness", 0))}`,
},
{
label: "자살사고 단계",
value: String(
numberValue(draft.affect_baseline, "suicide_ideation_stage", 0),
),
},
{
label: "DSM-5 차원",
value: `불안 ${scaleLabel(numberValue(dsm5 as Record<string, number>, "anxiety", 0))}, 우울 ${scaleLabel(numberValue(dsm5 as Record<string, number>, "depression", 0))}, 신체 ${scaleLabel(numberValue(dsm5 as Record<string, number>, "somatic", 0))}`,
},
{ label: "DSM 메모", value: previewText(stringValue(dsm5, "note")) },
],
},
{
title: "역린과 안전 반응",
desc: "금지어가 아니라 상담자 개입에 대한 반응 조건으로 검토한다.",
rows: [
{ label: "민감 영역", value: previewListText(triggers, "sore_spots") },
{ label: "상담자 금기", value: previewListText(triggers, "forbidden") },
{ label: "금기어", value: previewListText(triggers, "taboo_terms") },
{
label: "반응 양상",
value: previewText(stringValue(triggers, "reaction")),
},
],
},
{
title: "회기 시나리오",
items: previewList(triggers, "session_scenarios"),
},
{
title: "추가 프롬프트 계약",
body: previewText(stringValue(triggers, "prompt_contract")),
},
];
}
export function validateDraft(
draft: PersonaStudioPayload,
submitForReview: boolean,
codeTaken: boolean,
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
const code = normalizeCode(draft.code);
if (!/^P\d+$/.test(code)) errors.push("코드는 P숫자 형식이어야 합니다.");
if (codeTaken)
errors.push(`${code} 코드는 이미 공개 또는 검수 큐에서 사용 중입니다.`);
if (!draft.display_name.trim()) errors.push("표시 이름이 비어 있습니다.");
if (!draft.source_provenance.trim())
warnings.push("출처/작성 근거가 비어 있습니다.");
if (!stringValue(draft.presenting, "complaint").trim()) {
if (submitForReview) errors.push("주호소가 비어 있습니다.");
else warnings.push("주호소가 비어 있습니다.");
}
if (!stringValue(draft.history, "precipitant").trim())
warnings.push("촉발 사건이 비어 있습니다.");
if (!stringValue(draft.ccd, "core_belief").trim()) {
if (submitForReview) errors.push("CCD 핵심신념이 비어 있습니다.");
else warnings.push("CCD 핵심신념이 비어 있습니다.");
}
if (listValue(draft.ccd, "automatic_thought").length === 0)
warnings.push("자동사고가 비어 있습니다.");
if (listValue(draft.triggers, "sore_spots").length === 0) {
if (submitForReview) errors.push("역린 민감 영역이 비어 있습니다.");
else warnings.push("역린 민감 영역이 비어 있습니다.");
}
if (listValue(draft.triggers, "forbidden").length === 0)
warnings.push("상담자 금기 반응이 비어 있습니다.");
if (!stringValue(draft.triggers, "reaction").trim())
warnings.push("역린 반응 양상이 비어 있습니다.");
if (listValue(draft.triggers, "session_scenarios").length === 0) {
warnings.push("회기별 시나리오가 비어 있습니다.");
}
return { errors, warnings };
}

View file

@ -0,0 +1,168 @@
import type {
SessionReviewResponse,
TeacherSessionReviewStatusResponse,
UserPrepostMeasureItem,
UserPrepostMeasuresResponse,
} from "../../lib/api";
export type PrepostMeasureName = UserPrepostMeasureItem["measure_name"];
export type PrepostTimepoint = UserPrepostMeasureItem["timepoint"];
export type WorksheetReviewStatus =
| "pending"
| "approved"
| "changes_requested"
| "rejected";
export type WorksheetDecisionStatus = Exclude<
WorksheetReviewStatus,
"pending"
>;
export const PREPOST_PILOT_ID = "phase3-pilot-draft";
export const PREPOST_INSTRUMENT_VERSION =
"pilot-prepost-scaffold-2026-06-28";
export const PREPOST_SCORE_MIN = 1;
export const PREPOST_SCORE_MAX = 5;
export const REVIEW_READY_POLL_INTERVAL_MS = 1500;
export const REVIEW_READY_POLL_LIMIT = 60;
export const PREPOST_MEASURES: Array<{
key: PrepostMeasureName;
label: string;
hint: string;
}> = [
{ key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" },
{ key: "skill_proficiency", label: "기술숙련도", hint: "기법 적용 숙련감" },
{
key: "training_satisfaction",
label: "수련만족도",
hint: "훈련 경험 만족도",
},
];
export const PREPOST_TIMEPOINTS: Array<{
key: PrepostTimepoint;
label: string;
}> = [
{ key: "pre", label: "사전" },
{ key: "post", label: "사후" },
];
export function teacherReviewFromResponse(
saved: TeacherSessionReviewStatusResponse,
): NonNullable<SessionReviewResponse["teacherReview"]> {
return {
status: saved.status,
note: saved.note,
reviewerId: saved.reviewer_id ?? null,
reviewedAt: saved.reviewed_at ?? null,
updatedAt: saved.updated_at ?? null,
worksheetStatus: saved.worksheet_status ?? "pending",
worksheetNote: saved.worksheet_note ?? "",
worksheetReviewedAt: saved.worksheet_reviewed_at ?? null,
};
}
export function prepostDraftKey(
measure: PrepostMeasureName,
timepoint: PrepostTimepoint,
) {
return `${measure}:${timepoint}`;
}
export function worksheetReviewStatusLabel(status: WorksheetReviewStatus) {
if (status === "approved") return "승인";
if (status === "changes_requested") return "수정요청";
if (status === "rejected") return "반려";
return "검수 대기";
}
export function splitPrepostDraftKey(
key: string,
): [PrepostMeasureName, PrepostTimepoint] | null {
const [measure, timepoint] = key.split(":");
if (
PREPOST_MEASURES.some((item) => item.key === measure) &&
PREPOST_TIMEPOINTS.some((item) => item.key === timepoint)
) {
return [measure as PrepostMeasureName, timepoint as PrepostTimepoint];
}
return null;
}
export function prepostScoreText(score: number) {
return Number.isInteger(score)
? String(score)
: score.toFixed(1).replace(/\.0$/, "");
}
export function prepostDraftFromResponse(
response: UserPrepostMeasuresResponse | null,
): Record<string, string> {
const next: Record<string, string> = {};
for (const item of response?.measures ?? []) {
next[prepostDraftKey(item.measure_name, item.timepoint)] = prepostScoreText(
item.raw_score,
);
}
return next;
}
export function parsePrepostScore(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null;
if (parsed < PREPOST_SCORE_MIN || parsed > PREPOST_SCORE_MAX) return null;
return parsed;
}
export function displayReviewSummary(summary: string) {
if (
/(?:사유:\s*)?(?:engine_error|session evaluation timeout|timeout|RuntimeError)/i.test(
summary,
)
) {
return "저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다.";
}
return summary;
}
export function displayEvaluationRetryError(message: string) {
if (!message.trim()) return "AI 평가를 다시 실행하지 못했습니다.";
return "AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.";
}
const REVIEW_DISPLAY_PLACEHOLDERS: Record<string, string> = {
"[NAME]": "익명 내담자",
"[ORG]": "소속 기관",
"[PHONE]": "연락처",
"[EMAIL]": "이메일",
"[RRN]": "주민등록번호",
"[ADDRESS]": "주소",
};
export function displayTranscriptText(text: string) {
let next = text;
for (const [placeholder, label] of Object.entries(
REVIEW_DISPLAY_PLACEHOLDERS,
)) {
next = next.split(placeholder).join(label);
}
return next;
}
export function shouldPollReviewReady(data: SessionReviewResponse) {
return (
!data.reviewReady &&
data.degraded &&
data.supervisorState === "평가 대기" &&
(data.turns?.length ?? 0) > 0
);
}
export function noteAuthorLabel(author: string) {
const normalized = author.trim();
if (normalized === "ai") return "AI";
if (normalized === "transcript") return "기록";
return normalized || "교수자";
}

View file

@ -111,125 +111,84 @@
color: var(--accent-deep);
}
/* 레이아웃 코어 (2026-07-15 재설계 같은 전환)
리뷰 요약은 상단 전폭 밴드(.sr-overview--band) 읽고 지나간다
(sticky 도킹/스크롤 추종 제거). 본문은 모든 폭에서 상단 가로 탭이
결정한 영역만 전폭 단일 컬럼으로 보여준다 탭별 areas·표시 규칙은
파일 끝의 '전 폭 탭' 블록이 단일 진실이다. */
.sr-cols {
min-width: 0;
display: grid;
grid-template-columns: minmax(236px, 280px) minmax(0, 1fr) minmax(286px, 340px);
grid-template-areas:
"overview chart rubric"
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript prepost"
"overview transcript worksheet"
"overview transcript feedback"
"session session session";
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-4);
align-items: start;
margin-top: 0;
}
.sr-cols--supervisor {
grid-template-areas:
"overview chart teacher"
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript prepost"
"overview transcript worksheet"
"overview transcript feedback"
"session session session";
}
.sr-cols--learner {
grid-template-columns: minmax(232px, 300px) minmax(380px, 1fr) minmax(264px, 328px);
grid-template-areas:
"overview transcript rubric"
"chart transcript good"
"flow transcript growth"
"worksheet worksheet prepost"
"worksheet worksheet feedback"
"session session session";
}
.sr-cols--learner .sr-overview {
position: sticky;
top: calc(var(--topbar-h) + var(--sp-4));
max-height: calc(100vh - var(--topbar-h) - var(--sp-5) - var(--sp-5));
overflow: auto;
scrollbar-gutter: stable;
}
.sr-root--empty .sr-cols {
grid-template-columns: minmax(236px, 300px) minmax(0, 1fr);
grid-template-areas:
"overview chart"
"overview flow"
"overview transcript"
"overview rubric"
"overview good"
"overview growth"
"overview prepost"
"overview worksheet"
"overview feedback"
"session session";
}
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview chart"
"overview flow"
"overview teacher"
"overview transcript"
"overview rubric"
"overview good"
"overview growth"
"overview prepost"
"overview worksheet"
"overview feedback"
"session session";
}
.sr-left,
.sr-right {
display: contents;
}
.sr-overview {
grid-area: overview;
min-width: 0;
display: grid;
gap: var(--sp-3);
align-self: start;
position: sticky;
top: calc(var(--topbar-h) + var(--sp-4));
z-index: 1;
max-height: calc(100vh - var(--topbar-h) - var(--sp-5) - var(--sp-5));
overflow: auto;
scrollbar-gutter: stable;
gap: var(--sp-4);
align-content: start;
align-items: start;
}
.sr-feedback {
.sr-left {
grid-area: insight;
}
.sr-right {
grid-area: feedback;
}
.sr-card--chart {
grid-area: chart;
.sr-left > *,
.sr-right > * {
min-width: 0;
}
.sr-card--flow {
grid-area: flow;
/* ── 리뷰 요약 밴드 ── */
.sr-overview {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-3) var(--sp-5);
align-items: start;
}
.sr-card--rubric {
grid-area: rubric;
.sr-overview > :first-child {
grid-column: 1 / -1;
}
.sr-card--teacher-review {
grid-area: teacher;
.sr-overview .sr-summary {
grid-column: 1;
margin: 0;
}
.sr-card--good {
grid-area: good;
.sr-overview .sr-readiness {
grid-column: 2;
grid-row: 2;
border-top: 0;
padding-top: 0;
grid-template-columns: repeat(3, minmax(112px, 148px));
align-self: center;
}
.sr-card--growth {
grid-area: growth;
.sr-overview .sr-actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-3);
padding-top: var(--sp-3);
border-top: 1px solid var(--hair);
}
.sr-card--prepost {
grid-area: prepost;
.sr-overview .sr-actions .vg-btn {
width: auto;
}
.sr-overview .sr-share-note {
flex: 1 1 260px;
margin: 0;
}
.sr-card--worksheet {
grid-area: worksheet;
}
.sr-card--transcript {
grid-area: transcript;
align-self: start;
}
.sr-session-id {
grid-area: session;
@ -243,56 +202,52 @@
align-self: start;
}
@media (min-width: 1181px) {
.sr-cols--supervisor {
grid-template-columns: minmax(0, 1fr) minmax(286px, 340px);
grid-template-areas: "main side";
align-items: start;
}
.sr-cols--supervisor .sr-left {
grid-area: main;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"overview overview"
"chart flow"
"transcript transcript";
gap: var(--sp-4);
align-items: start;
}
.sr-cols--supervisor .sr-right {
grid-area: side;
min-width: 0;
display: grid;
gap: var(--sp-4);
align-items: start;
}
.sr-cols--supervisor .sr-left > *,
.sr-cols--supervisor .sr-right > * {
grid-area: auto;
}
.sr-cols--supervisor .sr-overview {
grid-area: overview;
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
}
.sr-cols--supervisor .sr-card--chart {
grid-area: chart;
}
.sr-cols--supervisor .sr-card--flow {
grid-area: flow;
}
.sr-cols--supervisor .sr-card--transcript {
grid-area: transcript;
align-self: start;
}
/* ── 리뷰 영역 가로 탭 ── */
.sr-tabs {
display: flex;
gap: 6px;
padding: 4px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
}
.sr-root--empty .sr-card--side {
padding-top: var(--sp-5);
padding-bottom: var(--sp-5);
.sr-tabs button {
flex: 1 1 0;
min-width: 0;
padding: 9px 12px;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--text-body);
font-size: var(--fs-sm);
font-weight: 700;
cursor: pointer;
}
.sr-tabs button:hover {
color: var(--text-strong);
}
.sr-tabs button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.sr-tabs button.is-active {
background: var(--bg-surface);
border-color: var(--border-strong);
color: var(--text-strong);
box-shadow: var(--shadow-sm);
}
/* 대기 자리표시자는 사이드 레일에서 컴팩트하게 — 큰 빈 박스 금지 */
.sr-right .sr-empty {
gap: 3px;
padding: 10px 12px;
}
.sr-right .sr-empty .vg-empty__title {
font-size: var(--fs-sm);
}
.sr-right .sr-empty .vg-empty__desc {
font-size: var(--fs-xs);
line-height: 1.5;
}
.sr-teacher-review__head {
display: flex;
@ -1493,7 +1448,7 @@
background: linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98));
}
[data-theme="dark"] .sr-overview {
border-color: rgba(111, 179, 164, 0.2);
border-color: rgba(125, 162, 148, 0.2);
background: linear-gradient(180deg, rgba(32, 50, 45, 0.94), rgba(18, 31, 28, 0.98));
}
[data-theme="dark"] .sr-card--worksheet {
@ -1533,7 +1488,7 @@
background: rgba(212, 162, 74, 0.12);
}
[data-theme="dark"] .sr-turn--active .sr-turn__said {
background: rgba(111, 179, 164, 0.14);
background: rgba(125, 162, 148, 0.14);
}
[data-theme="dark"] .sr-feedback--filled {
border-color: rgba(203, 227, 220, 0.12);
@ -1541,76 +1496,16 @@
}
@media (max-width: 1180px) {
.sr-cols {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"overview overview"
"chart flow"
"rubric rubric"
"good growth"
"transcript transcript"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-root--empty .sr-cols {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"overview overview"
"chart flow"
"transcript transcript"
"rubric rubric"
"good growth"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-cols--supervisor {
grid-template-areas:
"overview overview"
"chart flow"
"teacher teacher"
"rubric rubric"
"good growth"
"transcript transcript"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview overview"
"chart flow"
"teacher teacher"
"transcript transcript"
"rubric rubric"
"good growth"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-overview {
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
grid-template-columns: minmax(0, 1fr);
}
.sr-readiness {
.sr-overview .sr-readiness {
grid-column: 1;
grid-row: auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sr-actions {
display: flex;
flex-wrap: wrap;
}
.sr-actions .vg-btn {
width: auto;
}
.sr-worksheet {
max-height: 520px;
max-height: none;
}
}
@ -1626,67 +1521,19 @@
.sr-head__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sr-cols {
.sr-cols,
.sr-cols--supervisor,
.sr-root--empty .sr-cols,
.sr-root--empty .sr-cols--supervisor {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"overview"
"chart"
"flow"
"rubric"
"good"
"growth"
"prepost"
"transcript"
"insight"
"feedback"
"worksheet"
"session";
gap: var(--sp-4);
}
.sr-root--empty .sr-cols {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"overview"
"chart"
"flow"
"transcript"
"rubric"
"good"
"growth"
"prepost"
"feedback"
"worksheet"
"session";
}
.sr-cols--supervisor {
grid-template-areas:
"overview"
"chart"
"flow"
"teacher"
"rubric"
"good"
"growth"
"prepost"
"transcript"
"feedback"
"worksheet"
"session";
}
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview"
"chart"
"flow"
"teacher"
"transcript"
"rubric"
"good"
"growth"
"prepost"
"feedback"
"worksheet"
"session";
}
.sr-summary {
font-size: 20px;
line-height: 1.45;
@ -1707,7 +1554,7 @@
flex: 1 1 150px;
}
.sr-worksheet {
max-height: 460px;
max-height: none; /* 워크시트 탭이 전용 화면이므로 내부 스크롤 불필요 */
}
.sr-ws-toolbar {
align-items: stretch;
@ -1824,3 +1671,42 @@
transition-duration: 0.01ms;
}
}
/* 2026-07-15 소유자 피드백: 리뷰는 폭에서 축어록/피드백/워크시트 탭을 유지한다.
기존 데스크톱 3컬럼은 정보 밀도보다 시선 이동과 공간이 커서 사용하지 않는다. */
.sr-cols,
.sr-cols--supervisor,
.sr-root--empty .sr-cols,
.sr-root--empty .sr-cols--supervisor {
grid-template-columns: minmax(0, 1fr);
}
.sr-cols--tab-transcript {
grid-template-areas:
"transcript"
"session";
}
.sr-cols--tab-insights {
grid-template-areas:
"insight"
"feedback"
"session";
}
.sr-cols--tab-worksheet {
grid-template-areas:
"worksheet"
"session";
}
.sr-cols:not(.sr-cols--tab-transcript) .sr-card--transcript,
.sr-cols:not(.sr-cols--tab-insights) .sr-left,
.sr-cols:not(.sr-cols--tab-insights) .sr-right,
.sr-cols:not(.sr-cols--tab-worksheet) .sr-card--worksheet {
display: none;
}
.sr-cols--supervisor .sr-left {
grid-template-columns: minmax(0, 1fr);
}

View file

@ -1,6 +1,8 @@
/* =====================================================================
Session 페이지 전용 스타일 ( 페이지에서만 사용. 공통 ui.css 분리)
토큰: tokens.css 변수만 참조. DESIGN_CONCEPT §5 3-column 골격.
토큰: 공통 의미는 tokens.css, 몰입형 dark stage와 아트 합성 색은 파일의
.sx-page--active 국소 팔레트가 소유한다. raw color는 디자인 SSOT 게이트의
고정 예산을 넘길 없다. DESIGN_CONCEPT §0.3, §5 3-column 골격.
철칙: border-left 0 · 이모지 0(아이콘 inline SVG) · 카드격자 덤프 0 ·
순흑/순백 금지 · 둥근모서리 과다 금지 · 강조는 weight/tint/kicker/dot ·
화면 목적(= 가상 내담자와 듣고 말하기). 빨강은 종료에만.
@ -39,35 +41,42 @@
background: transparent;
padding: 0;
}
/* 프리플라이트(목표 선택 포함) 데스크톱에서 문서 스크롤 없이 화면에 들어가야 한다.
기본 패딩(48/64) 과해 세로가 px 넘치던 회귀 수정 (2026-07-15). */
.vg-main:has(.sx-page--prestart) {
padding-top: var(--sp-5);
padding-bottom: var(--sp-5);
}
.sx-page--active {
--paper-2: #1b2b27;
--bg-surface: #14231f;
--bg-surface-2: #1d2d29;
--bg-stage: #0c1714;
--text-strong: #edf7f4;
--text-body: #c9d9d5;
--text-muted: #8fa29c;
--text-on-accent: #071411;
--border-subtle: rgba(169, 215, 204, 0.15);
--border-strong: rgba(169, 215, 204, 0.24);
--border-focus: #7cc8b8;
--focus-ring: rgba(124, 200, 184, 0.32);
--accent: #79bfae;
--accent-deep: #9ad3c7;
--accent-tint: rgba(121, 191, 174, 0.16);
--accent-bright: #79c8b7;
--clay: #d08b70;
--clay-deep: #ebb196;
--clay-tint: rgba(208, 139, 112, 0.15);
/* 2026-07-14 채도 완화: 회기 화면 국소 팔레트를 마른 세이지 톤으로 (한신대 피드백) */
--paper-2: #1a2723;
--bg-surface: #141f1c;
--bg-surface-2: #1c2925;
--bg-stage: #0d1512;
--text-strong: #ecf2ef;
--text-body: #c7d2ce;
--text-muted: #8d9c96;
--text-on-accent: #0a1310;
--border-subtle: rgba(178, 199, 191, 0.14);
--border-strong: rgba(178, 199, 191, 0.22);
--border-focus: #83a89b;
--focus-ring: rgba(131, 168, 155, 0.3);
--accent: #83a89b;
--accent-deep: #a3bfb4;
--accent-tint: rgba(131, 168, 155, 0.14);
--accent-bright: #8db1a4;
--clay: #c98f76;
--clay-deep: #e0ac93;
--clay-tint: rgba(201, 143, 118, 0.14);
height: 100vh;
height: 100dvh;
grid-template-rows: min-content minmax(0, 1fr) min-content;
padding: 10px 14px 14px;
gap: 10px;
background:
radial-gradient(circle at 18% 8%, rgba(121, 191, 174, 0.12), transparent 28%),
radial-gradient(circle at 86% 12%, rgba(208, 139, 112, 0.1), transparent 24%),
linear-gradient(135deg, #0a1210 0%, #0e1714 48%, #101d19 100%);
radial-gradient(circle at 18% 8%, rgba(131, 168, 155, 0.09), transparent 28%),
radial-gradient(circle at 86% 12%, rgba(201, 143, 118, 0.07), transparent 24%),
linear-gradient(135deg, #0b1210 0%, #0e1513 48%, #101a16 100%);
color: var(--text-strong);
color-scheme: dark;
}
@ -164,9 +173,9 @@
gap: 7px;
}
.sx-sessionbar .sx-sessionbar__review {
border-color: rgba(145, 200, 189, 0.3);
background: rgba(145, 200, 189, 0.14);
color: #c6efe4;
border-color: rgba(151, 175, 166, 0.3);
background: rgba(151, 175, 166, 0.14);
color: #d0ddd7;
}
/* ── 페이지 헤드라인 + 가로 회기 단계 미니 트랙 ── */
@ -598,7 +607,7 @@
}
.sx-page--active .sx-stage {
background:
radial-gradient(circle at 50% 47%, rgba(121, 191, 174, 0.22), transparent 36%),
radial-gradient(circle at 50% 47%, rgba(131, 168, 155, 0.22), transparent 36%),
radial-gradient(circle at 50% 82%, rgba(208, 139, 112, 0.12), transparent 24%),
linear-gradient(145deg, rgba(11, 22, 19, 0.98), rgba(20, 34, 30, 0.96) 56%, rgba(10, 19, 17, 0.98));
border-color: rgba(169, 215, 204, 0.18);
@ -1182,7 +1191,7 @@
}
.sx-page--active button:focus-visible,
.sx-page--active textarea:focus-visible {
outline: 2px solid rgba(124, 200, 184, 0.88);
outline: 2px solid rgba(131, 168, 155, 0.88);
outline-offset: 2px;
}
.sx-turn-error {
@ -1882,7 +1891,7 @@
}
.sx-safety--ok .sx-safety__ico,
.sx-safety--ok .sx-safety__text {
color: #9ed6ca;
color: #a6bcb4;
}
.sx-safety__head {
display: flex;
@ -2024,13 +2033,13 @@
background: rgba(237, 247, 244, 0.11);
}
.sx-page--active .sx-review-button {
background: rgba(145, 200, 189, 0.14);
border-color: rgba(145, 200, 189, 0.3);
color: #c6efe4;
background: rgba(151, 175, 166, 0.14);
border-color: rgba(151, 175, 166, 0.3);
color: #d0ddd7;
}
.sx-page--active .sx-review-button:hover {
background: rgba(145, 200, 189, 0.2);
color: #ecfffa;
background: rgba(151, 175, 166, 0.2);
color: #edf3f0;
}
.sx-page--active .sx-ctx__row,
@ -2083,14 +2092,14 @@
border-color: rgba(229, 173, 149, 0.22);
}
.sx-page--active .sx-utt.is-learner .sx-utt__line {
background: rgba(111, 179, 164, 0.13);
background: rgba(125, 162, 148, 0.13);
color: #edf5f2;
}
.sx-page--active .sx-utt.is-client .sx-utt__spk {
color: #e5ad95;
}
.sx-page--active .sx-utt.is-learner .sx-utt__spk {
color: #9ed1c6;
color: #a4bab2;
}
.sx-page--active .sx-compose textarea {
background: rgba(7, 18, 15, 0.58);
@ -2112,7 +2121,7 @@
box-shadow: 0 0 0 1px rgba(237, 247, 244, 0.08), 0 10px 22px rgba(3, 12, 10, 0.24);
}
.sx-page--active .sx-compose .vg-btn--primary:hover:not(:disabled) {
background: #8ad1c2;
background: #93b3a7;
}
/* 마이크 (주 컨트롤, 음성 호흡 펄스) — 원형 예외 허용 */
.sx-mic-block {
@ -2626,9 +2635,9 @@
/* ── 시작 전 오버레이 (세션 시작) ── */
.sx-prestart {
width: min(1120px, 100%);
width: 100%;
display: grid;
grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) minmax(210px, 270px);
grid-template-columns: minmax(220px, 0.82fr) minmax(360px, 1.72fr) minmax(260px, 0.98fr);
align-items: center;
gap: var(--sp-5);
max-width: none;
@ -2638,13 +2647,8 @@
justify-self: center;
text-align: left;
position: relative;
background:
linear-gradient(132deg, rgba(15, 28, 26, 0.96), rgba(31, 44, 40, 0.92) 56%, rgba(46, 55, 49, 0.86)),
var(--asset-warm-elements) center / cover no-repeat;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius-lg);
box-shadow: 0 18px 52px rgba(6, 14, 13, 0.34);
color: #eef4f2;
color: var(--text-strong);
overflow: hidden;
}
.sx-page--prestart .sx-head {
@ -2653,33 +2657,33 @@
}
.sx-page--prestart .sx-head__title,
.sx-page--prestart .sx-ph.is-cur .sx-ph__name {
color: #f3f8f6;
color: var(--text-strong);
}
.sx-page--prestart .sx-head__title em {
color: #91c8bd;
color: var(--accent-deep);
}
.sx-page--prestart .sx-head__sub,
.sx-page--prestart .sx-ph__name,
.sx-page--prestart .sx-ph__t {
color: rgba(238, 244, 242, 0.62);
color: var(--text-muted);
}
.sx-page--prestart .sx-ph__dot {
background: rgba(255, 255, 255, 0.09);
border-color: rgba(255, 255, 255, 0.22);
background: var(--bg-surface-2);
border-color: var(--border-strong);
}
.sx-page--prestart .sx-ph__link {
background: rgba(255, 255, 255, 0.18);
background: var(--border-subtle);
}
.sx-page--prestart .sx-ph__link.is-fill {
background: rgba(145, 200, 189, 0.74);
background: var(--accent-bright);
}
.sx-page--prestart .sx-prestart::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.08), transparent 38%),
radial-gradient(circle at 20% 22%, rgba(126, 184, 173, 0.22), transparent 32%);
linear-gradient(90deg, color-mix(in srgb, var(--text-strong) 6%, transparent), transparent 38%),
radial-gradient(circle at 20% 22%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 32%);
pointer-events: none;
}
.sx-prestart__visual {
@ -2692,11 +2696,7 @@
align-self: stretch;
align-content: center;
padding: var(--sp-4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius);
background:
radial-gradient(circle at 50% 45%, rgba(145, 200, 189, 0.2), transparent 52%),
rgba(251, 250, 248, 0.06);
}
.sx-prestart__case {
width: 100%;
@ -2718,13 +2718,13 @@
}
.sx-prestart__case b {
display: block;
color: #f4f8f7;
color: var(--text-strong);
font-size: 18px;
line-height: 1.35;
}
.sx-prestart__case p {
margin: 5px 0 0;
color: rgba(238, 244, 242, 0.68);
color: var(--text-body);
font-size: 13px;
line-height: 1.45;
}
@ -2740,17 +2740,15 @@
display: grid;
gap: 3px;
padding: 9px 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.055);
}
.sx-prestart__case li span {
color: rgba(238, 244, 242, 0.52);
color: var(--text-muted);
font-size: 11px;
font-weight: 760;
}
.sx-prestart__case li em {
color: rgba(244, 248, 247, 0.94);
color: var(--text-strong);
font-size: 12.5px;
font-style: normal;
font-weight: 680;
@ -2769,7 +2767,7 @@
display: inline-flex;
align-items: center;
gap: 8px;
color: #a8d4ca;
color: var(--accent-deep);
font-family: var(--font-num);
font-size: var(--fs-kicker);
font-weight: 700;
@ -2786,13 +2784,13 @@
font-size: var(--fs-h2);
font-weight: 700;
letter-spacing: 0;
color: #f4f8f7;
color: var(--text-strong);
line-height: 1.3;
}
.sx-prestart__desc {
margin-top: var(--sp-3);
font-size: var(--fs-sm);
color: rgba(238, 244, 242, 0.76);
color: var(--text-body);
line-height: 1.6;
max-width: 58ch;
}
@ -2806,19 +2804,19 @@
gap: var(--sp-4);
}
.sx-page--prestart .sx-prestart__facts {
border-color: rgba(255, 255, 255, 0.13);
border-color: var(--glass-inset-border);
}
.sx-prestart__facts div {
min-width: 0;
}
.sx-prestart__facts dt {
color: rgba(238, 244, 242, 0.52);
color: var(--text-muted);
font-size: 11.5px;
font-weight: 700;
}
.sx-prestart__facts dd {
margin: 6px 0 0;
color: #f4f8f7;
color: var(--text-strong);
font-size: 13.5px;
font-weight: 650;
line-height: 1.4;
@ -2839,15 +2837,15 @@
min-height: 26px;
padding: 0 10px;
border-radius: var(--radius-sm);
background: rgba(145, 200, 189, 0.16);
color: #bfe0d9;
background: var(--accent-tint);
color: var(--accent-deep);
font-family: var(--font-num);
font-size: 11.5px;
font-weight: 700;
}
.sx-prestart__chips span.is-clay {
background: rgba(204, 143, 119, 0.17);
color: #f0bda9;
background: var(--clay-tint);
color: var(--clay-deep);
}
.sx-theory {
width: min(100%, 520px);
@ -2859,7 +2857,7 @@
}
.sx-theory legend {
padding: 0;
color: rgba(238, 244, 242, 0.54);
color: var(--text-muted);
font-size: 11.5px;
font-weight: 760;
}
@ -2869,8 +2867,8 @@
gap: 6px;
padding: 4px;
border-radius: var(--radius-sm);
background: rgba(6, 15, 14, 0.26);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--glass-surface-inset);
border: 1px solid var(--glass-inset-border);
}
.sx-theory__seg button {
min-width: 0;
@ -2882,23 +2880,23 @@
border: 1px solid transparent;
border-radius: calc(var(--radius-sm) - 2px);
background: transparent;
color: rgba(238, 244, 242, 0.72);
color: var(--text-body);
text-align: center;
cursor: pointer;
}
.sx-theory__seg button:hover {
border-color: rgba(145, 200, 189, 0.3);
color: #f4f8f7;
border-color: color-mix(in srgb, var(--accent) 40%, var(--glass-inset-border));
color: var(--text-strong);
}
.sx-theory__seg button:focus-visible {
outline: 2px solid rgba(145, 200, 189, 0.7);
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.sx-theory__seg button.is-selected {
background: rgba(145, 200, 189, 0.18);
border-color: rgba(145, 200, 189, 0.46);
color: #f4f8f7;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
background: var(--accent-tint);
border-color: color-mix(in srgb, var(--accent) 58%, var(--glass-inset-border));
color: var(--text-strong);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-strong) 5%, transparent);
}
.sx-theory__seg span,
.sx-theory__seg small {
@ -2911,14 +2909,14 @@
font-weight: 760;
}
.sx-theory__seg small {
color: rgba(238, 244, 242, 0.56);
color: var(--text-muted);
font-size: 11px;
font-weight: 650;
}
.sx-prestart__note {
max-width: 460px;
font-size: 12.5px;
color: rgba(238, 244, 242, 0.6);
color: var(--text-muted);
line-height: 1.55;
margin-top: calc(var(--sp-3) * -1);
}
@ -2931,15 +2929,15 @@
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid rgba(132, 203, 179, 0.28);
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--glass-inset-border));
border-radius: var(--radius-sm);
background: rgba(30, 86, 77, 0.22);
background: color-mix(in srgb, var(--accent-tint) 72%, transparent);
}
.sx-consent label {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
color: rgba(238, 244, 242, 0.84);
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.5;
}
@ -2947,7 +2945,7 @@
width: 17px;
height: 17px;
margin-top: 2px;
accent-color: #6fc6a8;
accent-color: var(--accent);
flex: 0 0 auto;
}
.sx-consent .vg-btn {
@ -2961,7 +2959,7 @@
margin-top: var(--sp-5);
}
.sx-prestart__actions span {
color: rgba(238, 244, 242, 0.58);
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.45;
}
@ -2969,41 +2967,51 @@
min-width: 0;
align-self: stretch;
display: grid;
align-content: center;
gap: var(--sp-4);
align-content: start;
gap: var(--sp-3);
position: relative;
z-index: 1;
padding: var(--sp-4);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: clamp(18px, 1.7vw, 24px);
border-radius: var(--radius);
background: rgba(251, 250, 248, 0.07);
}
.sx-prestart__plan ol {
list-style: none;
.sx-prestart__plan-summary {
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-3);
color: var(--text-muted);
font-size: 11.5px;
line-height: 1.55;
text-wrap: pretty;
}
.sx-prestart__plan li {
.sx-prestart__plan-list {
margin: 0;
display: grid;
gap: 0;
}
.sx-prestart__plan-list div {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
gap: 5px;
padding: 12px 0;
border-top: 1px solid var(--glass-inset-border);
}
.sx-prestart__plan li span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--clay);
margin-top: 8px;
.sx-prestart__plan-list dt {
color: var(--accent-deep);
font-size: 10.5px;
font-weight: 760;
letter-spacing: 0.04em;
}
.sx-prestart__plan p {
.sx-prestart__plan-list dd {
margin: 0;
color: rgba(238, 244, 242, 0.74);
font-size: var(--fs-xs);
line-height: 1.6;
color: var(--text-body);
font-size: 11.5px;
line-height: 1.55;
text-wrap: pretty;
}
.sx-prestart__plan-list dd b {
display: block;
margin-bottom: 2px;
color: var(--text-strong);
font-size: 12.5px;
font-weight: 720;
}
/* 일시정지 stage 오버레이 (차분) */
@ -3022,7 +3030,7 @@
border: 1px solid rgba(203, 227, 220, 0.14);
border-radius: calc(var(--radius-lg) - 2px);
background:
linear-gradient(135deg, rgba(238, 244, 242, 0.08), rgba(145, 200, 189, 0.06));
linear-gradient(135deg, rgba(238, 244, 242, 0.08), rgba(151, 175, 166, 0.06));
backdrop-filter: blur(0.5px);
text-shadow: 0 1px 2px rgba(7, 16, 14, 0.5);
}
@ -3037,8 +3045,9 @@
padding-top: var(--sp-4);
border-top: 1px solid var(--hair);
}
.sx-prestart__plan ol {
grid-template-columns: repeat(3, minmax(0, 1fr));
.sx-prestart__plan-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: var(--sp-5);
}
}
@ -3083,6 +3092,21 @@
.sx-page--active .sx-col-right {
display: none;
}
/* 위기 신호가 감지되면 어떤 화면 모드에서도 안전 패널(109 리소스 포함) 노출한다.
압축 레이아웃이 안전 안내를 가리던 결함 수정 (2026-07-15). */
.sx-page--active.sx-has-safety .sx-col-right {
display: flex;
grid-column: 1 / -1;
grid-row: 3;
min-height: 0;
overflow: visible;
}
.sx-page--active.sx-has-safety:not(.sx-feedback-coached) .sx-col-right .sx-signal {
display: none;
}
.sx-page--active.sx-has-safety .sx-col-right .sx-meters {
display: none;
}
.sx-mobile-context__row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -3319,6 +3343,20 @@
.sx-page--active .sx-col-right {
display: none;
}
/* 위기 감지 시 안전 패널 노출 — ≤1180 규칙과 동일한 원칙 (2026-07-15). */
.sx-page--active.sx-has-safety .sx-col-right {
display: flex;
grid-column: 1;
grid-row: 3;
min-height: 0;
overflow: visible;
}
.sx-page--active.sx-has-safety:not(.sx-feedback-coached) .sx-col-right .sx-signal {
display: none;
}
.sx-page--active.sx-has-safety .sx-col-right .sx-meters {
display: none;
}
.sx-page--active.sx-feedback-coached .sx-grid {
grid-template-rows: auto minmax(0, 1fr) auto;
}
@ -3330,7 +3368,7 @@
overflow: visible;
}
.sx-page--active.sx-feedback-coached .sx-col-right .sx-meters,
.sx-page--active.sx-feedback-coached .sx-col-right .sx-safety,
.sx-page--active.sx-feedback-coached .sx-col-right .sx-safety--ok,
.sx-page--active.sx-feedback-coached .sx-signal__head,
.sx-page--active.sx-feedback-coached .sx-signal__one,
.sx-page--active.sx-feedback-coached .sx-signal__rest,
@ -3365,9 +3403,8 @@
.sx-page--active.sx-feedback-coached .sx-coach-bubble blockquote {
display: none;
}
.sx-page--active.sx-feedback-coached .sx-coach-bubble__actions {
display: none;
}
/* 근거·이력 접근은 코칭 신뢰성의 핵심 모바일에서도 유지한다 (2026-07-15).
공간 절약은 blockquote(발화 예시) 숨김까지만. */
.sx-head {
flex-direction: column;
align-items: flex-start;
@ -3752,7 +3789,7 @@
display: block;
max-width: min(220px, calc(100% - 12px));
padding: 3px 6px;
border: 1px solid rgba(145, 200, 189, 0.26);
border: 1px solid rgba(151, 175, 166, 0.26);
border-radius: 999px;
background: rgba(11, 24, 21, 0.9);
color: rgba(228, 250, 244, 0.9);
@ -3962,3 +3999,342 @@
animation: none !important;
}
}
/*
시간 기반 회기(2026-07-13 회의 P1): 목표 선택 · 시간 알람 · 목표 배지
*/
/* ── 시작 전: 이번 회기 목표 선택 ── */
.sx-goals {
width: min(100%, 520px);
margin: var(--sp-3) 0 0;
padding: 0;
border: 0;
display: grid;
gap: 8px;
}
.sx-goals legend {
padding: 0;
color: var(--text-muted);
font-size: 11.5px;
font-weight: 760;
}
.sx-goals legend small {
margin-left: 6px;
font-size: 11px;
font-weight: 650;
color: var(--text-muted);
}
.sx-goals__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding: 4px;
border-radius: var(--radius-sm);
background: var(--glass-surface-inset);
border: 1px solid var(--glass-inset-border);
}
.sx-goals__grid button {
min-width: 0;
min-height: 62px;
display: grid;
align-content: center;
gap: 3px;
padding: 8px 7px;
border: 1px solid transparent;
border-radius: calc(var(--radius-sm) - 2px);
background: transparent;
color: var(--text-body);
text-align: center;
cursor: pointer;
}
.sx-goals__grid button:hover {
border-color: color-mix(in srgb, var(--accent) 40%, var(--glass-inset-border));
color: var(--text-strong);
}
.sx-goals__grid button:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.sx-goals__grid button.is-selected {
background: var(--accent-tint);
border-color: color-mix(in srgb, var(--accent) 58%, var(--glass-inset-border));
color: var(--text-strong);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-strong) 5%, transparent);
}
.sx-goals__grid span {
font-size: 13px;
font-weight: 760;
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.2;
}
.sx-goals__grid small {
color: var(--text-muted);
font-size: 10.5px;
font-weight: 650;
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.25;
}
.sx-goals__hint {
margin: 0;
font-size: 12px;
line-height: 1.55;
color: var(--text-muted);
}
@media (max-width: 720px) {
.sx-goals__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ── 시간 알람 바(10분 전 경고 · 시간 만료) — 강제 노출 배너 ── */
.sx-timebar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
margin: 0 0 10px;
padding: 9px 14px;
border-radius: var(--radius);
border: 1px solid rgba(228, 179, 99, 0.5);
background:
linear-gradient(0deg, rgba(228, 179, 99, 0.12), rgba(228, 179, 99, 0.12)),
var(--bg-panel, rgba(20, 26, 24, 0.92));
color: var(--text-strong, #eef4f2);
font-size: 13px;
}
.sx-timebar b {
font-weight: 780;
letter-spacing: -0.01em;
}
.sx-timebar__desc {
color: var(--text-body, rgba(238, 244, 242, 0.74));
font-weight: 560;
min-width: 0;
overflow-wrap: anywhere;
}
.sx-timebar__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #e4b363;
box-shadow: 0 0 0 0 rgba(228, 179, 99, 0.5);
animation: sx-timebar-pulse 1.8s ease-out infinite;
}
.sx-timebar.is-over {
border-color: rgba(224, 122, 95, 0.55);
background:
linear-gradient(0deg, rgba(224, 122, 95, 0.14), rgba(224, 122, 95, 0.14)),
var(--bg-panel, rgba(20, 26, 24, 0.92));
}
.sx-timebar.is-over .sx-timebar__dot {
background: #e07a5f;
box-shadow: 0 0 0 0 rgba(224, 122, 95, 0.5);
}
.sx-timebar__end {
margin-left: auto;
padding: 6px 12px;
border-radius: calc(var(--radius-sm) - 1px);
border: 1px solid rgba(224, 122, 95, 0.6);
background: rgba(224, 122, 95, 0.16);
color: inherit;
font-size: 12.5px;
font-weight: 740;
cursor: pointer;
}
.sx-timebar__end:hover {
background: rgba(224, 122, 95, 0.26);
}
@keyframes sx-timebar-pulse {
0% { box-shadow: 0 0 0 0 rgba(228, 179, 99, 0.45); }
70% { box-shadow: 0 0 0 7px rgba(228, 179, 99, 0); }
100% { box-shadow: 0 0 0 0 rgba(228, 179, 99, 0); }
}
/* ── 스테이지 타이머 경고 상태 ── */
.sx-page--active .sx-stage__timer.is-warning {
color: #e8bd7a;
font-weight: 780;
}
/* ── 세션 진행 패널: 남은 시간 경고 + 목표 도달 노트 ── */
.sx-session-progress__grid span.is-warning b {
color: #e8bd7a;
}
.sx-session-progress__goalnote {
margin: 8px 0 0;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(151, 175, 166, 0.32);
background: rgba(151, 175, 166, 0.1);
color: var(--text-body, rgba(238, 244, 242, 0.78));
font-size: 12px;
line-height: 1.5;
}
/* ── 이번 회기 목표 배지(세로 트랙 + 헤더 미니) ── */
.sx-vstep__goal {
margin-left: 6px;
padding: 1.5px 6px;
border-radius: 999px;
border: 1px solid rgba(151, 175, 166, 0.4);
background: rgba(151, 175, 166, 0.12);
color: var(--accent-bright, #8fc0b4);
font-size: 10px;
font-weight: 740;
letter-spacing: 0.02em;
vertical-align: 1px;
}
.sx-vstep__goal.is-reached {
border-color: rgba(151, 175, 166, 0.62);
background: rgba(151, 175, 166, 0.24);
}
.sx-ph__goal {
margin-left: 4px;
padding: 0.5px 5px;
border-radius: 999px;
border: 1px solid rgba(151, 175, 166, 0.42);
background: rgba(151, 175, 166, 0.12);
color: var(--accent-bright, #8fc0b4);
font-size: 9.5px;
font-style: normal;
font-weight: 740;
}
@media (prefers-reduced-motion: reduce) {
.sx-timebar__dot {
animation: none !important;
}
}
/* ── 코칭 넛지 칩(코칭 탭이 닫혀 있을 때 도착한 코치 제안 안내) ── */
.sx-seg-block {
position: relative;
}
.sx-coach-nudge {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(151, 175, 166, 0.5);
background: var(--bg-panel, rgba(16, 22, 21, 0.97));
color: var(--text-strong, #eef4f2);
font-size: 12px;
font-weight: 640;
cursor: pointer;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
z-index: 30;
}
.sx-coach-nudge::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: rgba(151, 175, 166, 0.5);
}
.sx-coach-nudge:hover {
border-color: rgba(151, 175, 166, 0.8);
}
.sx-coach-nudge em {
font-style: normal;
font-weight: 780;
color: var(--accent-bright, #8fc0b4);
}
.sx-coach-nudge__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent-bright, #8fc0b4);
animation: sx-timebar-pulse 1.8s ease-out infinite;
}
.sx-segmented__badge.is-pulsing {
animation: sx-coach-badge-pulse 1.6s ease-in-out infinite;
}
@keyframes sx-coach-badge-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.18); }
}
@media (prefers-reduced-motion: reduce) {
.sx-coach-nudge__dot,
.sx-segmented__badge.is-pulsing {
animation: none !important;
}
}
/* ── P2 단계 누적 게이지(세로 트랙) ── */
.sx-vstep__gauge {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.sx-vstep__gauge .sx-gauge {
flex: 1 1 auto;
min-width: 0;
}
.sx-vstep__gauge em {
flex: 0 0 auto;
font-style: normal;
font-size: 10.5px;
font-weight: 740;
font-variant-numeric: tabular-nums;
color: var(--text-dim, rgba(238, 244, 242, 0.55));
min-width: 20px;
text-align: right;
}
.sx-track__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
font-size: 11px;
line-height: 1.5;
color: var(--text-dim, rgba(238, 244, 242, 0.5));
}
/* ── 패널 컬랩싱 토글(과밀 완화, 2026-07-14) ── */
.sx-panel-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.sx-panel-toggle:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
border-radius: 4px;
}
.sx-panel-toggle__hint {
flex: 0 0 auto;
font-size: 10.5px;
font-weight: 680;
color: var(--text-muted, rgba(238, 244, 242, 0.5));
white-space: nowrap;
}
.sx-panel-toggle:hover .sx-panel-toggle__hint {
color: var(--accent-bright);
}
.sx-panel.is-collapsed {
padding-bottom: 10px;
}
.sx-safety .sx-panel-toggle {
justify-content: flex-start;
}
.sx-safety .sx-panel-toggle .sx-safety__badge {
margin-left: auto;
}

View file

@ -0,0 +1,466 @@
import type { CrisisResource, SessionProgress, SessionStage } from "../../lib/api";
export type VoiceStatus =
| "idle"
| "requesting"
| "connecting"
| "recording"
| "thinking"
| "speaking"
| "degraded"
| "error";
type VoiceServerState = "idle" | "listening" | "thinking" | "speaking";
export interface VoiceEvent {
type?: string;
code?: string;
state?: VoiceServerState;
text?: string;
final?: boolean;
reason?: string;
detail?: string;
stage?: SessionStage;
effective_openness?: number;
turn_seq?: number;
safety_flagged?: boolean;
crisis_resource?: CrisisResource | null;
conversation_stopped?: boolean;
progress?: SessionProgress | null;
}
export const VOICE_TURN_SAVE_FAILED =
"음성 발화를 저장하지 못했습니다. 방금 말한 내용을 확인한 뒤 다시 시도해 주세요.";
export const VOICE_CONNECTION_SAVE_FAILED =
"음성 연결이 종료되어 발화를 저장하지 못했습니다. 방금 말한 내용을 확인한 뒤 다시 시도해 주세요.";
export function userFacingVoiceError(payload: VoiceEvent): string {
const detail = payload.detail ?? "";
if (
payload.code === "turn_persistence_unavailable" ||
(detail.includes("turn append") && detail.includes("persistence unavailable"))
) {
return VOICE_TURN_SAVE_FAILED;
}
return detail || "음성 처리 중 오류가 발생했습니다.";
}
export type AudioContextWindow = Window & { webkitAudioContext?: typeof AudioContext };
type VoiceCaptureMode = "audio-worklet" | "media-recorder";
interface VoiceProviderEvent {
type: string;
source: string;
duration_ms?: number;
confidence?: number;
}
export interface VoiceCaptureControl {
type: "audio_start" | "audio_end";
format: string;
sample_rate?: number;
channels?: number;
sample_width?: number;
silence_ms?: number;
barge_in?: boolean;
provider_events?: VoiceProviderEvent[];
}
export interface VoiceCaptureController {
mode: VoiceCaptureMode;
startControl: VoiceCaptureControl;
start: () => Promise<void> | void;
finish: () => void;
abort: () => void;
isRecording: () => boolean;
}
interface VoiceCaptureMetrics {
durationMs?: number;
voiceMs?: number;
silenceMs?: number;
trailingSilenceMs?: number;
rms?: number;
peak?: number;
}
interface VoiceWorkletMessage {
type?: string;
pcm?: ArrayBuffer;
metrics?: VoiceCaptureMetrics;
}
interface SessionVoiceStatusInput {
voiceStatus: VoiceStatus;
sessionEnded: boolean;
paused: boolean;
sending: boolean;
clientReplyPending: boolean;
clientName: string;
utteranceCount: number;
voiceAvailable: boolean | null;
micOn: boolean;
}
export interface SessionVoiceStatusView {
micDisabled: boolean;
micLabel: string;
micButtonAriaLabel: string;
sessionStatusLabel: string;
transcriptLiveLabel: string;
transcriptIsLive: boolean;
textTurnBlocked: boolean;
voiceInputStatus: string;
responseStatus: string;
}
export function isVoiceStatusBusy(voiceStatus: VoiceStatus): boolean {
return (
voiceStatus === "requesting" ||
voiceStatus === "connecting" ||
voiceStatus === "thinking" ||
voiceStatus === "speaking"
);
}
export function sessionVoiceStatusView(input: SessionVoiceStatusInput): SessionVoiceStatusView {
const {
voiceStatus,
sessionEnded,
paused,
sending,
clientReplyPending,
clientName,
utteranceCount,
voiceAvailable,
micOn,
} = input;
const micBusy = isVoiceStatusBusy(voiceStatus);
const micDisabled = sessionEnded || paused || micBusy || voiceAvailable === false;
const micButtonAriaLabel = micOn ? "발화 보내기" : "마이크 켜기";
const micLabel = sessionEnded
? "종료됨"
: paused
? "일시정지"
: voiceStatus === "requesting"
? "권한 요청 중"
: voiceStatus === "connecting"
? "연결 중"
: voiceStatus === "recording"
? "녹음 중"
: voiceStatus === "thinking"
? "전사 중"
: voiceStatus === "speaking"
? "재생 중"
: voiceStatus === "degraded"
? "음성 미설정"
: voiceStatus === "error"
? "마이크 오류"
: "마이크 꺼짐";
const sessionStatusLabel = sessionEnded
? "회기 종료됨"
: paused
? "일시정지"
: voiceStatus === "recording"
? "학습자 발화 수신 중"
: voiceStatus === "thinking"
? "내담자 응답 준비 중"
: voiceStatus === "speaking"
? `${clientName} 응답 중`
: "회기 진행 중";
const transcriptLiveLabel =
voiceStatus === "recording"
? "받아쓰는 중"
: voiceStatus === "thinking"
? "전사 중"
: voiceStatus === "speaking"
? "응답 표시 중"
: sending
? "응답 대기"
: utteranceCount > 0
? "기록 중"
: "준비됨";
const transcriptIsLive =
voiceStatus === "recording" ||
voiceStatus === "thinking" ||
voiceStatus === "speaking" ||
sending;
const textTurnBlocked = sending || voiceStatus === "thinking";
const voiceInputStatus =
voiceAvailable === false
? "음성 미설정"
: micOn
? "수신 중"
: voiceStatus === "requesting"
? "권한 요청"
: "대기";
const responseStatus =
sending || clientReplyPending
? "응답 대기"
: voiceStatus === "thinking"
? "전사 중"
: voiceStatus === "speaking"
? "재생 중"
: "안정";
return {
micDisabled,
micLabel,
micButtonAriaLabel,
sessionStatusLabel,
transcriptLiveLabel,
transcriptIsLive,
textTurnBlocked,
voiceInputStatus,
responseStatus,
};
}
const VOICE_WORKLET_MODULE_URL = "/worklets/voice-capture-worklet.js";
const VOICE_WORKLET_PROCESSOR = "voice-capture-processor";
function recorderMimeType(): string {
if (typeof MediaRecorder === "undefined") return "";
const supported = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"];
return supported.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? "";
}
function voiceFormatFromMime(mime: string): string {
if (mime.includes("ogg")) return "ogg";
if (mime.includes("mp4")) return "mp4";
return "webm";
}
export function sendVoiceControl(ws: WebSocket, payload: VoiceCaptureControl): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function roundedNonnegativeMs(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
return Math.max(0, Math.round(value));
}
function voiceActivityConfidence(metrics: VoiceCaptureMetrics): number | undefined {
const peak = typeof metrics.peak === "number" && Number.isFinite(metrics.peak) ? metrics.peak : 0;
if (peak <= 0) return undefined;
return Math.max(0.01, Math.min(0.99, Number(peak.toFixed(2))));
}
function voiceProviderEventsFromMetrics(
metrics: VoiceCaptureMetrics | null,
source: string,
): VoiceProviderEvent[] {
if (!metrics) return [];
const durationMs = roundedNonnegativeMs(metrics.durationMs);
const voiceMs = roundedNonnegativeMs(metrics.voiceMs);
const trailingSilenceMs = roundedNonnegativeMs(metrics.trailingSilenceMs);
const events: VoiceProviderEvent[] = [];
if (voiceMs !== undefined) {
const activity: VoiceProviderEvent = {
type: "voice_activity",
source,
duration_ms: voiceMs,
};
const confidence = voiceActivityConfidence(metrics);
if (confidence !== undefined) activity.confidence = confidence;
events.push(activity);
} else if (durationMs !== undefined) {
events.push({ type: "voice_activity", source, duration_ms: durationMs });
}
if (trailingSilenceMs !== undefined && trailingSilenceMs >= 1000) {
events.push({ type: "silence", source, duration_ms: trailingSilenceMs });
}
return events;
}
function audioEndControlWithMetrics(
base: VoiceCaptureControl,
metrics: VoiceCaptureMetrics | null,
source: string,
): VoiceCaptureControl {
const silenceMs = roundedNonnegativeMs(metrics?.trailingSilenceMs);
const providerEvents = voiceProviderEventsFromMetrics(metrics, source);
return {
...base,
...(silenceMs !== undefined ? { silence_ms: silenceMs } : {}),
barge_in: false,
...(providerEvents.length ? { provider_events: providerEvents } : {}),
};
}
export function supportsAudioWorkletCapture(ctx: AudioContext | null): ctx is AudioContext & {
audioWorklet: AudioWorklet;
} {
return Boolean(
ctx &&
ctx.state !== "closed" &&
ctx.audioWorklet &&
typeof AudioWorkletNode !== "undefined" &&
typeof ctx.createMediaStreamSource === "function",
);
}
export function createMediaRecorderCapture(
stream: MediaStream,
ws: WebSocket,
onRelease: () => void,
): VoiceCaptureController | null {
if (typeof MediaRecorder === "undefined") return null;
const mimeType = recorderMimeType();
const format = voiceFormatFromMime(mimeType);
const startControl: VoiceCaptureControl = { type: "audio_start", format };
const endControl: VoiceCaptureControl = { type: "audio_end", format };
let recorder: MediaRecorder;
try {
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
} catch {
return null;
}
let finishRequested = false;
let released = false;
let startedAt = 0;
const release = () => {
if (released) return;
released = true;
onRelease();
};
recorder.ondataavailable = (event) => {
if (!event.data.size || ws.readyState !== WebSocket.OPEN) return;
ws.send(event.data);
};
recorder.onstop = () => {
release();
if (finishRequested) {
const durationMs = startedAt > 0 ? performance.now() - startedAt : undefined;
sendVoiceControl(
ws,
audioEndControlWithMetrics(endControl, { durationMs, voiceMs: durationMs }, "browser_media_recorder"),
);
}
};
return {
mode: "media-recorder",
startControl,
start: () => {
startedAt = performance.now();
recorder.start(500);
},
finish: () => {
finishRequested = true;
if (recorder.state !== "inactive") {
try {
recorder.stop();
return;
} catch {
/* recorder stop race 무시 */
}
}
release();
const durationMs = startedAt > 0 ? performance.now() - startedAt : undefined;
sendVoiceControl(
ws,
audioEndControlWithMetrics(endControl, { durationMs, voiceMs: durationMs }, "browser_media_recorder"),
);
},
abort: () => {
finishRequested = false;
if (recorder.state !== "inactive") {
try {
recorder.stop();
} catch {
/* recorder stop race 무시 */
}
}
release();
},
isRecording: () => recorder.state === "recording",
};
}
export async function createAudioWorkletCapture(
stream: MediaStream,
ws: WebSocket,
ctx: AudioContext & { audioWorklet: AudioWorklet },
onRelease: () => void,
): Promise<VoiceCaptureController> {
await ctx.audioWorklet.addModule(VOICE_WORKLET_MODULE_URL);
const source = ctx.createMediaStreamSource(stream);
const node = new AudioWorkletNode(ctx, VOICE_WORKLET_PROCESSOR, {
numberOfInputs: 1,
numberOfOutputs: 0,
});
const startControl: VoiceCaptureControl = {
type: "audio_start",
format: "pcm",
sample_rate: Math.round(ctx.sampleRate || 48000),
channels: 1,
sample_width: 2,
};
const endControl: VoiceCaptureControl = { ...startControl, type: "audio_end" };
let recording = false;
let released = false;
let finishTimer: number | null = null;
let latestMetrics: VoiceCaptureMetrics | null = null;
const release = () => {
if (released) return;
released = true;
if (finishTimer !== null) {
window.clearTimeout(finishTimer);
finishTimer = null;
}
try {
source.disconnect();
node.disconnect();
} catch {
/* disconnect race 무시 */
}
node.port.onmessage = null;
try {
node.port.close();
} catch {
/* 일부 브라우저/테스트 double은 close가 없을 수 있다. */
}
onRelease();
};
node.port.onmessage = (event: MessageEvent<VoiceWorkletMessage>) => {
if (!recording || ws.readyState !== WebSocket.OPEN) return;
const payload = event.data;
if (payload?.type !== "chunk" || !payload.pcm?.byteLength) return;
latestMetrics = payload.metrics ?? latestMetrics;
ws.send(payload.pcm);
};
return {
mode: "audio-worklet",
startControl,
start: () => {
recording = true;
source.connect(node);
},
finish: () => {
if (!recording || finishTimer !== null) return;
try {
node.port.postMessage({ type: "flush" });
} catch {
/* flush 실패 시 이미 보낸 chunk만 사용한다. */
}
finishTimer = window.setTimeout(() => {
finishTimer = null;
recording = false;
release();
sendVoiceControl(ws, audioEndControlWithMetrics(endControl, latestMetrics, "browser_audio_worklet"));
}, 20);
},
abort: () => {
recording = false;
release();
},
isRecording: () => recording,
};
}

View file

@ -5,44 +5,6 @@
강조 = weight + accent 텍스트 + accent-tint 배경 + kicker(+dot).
===================================================================== */
/* ── 레이아웃: 좌측 섹션 내비(sticky) + 우측 폼 ── */
body[data-page="settings"] .vg-shell {
background: var(--bg-app);
}
body[data-page="settings"] .vg-topbar {
background: var(--bg-surface);
border-bottom-color: var(--hair);
color: var(--text-strong);
}
body[data-page="settings"] .vg-topbar__wm,
body[data-page="settings"] .vg-topbar__wm .v,
body[data-page="settings"] .vg-topbar__role,
body[data-page="settings"] .vg-topbar__uname,
body[data-page="settings"] .vg-iconbtn,
body[data-page="settings"] .vg-topbar__mark {
color: var(--text-strong);
}
body[data-page="settings"] .vg-topbar__brand svg {
color: var(--accent);
}
body[data-page="settings"] .vg-topbar__role {
border-left-color: var(--hair);
}
body[data-page="settings"] .vg-topbar__user {
background: var(--bg-surface-2);
}
body[data-page="settings"] .vg-topbar__avatar {
background: var(--accent-tint);
color: var(--accent-deep);
}
body[data-page="settings"] .vg-iconbtn:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
}
body[data-page="settings"] .vg-main {
padding: var(--sp-6) var(--sp-5) var(--sp-8);
}
.vg-set {
display: grid;
grid-template-columns: 204px minmax(0, 780px);
@ -1214,19 +1176,6 @@ body[data-page="settings"] .vg-main {
}
/* Image-aligned workspace: generated settings concept -> production controls. */
body[data-page="settings"] .vg-shell {
min-height: 100dvh;
background:
radial-gradient(circle at 8% 0%, rgba(222, 238, 234, 0.62), transparent 34%),
linear-gradient(135deg, rgba(252, 250, 247, 0.97), rgba(244, 242, 237, 0.94)),
var(--asset-warm-elements) center / cover no-repeat;
}
body[data-page="settings"] .vg-main {
padding: 0;
}
body[data-page="settings"] .vg-main__inner {
max-width: none;
}
body[data-page="settings"] .vg-set__callout {
max-width: 1120px;
margin: var(--sp-5) auto 0;
@ -1247,12 +1196,7 @@ body[data-page="settings"] .vg-set__rail {
top: 24px;
min-height: calc(100dvh - 60px);
padding: 24px 16px 18px;
border: 1px solid rgba(38, 101, 92, 0.14);
border-radius: var(--radius);
background:
linear-gradient(180deg, rgba(238, 248, 245, 0.92), rgba(248, 250, 248, 0.72)),
rgba(255, 255, 255, 0.82);
box-shadow: 0 24px 58px rgba(37, 45, 42, 0.08);
}
body[data-page="settings"] .vg-set__rail-title {
display: flex;
@ -1289,10 +1233,7 @@ body[data-page="settings"] .vg-set__nav {
position: static;
padding: 0;
gap: 10px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
border-radius: var(--radius);
}
body[data-page="settings"] .vg-set__nav-kicker {
display: none;
@ -1301,21 +1242,21 @@ body[data-page="settings"] .vg-set__nav-item {
min-height: 49px;
padding: 0 14px;
border-radius: var(--radius);
color: #1f5f58;
color: var(--accent-deep);
font-size: 15px;
font-weight: 650;
}
body[data-page="settings"] .vg-set__nav-item:hover {
background: rgba(24, 121, 103, 0.09);
color: #124d47;
background: var(--accent-tint);
color: var(--accent-deep);
}
body[data-page="settings"] .vg-set__nav-item.is-active {
background: linear-gradient(135deg, #187967, #106759);
color: #ffffff;
box-shadow: 0 12px 24px rgba(16, 103, 89, 0.22);
background: var(--accent);
color: var(--text-on-accent);
box-shadow: var(--shadow-sm);
}
body[data-page="settings"] .vg-set__nav-item.is-active svg {
color: #ffffff;
color: var(--text-on-accent);
}
body[data-page="settings"] .vg-set__rail-status {
display: flex;
@ -1351,10 +1292,7 @@ body[data-page="settings"] #set-notify {
}
body[data-page="settings"] .vg-set__group {
padding: 18px;
border: 1px solid rgba(37, 45, 42, 0.12);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.93);
box-shadow: 0 18px 38px rgba(38, 45, 43, 0.07);
}
body[data-page="settings"] .vg-set__group-head {
align-items: flex-start;
@ -1371,10 +1309,9 @@ body[data-page="settings"] .vg-set__group-desc {
body[data-page="settings"] .vg-set__profile {
margin-bottom: 12px;
padding: 13px;
background: rgba(244, 241, 236, 0.78);
}
body[data-page="settings"] .vg-set__avatar {
background: linear-gradient(135deg, #1d8774, #0f6559);
background: var(--accent);
}
body[data-page="settings"] .vg-set__field-grid--account,
body[data-page="settings"] .vg-set__field-grid--engine {
@ -1387,8 +1324,6 @@ body[data-page="settings"] .vg-set__field,
body[data-page="settings"] .vg-set__control-block,
body[data-page="settings"] .vg-set__opt,
body[data-page="settings"] .vg-set__range-row {
border-color: rgba(37, 45, 42, 0.12);
background: rgba(255, 255, 255, 0.86);
}
body[data-page="settings"] .vg-set__engine-grid {
grid-template-columns: 1fr;
@ -1396,11 +1331,11 @@ body[data-page="settings"] .vg-set__engine-grid {
}
body[data-page="settings"] .vg-set__seg {
grid-template-columns: repeat(4, minmax(0, 1fr));
background: rgba(244, 241, 236, 0.82);
background: var(--glass-surface-inset);
}
body[data-page="settings"] .vg-set__seg-btn.is-active {
background: #187967;
color: #ffffff;
background: var(--accent);
color: var(--text-on-accent);
}
body[data-page="settings"] .vg-set__ops {
width: min(300px, 100%);
@ -1421,9 +1356,7 @@ body[data-page="settings"] .vg-set__ticket {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid rgba(37, 45, 42, 0.12);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.86);
}
body[data-page="settings"] .vg-set__ticket-top,
body[data-page="settings"] .vg-set__ticket-meta {
@ -1449,7 +1382,7 @@ body[data-page="settings"] .vg-set__ticket-note {
gap: 4px;
padding: 9px 10px;
border-radius: var(--radius-sm);
background: rgba(244, 241, 236, 0.78);
background: var(--glass-surface-inset);
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.55;
@ -1468,7 +1401,7 @@ body[data-page="settings"] .vg-set__foot {
padding-top: 12px;
}
body[data-page="settings"] .vg-set__pill {
background: rgba(255, 255, 255, 0.68);
background: var(--glass-surface-inset);
}
@media (max-width: 980px) {
@ -1515,11 +1448,8 @@ body[data-page="settings"] .vg-set__pill {
min-height: auto;
margin: 0 -16px 12px;
padding: 13px 16px 10px;
border: 0;
border-bottom: 1px solid rgba(37, 45, 42, 0.1);
border-radius: 0;
background: rgba(252, 250, 247, 0.96);
box-shadow: 0 12px 26px rgba(37, 45, 42, 0.08);
border-bottom-color: var(--glass-border);
}
body[data-page="settings"] .vg-set__rail-title {
justify-content: center;
@ -1589,123 +1519,3 @@ body[data-page="settings"] .vg-set__pill {
min-height: 0;
}
}
html[data-theme="dark"] body[data-page="settings"] .vg-shell {
background: var(--bg-app);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__rail,
html[data-theme="dark"] body[data-page="settings"] .vg-set__group {
border-color: var(--border-subtle);
background: var(--bg-surface);
box-shadow: var(--shadow-sm);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__rail-title h1,
html[data-theme="dark"] body[data-page="settings"] .vg-set__group-title,
html[data-theme="dark"] body[data-page="settings"] .vg-set__profile strong,
html[data-theme="dark"] body[data-page="settings"] .vg-set__label,
html[data-theme="dark"] body[data-page="settings"] .vg-set__opt-main,
html[data-theme="dark"] body[data-page="settings"] .vg-set__voice-title {
color: rgba(241, 247, 245, 0.96);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__rail-title p,
html[data-theme="dark"] body[data-page="settings"] .vg-set__group-desc,
html[data-theme="dark"] body[data-page="settings"] .vg-set__profile span,
html[data-theme="dark"] body[data-page="settings"] .vg-set__hint,
html[data-theme="dark"] body[data-page="settings"] .vg-set__opt-sub,
html[data-theme="dark"] body[data-page="settings"] .vg-set__voice-meta,
html[data-theme="dark"] body[data-page="settings"] .vg-set__foot,
html[data-theme="dark"] body[data-page="settings"] .vg-set__callout {
color: rgba(214, 228, 224, 0.72);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__nav-item {
color: rgba(205, 232, 225, 0.82);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__nav-item:hover {
background: rgba(111, 179, 164, 0.12);
color: rgba(239, 248, 245, 0.96);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__nav-item.is-active {
background: linear-gradient(135deg, rgba(111, 179, 164, 0.9), rgba(62, 136, 121, 0.94));
color: #061511;
box-shadow: 0 14px 28px rgba(7, 20, 17, 0.34);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__nav-item.is-active svg {
color: #061511;
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__profile,
html[data-theme="dark"] body[data-page="settings"] .vg-set__field,
html[data-theme="dark"] body[data-page="settings"] .vg-set__control-block,
html[data-theme="dark"] body[data-page="settings"] .vg-set__opt,
html[data-theme="dark"] body[data-page="settings"] .vg-set__range-row,
html[data-theme="dark"] body[data-page="settings"] .vg-set__seg,
html[data-theme="dark"] body[data-page="settings"] .vg-set__pill,
html[data-theme="dark"] body[data-page="settings"] .vg-set__callout,
html[data-theme="dark"] body[data-page="settings"] .vg-set__ticket,
html[data-theme="dark"] body[data-page="settings"] .vg-set__ticket-note,
html[data-theme="dark"] body[data-page="settings"] .vg-badge {
border-color: rgba(203, 227, 220, 0.11);
background: rgba(255, 255, 255, 0.055);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__voice,
html[data-theme="dark"] body[data-page="settings"] .vg-set__select,
html[data-theme="dark"] body[data-page="settings"] .vg-input {
border-color: rgba(203, 227, 220, 0.12);
background: rgba(11, 24, 21, 0.78);
color: rgba(241, 247, 245, 0.94);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__voice.is-active,
html[data-theme="dark"] body[data-page="settings"] .vg-set__seg-btn.is-active {
border-color: rgba(111, 179, 164, 0.48);
background: rgba(111, 179, 164, 0.2);
color: rgba(239, 248, 245, 0.98);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__seg-btn {
color: rgba(205, 232, 225, 0.76);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__seg-btn:not(.is-active):hover,
html[data-theme="dark"] body[data-page="settings"] .vg-set__voice:not(.is-active):hover {
background: rgba(111, 179, 164, 0.11);
color: rgba(239, 248, 245, 0.96);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__range {
accent-color: rgba(111, 179, 164, 0.95);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__avatar {
background: linear-gradient(135deg, rgba(111, 179, 164, 0.92), rgba(49, 116, 102, 0.95));
color: #061511;
}
html[data-theme="dark"] body[data-page="settings"] .vg-set .vg-btn--primary {
border-color: rgba(111, 179, 164, 0.48);
background: linear-gradient(135deg, rgba(111, 179, 164, 0.88), rgba(73, 145, 130, 0.9));
color: #061511;
}
html[data-theme="dark"] body[data-page="settings"] .vg-set .vg-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(129, 202, 186, 0.94), rgba(84, 160, 143, 0.96));
}
@media (max-width: 720px) {
html[data-theme="dark"] body[data-page="settings"] .vg-set__rail {
border-bottom-color: rgba(203, 227, 220, 0.12);
background:
linear-gradient(180deg, rgba(22, 36, 32, 0.98), rgba(13, 26, 23, 0.96)),
rgba(13, 26, 23, 0.96);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.28);
}
}

View file

@ -1,36 +0,0 @@
/* =====================================================================
useTheme / .
Topbar lib/theme .
===================================================================== */
import { useCallback, useEffect, useState } from "react";
import { applyTheme, readInitialTheme } from "../../lib/theme";
/** [dark, setDark] — Topbar 토글과 같은 키/속성을 사용. */
export function useTheme(): [boolean, (next: boolean) => void] {
const [dark, setDark] = useState<boolean>(() => {
if (typeof document !== "undefined") {
const attr = document.documentElement.getAttribute("data-theme");
if (attr === "dark") return true;
if (attr === "light") return false;
}
return readInitialTheme() === "dark";
});
useEffect(() => {
applyTheme(dark ? "dark" : "light");
}, [dark]);
// 다른 곳(Topbar)에서 속성을 바꾸면 따라잡기 위해 마운트 시 1회 동기화
useEffect(() => {
const root = document.documentElement;
const observer = new MutationObserver(() => {
const isDark = root.getAttribute("data-theme") === "dark";
setDark((prev) => (prev === isDark ? prev : isDark));
});
observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
return () => observer.disconnect();
}, []);
const set = useCallback((next: boolean) => setDark(next), []);
return [dark, set];
}