모바일 메뉴와 주요 화면 UX 개선 및 시각 검수
This commit is contained in:
parent
89b5093ec7
commit
e8b770e4d9
50 changed files with 3189 additions and 457 deletions
|
|
@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
|
|||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Topbar } from "./Topbar";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { canAccessRole, designRoleOf, useAuth, type Role } from "../../lib/auth";
|
||||
import { canAccessRole, designRoleOf, roleHomePath, useAuth, type Role } from "../../lib/auth";
|
||||
|
||||
export interface AppShellProps {
|
||||
children: ReactNode;
|
||||
|
|
@ -84,14 +84,9 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
|
|||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.repeat) return;
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
const isInput = target?.closest("input, textarea, select, [contenteditable='true']");
|
||||
|
||||
const isAlt = e.altKey && !e.ctrlKey && !e.metaKey;
|
||||
const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey;
|
||||
|
||||
// Ctrl+W 또는 Escape: 활성 모달 닫기
|
||||
if ((isCtrl && e.key.toLowerCase() === "w") || e.key === "Escape") {
|
||||
// native dialog는 Escape와 포커스 복귀를 스스로 처리한다.
|
||||
if (e.key === "Escape" && !document.querySelector("dialog[open]")) {
|
||||
const openModalCloseBtn = document.querySelector<HTMLButtonElement>(
|
||||
"[role='dialog'] button:has(svg), [role='dialog'] .sx-panel-close-btn, .vg-dialog__close, [role='dialog'] button[aria-label*='닫기']"
|
||||
);
|
||||
|
|
@ -104,14 +99,14 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
|
|||
|
||||
// Alt+H: 홈으로 이동
|
||||
if (isAlt && e.key.toLowerCase() === "h") {
|
||||
if (!shellRole) return;
|
||||
e.preventDefault();
|
||||
const homePath = user?.role === "admin" ? "/admin" : user?.role === "teacher" ? "/professor" : "/learn";
|
||||
navigate(homePath);
|
||||
navigate(roleHomePath(shellRole));
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+N 또는 Ctrl+N 또는 Ctrl+T (입력창 밖): 새 회기 시작 / 페르소나 선택
|
||||
if ((isAlt && e.key.toLowerCase() === "n") || (!isInput && isCtrl && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "t"))) {
|
||||
// Alt+N: 새 회기 시작 / 페르소나 선택
|
||||
if (isAlt && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault();
|
||||
navigate("/learn");
|
||||
return;
|
||||
|
|
@ -149,7 +144,7 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
|
|||
|
||||
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
}, [navigate, user]);
|
||||
}, [navigate, shellRole]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { NavLink } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { Icon, type IconName } from "../ui/Icon";
|
||||
import type { Role } from "../../lib/auth";
|
||||
|
||||
|
|
@ -8,6 +9,8 @@ export interface NavItem {
|
|||
icon: IconName;
|
||||
/** NavLink end (정확 매칭) */
|
||||
end?: boolean;
|
||||
/** 데스크톱과 전체 메뉴에서 항목의 역할을 설명하는 그룹 */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -16,41 +19,48 @@ export interface NavItem {
|
|||
*/
|
||||
const NAV_BY_ROLE: Record<Role, NavItem[]> = {
|
||||
learner: [
|
||||
{ to: "/learn", label: "대시보드", icon: "home", end: true },
|
||||
{ to: "/learn/practice", label: "학습", icon: "session" },
|
||||
{ to: "/learn/history", label: "기록", icon: "review" },
|
||||
{ to: "/settings", label: "설정", icon: "settings" },
|
||||
{ to: "/learn", label: "학습자 홈", icon: "home", end: true, group: "학습" },
|
||||
{ to: "/learn/practice", label: "상담 연습", icon: "session", group: "학습" },
|
||||
{ to: "/learn/history", label: "학습 기록", icon: "review", group: "학습" },
|
||||
{ to: "/settings", label: "설정", icon: "settings", group: "설정" },
|
||||
],
|
||||
teacher: [
|
||||
{ to: "/teach", label: "콘솔", icon: "users", end: true },
|
||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
|
||||
{ to: "/teach/supervision", label: "감독·연구", icon: "shield" },
|
||||
{ to: "/teach/personas", label: "페르소나", icon: "review" },
|
||||
{ to: "/settings", label: "설정", icon: "settings" },
|
||||
{ to: "/teach", label: "교수자 홈", icon: "users", end: true, group: "교수" },
|
||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review", group: "교수" },
|
||||
{ to: "/teach/supervision", label: "감독·연구", icon: "shield", group: "교수" },
|
||||
{ to: "/teach/personas", label: "내담자 설정", icon: "review", group: "교수" },
|
||||
{ to: "/settings", label: "설정", icon: "settings", group: "설정" },
|
||||
],
|
||||
admin: [
|
||||
{ to: "/admin", label: "운영 홈", icon: "shield", end: true },
|
||||
{ to: "/admin/ai", label: "AI 운영", icon: "session" },
|
||||
{ to: "/admin/continuous-improvement", label: "지속 개선", icon: "refresh" },
|
||||
{ to: "/admin/users", label: "사용자", icon: "users" },
|
||||
{ to: "/admin/access", label: "권한", icon: "settings" },
|
||||
{ to: "/admin/tickets", label: "티켓", icon: "review" },
|
||||
{ to: "/teach", label: "교수 콘솔", icon: "users", end: true },
|
||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
|
||||
{ to: "/teach/supervision", label: "감독·연구", icon: "shield" },
|
||||
{ to: "/teach/personas", label: "페르소나", icon: "review" },
|
||||
{ to: "/learn", label: "학습자 홈", icon: "home", end: true },
|
||||
{ to: "/learn/practice", label: "학습", icon: "session" },
|
||||
{ to: "/learn/history", label: "기록", icon: "review" },
|
||||
{ to: "/settings", label: "설정", icon: "settings" },
|
||||
{ to: "/admin", label: "운영 홈", icon: "shield", end: true, group: "운영" },
|
||||
{ to: "/admin/ai", label: "AI 운영", icon: "session", group: "운영" },
|
||||
{ to: "/admin/continuous-improvement", label: "지속 개선", icon: "refresh", group: "운영" },
|
||||
{ to: "/admin/users", label: "사용자 관리", icon: "users", group: "운영" },
|
||||
{ to: "/admin/access", label: "접근 관리", icon: "settings", group: "운영" },
|
||||
{ to: "/admin/tickets", label: "지원 요청", icon: "review", group: "운영" },
|
||||
{ to: "/teach", label: "교수자 홈", icon: "users", end: true, group: "교수" },
|
||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review", group: "교수" },
|
||||
{ to: "/teach/supervision", label: "감독·연구", icon: "shield", group: "교수" },
|
||||
{ to: "/teach/personas", label: "내담자 설정", icon: "review", group: "교수" },
|
||||
{ to: "/learn", label: "학습자 홈", icon: "home", end: true, group: "학습" },
|
||||
{ to: "/learn/practice", label: "상담 연습", icon: "session", group: "학습" },
|
||||
{ to: "/learn/history", label: "학습 기록", icon: "review", group: "학습" },
|
||||
{ to: "/settings", label: "설정", icon: "settings", group: "설정" },
|
||||
],
|
||||
};
|
||||
|
||||
const MOBILE_PRIMARY_PATHS: Record<Role, string[]> = {
|
||||
learner: ["/learn", "/learn/practice", "/learn/history"],
|
||||
teacher: ["/teach", "/teach/analysis", "/teach/supervision"],
|
||||
admin: ["/admin", "/admin/ai", "/admin/users"],
|
||||
};
|
||||
|
||||
const ADMIN_ENTRY: NavItem = {
|
||||
to: "/admin",
|
||||
label: "운영 콘솔",
|
||||
label: "운영 홈",
|
||||
icon: "shield",
|
||||
end: true,
|
||||
group: "운영",
|
||||
};
|
||||
|
||||
export function navItemsFor(role: Role, showAdminEntry = false): NavItem[] {
|
||||
|
|
@ -63,32 +73,161 @@ export interface SidebarProps {
|
|||
role: Role;
|
||||
/** 기본 역할 화면에서도 관리자 권한 사용자가 운영 콘솔로 돌아갈 수 있게 한다. */
|
||||
showAdminEntry?: boolean;
|
||||
/** 상단 그룹 라벨 (기본 "메뉴") */
|
||||
/** 이전 호출부 호환용 기본 그룹 라벨 */
|
||||
groupLabel?: string;
|
||||
}
|
||||
|
||||
/** 좌측 네비 (240px). 활성 = accent-tint 알약. 강조바 금지. §6.3 */
|
||||
function isPathActive(pathname: string, item: NavItem): boolean {
|
||||
if (item.end) return pathname === item.to;
|
||||
return pathname === item.to || pathname.startsWith(`${item.to}/`);
|
||||
}
|
||||
|
||||
function groupsFor(items: NavItem[], fallbackLabel: string): Array<[string, NavItem[]]> {
|
||||
const groups = new Map<string, NavItem[]>();
|
||||
for (const item of items) {
|
||||
const group = item.group ?? fallbackLabel;
|
||||
groups.set(group, [...(groups.get(group) ?? []), item]);
|
||||
}
|
||||
return [...groups.entries()];
|
||||
}
|
||||
|
||||
/** 좌측 네비와 좁은 화면의 하단 내비. */
|
||||
export function Sidebar({ role, showAdminEntry = false, groupLabel = "메뉴" }: SidebarProps) {
|
||||
const items = navItemsFor(role, showAdminEntry);
|
||||
const groups = groupsFor(items, groupLabel);
|
||||
const location = useLocation();
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const primaryPaths = MOBILE_PRIMARY_PATHS[role];
|
||||
const isMoreActive = !items.some(
|
||||
(item) => primaryPaths.includes(item.to) && isPathActive(location.pathname, item),
|
||||
);
|
||||
|
||||
const closeMenu = () => {
|
||||
const dialog = dialogRef.current;
|
||||
if (dialog?.open) dialog.close();
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog || dialog.open) return;
|
||||
dialog.showModal();
|
||||
setMenuOpen(true);
|
||||
window.requestAnimationFrame(() => closeButtonRef.current?.focus());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
closeMenu();
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const mobileViewport = window.matchMedia("(max-width: 720px)");
|
||||
const closeOutsideMobile = () => {
|
||||
if (!mobileViewport.matches && dialogRef.current?.open) dialogRef.current.close();
|
||||
};
|
||||
mobileViewport.addEventListener("change", closeOutsideMobile);
|
||||
return () => mobileViewport.removeEventListener("change", closeOutsideMobile);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="vg-nav" aria-label="주 메뉴">
|
||||
<span className="vg-nav__label">{groupLabel}</span>
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
"vg-nav__item" + (isActive ? " is-active" : "")
|
||||
}
|
||||
>
|
||||
<span className="vg-nav__ic">
|
||||
<Icon name={item.icon} size={18} strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className="vg-nav__txt">{item.label}</span>
|
||||
</NavLink>
|
||||
{groups.map(([group, groupItems]) => (
|
||||
<section className="vg-nav__group" key={group} aria-label={`${group} 메뉴`}>
|
||||
<span className="vg-nav__label">{group}</span>
|
||||
{groupItems.map((item) => {
|
||||
const isPrimary = primaryPaths.includes(item.to);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={closeMenu}
|
||||
className={({ isActive }) =>
|
||||
"vg-nav__item" +
|
||||
(isActive ? " is-active" : "") +
|
||||
(isPrimary ? " vg-nav__item--mobile-primary" : "")
|
||||
}
|
||||
>
|
||||
<span className="vg-nav__ic">
|
||||
<Icon name={item.icon} size={18} strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className="vg-nav__txt">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
className={"vg-nav__more" + (isMoreActive ? " is-active" : "")}
|
||||
aria-label="전체 메뉴"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="vg-all-menu-dialog"
|
||||
onClick={openMenu}
|
||||
>
|
||||
<span className="vg-nav__ic">
|
||||
<Icon name="menu" size={20} strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className="vg-nav__txt">전체 메뉴</span>
|
||||
</button>
|
||||
|
||||
<dialog
|
||||
id="vg-all-menu-dialog"
|
||||
ref={dialogRef}
|
||||
className="vg-nav__dialog"
|
||||
aria-labelledby="vg-all-menu-title"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
}}
|
||||
onClose={() => {
|
||||
setMenuOpen(false);
|
||||
const trigger = menuButtonRef.current;
|
||||
if (trigger && window.getComputedStyle(trigger).display !== "none") trigger.focus();
|
||||
}}
|
||||
>
|
||||
<div className="vg-nav__dialog-head">
|
||||
<div>
|
||||
<p className="vg-nav__dialog-kicker">현재 역할에서 이동</p>
|
||||
<h2 id="vg-all-menu-title">전체 메뉴</h2>
|
||||
</div>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
className="vg-nav__dialog-close"
|
||||
aria-label="전체 메뉴 닫기"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<Icon name="x" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="vg-nav__dialog-body">
|
||||
{groups.map(([group, groupItems]) => (
|
||||
<section className="vg-nav__dialog-group" key={group} aria-label={`${group} 메뉴`}>
|
||||
<h3>{group}</h3>
|
||||
{groupItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={closeMenu}
|
||||
className={({ isActive }) =>
|
||||
"vg-nav__dialog-item" + (isActive ? " is-active" : "")
|
||||
}
|
||||
>
|
||||
<Icon name={item.icon} size={18} strokeWidth={1.75} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<span className="vg-nav__spacer" />
|
||||
|
||||
<div className="vg-nav__foot">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { type ChangeEvent } from "react";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { ResilientImage } from "../avatar/ResilientImage";
|
||||
import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
|
||||
|
|
@ -27,6 +28,17 @@ function initials(name?: string | null): string {
|
|||
return trimmed.slice(0, 1);
|
||||
}
|
||||
|
||||
function mobileRoleLabel(role: Role): string {
|
||||
switch (role) {
|
||||
case "learner":
|
||||
return "학습자";
|
||||
case "teacher":
|
||||
return "교수자";
|
||||
case "admin":
|
||||
return "관리자";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TopbarProps {
|
||||
/** 역할 컨텍스트 라벨 오버라이드 (없으면 user.role 라벨) */
|
||||
contextLabel?: string;
|
||||
|
|
@ -41,12 +53,22 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
|||
|
||||
const label = contextLabel ?? (user ? roleLabel(user.role) : null);
|
||||
const switchRoles: Role[] = user ? accessibleRolesFor(user) : [];
|
||||
const availableSwitchRoles = switchRoles.filter((role) => user && canAccessRole(user, role));
|
||||
const activeRole =
|
||||
availableSwitchRoles.find((role) => {
|
||||
const home = roleHomePath(role);
|
||||
return location.pathname === home || location.pathname.startsWith(`${home}/`);
|
||||
}) ?? user?.role;
|
||||
|
||||
const onLogout = async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
const onRoleSelect = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
navigate(roleHomePath(event.target.value as Role));
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="vg-topbar">
|
||||
<Link className="vg-topbar__brand" to={user ? "/" : "/login"}>
|
||||
|
|
@ -62,11 +84,10 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
|||
<span className="vg-topbar__spacer" />
|
||||
|
||||
<div className="vg-topbar__actions">
|
||||
{user && switchRoles.length > 0 ? (
|
||||
{user && availableSwitchRoles.length > 0 ? (
|
||||
<>
|
||||
<div className="vg-topbar__switch" aria-label="공간 전환">
|
||||
{switchRoles
|
||||
.filter((role) => canAccessRole(user, role))
|
||||
.map((role) => {
|
||||
{availableSwitchRoles.map((role) => {
|
||||
const to = roleHomePath(role);
|
||||
const active = location.pathname === to || location.pathname.startsWith(`${to}/`);
|
||||
return (
|
||||
|
|
@ -86,6 +107,22 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="vg-topbar__switch-select-wrap">
|
||||
<span className="vg-topbar__switch-select-label">공간 선택</span>
|
||||
<select
|
||||
className="vg-topbar__switch-select"
|
||||
aria-label="공간 선택"
|
||||
value={activeRole}
|
||||
onChange={onRoleSelect}
|
||||
>
|
||||
{availableSwitchRoles.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{mobileRoleLabel(role)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -138,6 +138,32 @@
|
|||
color: var(--text-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
.vg-topbar__switch-select-wrap {
|
||||
display: none;
|
||||
}
|
||||
.vg-topbar__switch-select-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.vg-topbar__switch-select {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
padding: 0 28px 0 10px;
|
||||
}
|
||||
|
||||
/* 톱바 아이콘 버튼 (테마/로그아웃) */
|
||||
.vg-iconbtn {
|
||||
|
|
@ -243,6 +269,11 @@
|
|||
gap: var(--sp-1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vg-nav__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.vg-nav__label {
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
|
|
@ -291,6 +322,10 @@
|
|||
.vg-nav__item.is-active .vg-nav__ic {
|
||||
color: var(--accent);
|
||||
}
|
||||
.vg-nav__more,
|
||||
.vg-nav__dialog {
|
||||
display: none;
|
||||
}
|
||||
.vg-nav__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
|
@ -353,6 +388,23 @@
|
|||
padding: 11px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 721px) and (max-width: 1024px) {
|
||||
.vg-shell__body {
|
||||
--nav-cur: 168px;
|
||||
}
|
||||
.vg-nav {
|
||||
padding: var(--sp-4) var(--sp-2);
|
||||
}
|
||||
.vg-nav__label,
|
||||
.vg-nav__item span:not(.vg-nav__ic),
|
||||
.vg-nav__ethic {
|
||||
display: block;
|
||||
}
|
||||
.vg-nav__item {
|
||||
justify-content: flex-start;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
html {
|
||||
scroll-padding-top: calc(var(--topbar-h) + var(--sp-2));
|
||||
|
|
@ -360,6 +412,7 @@
|
|||
}
|
||||
.vg-shell {
|
||||
height: 100dvh;
|
||||
--bottom-bar-h: 68px;
|
||||
}
|
||||
.vg-shell__body {
|
||||
display: flex;
|
||||
|
|
@ -384,11 +437,10 @@
|
|||
border-right: 0;
|
||||
border-top: 1px solid var(--hair);
|
||||
border-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-island);
|
||||
overflow-x: auto;
|
||||
|
|
@ -401,22 +453,35 @@
|
|||
.vg-nav__foot {
|
||||
display: none;
|
||||
}
|
||||
.vg-nav__group {
|
||||
display: contents;
|
||||
}
|
||||
.vg-nav__item {
|
||||
flex: 1 1 0;
|
||||
display: none;
|
||||
}
|
||||
.vg-nav__item--mobile-primary,
|
||||
.vg-nav__more {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 96px;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
padding: 3px 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-body);
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.vg-nav__item:active {
|
||||
.vg-nav__item--mobile-primary:active,
|
||||
.vg-nav__more:active {
|
||||
transform: scale(0.93);
|
||||
}
|
||||
.vg-nav__item .vg-nav__ic {
|
||||
|
|
@ -429,16 +494,17 @@
|
|||
height: 20px;
|
||||
}
|
||||
.vg-nav__item .vg-nav__txt,
|
||||
.vg-nav__item span:not(.vg-nav__ic) {
|
||||
.vg-nav__more .vg-nav__txt {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.vg-topbar__role {
|
||||
display: none;
|
||||
|
|
@ -471,20 +537,20 @@
|
|||
gap: 4px;
|
||||
}
|
||||
.vg-topbar__switch {
|
||||
height: 38px;
|
||||
padding: 2px;
|
||||
display: none;
|
||||
}
|
||||
.vg-topbar__switch-link {
|
||||
min-height: 34px;
|
||||
padding: 0 8px;
|
||||
font-size: 11.5px;
|
||||
.vg-topbar__switch-select-wrap {
|
||||
display: block;
|
||||
}
|
||||
.vg-topbar__switch-link span {
|
||||
display: inline;
|
||||
.vg-topbar__switch-select {
|
||||
box-sizing: border-box;
|
||||
width: min(104px, calc(100vw - 224px));
|
||||
min-height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.vg-iconbtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.vg-main {
|
||||
padding: var(--sp-4) var(--sp-4) calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-6));
|
||||
|
|
@ -495,14 +561,10 @@
|
|||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* 관리자처럼 세 역할을 모두 전환할 수 있는 계정도 테마·로그아웃 버튼을
|
||||
밀어내지 않게 한다. 링크 이름은 aria-label/title에 그대로 남는다. */
|
||||
.vg-topbar__switch-link {
|
||||
flex: 0 0 34px;
|
||||
padding: 0;
|
||||
}
|
||||
.vg-topbar__switch-link span {
|
||||
display: none;
|
||||
.vg-topbar {
|
||||
min-height: 56px;
|
||||
padding: 0 var(--sp-2);
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -758,3 +820,173 @@ body[data-role] .vg-shell--botanical .vg-nav__foot {
|
|||
padding: 3px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body[data-role] .vg-shell--botanical .vg-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
gap: 2px;
|
||||
overflow: visible;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__group {
|
||||
display: contents;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||
display: none;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item--mobile-primary,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item--mobile-primary.is-active,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__more {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
padding: 3px 2px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text-body);
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item--mobile-primary.is-active,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__more.is-active {
|
||||
background: linear-gradient(
|
||||
112deg,
|
||||
color-mix(in srgb, var(--bg-surface) 92%, var(--botanical-gold)),
|
||||
color-mix(in srgb, var(--bg-surface-2) 80%, transparent)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--botanical-gold) 34%, var(--border-subtle));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 84%, transparent);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item--mobile-primary .vg-nav__txt,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__more .vg-nav__txt {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.vg-nav__dialog[open] {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
z-index: 60;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: min(92dvh, 680px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--hair);
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-strong);
|
||||
box-shadow: var(--shadow-float);
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
}
|
||||
.vg-nav__dialog::backdrop {
|
||||
background: color-mix(in srgb, var(--text-strong) 28%, transparent);
|
||||
}
|
||||
.vg-nav__dialog-head {
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--hair);
|
||||
}
|
||||
.vg-nav__dialog-kicker {
|
||||
margin: 0 0 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.vg-nav__dialog h2,
|
||||
.vg-nav__dialog h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vg-nav__dialog h2 {
|
||||
font-size: 18px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.vg-nav__dialog h3 {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.vg-nav__dialog-close {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
.vg-nav__dialog-close:hover {
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vg-nav__dialog-body {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--sp-3) var(--sp-4) calc(var(--sp-5) + env(safe-area-inset-bottom));
|
||||
}
|
||||
.vg-nav__dialog-group + .vg-nav__dialog-group {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.vg-nav__dialog-group h3 {
|
||||
padding: 0 var(--sp-3) var(--sp-2);
|
||||
}
|
||||
.vg-nav__dialog-item {
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 10px var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.vg-nav__dialog-item:hover,
|
||||
.vg-nav__dialog-item.is-active {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 721px) and (max-width: 1024px) {
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
--nav-cur: 168px;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||
justify-content: flex-start;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ const TICKET_STATUS_OPTIONS: Array<{
|
|||
label: string;
|
||||
}> = [
|
||||
{ value: "all", label: "전체 상태" },
|
||||
{ value: "open", label: "미해결" },
|
||||
{ value: "triaged", label: "분류됨" },
|
||||
{ value: "open", label: "접수" },
|
||||
{ value: "triaged", label: "분류" },
|
||||
{ value: "in_progress", label: "처리 중" },
|
||||
{ value: "resolved", label: "해결" },
|
||||
{ value: "closed", label: "종결" },
|
||||
|
|
@ -407,8 +407,8 @@ function lastDownLabel(data: AdminUptimeResponse | null): string {
|
|||
}
|
||||
|
||||
function ticketStatusLabel(status: AdminSupportTicket["status"]): string {
|
||||
if (status === "open") return "미해결";
|
||||
if (status === "triaged") return "분류됨";
|
||||
if (status === "open") return "접수";
|
||||
if (status === "triaged") return "분류";
|
||||
if (status === "in_progress") return "처리 중";
|
||||
if (status === "resolved") return "해결";
|
||||
return "종결";
|
||||
|
|
@ -2862,12 +2862,11 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
{/* eyebrow "접근 권한"은 아래 "역할, 그룹, 접근 범위"와 같은 말이라 제거 */}
|
||||
<PageHeader
|
||||
title="역할, 그룹, 접근 범위"
|
||||
description="운영 권한 정책과 평가용 상담 프로토콜의 수명주기를 관리합니다."
|
||||
description="역할별 접근 범위와 상담 프로토콜을 확인합니다."
|
||||
/>
|
||||
{accessTab !== "protocols" ? (
|
||||
<div className="vgops-users-note">
|
||||
역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책
|
||||
기준을 먼저 고정합니다.
|
||||
역할과 그룹의 접근 범위를 확인할 수 있습니다. 이 화면에서는 역할·그룹을 변경할 수 없습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<Tabs<AccessTab>
|
||||
|
|
@ -2963,10 +2962,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
|
||||
const renderTickets = () => (
|
||||
<>
|
||||
{/* eyebrow "운영 티켓"은 아래 "사용자 문제 큐"와 같은 말이라 제거 */}
|
||||
{/* eyebrow "운영 티켓"은 아래 "지원 요청"과 같은 말이라 제거 */}
|
||||
<PageHeader
|
||||
title="사용자 문제 큐"
|
||||
description="미해결 접수를 우선 확인하고 처리 상태를 갱신합니다."
|
||||
title="지원 요청"
|
||||
description="접수된 요청과 처리 상태를 확인합니다."
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -2978,15 +2977,14 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
</Button>
|
||||
</PageHeader>
|
||||
<div className="vgops-users-note">
|
||||
사용자가 제출한 실제 DB 큐만 표시합니다. 해결된 항목은 큐에서 내려 최근
|
||||
이력으로 분리합니다.
|
||||
해결된 요청은 최근 해결 이력에서 확인할 수 있습니다.
|
||||
</div>
|
||||
<section className="vgops-ticket-filter" aria-label="운영 티켓 필터">
|
||||
<input
|
||||
value={ticketSearch}
|
||||
onChange={(event) => setTicketSearch(event.target.value)}
|
||||
placeholder="제목, 본문, 신고자, 경로 검색"
|
||||
aria-label="티켓 검색"
|
||||
placeholder="지원 요청 검색"
|
||||
aria-label="지원 요청 검색"
|
||||
/>
|
||||
<select
|
||||
value={ticketStatusFilter}
|
||||
|
|
@ -3077,19 +3075,19 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
<div className="vgops-kpi">
|
||||
<span className="vgops-kpi__lab">전체 접수</span>
|
||||
<b>{tickets ? countLabel(tickets.summary.total) : "-"}</b>
|
||||
<small>{tickets?.durable ? "DB 티켓" : "저장소 확인 중"}</small>
|
||||
<small>{tickets?.durable ? "저장된 요청" : "저장소 확인 중"}</small>
|
||||
</div>
|
||||
<div className="vgops-kpi">
|
||||
<span className="vgops-kpi__lab">미해결</span>
|
||||
<b>{tickets ? countLabel(tickets.summary.open_count) : "-"}</b>
|
||||
<small>open, triaged, in_progress</small>
|
||||
<small>접수 · 분류 · 처리 중</small>
|
||||
</div>
|
||||
<div className="vgops-kpi">
|
||||
<span className="vgops-kpi__lab">높은 우선순위</span>
|
||||
<b>
|
||||
{tickets ? countLabel(tickets.summary.high_priority_count) : "-"}
|
||||
</b>
|
||||
<small>미해결 high, urgent</small>
|
||||
<small>높음 · 긴급 요청</small>
|
||||
</div>
|
||||
<div className="vgops-kpi">
|
||||
<span className="vgops-kpi__lab">정체</span>
|
||||
|
|
|
|||
|
|
@ -190,6 +190,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
// 데스크톱(>1180px)은 2컬럼 그리드 유지 — 탭 바는 CSS로 숨겨지고 이 상태는 무시된다.
|
||||
const [dashTab, setDashTab] = useState<DashboardTab>("today");
|
||||
const dashTabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const personaListRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showUnpracticedPersonaProgress, setShowUnpracticedPersonaProgress] = useState(false);
|
||||
|
||||
const updateHistorySearchParams = (
|
||||
update: (params: URLSearchParams) => void,
|
||||
|
|
@ -489,6 +491,54 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
const reviewCount = overview?.review_ready_sessions ?? sessionReviewCount;
|
||||
const completedCount = overview?.completed_sessions ?? sessionCompletedCount;
|
||||
const totalCount = overview?.total_sessions ?? sessions.length;
|
||||
const endedWithoutDialogueCount = Math.max(
|
||||
0,
|
||||
totalCount - completedCount - activeCount,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "practice" || loadState !== "ready" || !selected?.code) return;
|
||||
const list = personaListRef.current;
|
||||
const option = list?.querySelector<HTMLElement>(`[data-persona-code="${selected.code}"]`);
|
||||
if (!list || !option) return;
|
||||
let frame: number | null = null;
|
||||
const ensureSelectedPersonaVisible = () => {
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const optionRect = option.getBoundingClientRect();
|
||||
const nextTop =
|
||||
optionRect.top < listRect.top
|
||||
? list.scrollTop + optionRect.top - listRect.top
|
||||
: optionRect.bottom > listRect.bottom
|
||||
? list.scrollTop + optionRect.bottom - listRect.bottom
|
||||
: null;
|
||||
const nextLeft =
|
||||
optionRect.left < listRect.left
|
||||
? list.scrollLeft + optionRect.left - listRect.left
|
||||
: optionRect.right > listRect.right
|
||||
? list.scrollLeft + optionRect.right - listRect.right
|
||||
: null;
|
||||
if (nextTop !== null || nextLeft !== null) {
|
||||
list.scrollTo({
|
||||
top: nextTop ?? list.scrollTop,
|
||||
left: nextLeft ?? list.scrollLeft,
|
||||
behavior: "auto",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ensureSelectedPersonaVisible();
|
||||
const observer = new ResizeObserver(ensureSelectedPersonaVisible);
|
||||
observer.observe(list);
|
||||
observer.observe(option);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [loadState, selected?.code, view]);
|
||||
const recentFeedback = dashboard?.recent_feedback ?? [];
|
||||
const historyReady = sessionLoadState === "ready";
|
||||
const dashboardStatus =
|
||||
|
|
@ -915,11 +965,16 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
|
||||
const dashboardMetrics = [
|
||||
{
|
||||
label: "진행 회기",
|
||||
label: "전체 회기",
|
||||
value: dashboardReady ? `${totalCount}회` : "-",
|
||||
meta: dashboardReady
|
||||
? `${completedCount}회 종료 · ${activeCount}회 진행`
|
||||
? `상담 완료 ${completedCount}회 · 진행 중 ${activeCount}회`
|
||||
: dashboardStatus,
|
||||
note:
|
||||
dashboardReady && endedWithoutDialogueCount > 0
|
||||
? "대화 없이 종료한 회기 포함"
|
||||
: null,
|
||||
title: "전체 회기에는 대화 없이 종료한 회기도 포함합니다.",
|
||||
to: "/learn/history",
|
||||
},
|
||||
{
|
||||
|
|
@ -927,6 +982,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
value: dashboardReady ? `${reviewCount}건` : "-",
|
||||
meta:
|
||||
reviewCount > 0 ? "종료 회기 피드백 확인 필요" : "대기 중인 리뷰 없음",
|
||||
note: null,
|
||||
to: "/learn/history",
|
||||
},
|
||||
{
|
||||
|
|
@ -936,6 +992,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
dashboardReady && growth?.evaluated_sessions
|
||||
? `${growth.evaluated_sessions}회 평가 · ${trendLabel(growth.trend)}`
|
||||
: "첫 회기 후 표시",
|
||||
note: null,
|
||||
to: "/learn/history",
|
||||
},
|
||||
{
|
||||
|
|
@ -944,6 +1001,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
meta: topPersona?.total
|
||||
? `${topPersona.name} · ${topPersona.total}회`
|
||||
: "아직 연습 없음",
|
||||
note: null,
|
||||
to: "/learn/practice",
|
||||
},
|
||||
];
|
||||
|
|
@ -1157,7 +1215,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
);
|
||||
}
|
||||
return (
|
||||
<div className="lh-personas" role="listbox" aria-label="연습 페르소나">
|
||||
<div ref={personaListRef} className="lh-personas" role="listbox" aria-label="연습 페르소나">
|
||||
{loadState === "loading"
|
||||
? Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
|
|
@ -1192,6 +1250,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
key={persona.code}
|
||||
type="button"
|
||||
role="option"
|
||||
data-persona-code={persona.code}
|
||||
aria-selected={selectedPersona}
|
||||
aria-disabled={!usablePersona}
|
||||
disabled={!usablePersona}
|
||||
|
|
@ -1247,26 +1306,17 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
navRole="learner"
|
||||
wide
|
||||
>
|
||||
<main className={rootClassName}>
|
||||
<div className={rootClassName}>
|
||||
{view === "dashboard" ? (
|
||||
<>
|
||||
<header className="lh-head">
|
||||
<div className="lh-head__copy">
|
||||
{/* D1: 단순 섹션 라벨이라 상태 dot 제거(대시보드 화면 dot 밀집 정리) */}
|
||||
<Kicker dot={false}>학습 대시보드</Kicker>
|
||||
<h1 className="lh-title">오늘 이어갈 회기를 먼저 봅니다.</h1>
|
||||
<h1 className="lh-title">오늘의 상담 연습</h1>
|
||||
<p className="lh-sub">
|
||||
진행 중인 상담, 리뷰 대기, 다음 연습 기준을 한 화면에서
|
||||
정리합니다.
|
||||
진행 중인 회기를 이어가거나, 지난 상담의 피드백을 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lh-head__tools">
|
||||
<div className="lh-head__status" aria-live="polite">
|
||||
<Icon name="users" size={17} />
|
||||
{loadState === "ready"
|
||||
? `${usablePersonas.length}명 · ${historyStatus}`
|
||||
: "확인 중"}
|
||||
</div>
|
||||
<Button
|
||||
className="lh-head__primary"
|
||||
size="lg"
|
||||
|
|
@ -1279,23 +1329,6 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<section className="lh-dashboard-status" aria-label="학습 현황">
|
||||
{dashboardMetrics.map((metric) => (
|
||||
<button
|
||||
type="button"
|
||||
className={surfaceClassName("lh-metric-card", {
|
||||
variant: "interactive",
|
||||
})}
|
||||
key={metric.label}
|
||||
onClick={() => navigate(metric.to)}
|
||||
>
|
||||
<span>{metric.label}</span>
|
||||
<b>{metric.value}</b>
|
||||
<p>{metric.meta}</p>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 CSS로 숨김 */}
|
||||
<div
|
||||
className="lh-tabs"
|
||||
|
|
@ -1335,14 +1368,6 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
tabIndex={0}
|
||||
hidden={dashTab !== "today"}
|
||||
>
|
||||
<div className="lh-work-cluster__head">
|
||||
<div>
|
||||
{/* D1: 섹션 라벨 — 상태가 아니므로 dot 제거 */}
|
||||
<Kicker dot={false}>오늘의 회기 작업</Kicker>
|
||||
<h2>이어가기, 코칭, 다음 연습을 한 흐름으로 봅니다.</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lh-work-cluster__primary">
|
||||
<article
|
||||
className={surfaceClassName("lh-session-focus", {
|
||||
|
|
@ -1715,6 +1740,28 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
hidden={dashTab !== "growth"}
|
||||
>
|
||||
|
||||
<section
|
||||
className="lh-dashboard-status lh-growth-status"
|
||||
aria-label="학습 현황"
|
||||
>
|
||||
{dashboardMetrics.map((metric) => (
|
||||
<button
|
||||
type="button"
|
||||
className={surfaceClassName("lh-metric-card", {
|
||||
variant: "interactive",
|
||||
})}
|
||||
key={metric.label}
|
||||
onClick={() => navigate(metric.to)}
|
||||
title={metric.title}
|
||||
>
|
||||
<span>{metric.label}</span>
|
||||
<b>{metric.value}</b>
|
||||
<p>{metric.meta}</p>
|
||||
{metric.note ? <small>{metric.note}</small> : null}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{weaknessInsights.length > 0 ? (
|
||||
<article
|
||||
className={surfaceClassName(
|
||||
|
|
@ -2489,7 +2536,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
<Kicker>페르소나별 진행</Kicker>
|
||||
<span>
|
||||
{historyReady
|
||||
? `${personaProgressRows.length}명`
|
||||
? `연습한 내담자 ${personaProgressRows.filter((row) => row.total > 0).length}명`
|
||||
: "확인 중"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -2497,9 +2544,9 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
<p className="lh-persona-progress__note">
|
||||
회기 기록을 불러오고 있습니다.
|
||||
</p>
|
||||
) : personaProgressRows.length > 0 ? (
|
||||
) : showUnpracticedPersonaProgress || personaProgressRows.some((row) => row.total > 0) ? (
|
||||
<div className="lh-persona-progress__rows">
|
||||
{personaProgressRows.map((row) => {
|
||||
{personaProgressRows.filter((row) => showUnpracticedPersonaProgress || row.total > 0).map((row) => {
|
||||
const progressWidth = Math.round(
|
||||
(row.total / maxPersonaSessionCount) * 100,
|
||||
);
|
||||
|
|
@ -2554,16 +2601,24 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="lh-persona-progress__note">
|
||||
표시할 페르소나 진행 기록이 없습니다.
|
||||
</p>
|
||||
<div className="lh-persona-progress__empty">
|
||||
<p className="lh-persona-progress__note">
|
||||
아직 연습 기록이 없습니다.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => navigate("/learn/practice")}>상담 연습으로</Button>
|
||||
</div>
|
||||
)}
|
||||
{sessionLoadState === "ready" && personaProgressRows.some((row) => row.total === 0) ? (
|
||||
<button type="button" className="lh-persona-progress__toggle" onClick={() => setShowUnpracticedPersonaProgress((current) => !current)}>
|
||||
{showUnpracticedPersonaProgress ? "미진행 내담자 숨기기" : `미진행 내담자 ${personaProgressRows.filter((row) => row.total === 0).length}명 보기`}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,15 +345,15 @@ export default function Onboarding() {
|
|||
<div className="ob-legal">
|
||||
<details>
|
||||
<summary>
|
||||
<span>{docs?.terms.title ?? "서비스 이용약관"}</span>
|
||||
<b>{docs?.terms.version ?? "확인 중"}</b>
|
||||
<span className="ob-legal__title">{docs?.terms.title ?? "서비스 이용약관"}</span>
|
||||
<b className="ob-legal__version">{docs?.terms.version ?? "확인 중"}</b>
|
||||
</summary>
|
||||
<div className="ob-doc-text">{docs?.terms.body ?? "문서를 불러오는 중입니다."}</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>
|
||||
<span>{docs?.privacy.title ?? "개인정보 처리방침"}</span>
|
||||
<b>{docs?.privacy.version ?? "확인 중"}</b>
|
||||
<span className="ob-legal__title">{docs?.privacy.title ?? "개인정보 처리방침"}</span>
|
||||
<b className="ob-legal__version">{docs?.privacy.version ?? "확인 중"}</b>
|
||||
</summary>
|
||||
<div className="ob-doc-text">{docs?.privacy.body ?? "문서를 불러오는 중입니다."}</div>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -2479,7 +2479,7 @@ export default function PersonaStudio() {
|
|||
|
||||
const workspaceView = (
|
||||
<AppShell contextLabel="페르소나" navRole="teacher" wide>
|
||||
<main className="ps-root">
|
||||
<div className="ps-root">
|
||||
<header className="ps-head">
|
||||
<div>
|
||||
{/* eyebrow "페르소나"는 아래 h1(페르소나 운영/카탈로그/관리/상세)과 같은 말이라 제거 */}
|
||||
|
|
@ -2550,14 +2550,14 @@ export default function PersonaStudio() {
|
|||
: workspaceTab === "catalog"
|
||||
? catalogView
|
||||
: personaListView}
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
const draftName = splitPersonaName(draft.display_name.trim());
|
||||
const legacyAuthoring = (
|
||||
<AppShell contextLabel="페르소나" navRole="teacher" wide>
|
||||
<main className="ps-root">
|
||||
<div className="ps-root">
|
||||
<header className="ps-head">
|
||||
<div>
|
||||
<Kicker dot={false}>
|
||||
|
|
@ -2977,7 +2977,7 @@ export default function PersonaStudio() {
|
|||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ export default function Professor({
|
|||
|
||||
return (
|
||||
<AppShell navRole="teacher" wide>
|
||||
<main
|
||||
<div
|
||||
className={`pf-root ${isAnalysisView ? "pf-root--analysis" : "pf-root--console"}`}
|
||||
>
|
||||
<header className="pf-head">
|
||||
|
|
@ -431,9 +431,7 @@ export default function Professor({
|
|||
</Kicker>
|
||||
{isAnalysisView ? (
|
||||
<>
|
||||
<h1>
|
||||
전체 학습자를 먼저 보고, 필요한 학습자만 상세로 들어갑니다.
|
||||
</h1>
|
||||
<h1>학생별 학습 현황</h1>
|
||||
{/* [D3] 탭 이름 나열의 가운뎃점 2개를 쉼표로 풀어 한 줄 1개 규칙을 지킨다. */}
|
||||
<p>
|
||||
검색 테이블에서 학습자별 회기 수, 최근 점수, 변화폭, 라포와
|
||||
|
|
@ -1036,7 +1034,7 @@ export default function Professor({
|
|||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,8 +221,8 @@ function AttentionWorkbench({ items }: { items: AttentionItem[] }) {
|
|||
if (!selected) {
|
||||
return (
|
||||
<Notice
|
||||
title="현재 우선 검토 항목이 없어"
|
||||
description="새 원장 근거가 들어오면 위험·정체·미해결 관계 사건 순서로 이 큐에 나타나."
|
||||
title="현재 우선 검토 항목이 없습니다"
|
||||
description="새 상담 근거가 들어오면 위험·정체·미해결 관계 사건 순서로 이 목록에 표시됩니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -284,7 +284,7 @@ function AttentionWorkbench({ items }: { items: AttentionItem[] }) {
|
|||
|
||||
<section className="g6-ledger" aria-labelledby="g6-ledger-title">
|
||||
<div className="g6-ledger__head">
|
||||
<h4 id="g6-ledger-title">분류를 만든 원장 근거</h4>
|
||||
<h4 id="g6-ledger-title">분류 근거</h4>
|
||||
<span>{evidence.length}/3 표시</span>
|
||||
</div>
|
||||
{evidence.length > 0 ? (
|
||||
|
|
@ -298,20 +298,20 @@ function AttentionWorkbench({ items }: { items: AttentionItem[] }) {
|
|||
</ul>
|
||||
) : (
|
||||
<Notice
|
||||
title="원장 포인터가 비어 있어"
|
||||
description="분류는 보이지만 근거 연결이 없어 저하 상태로 취급해."
|
||||
title="근거 연결이 비어 있습니다"
|
||||
description="분류는 보이지만 근거 연결이 없어 저하 상태로 표시됩니다."
|
||||
tone="warn"
|
||||
/>
|
||||
)}
|
||||
<p className="g6-ledger__policy">
|
||||
원문 축어록은 표시하지 않아. 한 항목에서 근거 포인터는 최대 3개만 노출해.
|
||||
원문 축어록은 표시하지 않습니다. 한 항목에서 근거 연결은 최대 3개만 표시합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<nav className="g6-drilldown" aria-label={`${selected.learner_ref} 근거 드릴다운`}>
|
||||
<div>
|
||||
<strong>근거까지 바로 이동</strong>
|
||||
<span data-testid="drilldown-depth">항목 선택 후 최대 2번 · 계약 상한 3번</span>
|
||||
<strong>상세 근거</strong>
|
||||
<span data-testid="drilldown-depth">선택한 항목의 근거를 확인합니다.</span>
|
||||
</div>
|
||||
{drilldowns.length > 0 ? (
|
||||
<ol>
|
||||
|
|
@ -319,7 +319,7 @@ function AttentionWorkbench({ items }: { items: AttentionItem[] }) {
|
|||
<li key={route}>
|
||||
<a href={route}>
|
||||
<span>{index + 1}</span>
|
||||
원장 근거 열기
|
||||
상담 근거 확인
|
||||
<Icon name="chevron-right" size={15} />
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -338,8 +338,8 @@ function CurriculumGaps({ gaps }: { gaps: CurriculumGap[] }) {
|
|||
if (gaps.length === 0) {
|
||||
return (
|
||||
<Notice
|
||||
title="현재 교육과정 공백이 없어"
|
||||
description="코호트 수준에서 반복되는 근거가 생기면 역량·공백 유형별로 나타나."
|
||||
title="현재 교육과정 공백이 없습니다"
|
||||
description="코호트 수준에서 반복되는 근거가 생기면 역량·공백 유형별로 표시됩니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -366,12 +366,12 @@ function CurriculumGaps({ gaps }: { gaps: CurriculumGap[] }) {
|
|||
<dd>{gap.affected_learner_count}명</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>근거 포인터</dt>
|
||||
<dt>근거 연결</dt>
|
||||
<dd>{Math.min(gap.evidence_pointer_ids.length, 3)}개 표시</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{gap.status === "insufficient_evidence" ? (
|
||||
<span className="g6-inline-degraded">근거 없는 상태를 유지하고 해석하지 않아.</span>
|
||||
<span className="g6-inline-degraded">근거가 부족한 상태를 유지하고 해석하지 않습니다.</span>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
|
|
@ -383,8 +383,8 @@ function DisagreementMetadata({ rows }: { rows: CalibrationDatasetMetadata[] })
|
|||
if (rows.length === 0) {
|
||||
return (
|
||||
<Notice
|
||||
title="교수자–AI 불일치 메타데이터가 없어"
|
||||
description="교수자 교정이 기록되면 축어록 없이 버전과 근거 포인터만 연구 행으로 들어와."
|
||||
title="교수자–AI 불일치 메타데이터가 없습니다"
|
||||
description="교수자 교정이 기록되면 축어록 없이 버전과 근거 연결만 연구 행으로 표시됩니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -440,11 +440,11 @@ function DisagreementMetadata({ rows }: { rows: CalibrationDatasetMetadata[] })
|
|||
</dl>
|
||||
<footer>
|
||||
<span>행 해시 {shortId(row.row_hash, 14)}</span>
|
||||
<span>근거 {row.evidence_pointer_ids.length}개</span>
|
||||
<span>근거 연결 {row.evidence_pointer_ids.length}개</span>
|
||||
</footer>
|
||||
{!versionReady ? (
|
||||
<span className="g6-inline-degraded">
|
||||
읽기 API에 모델·프롬프트·도구 버전 일부가 없어 메타데이터 저하 상태야.
|
||||
모델·프롬프트·도구 버전 중 제공되지 않은 정보가 있습니다. 값을 추정하지 않습니다.
|
||||
</span>
|
||||
) : null}
|
||||
</article>
|
||||
|
|
@ -495,8 +495,8 @@ function DriftReport({ report }: { report: EvaluationDriftReport }) {
|
|||
|
||||
{report.status === "insufficient_evidence" ? (
|
||||
<Notice
|
||||
title="비교 근거가 부족해"
|
||||
description="계약대로 정확도와 변화값을 비워 둔 채 판정을 보류해."
|
||||
title="비교 근거가 부족합니다"
|
||||
description="정확도와 변화값을 비워 둔 채 판정을 보류합니다."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -521,8 +521,8 @@ function DriftReport({ report }: { report: EvaluationDriftReport }) {
|
|||
|
||||
{!versionReady ? (
|
||||
<Notice
|
||||
title="버전 출처가 일부 비어 있어"
|
||||
description="현재 요약 API가 모델·프롬프트·도구 버전을 모두 주지 않아 저하 상태로 표시해. 값을 추정하지 않아."
|
||||
title="버전 출처가 일부 비어 있습니다"
|
||||
description="모델·프롬프트·도구 버전 중 제공되지 않은 정보가 있습니다. 값을 추정하지 않습니다."
|
||||
tone="warn"
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -579,8 +579,8 @@ function ManifestArtifact({
|
|||
<dd>{artifact?.artifact_id ?? "미제공"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>provenance</dt>
|
||||
<dd>{artifact?.provenance_uri ?? "원본 provenance 미제공"}</dd>
|
||||
<dt>출처 연결</dt>
|
||||
<dd>{artifact?.provenance_uri ?? "원본 출처 미제공"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>sha256</dt>
|
||||
|
|
@ -617,7 +617,7 @@ function ResearchLens({
|
|||
>
|
||||
{error ? (
|
||||
<Notice
|
||||
title="연구 읽기 모델이 저하 상태야"
|
||||
title="연구 정보를 일부 불러오지 못했습니다"
|
||||
description={error}
|
||||
tone="warn"
|
||||
alert
|
||||
|
|
@ -639,8 +639,8 @@ function ResearchLens({
|
|||
</div>
|
||||
) : (
|
||||
<Notice
|
||||
title="버전 드리프트 비교가 없어"
|
||||
description="기준·후보 배치가 같은 사례와 역량으로 연결되면 여기에서 비교할 수 있어."
|
||||
title="버전 드리프트 비교가 없습니다"
|
||||
description="기준·후보 배치가 같은 사례와 역량으로 연결되면 여기에서 비교할 수 있습니다."
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -652,7 +652,7 @@ function ResearchLens({
|
|||
description="동맹, 파열·수선, 전이, 보정이 각각 schema·hash·원천 URI를 가져야 파일럿 근거가 완성됩니다."
|
||||
side={
|
||||
<Badge tone={manifestComplete ? "accent" : "warn"}>
|
||||
{manifestComplete ? "4/4 provenance 연결" : "provenance 저하"}
|
||||
{manifestComplete ? "4/4 출처 연결" : "출처 연결 저하"}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
|
@ -665,8 +665,8 @@ function ResearchLens({
|
|||
</div>
|
||||
{!manifestComplete ? (
|
||||
<Notice
|
||||
title="매니페스트 요약만 도착했어"
|
||||
description="artifact_count는 보이지만 네 도메인의 provenance 상세가 읽기 API에 없어 저하 상태야. 출처를 추정하지 않아."
|
||||
title="매니페스트 요약만 있습니다"
|
||||
description="artifact_count는 보이지만 네 도메인의 출처 상세가 읽기 API에 없어 저하 상태입니다. 출처를 추정하지 않습니다."
|
||||
tone="warn"
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -682,8 +682,8 @@ function ResearchLens({
|
|||
</>
|
||||
) : (
|
||||
<Notice
|
||||
title="Phase 3 매니페스트가 없어"
|
||||
description="네 도메인의 증거 artifact와 provenance가 모두 연결된 뒤에만 완성 상태로 표시해."
|
||||
title="Phase 3 매니페스트가 없습니다"
|
||||
description="네 도메인의 증거 artifact와 출처가 모두 연결된 뒤에만 완성 상태로 표시됩니다."
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -691,8 +691,8 @@ function ResearchLens({
|
|||
<section className="g6-section">
|
||||
<SectionHeading
|
||||
kicker="CALIBRATION DATASET"
|
||||
title="교수자–AI 불일치 메타데이터"
|
||||
description="라벨 차이와 버전 출처, 해시, 근거 포인터만 사용합니다. 원문 축어록은 데이터셋에 넣지 않습니다."
|
||||
title="교수자와 AI의 평가 차이"
|
||||
description="라벨 차이와 버전 출처, 해시, 근거 연결만 사용합니다. 원문 축어록은 데이터셋에 넣지 않습니다."
|
||||
/>
|
||||
<DisagreementMetadata rows={data?.calibration_dataset ?? []} />
|
||||
</section>
|
||||
|
|
@ -720,7 +720,7 @@ function SupervisionLens({
|
|||
>
|
||||
{supervisionError ? (
|
||||
<Notice
|
||||
title="감독 읽기 모델이 저하 상태야"
|
||||
title="감독 정보를 일부 불러오지 못했습니다"
|
||||
description={supervisionError}
|
||||
tone="warn"
|
||||
alert
|
||||
|
|
@ -729,9 +729,9 @@ function SupervisionLens({
|
|||
|
||||
<section className="g6-section g6-section--attention">
|
||||
<SectionHeading
|
||||
kicker="ATTENTION QUEUE"
|
||||
kicker="검토 대기"
|
||||
title="개입이 필요한 학습 경로부터"
|
||||
description="악화·위험 경계, 정체, 미해결 관계 사건을 원장 근거와 함께 우선 배치합니다."
|
||||
description="악화·위험 경계, 정체, 미해결 관계 사건을 상담 근거와 함께 우선 배치합니다."
|
||||
side={<Badge tone="warn">분류 근거 최대 3개</Badge>}
|
||||
/>
|
||||
<AttentionRadar items={supervision?.attention_items ?? []} />
|
||||
|
|
@ -740,8 +740,8 @@ function SupervisionLens({
|
|||
|
||||
<section className="g6-section">
|
||||
<SectionHeading
|
||||
kicker="CURRICULUM GAPS"
|
||||
title="개인의 순위를 만들지 않는 교육과정 공백"
|
||||
kicker="교육과정 보완"
|
||||
title="반복되는 학습 보완점"
|
||||
description="코호트에서 반복되는 역량 공백만 보며, 근거 부족 상태는 해석 없이 그대로 유지합니다."
|
||||
/>
|
||||
<CurriculumGaps gaps={supervision?.curriculum_gaps ?? []} />
|
||||
|
|
@ -749,14 +749,14 @@ function SupervisionLens({
|
|||
|
||||
<section className="g6-section">
|
||||
<SectionHeading
|
||||
kicker="TEACHER × AI"
|
||||
title="교수자 교정을 연구 가능한 메타데이터로"
|
||||
kicker="교수자·AI 평가 비교"
|
||||
title="교수자와 AI의 평가 차이"
|
||||
description="교수자 라벨과 AI 라벨의 차이만 연결합니다. 사례 원문이나 임상 판정은 이 화면에 들어오지 않습니다."
|
||||
side={<Badge tone="accent">metadata only</Badge>}
|
||||
side={<Badge tone="accent">평가 정보만 표시</Badge>}
|
||||
/>
|
||||
{researchError ? (
|
||||
<Notice
|
||||
title="불일치 메타데이터가 저하 상태야"
|
||||
title="평가 정보를 일부 불러오지 못했습니다"
|
||||
description={researchError}
|
||||
tone="warn"
|
||||
/>
|
||||
|
|
@ -791,12 +791,12 @@ export default function SupervisionResearch() {
|
|||
if (supervisionResult.status === "fulfilled") {
|
||||
setSupervision(supervisionResult.value);
|
||||
} else {
|
||||
setSupervisionError("우선순위 큐와 교육과정 공백을 불러오지 못했어. 원장 데이터가 수정된 것은 아니야.");
|
||||
setSupervisionError("우선순위 목록과 교육과정 공백을 불러오지 못했습니다. 기록 데이터는 수정되지 않았습니다.");
|
||||
}
|
||||
if (researchResult.status === "fulfilled") {
|
||||
setResearch(researchResult.value);
|
||||
} else {
|
||||
setResearchError("불일치·드리프트·매니페스트 요약을 불러오지 못했어. 감독 큐는 계속 확인할 수 있어.");
|
||||
setResearchError("불일치·드리프트·매니페스트 요약을 불러오지 못했습니다. 감독 목록은 계속 확인할 수 있습니다.");
|
||||
}
|
||||
setUpdatedAt(new Date());
|
||||
setLoadState("ready");
|
||||
|
|
@ -819,11 +819,13 @@ export default function SupervisionResearch() {
|
|||
<div className="g6-root">
|
||||
<header className="g6-hero">
|
||||
<div className="g6-hero__copy">
|
||||
<Kicker dot={false}>G6 · SUPERVISION & RESEARCH OS</Kicker>
|
||||
<h1>먼저 볼 경로와, 믿을 수 있는 출처를 분리해 본다.</h1>
|
||||
<Kicker dot={false}>학습 검토</Kicker>
|
||||
<h1>감독·연구</h1>
|
||||
<p>
|
||||
교수 감독은 개입 순서를, 연구 품질은 평가 버전과 provenance를 봐. 이 화면은 교육용
|
||||
운영 근거이며 개인을 한 숫자로 줄이거나 진단·임상 판정을 만들지 않아.
|
||||
검토가 필요한 학습 회기와 평가 근거를 확인합니다.
|
||||
{canUseResearchLens
|
||||
? " 연구 품질 탭에서는 평가 버전과 출처를 확인할 수 있습니다."
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="g6-hero__status" aria-label="데이터 안전 경계">
|
||||
|
|
@ -877,7 +879,7 @@ export default function SupervisionResearch() {
|
|||
{loadState === "loading" && !supervision && !research ? (
|
||||
<div className="g6-loading" role="status">
|
||||
<span aria-hidden="true" />
|
||||
감독 원장과 연구 provenance를 동기화하는 중
|
||||
감독 근거와 연구 출처를 동기화하는 중입니다
|
||||
</div>
|
||||
) : lens === "research" && canUseResearchLens ? (
|
||||
<ResearchLens data={research} error={researchError} />
|
||||
|
|
|
|||
|
|
@ -118,11 +118,11 @@ function decisionIntentLabel(intent: DecisionIntent | null): string {
|
|||
}
|
||||
|
||||
function recordedDecisionLabel(decision: ApprovalDecision): string {
|
||||
if (decision === "approve_content") return "콘텐츠 승인 원장 기록됨";
|
||||
if (decision === "approve_promotion") return "승격 승인 원장 기록됨";
|
||||
if (decision === "authorize_rollback") return "롤백 승인 원장 기록됨";
|
||||
if (decision === "keep_quarantine") return "보류 원장 기록됨";
|
||||
return "반려 원장 기록됨";
|
||||
if (decision === "approve_content") return "콘텐츠 승인 기록됨";
|
||||
if (decision === "approve_promotion") return "변경 승인 기록됨";
|
||||
if (decision === "authorize_rollback") return "복구 승인 기록됨";
|
||||
if (decision === "keep_quarantine") return "보류 기록됨";
|
||||
return "반려 기록됨";
|
||||
}
|
||||
|
||||
function decisionLabel(item: GateItem): string {
|
||||
|
|
@ -181,10 +181,10 @@ function isBoundarySafe(view: ContinuousImprovementViewResponse): boolean {
|
|||
|
||||
function BoundaryStrip({ view }: { view: ContinuousImprovementViewResponse }) {
|
||||
const boundaries = [
|
||||
["입력", "합성 replay · red-team · coverage drift"],
|
||||
["승격", "silent auto-promotion 금지"],
|
||||
["효과", "사람 승인 후 append-only"],
|
||||
["데이터", "원문 · PII · 임상 주장 미포함"],
|
||||
["입력", "상담 재현 검증 · 독립 안전성 검증 · 평가 범위 점검"],
|
||||
["승격", "관리자 승인 후 적용"],
|
||||
["효과", "변경 이력 보존"],
|
||||
["데이터", "원문 · 개인정보 · 임상 주장 미포함"],
|
||||
];
|
||||
const safe = isBoundarySafe(view);
|
||||
|
||||
|
|
@ -386,19 +386,19 @@ function ContentQualificationCard({
|
|||
const approvalBlockedReason = !boundarySafe
|
||||
? "데이터 경계 위반으로 승인 차단"
|
||||
: !payload
|
||||
? "검수 가능한 visible payload가 없어 승인 차단"
|
||||
? "검수할 수 있는 콘텐츠가 없어 승인할 수 없습니다"
|
||||
: !evidenceReady
|
||||
? "repo provenance가 없어 승인 차단"
|
||||
? "출처 기록이 없어 승인할 수 없습니다"
|
||||
: "승인 조건 충족";
|
||||
const requirementCopy = catalog
|
||||
? "이미 카탈로그에 반영되어 추가 결정을 기록할 수 없음"
|
||||
? "이미 카탈로그에 반영되어 추가 결정을 기록할 수 없습니다"
|
||||
: !intent
|
||||
? "승인·보류·반려 중 하나를 명시적으로 선택해야 함"
|
||||
: intent === "approve" && !approvalAllowed
|
||||
? approvalBlockedReason
|
||||
: !reason.trim()
|
||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
|
||||
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
|
||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 합니다`
|
||||
: `${decisionIntentLabel(intent)} 결정과 사유를 변경 이력에 기록할 준비가 되었습니다`;
|
||||
|
||||
return (
|
||||
<article
|
||||
|
|
@ -474,7 +474,7 @@ function ContentQualificationCard({
|
|||
</details>
|
||||
) : (
|
||||
<div className="cic-content-card__blocked" role="alert">
|
||||
메타데이터 후보라 visible payload를 검수할 수 없어. 카탈로그 승인은 닫혀 있어.
|
||||
검수할 수 있는 콘텐츠가 없어 카탈로그 승인을 진행할 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -562,19 +562,19 @@ function PipelineSection({
|
|||
<section className="cic-panel cic-pipeline" aria-labelledby="cic-pipeline-title">
|
||||
<header className="cic-section-head">
|
||||
<div>
|
||||
<Kicker dot={false}>CONTENT QUALIFICATION</Kicker>
|
||||
<h2 id="cic-pipeline-title">합성 콘텐츠 검증 파이프라인</h2>
|
||||
<p>생성물은 독립 red-team과 변형 benchmark를 통과해도 카탈로그에 자동 게시되지 않아.</p>
|
||||
<Kicker dot={false}>콘텐츠 검증</Kicker>
|
||||
<h2 id="cic-pipeline-title">생성 콘텐츠 검토</h2>
|
||||
<p>생성 콘텐츠는 독립 안전성 검증과 평가 범위 점검을 통과해도 자동으로 게시되지 않습니다.</p>
|
||||
</div>
|
||||
<span className="cic-state-chip">사람 검토 대기</span>
|
||||
</header>
|
||||
|
||||
<div className="cic-pipeline__flow" aria-label="콘텐츠 검증 단계">
|
||||
{[
|
||||
["01", "Synthetic replay", "비식별 합성 입력"],
|
||||
["02", "Independent red-team", "안전 · 누출 · 편향"],
|
||||
["03", "Coverage drift", "변형 benchmark"],
|
||||
["04", "Human catalog gate", "append-only 승인"],
|
||||
["01", "상담 재현 검증", "비식별 합성 입력"],
|
||||
["02", "독립 안전성 검증", "안전 · 누출 · 편향"],
|
||||
["03", "평가 범위 점검", "변형 평가"],
|
||||
["04", "관리자 게시 승인", "변경 이력 보존"],
|
||||
].map(([step, title, detail]) => (
|
||||
<div className="cic-pipeline__step" key={step}>
|
||||
<span>{step}</span>
|
||||
|
|
@ -588,8 +588,8 @@ function PipelineSection({
|
|||
<div className="cic-empty" role="status">
|
||||
<Icon name="hourglass" size={20} />
|
||||
<div>
|
||||
<strong>검토할 콘텐츠 후보가 없어</strong>
|
||||
<p>에이전트 파이프라인이 qualified candidate를 적재하면 여기에 나타나.</p>
|
||||
<strong>검토할 콘텐츠 후보가 없습니다</strong>
|
||||
<p>검토 대상이 생성되면 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -655,11 +655,11 @@ function GateCard({
|
|||
const approvalBlockedReason = !boundarySafe
|
||||
? "데이터 경계 위반으로 승인 차단"
|
||||
: !complete
|
||||
? "필수 증거 4종 미완료로 승인 차단"
|
||||
? "필수 증거 4종이 갖춰지지 않아 승인할 수 없습니다"
|
||||
: !approvalCandidateReady
|
||||
? item.kind === "release_gate"
|
||||
? "계산 게이트 미통과로 승격 승인 차단"
|
||||
: "계산 결과가 격리 유지라 승격 승인 차단"
|
||||
? "계산 게이트를 통과하지 않아 승인할 수 없습니다"
|
||||
: "계산 결과가 격리 유지여서 승인할 수 없습니다"
|
||||
: "승인 조건 충족";
|
||||
const requirementId = `approval-requirement-${id}`;
|
||||
const requirementCopy = !intent
|
||||
|
|
@ -667,8 +667,8 @@ function GateCard({
|
|||
: intent === "approve" && !approvalAllowed
|
||||
? approvalBlockedReason
|
||||
: !reason.trim()
|
||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
|
||||
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
|
||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 합니다`
|
||||
: `${decisionIntentLabel(intent)} 결정과 사유를 변경 이력에 기록할 준비가 되었습니다`;
|
||||
|
||||
return (
|
||||
<article className="cic-gate-card" data-gate-id={id}>
|
||||
|
|
@ -787,17 +787,17 @@ function GateSection({
|
|||
<section className="cic-gates" aria-labelledby="cic-gates-title">
|
||||
<header className="cic-section-head">
|
||||
<div>
|
||||
<Kicker dot={false}>PROMOTION CONTROL</Kicker>
|
||||
<h2 id="cic-gates-title">모델 변경 · 릴리스 게이트</h2>
|
||||
<p>계산 결과는 제안일 뿐이야. 4종 증거와 명시적 사람 승인 없이는 효과가 발생하지 않아.</p>
|
||||
<Kicker dot={false}>변경 승인</Kicker>
|
||||
<h2 id="cic-gates-title">모델 변경과 릴리스 검토</h2>
|
||||
<p>계산 결과는 제안입니다. 필수 증거와 관리자 승인을 거친 뒤에만 적용할 수 있습니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
{items.length === 0 ? (
|
||||
<div className="cic-empty" role="status">
|
||||
<Icon name="shield" size={20} />
|
||||
<div>
|
||||
<strong>대기 중인 게이트가 없어</strong>
|
||||
<p>모델 변경 또는 에이전틱 릴리스 후보가 생성되면 여기서 검토해.</p>
|
||||
<strong>대기 중인 변경 검토가 없습니다</strong>
|
||||
<p>모델 변경 또는 릴리스 후보가 생성되면 여기에서 검토할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -828,17 +828,17 @@ function IncidentDag({ view }: { view: ContinuousImprovementViewResponse }) {
|
|||
<section className="cic-panel" aria-labelledby="cic-incidents-title">
|
||||
<header className="cic-section-head cic-section-head--compact">
|
||||
<div>
|
||||
<Kicker dot={false}>INCIDENT → REGRESSION</Kicker>
|
||||
<h2 id="cic-incidents-title">사건 회귀 DAG</h2>
|
||||
<p>운영 사건을 재현 테스트부터 런타임 증명까지 끊김 없이 추적해.</p>
|
||||
<Kicker dot={false}>문제 재발 방지</Kicker>
|
||||
<h2 id="cic-incidents-title">문제 재발 방지 기록</h2>
|
||||
<p>운영 문제를 재현 검증부터 실제 환경 확인까지 연결해 추적합니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
{incidents.length === 0 ? (
|
||||
<div className="cic-empty cic-empty--plain" role="status">
|
||||
<Icon name="check" size={19} />
|
||||
<div>
|
||||
<strong>등록된 운영 사건이 없어</strong>
|
||||
<p>합성 증거 기반 사건이 적재되면 회귀 DAG가 생성돼.</p>
|
||||
<strong>등록된 운영 문제가 없습니다</strong>
|
||||
<p>재발 방지 대상이 등록되면 검증 기록이 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -856,7 +856,7 @@ function IncidentDag({ view }: { view: ContinuousImprovementViewResponse }) {
|
|||
</div>
|
||||
<span>{dateTimeLabel(incident.created_at)}</span>
|
||||
</header>
|
||||
<ol className="cic-dag" aria-label={`${incident.incident_id} 회귀 DAG`}>
|
||||
<ol className="cic-dag" aria-label={`${incident.incident_id} 재발 방지 기록`}>
|
||||
{(["reproduction_test", "implementation", "e2e", "runtime_proof"] as const).map(
|
||||
(kind) => {
|
||||
const node = nodes.find((candidate) => candidate.node_type === kind);
|
||||
|
|
@ -886,17 +886,17 @@ function LifecycleLedger({ events }: { events: LifecycleEventView[] }) {
|
|||
<section className="cic-panel" aria-labelledby="cic-lifecycle-title">
|
||||
<header className="cic-section-head cic-section-head--compact">
|
||||
<div>
|
||||
<Kicker dot={false}>APPEND-ONLY EFFECTS</Kicker>
|
||||
<h2 id="cic-lifecycle-title">모니터 · 롤백 원장</h2>
|
||||
<p>승인 이후 효과와 사후 검증은 별도 이벤트로 남아.</p>
|
||||
<Kicker dot={false}>적용·복구 기록</Kicker>
|
||||
<h2 id="cic-lifecycle-title">적용과 복구 이력</h2>
|
||||
<p>승인 뒤 적용 결과와 사후 검증을 변경 이력으로 보존합니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
{events.length === 0 ? (
|
||||
<div className="cic-empty cic-empty--plain" role="status">
|
||||
<Icon name="hourglass" size={19} />
|
||||
<div>
|
||||
<strong>아직 발생한 효과가 없어</strong>
|
||||
<p>사람 승인 전에는 정상적인 빈 상태야.</p>
|
||||
<strong>기록된 적용 결과가 없습니다</strong>
|
||||
<p>관리자 승인 전에는 정상적인 빈 상태입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -922,7 +922,7 @@ function LifecycleLedger({ events }: { events: LifecycleEventView[] }) {
|
|||
{event.target_kind === "model_change_gate" ? "모델" : "릴리스"} · {shortId(event.target_id)}
|
||||
</span>
|
||||
{rollbackVerificationPending ? (
|
||||
<em>롤백은 실행됐지만 verification 이벤트가 아직 없어</em>
|
||||
<em>복구는 실행됐지만 검증 기록이 아직 없습니다</em>
|
||||
) : null}
|
||||
</div>
|
||||
<time dateTime={event.created_at}>{dateTimeLabel(event.created_at)}</time>
|
||||
|
|
@ -952,7 +952,7 @@ export function ContinuousImprovementCockpit() {
|
|||
setView(next);
|
||||
} catch (cause) {
|
||||
if (signal?.aborted) return;
|
||||
setError(cause instanceof Error ? cause.message : "개선 원장을 불러오지 못했어.");
|
||||
setError(cause instanceof Error ? cause.message : "개선 기록을 불러오지 못했습니다.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
|
|
@ -1004,14 +1004,14 @@ export function ContinuousImprovementCockpit() {
|
|||
});
|
||||
setNotice(
|
||||
intent === "approve"
|
||||
? `${decisionIntentLabel(intent)}과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
|
||||
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
|
||||
? `${decisionIntentLabel(intent)}과 적용 기록 ${shortId(result.effect_record_id, 8)}을 보존했습니다`
|
||||
: `${decisionIntentLabel(intent)} 결정을 변경 이력에 기록했습니다`,
|
||||
);
|
||||
setDecisions((current) => ({ ...current, [id]: undefined }));
|
||||
setReasons((current) => ({ ...current, [id]: "" }));
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "사람 결정 원장을 기록하지 못했어.");
|
||||
setError(cause instanceof Error ? cause.message : "관리자 결정 기록을 저장하지 못했습니다.");
|
||||
} finally {
|
||||
setApprovingId(null);
|
||||
}
|
||||
|
|
@ -1068,14 +1068,14 @@ export function ContinuousImprovementCockpit() {
|
|||
});
|
||||
setNotice(
|
||||
intent === "approve"
|
||||
? `콘텐츠 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
|
||||
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
|
||||
? `콘텐츠 승인과 적용 기록 ${shortId(result.effect_record_id, 8)}을 보존했습니다`
|
||||
: `${decisionIntentLabel(intent)} 결정을 변경 이력에 기록했습니다`,
|
||||
);
|
||||
setDecisions((current) => ({ ...current, [id]: undefined }));
|
||||
setReasons((current) => ({ ...current, [id]: "" }));
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "콘텐츠 결정 원장을 기록하지 못했어.");
|
||||
setError(cause instanceof Error ? cause.message : "콘텐츠 결정 기록을 저장하지 못했습니다.");
|
||||
} finally {
|
||||
setApprovingId(null);
|
||||
}
|
||||
|
|
@ -1105,9 +1105,9 @@ export function ContinuousImprovementCockpit() {
|
|||
<section className="cic-degraded" role="alert">
|
||||
<Icon name="alert" size={22} />
|
||||
<div>
|
||||
<Kicker dot={false}>DEGRADED READ MODEL</Kicker>
|
||||
<h1>개선 원장을 확인할 수 없어</h1>
|
||||
<p>{error ?? "운영 원장이 응답하지 않았어."} 승인 동작은 닫힌 상태로 유지돼.</p>
|
||||
<Kicker dot={false}>정보 불러오기 실패</Kicker>
|
||||
<h1>개선 기록을 확인할 수 없습니다</h1>
|
||||
<p>{error ?? "운영 기록이 응답하지 않았습니다."} 승인 동작은 사용할 수 없습니다.</p>
|
||||
<Button size="sm" variant="secondary" onClick={() => void load()}>
|
||||
다시 확인
|
||||
</Button>
|
||||
|
|
@ -1125,12 +1125,9 @@ export function ContinuousImprovementCockpit() {
|
|||
>
|
||||
<header className="cic-hero">
|
||||
<div>
|
||||
<Kicker>관리자 · CONTINUOUS IMPROVEMENT OS</Kicker>
|
||||
<h1>승격보다 근거를 먼저 본다</h1>
|
||||
<p>
|
||||
합성 콘텐츠, 모델 변경, 에이전틱 릴리스를 한 원장에서 검토하고 사람 승인 이후의
|
||||
효과와 롤백 검증까지 닫아.
|
||||
</p>
|
||||
<Kicker>관리자 · 지속 개선</Kicker>
|
||||
<h1>개선안 검토와 승인</h1>
|
||||
<p>생성 콘텐츠와 모델 변경의 검증 결과를 확인하고, 승인 이후 적용·복구 기록을 추적합니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -1139,7 +1136,7 @@ export function ContinuousImprovementCockpit() {
|
|||
disabled={loading}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
원장 새로고침
|
||||
새로고침
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
|
|
@ -1188,8 +1185,8 @@ export function ContinuousImprovementCockpit() {
|
|||
<footer className="cic-footnote">
|
||||
<Icon name="info" size={16} />
|
||||
<p>
|
||||
이 표면은 관리자 전용 운영 메타데이터만 표시해. 학습자·교수자 화면, 원문 트랜스크립트,
|
||||
PII, 임상 주장, 합산 총점은 이 계약에 포함하지 않아.
|
||||
이 화면은 관리자 전용 운영 메타데이터만 표시합니다. 학습자·교수자 화면, 원문 대화 기록,
|
||||
개인정보, 임상 주장, 합산 총점은 이 범위에 포함하지 않습니다.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ export function ProviderConnectionsPanel() {
|
|||
className={`aic-runtime ${data?.gateway_sync.synced ? "aic-runtime--ok" : ""}`}
|
||||
title={data?.gateway_sync.detail ?? undefined}
|
||||
>
|
||||
{data === null ? "확인 중" : data.gateway_sync.synced ? "게이트웨이 적용됨" : "적용 대기"}
|
||||
{data === null ? (error ? "연결 상태 확인 실패" : "확인 중") : data.gateway_sync.synced ? "게이트웨이 적용됨" : "적용 대기"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -234,6 +234,7 @@ export function ProviderConnectionsPanel() {
|
|||
<div className="aic-alert" role="alert">
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{error}</span>
|
||||
{data === null ? <Button onClick={() => void load()}>다시 확인</Button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{flash ? (
|
||||
|
|
|
|||
|
|
@ -823,6 +823,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.aic-head__actions .vg-btn,
|
||||
.aic-window button {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aic-chart__bar {
|
||||
transition: none;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
.vgops-root {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
|
@ -600,6 +602,7 @@
|
|||
.vgops-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
|
|
@ -1509,7 +1512,7 @@
|
|||
.vgops-ticket-filter input,
|
||||
.vgops-ticket-filter select {
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
|
|
@ -1520,7 +1523,7 @@
|
|||
}
|
||||
|
||||
.vgops-ticket-filter__check {
|
||||
min-height: 36px;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -1535,6 +1538,10 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vgops-ticket-filter .vg-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.vgops-ticket-filter__check input {
|
||||
min-height: auto;
|
||||
width: 14px;
|
||||
|
|
@ -1897,6 +1904,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vgops-root[data-admin-section="users"] {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: calc(100vw - 32px);
|
||||
margin: 0;
|
||||
}
|
||||
.vgops-head .vg-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.vgops-status {
|
||||
grid-template-columns: 12px minmax(0, 1fr);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@
|
|||
.cic-hero h1 {
|
||||
max-width: 780px;
|
||||
margin: 9px 0 0;
|
||||
font-size: clamp(2rem, 4vw, 3.35rem);
|
||||
line-height: 0.98;
|
||||
font-size: clamp(1.5rem, 2.5vw, 2rem);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cic-hero p {
|
||||
|
|
@ -34,6 +34,10 @@
|
|||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.cic .vg-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.cic-boundary {
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(4, minmax(0, 1fr));
|
||||
|
|
@ -368,6 +372,9 @@
|
|||
}
|
||||
|
||||
.cic-content-review summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 11px 12px;
|
||||
color: var(--text-strong);
|
||||
cursor: pointer;
|
||||
|
|
@ -1112,8 +1119,8 @@
|
|||
}
|
||||
|
||||
.cic-hero h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 1.03;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.cic-boundary {
|
||||
|
|
|
|||
|
|
@ -555,6 +555,12 @@
|
|||
line-height:1.45;
|
||||
word-break:keep-all;
|
||||
}
|
||||
.lh-metric-card small{
|
||||
color:var(--text-muted);
|
||||
font-size:11px;
|
||||
font-weight:650;
|
||||
line-height:1.4;
|
||||
}
|
||||
.lh-dashboard-columns,
|
||||
.lh-history-layout{
|
||||
min-width:0;
|
||||
|
|
@ -1933,6 +1939,19 @@
|
|||
background:var(--accent);
|
||||
}
|
||||
.lh-persona-progress__note{margin:0;}
|
||||
.lh-persona-progress__empty{display:grid;gap:8px;}
|
||||
.lh-persona-progress__toggle{
|
||||
min-height:44px;
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface-2);
|
||||
color:var(--accent-deep);
|
||||
font:inherit;
|
||||
font-size:12px;
|
||||
font-weight:760;
|
||||
text-align:left;
|
||||
cursor:pointer;
|
||||
}
|
||||
.lh-archive-note{
|
||||
display:grid;
|
||||
gap:12px;
|
||||
|
|
@ -2133,10 +2152,16 @@
|
|||
gap:8px;
|
||||
overflow-x:auto;
|
||||
overflow-y:hidden;
|
||||
padding-bottom:2px;
|
||||
padding:0 1px 2px 0;
|
||||
}
|
||||
.lh-personas::after{
|
||||
content:"";
|
||||
flex:0 0 1px;
|
||||
}
|
||||
.lh-persona{
|
||||
flex:0 0 min(82vw,320px);
|
||||
flex:0 0 min(78vw,calc(100% - 1px));
|
||||
width:min(78vw,calc(100% - 1px));
|
||||
max-width:100%;
|
||||
min-height:66px;
|
||||
grid-template-columns:38px minmax(0,1fr);
|
||||
align-items:center;
|
||||
|
|
@ -2195,6 +2220,15 @@
|
|||
.lh-session-actions{
|
||||
justify-content:flex-start;
|
||||
}
|
||||
.lh-history-filter button,
|
||||
.lh-session-actions button{
|
||||
min-width:44px;
|
||||
min-height:44px;
|
||||
}
|
||||
.lh-persona-progress__empty .vg-btn,
|
||||
.lh-persona-progress__toggle{
|
||||
min-height:44px;
|
||||
}
|
||||
.lh-persona-progress__row{
|
||||
grid-template-columns:1fr;
|
||||
gap:6px;
|
||||
|
|
@ -2218,9 +2252,7 @@
|
|||
.lh-head__primary{
|
||||
width:100%;
|
||||
}
|
||||
.lh-history-overview{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.lh-history-overview{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||
.lh-session-focus__head{
|
||||
display:grid;
|
||||
gap:10px;
|
||||
|
|
@ -2229,8 +2261,8 @@
|
|||
justify-self:start;
|
||||
}
|
||||
.lh-persona{
|
||||
flex-basis:min(78vw,300px);
|
||||
width:min(78vw,300px);
|
||||
flex-basis:min(78vw,calc(100% - 1px));
|
||||
width:min(78vw,calc(100% - 1px));
|
||||
}
|
||||
.lh-recap__facts{
|
||||
grid-template-columns:1fr;
|
||||
|
|
@ -2242,10 +2274,6 @@
|
|||
flex-direction:row;
|
||||
justify-content:flex-start;
|
||||
}
|
||||
.lh-review-link{
|
||||
min-height:32px;
|
||||
padding:4px 7px;
|
||||
}
|
||||
}
|
||||
/* 초소형 폭(320~420px, iPhone SE급) 전용 밴드.
|
||||
기존 최소 브레이크포인트가 460px이라 이 구간이 비어 있었다.
|
||||
|
|
@ -2610,6 +2638,46 @@
|
|||
.vg-shell--learner-dashboard .lh-dashboard-side__tabpanel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.vg-shell--learner-dashboard .lh-growth-status {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (min-width:721px) and (max-width:1180px){
|
||||
.lh-personas{
|
||||
grid-auto-rows:max-content;
|
||||
max-height:240px;
|
||||
overflow-x:hidden;
|
||||
overflow-y:auto;
|
||||
padding-right:4px;
|
||||
}
|
||||
.lh-persona{align-self:start;}
|
||||
.lh-persona__summary{
|
||||
overflow:hidden;
|
||||
display:-webkit-box;
|
||||
-webkit-line-clamp:3;
|
||||
-webkit-box-orient:vertical;
|
||||
}
|
||||
}
|
||||
@media (min-width:1181px){
|
||||
.lh-personas{
|
||||
grid-auto-rows:max-content;
|
||||
max-height:max(300px,calc(100dvh - 260px));
|
||||
overflow-x:hidden;
|
||||
overflow-y:auto;
|
||||
padding-right:4px;
|
||||
}
|
||||
.lh-persona{align-self:start;}
|
||||
.lh-persona__summary{
|
||||
overflow:hidden;
|
||||
display:-webkit-box;
|
||||
-webkit-line-clamp:3;
|
||||
-webkit-box-orient:vertical;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1421px) {
|
||||
.vg-shell--learner-dashboard .lh-work-cluster__primary {
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(280px, .75fr);
|
||||
}
|
||||
}
|
||||
|
||||
.vg-shell--learner-dashboard .lh-root {
|
||||
|
|
@ -2675,14 +2743,6 @@
|
|||
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;
|
||||
|
|
@ -2690,7 +2750,7 @@
|
|||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
gap: 18px;
|
||||
min-height: 466px;
|
||||
min-height: 0;
|
||||
padding: 28px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
|
@ -2753,7 +2813,7 @@
|
|||
padding: 12px 14px;
|
||||
}
|
||||
.vg-shell--learner-dashboard .lh-coach-card {
|
||||
min-height: 300px;
|
||||
min-height: 0;
|
||||
gap: 14px;
|
||||
padding: 28px;
|
||||
border-radius: 9px;
|
||||
|
|
|
|||
|
|
@ -36,12 +36,12 @@ export function LoginBrand({ oauthChecking, oauthReady }: LoginBrandProps) {
|
|||
상담 시뮬레이션 학습
|
||||
</span>
|
||||
<h1>
|
||||
<span className="lg-highlight">실제 계정으로 들어가고</span>, <br />
|
||||
실제 회기만 남깁니다.
|
||||
<span className="lg-highlight">상담을 연습하고,</span>
|
||||
<br />
|
||||
함께 돌아봅니다.
|
||||
</h1>
|
||||
<p>
|
||||
Vignette는 상담 연습, 교수자 피드백, 운영 관리 권한을 안전한 로그인 세션으로 분리합니다.
|
||||
브라우저에는 인증 토큰을 저장하지 않습니다.
|
||||
가상 내담자와 상담을 연습하고, 회기 기록과 피드백으로 다음 연습을 준비하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -227,28 +227,27 @@
|
|||
}
|
||||
.ob-legal summary{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
min-height:42px;
|
||||
padding:0 12px;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0, 1fr);
|
||||
align-content:center;
|
||||
gap:2px;
|
||||
min-height:44px;
|
||||
padding:10px 12px;
|
||||
cursor:pointer;
|
||||
}
|
||||
.ob-legal summary span{
|
||||
.ob-legal__title{
|
||||
min-width:0;
|
||||
color:var(--text-strong);
|
||||
font-size:13px;
|
||||
font-weight:760;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
line-height:1.35;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.ob-legal summary b{
|
||||
flex:none;
|
||||
.ob-legal__version{
|
||||
color:var(--text-muted);
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
line-height:1.35;
|
||||
}
|
||||
.ob-note,
|
||||
.ob-error{
|
||||
|
|
|
|||
|
|
@ -907,7 +907,8 @@
|
|||
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--hair);color:var(--text-body);font-size:var(--fs-xs);}
|
||||
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
|
||||
.ps-point-list small{color:var(--text-muted);text-align:right;}
|
||||
.ps-stepper 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;transition:transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);}
|
||||
.ps-stepper{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));}
|
||||
.ps-stepper button{min-width:0;min-height:44px;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;transition:transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);}
|
||||
.ps-stepper button:active{transform:scale(0.96);}
|
||||
.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;}
|
||||
|
|
@ -984,8 +985,8 @@
|
|||
.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{grid-template-columns:repeat(4,minmax(0,1fr));overflow:visible;}
|
||||
.ps-stepper button{min-height:44px;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));}
|
||||
|
|
@ -993,3 +994,7 @@
|
|||
.ps-point-list > div{grid-template-columns:minmax(0,1fr) 64px;}
|
||||
.ps-point-list small{grid-column:1 / -1;text-align:left;}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
.ps-root .vg-btn{min-height:44px;}
|
||||
.ps-root .ps-tabs button{min-height:44px;}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
.pf-root{
|
||||
max-width:1280px;
|
||||
margin:0 auto;
|
||||
width:100%;
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:16px;
|
||||
|
|
@ -183,6 +185,7 @@
|
|||
}
|
||||
.pf-analysis-page{
|
||||
order:2;
|
||||
width:100%;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:12px;
|
||||
|
|
@ -327,9 +330,13 @@
|
|||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
||||
}
|
||||
.pf-learner-overview{
|
||||
width:100%;
|
||||
min-width:0;
|
||||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
|
||||
}
|
||||
.pf-learner-toolbar{
|
||||
min-width:0;
|
||||
width:100%;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(240px,.72fr) auto;
|
||||
gap:10px;
|
||||
|
|
@ -403,6 +410,8 @@
|
|||
}
|
||||
.pf-learner-table{
|
||||
min-width:0;
|
||||
width:100%;
|
||||
max-width:100%;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
/* 세로 가상화 스크롤 컨테이너. height:auto + max-height라 행이 적으면
|
||||
|
|
@ -1785,6 +1794,35 @@
|
|||
justify-self:start;
|
||||
}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
.pf-root--analysis{
|
||||
width:calc(100vw - 32px);
|
||||
max-width:calc(100vw - 32px);
|
||||
margin:0;
|
||||
}
|
||||
.pf-head__actions .vg-btn,
|
||||
.pf-studio-card__actions .vg-btn,
|
||||
.pf-learner-row__action .vg-btn,
|
||||
.pf-analysis-detail-head > .vg-btn{
|
||||
min-height:44px;
|
||||
white-space:normal;
|
||||
text-align:center;
|
||||
}
|
||||
.pf-sort-segment button{
|
||||
min-height:44px;
|
||||
}
|
||||
.pf-studio-card{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-studio-card__actions{
|
||||
justify-content:flex-start;
|
||||
}
|
||||
.pf-session--action,
|
||||
.pf-recent-row--action{
|
||||
flex:0 0 auto;
|
||||
min-height:44px;
|
||||
}
|
||||
}
|
||||
@media (max-width:520px){
|
||||
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||
.pf-kpi,
|
||||
|
|
|
|||
|
|
@ -666,6 +666,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ap-self-form__commit .vg-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.ap-card {
|
||||
padding: var(--sp-4);
|
||||
|
|
|
|||
|
|
@ -6185,7 +6185,8 @@
|
|||
@media (max-width: 720px) {
|
||||
.vg-main:has(.sx-page--prestart),
|
||||
.vg-main:has(.sx-page--prestart .sx-prestart--prescribed) {
|
||||
padding-bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-6));
|
||||
--prestart-actions-reserve: 10rem;
|
||||
padding-bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--prestart-actions-reserve));
|
||||
}
|
||||
.sx-page--prestart .sx-prestart {
|
||||
grid-template-areas:
|
||||
|
|
@ -6222,6 +6223,16 @@
|
|||
.sx-page--prestart .sx-prestart__facts {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__facts > div:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__facts dd {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__main {
|
||||
z-index: 2;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__focus-head {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
|
|
@ -6255,13 +6266,15 @@
|
|||
}
|
||||
.sx-page--prestart .sx-prestart__actions,
|
||||
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__actions {
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-2));
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
right: 16px;
|
||||
bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom));
|
||||
left: 16px;
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--glass-inset-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-surface);
|
||||
background: var(--bg-app);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__actions .vg-btn {
|
||||
|
|
|
|||
|
|
@ -1419,6 +1419,23 @@ body[data-page="settings"] .vg-set__forms {
|
|||
gap: 14px;
|
||||
max-width: 820px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set,
|
||||
body[data-page="settings"] .vg-set__rail,
|
||||
body[data-page="settings"] .vg-set__forms,
|
||||
body[data-page="settings"] .vg-set__group,
|
||||
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 {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
body[data-page="settings"] .vg-set input,
|
||||
body[data-page="settings"] .vg-set select,
|
||||
body[data-page="settings"] .vg-set textarea {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
body[data-page="settings"] #set-account {
|
||||
order: 1;
|
||||
}
|
||||
|
|
@ -1614,6 +1631,9 @@ body[data-page="settings"] .vg-set__pill {
|
|||
padding: 14px 16px 10px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__group {
|
||||
scroll-margin-top: calc(var(--topbar-h) + 140px);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title {
|
||||
gap: 10px;
|
||||
padding: 0 0 12px;
|
||||
|
|
@ -1631,19 +1651,18 @@ body[data-page="settings"] .vg-set__pill {
|
|||
body[data-page="settings"] .vg-set__rail-status {
|
||||
display: none;
|
||||
}
|
||||
/* 탭이 많아 폭이 모자라면 가로 스크롤로 흘린다(라벨은 계속 노출). */
|
||||
/* 태블릿에서는 폭에 맞춰 자연스럽게 다음 줄로 넘긴다. */
|
||||
body[data-page="settings"] .vg-set__nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 0 0 2px;
|
||||
scrollbar-width: thin;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item {
|
||||
flex: 0 0 auto;
|
||||
min-height: 38px;
|
||||
min-height: 44px;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
|
|
@ -1655,6 +1674,9 @@ body[data-page="settings"] .vg-set__pill {
|
|||
body[data-page="settings"] .vg-set__forms {
|
||||
max-width: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__voicelist {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ≤720px 는 위 1080 가로 탭 배치를 그대로 이어받고 여백·타이포만 더 좁힌다.
|
||||
|
|
@ -1668,10 +1690,13 @@ body[data-page="settings"] .vg-set__pill {
|
|||
}
|
||||
/* ≤720px 만 full-bleed 상단 바(모바일 헤더 패턴)로 되돌린다. */
|
||||
body[data-page="settings"] .vg-set__rail {
|
||||
margin: 0 -16px 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 0 36px;
|
||||
padding: 13px 16px 10px;
|
||||
border-radius: 0;
|
||||
border-bottom-color: var(--glass-border);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title {
|
||||
justify-content: center;
|
||||
|
|
@ -1686,14 +1711,25 @@ body[data-page="settings"] .vg-set__pill {
|
|||
height: 24px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav {
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
justify-content: initial;
|
||||
gap: 4px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item {
|
||||
min-height: 34px;
|
||||
gap: 5px;
|
||||
padding: 0 7px;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 0 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
white-space: normal;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item span {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item svg {
|
||||
width: 14px;
|
||||
|
|
@ -1705,6 +1741,39 @@ body[data-page="settings"] .vg-set__pill {
|
|||
body[data-page="settings"] .vg-set__group {
|
||||
padding: 13px;
|
||||
}
|
||||
body[data-page="settings"] #set-support .vg-set__group-head {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
body[data-page="settings"] #set-support .vg-set__group-head > div:first-child,
|
||||
body[data-page="settings"] #set-support .vg-set__support-head-ops {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
body[data-page="settings"] #set-support .vg-set__support-head-ops {
|
||||
align-items: center;
|
||||
}
|
||||
body[data-page="settings"] #set-consent .vg-set__opt {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
body[data-page="settings"] #set-consent .vg-set__opt-text {
|
||||
width: 100%;
|
||||
}
|
||||
body[data-page="settings"] #set-consent .vg-set__opt > .vg-btn {
|
||||
align-self: flex-start;
|
||||
min-height: 44px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__profile {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__avatar-actions {
|
||||
grid-column: 1 / -1;
|
||||
margin-left: 0;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__field-grid--account,
|
||||
body[data-page="settings"] .vg-set__field-grid--engine,
|
||||
body[data-page="settings"] .vg-set__seg,
|
||||
|
|
@ -1720,7 +1789,25 @@ body[data-page="settings"] .vg-set__pill {
|
|||
body[data-page="settings"] .vg-set__ops {
|
||||
width: 100%;
|
||||
}
|
||||
body[data-page="settings"] .vg-set .vg-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set .vg-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set .vg-toggle__track {
|
||||
inset: 10px 1px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set .vg-toggle__knob {
|
||||
top: 13px;
|
||||
left: 4px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__voice {
|
||||
min-height: 0;
|
||||
min-height: 44px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__voice-play {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.g6-root {
|
||||
width: min(100%, 1440px);
|
||||
margin: 0 auto;
|
||||
padding: 32px clamp(18px, 3vw, 44px) 72px;
|
||||
padding: 24px clamp(18px, 3vw, 44px) 72px;
|
||||
color: var(--text-body);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
max-width: 760px;
|
||||
margin: 10px 0 12px;
|
||||
color: var(--text-strong);
|
||||
font-size: clamp(2rem, 4vw, 3.65rem);
|
||||
font-size: clamp(1.75rem, 3vw, 2rem);
|
||||
font-weight: 650;
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.048em;
|
||||
|
|
@ -349,6 +349,10 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.g6-case__head > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.g6-case__eyebrow {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-kicker);
|
||||
|
|
@ -362,6 +366,7 @@
|
|||
color: var(--text-strong);
|
||||
font-size: var(--fs-h2);
|
||||
letter-spacing: -0.02em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.g6-case__facts {
|
||||
|
|
@ -1044,7 +1049,7 @@
|
|||
}
|
||||
|
||||
.g6-hero h1 {
|
||||
font-size: clamp(2rem, 8vw, 3rem);
|
||||
font-size: clamp(1.75rem, 6vw, 2rem);
|
||||
}
|
||||
|
||||
.g6-hero__status {
|
||||
|
|
@ -1098,7 +1103,7 @@
|
|||
}
|
||||
|
||||
.g6-hero h1 {
|
||||
font-size: clamp(1.85rem, 10vw, 2.55rem);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue