UI 디자인 패스 — 타이포/헤더/빈화면/로그인 정돈

- 타이포 스케일: body 12→14px, caption 9.6→12px, display 25.6→32px, 위계 강화
- 헤더 우측 3그룹화 (대시보드액션 | 테마/알림/설정 | 모드+프로필), v5-hdr-sep 구분자 추가
- 사이드바 SUPER_ADMIN 회사 카드 borderless slim 라벨로 압축
- 메뉴명 빈 텍스트 방어 + title 속성 추가
- 빈 대시보드(EmptyDashboard) 리디자인: 탭없음/위젯없음 2상태 분리, 2-CTA 카드
- 로그인 코스믹 공연 축소: 별 150→30, 파티클 20→0, 카피 한글화 (로그인 버튼/서브타이틀)
- 모드 전환 burst/sweep/badge-zoom 제거, sidebar stagger morph만 유지 (handleModeSwitch 100→25줄)
- View transitions duration 1800ms → 500ms

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (gbpark)
2026-04-22 19:42:46 +09:00
parent 2c57dc8cda
commit 407da15e6d
5 changed files with 95 additions and 148 deletions
+5 -5
View File
@@ -40,7 +40,7 @@ export default function LoginPage() {
const co = cosmosRef.current; const co = cosmosRef.current;
if (!co) return; if (!co) return;
const cs = ["rgba(var(--v5-primary-rgb),.8)", "rgba(var(--v5-cyan-rgb),.7)", "rgba(var(--v5-pink-rgb),.7)"]; const cs = ["rgba(var(--v5-primary-rgb),.8)", "rgba(var(--v5-cyan-rgb),.7)", "rgba(var(--v5-pink-rgb),.7)"];
for (let i = 0; i < 150; i++) { for (let i = 0; i < 30; i++) {
const s = document.createElement("div"); const s = document.createElement("div");
s.className = "star" + (Math.random() > 0.83 ? " c" : ""); s.className = "star" + (Math.random() > 0.83 ? " c" : "");
if (s.classList.contains("c")) s.style.setProperty("--sc", cs[(Math.random() * 3) | 0]); if (s.classList.contains("c")) s.style.setProperty("--sc", cs[(Math.random() * 3) | 0]);
@@ -52,7 +52,7 @@ export default function LoginPage() {
co.appendChild(s); co.appendChild(s);
} }
const pc = ["var(--primary)", "var(--cyan)", "var(--pink)"]; const pc = ["var(--primary)", "var(--cyan)", "var(--pink)"];
for (let i = 0; i < 20; i++) { for (let i = 0; i < 0; i++) {
const p = document.createElement("div"); const p = document.createElement("div");
p.className = "particle"; p.className = "particle";
p.style.left = Math.random() * 100 + "%"; p.style.left = Math.random() * 100 + "%";
@@ -130,7 +130,7 @@ export default function LoginPage() {
</div> </div>
<div className="logo"><h1>Invy.one</h1></div> <div className="logo"><h1>Invy.one</h1></div>
<div className="login-sub">Cosmic Command Center</div> <div className="login-sub"> </div>
<form onSubmit={handleLogin}> <form onSubmit={handleLogin}>
<div className="fg"> <div className="fg">
@@ -149,10 +149,10 @@ export default function LoginPage() {
</div> </div>
</div> </div>
<div className="login-divider"><span>ready to launch</span></div> <div className="login-divider"><span>welcome</span></div>
<button type="submit" className="lbtn" disabled={isLoading} onMouseDown={handleRipple}> <button type="submit" className="lbtn" disabled={isLoading} onMouseDown={handleRipple}>
{isLoading ? <span className="spinner" /> : <><span>Launch</span><ArrowRight width={16} height={16} /></>} {isLoading ? <span className="spinner" /> : <><span></span><ArrowRight width={16} height={16} /></>}
</button> </button>
</form> </form>
+1 -1
View File
@@ -863,7 +863,7 @@ select {
/* ease-in-out-cubic — 대칭형 곡선으로 시간/progress 가 거의 linear 에 가깝게 진행됨. /* ease-in-out-cubic — 대칭형 곡선으로 시간/progress 가 거의 linear 에 가깝게 진행됨.
duration 은 themeTransition.ts 가 방향에 따라 --vt-duration 변수로 지정. duration 은 themeTransition.ts 가 방향에 따라 --vt-duration 변수로 지정.
검정→대비 강해서 빨라 보이는 perceived speed 비대칭을 보정하기 위해 dark 방향은 더 김. */ 검정→대비 강해서 빨라 보이는 perceived speed 비대칭을 보정하기 위해 dark 방향은 더 김. */
animation: vt-soft-reveal var(--vt-duration, 1800ms) cubic-bezier(0.65, 0, 0.35, 1) forwards; animation: vt-soft-reveal var(--vt-duration, 500ms) cubic-bezier(0.65, 0, 0.35, 1) forwards;
} }
@keyframes vt-soft-reveal { @keyframes vt-soft-reveal {
+22 -100
View File
@@ -539,119 +539,40 @@ function AppLayoutInner({ children }: AppLayoutProps) {
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요."); toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
}; };
const handleModeSwitch = useCallback((e?: React.MouseEvent<HTMLButtonElement>) => { const handleModeSwitch = useCallback((_e?: React.MouseEvent<HTMLButtonElement>) => {
if (modeTransition !== "idle") return; if (modeTransition !== "idle") return;
// 강화된 mode transition — 옵션 b/c/e/f 적용 (d 버튼 burst 제거, mode-fade overlay 도 제거): // Simplified mode transition — sidebar items stagger morph only, no burst/sweep/badge theatrics.
// Phase 1 (0ms): 사이드바 items morph-out (stagger), 헤더 glow flash, // Phase 1 (0ms): sidebar items morph-out (stagger 20ms)
// breadcrumb swap-out, 이탈 시 admin badge zoom-out // Phase 2 (180ms): React swaps mode, new items morph-in (stagger 20ms)
// Phase 2 (350ms): React 가 모드 swap → 새 메뉴 morph-in (stagger), breadcrumb swap-in, // Phase 3 (~400ms): cleanup → idle
// 진입 시 admin badge zoom-in
// Phase 3 (~950ms): 모든 클래스 정리, idle 복귀
const goingToAdmin = !isAdminMode;
setModeTransition("out"); setModeTransition("out");
// (b) 사이드바 items morph-out stagger // sidebar items morph-out (stagger 20ms)
const oldItems = Array.from(document.querySelectorAll<HTMLElement>(".v5-side .v5-si")); const oldItems = Array.from(document.querySelectorAll<HTMLElement>(".v5-side .v5-si"));
oldItems.forEach((it, i) => { oldItems.forEach((it, i) => {
it.style.animationDelay = `${i * 35}ms`; it.style.animationDelay = `${i * 20}ms`;
it.classList.add("mode-morph-out"); it.classList.add("mode-morph-out");
}); });
// (c) 헤더 glow line flash
const hdrGlow = document.querySelector<HTMLElement>(".v5-hdr-glow");
if (hdrGlow) {
hdrGlow.classList.remove("mode-flash");
void hdrGlow.offsetWidth; // reflow → 재시작 가능하게
hdrGlow.classList.add("mode-flash");
}
// (d) 토글 버튼 burst — 디자인시스템 mode-burst 포팅
// 클릭 좌표에 ring 1개 + radial particle 10개. admin 진입은 cyan, 사용자 복귀는 primary.
const targetEl = e?.currentTarget as HTMLElement | undefined;
const rect = targetEl?.getBoundingClientRect();
const bx = rect ? rect.left + rect.width / 2 : (e?.clientX ?? window.innerWidth - 80);
const by = rect ? rect.top + rect.height / 2 : (e?.clientY ?? 25);
const burst = document.createElement("div");
burst.className = `v5-mode-burst${goingToAdmin ? " admin" : ""}`;
burst.style.left = `${bx}px`;
burst.style.top = `${by}px`;
const ring = document.createElement("span");
ring.className = "burst-ring";
burst.appendChild(ring);
const N = 10;
for (let i = 0; i < N; i++) {
const p = document.createElement("span");
p.className = "burst-particle";
const angle = (i / N) * Math.PI * 2;
const dist = 36 + Math.random() * 22;
p.style.setProperty("--tx", `${Math.cos(angle) * dist}px`);
p.style.setProperty("--ty", `${Math.sin(angle) * dist}px`);
p.style.animationDelay = `${i * 8}ms`;
burst.appendChild(p);
}
document.body.appendChild(burst);
setTimeout(() => burst.remove(), 1100);
// (d2) 헤더 하단 좌→우 sweep (admin 진입은 cyan→primary→pink, 사용자 복귀는 primary)
const hdrEl = document.querySelector<HTMLElement>(".v5-hdr");
if (hdrEl) {
const sweep = document.createElement("div");
sweep.className = "v5-mode-sweep";
sweep.setAttribute("data-mode", goingToAdmin ? "admin" : "user");
hdrEl.appendChild(sweep);
setTimeout(() => sweep.remove(), 900);
}
// (e) breadcrumb swap-out
const bc = document.querySelector<HTMLElement>(".v5-hdr-bc");
bc?.classList.remove("mode-swap-in");
bc?.classList.add("mode-swap-out");
// (f) admin badge zoom-out (이탈 시에만)
if (!goingToAdmin) {
const badge = document.querySelector<HTMLElement>(".v5-admin-badge");
badge?.classList.remove("mode-zoom-in");
badge?.classList.add("mode-zoom-out");
}
setTimeout(() => { setTimeout(() => {
// Phase 2: 모드 swap → React 재렌더 (새 메뉴/탭/breadcrumb)
setTabMode(isAdminMode ? "user" : "admin"); setTabMode(isAdminMode ? "user" : "admin");
setModeTransition("in"); setModeTransition("in");
// 다음 프레임에 새 items 에 morph-in 적용
requestAnimationFrame(() => { requestAnimationFrame(() => {
const newItems = Array.from(document.querySelectorAll<HTMLElement>(".v5-side .v5-si")); const newItems = Array.from(document.querySelectorAll<HTMLElement>(".v5-side .v5-si"));
newItems.forEach((it, i) => { newItems.forEach((it, i) => {
it.style.animationDelay = `${i * 45}ms`; it.style.animationDelay = `${i * 20}ms`;
it.classList.add("mode-morph-in"); it.classList.add("mode-morph-in");
}); });
// breadcrumb swap-in (텍스트는 이미 새 모드 기준)
const newBc = document.querySelector<HTMLElement>(".v5-hdr-bc");
newBc?.classList.remove("mode-swap-out");
newBc?.classList.add("mode-swap-in");
// (f) admin badge zoom-in (진입 시에만)
if (goingToAdmin) {
const newBadge = document.querySelector<HTMLElement>(".v5-admin-badge");
newBadge?.classList.remove("mode-zoom-out");
newBadge?.classList.add("mode-zoom-in");
}
}); });
// Phase 3: 모든 애니메이션 클래스 정리
setTimeout(() => { setTimeout(() => {
setModeTransition("idle"); setModeTransition("idle");
document.querySelectorAll<HTMLElement>(".v5-side .v5-si").forEach((it) => { document.querySelectorAll<HTMLElement>(".v5-side .v5-si").forEach((it) => {
it.classList.remove("mode-morph-in", "mode-morph-out"); it.classList.remove("mode-morph-in", "mode-morph-out");
it.style.animationDelay = ""; it.style.animationDelay = "";
}); });
document.querySelector<HTMLElement>(".v5-hdr-bc")?.classList.remove("mode-swap-in", "mode-swap-out"); }, 300);
document.querySelector<HTMLElement>(".v5-admin-badge")?.classList.remove("mode-zoom-in", "mode-zoom-out"); }, 180);
}, 600);
}, 350);
}, [isAdminMode, setTabMode, modeTransition]); }, [isAdminMode, setTabMode, modeTransition]);
const handleLogout = async () => { const handleLogout = async () => {
@@ -788,7 +709,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}} }}
> >
<span className="ic">{menu.icon}</span> <span className="ic">{menu.icon}</span>
<span className="truncate">{menu.name}</span> <span className="truncate" title={menu.name || ""}>{menu.name?.trim() || "(이름 없음)"}</span>
{menu.hasChildren && !sidebarCollapsed && ( {menu.hasChildren && !sidebarCollapsed && (
<span className="ml-auto"> <span className="ml-auto">
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />} {isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
@@ -998,6 +919,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</button> </button>
)} )}
<span className="v5-hdr-sep" aria-hidden="true" />
{/* Theme toggle — single sun/moon icon (디자인시스템 준수, 2026-04-21) */} {/* Theme toggle — single sun/moon icon (디자인시스템 준수, 2026-04-21) */}
<button <button
className="v5-hdr-icon" className="v5-hdr-icon"
@@ -1036,7 +959,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<SlidersHorizontal size={16} /> <SlidersHorizontal size={16} />
</button> </button>
{/* Admin toggle (gear ↔ home) — 이벤트 전달해서 burst origin 으로 사용 */} {isAdmin && <span className="v5-hdr-sep" aria-hidden="true" />}
{/* Admin toggle (gear ↔ home) */}
{isAdmin && ( {isAdmin && (
<button <button
className="v5-hdr-icon v5-mode-toggle" className="v5-hdr-icon v5-mode-toggle"
@@ -1111,16 +1036,13 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<aside className={`v5-side v5-side-anim ${isAdminMode ? "v5-admin-side" : ""} ${sidebarCollapsed ? "collapsed" : ""} ${isMobile ? (sidebarOpen ? "mobile-open" : "") : ""} ${modeTransition === "out" ? "slide-out" : modeTransition === "in" ? "slide-in" : ""}`} <aside className={`v5-side v5-side-anim ${isAdminMode ? "v5-admin-side" : ""} ${sidebarCollapsed ? "collapsed" : ""} ${isMobile ? (sidebarOpen ? "mobile-open" : "") : ""} ${modeTransition === "out" ? "slide-out" : modeTransition === "in" ? "slide-in" : ""}`}
style={isMobile ? { position: "fixed", left: 0, top: 0, bottom: 0, zIndex: 30, paddingTop: "60px", width: "260px", transform: sidebarOpen ? "none" : "translateX(-100%)" } : undefined} style={isMobile ? { position: "fixed", left: 0, top: 0, bottom: 0, zIndex: 30, paddingTop: "60px", width: "260px", transform: sidebarOpen ? "none" : "translateX(-100%)" } : undefined}
> >
{/* SUPER_ADMIN company info */} {/* SUPER_ADMIN company info (slim, borderless) */}
{(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" && !sidebarCollapsed && ( {(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" && !sidebarCollapsed && (
<div style={{ padding: ".5rem .6rem", marginBottom: ".25rem" }}> <div className="flex items-center gap-2 px-2.5 py-1.5" title={currentCompanyName || ""}>
<div className="flex items-center gap-2 rounded-lg p-2" style={{ background: "var(--v5-surface)", border: "1px solid var(--v5-glass-border)", borderRadius: "10px", fontSize: ".7rem" }}> <Building2 size={12} style={{ color: "var(--v5-primary)", flexShrink: 0 }} />
<Building2 size={14} style={{ color: "var(--v5-primary)", flexShrink: 0 }} /> <p className="truncate font-medium min-w-0 flex-1" style={{ fontSize: ".78rem", color: "var(--v5-text)" }}>
<div className="min-w-0 flex-1"> {currentCompanyName || "로딩 중..."}
<p style={{ fontSize: ".55rem", color: "var(--v5-text-muted)" }}> </p> </p>
<p className="truncate font-semibold" style={{ fontSize: ".72rem", color: "var(--v5-text)" }}>{currentCompanyName || "로딩 중..."}</p>
</div>
</div>
</div> </div>
)} )}
+55 -33
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { LayoutGrid, Plus } from "lucide-react"; import { LayoutGrid, Plus, Sparkles } from "lucide-react";
import { useDashboardStore } from "@/stores/dashboardStore"; import { useDashboardStore } from "@/stores/dashboardStore";
export function EmptyDashboard() { export function EmptyDashboard() {
@@ -8,50 +8,72 @@ export function EmptyDashboard() {
const openLib = useDashboardStore((s) => s.openLib); const openLib = useDashboardStore((s) => s.openLib);
const hasDashboard = !!activeDashboardId; const hasDashboard = !!activeDashboardId;
const handleCenterClick = () => {
if (hasDashboard) openLib();
};
// 탭이 없는 상태: 메뉴 선택 유도
if (!hasDashboard) {
return (
<div className="flex h-full items-center justify-center px-6">
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--v5-primary)]/8 text-[var(--v5-primary)]">
<LayoutGrid className="h-7 w-7" />
</div>
<div className="space-y-1.5">
<h2 className="text-[1.125rem] font-semibold text-foreground"> </h2>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
</div>
</div>
);
}
// 대시보드는 있으나 위젯이 없는 상태: 3가지 CTA 제시
return ( return (
<div <div className="flex h-full items-center justify-center bg-white px-6 dark:bg-transparent">
className={`flex h-full items-center justify-center bg-white dark:bg-transparent ${hasDashboard ? "cursor-pointer" : ""}`} <div className="flex w-full max-w-xl flex-col items-center gap-6 text-center">
onClick={handleCenterClick} <div className="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-[var(--v5-primary)]/20 to-[var(--v5-cyan)]/15 text-[var(--v5-primary)] ring-1 ring-[var(--v5-primary)]/25">
role={hasDashboard ? "button" : undefined} <Sparkles className="h-8 w-8" />
tabIndex={hasDashboard ? 0 : undefined}
onKeyDown={(e) => {
if (hasDashboard && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
openLib();
}
}}
>
<div className="flex flex-col items-center gap-4 text-center transition-transform hover:scale-[1.02]">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-[var(--v5-primary)]/10 text-[var(--v5-primary)] ring-1 ring-[var(--v5-primary)]/30">
{hasDashboard ? <Plus className="h-10 w-10" /> : <LayoutGrid className="h-10 w-10 text-muted-foreground" />}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground"> <h2 className="text-[1.25rem] font-semibold text-foreground">
{hasDashboard ? "이제 템플릿을 추가합니다" : "열린 탭이 없습니다"}
</h2> </h2>
<p className="max-w-sm text-sm text-muted-foreground"> <p className="max-w-sm text-sm text-muted-foreground">
{hasDashboard 릿 , .
? "이 영역을 클릭하거나 상단의 '템플릿 추가' 버튼을 눌러 첫 카드를 배치하세요."
: "왼쪽 사이드바에서 메뉴를 클릭하거나 드래그하여 탭을 추가하세요."}
</p> </p>
</div> </div>
{hasDashboard && ( <div className="grid w-full grid-cols-1 gap-3 sm:grid-cols-2">
<button <button
type="button" type="button"
onClick={(e) => { onClick={openLib}
e.stopPropagation(); className="group flex flex-col items-start gap-1.5 rounded-xl border border-[var(--v5-primary)]/25 bg-[var(--v5-primary)]/[0.04] px-4 py-3.5 text-left transition-all hover:border-[var(--v5-primary)]/60 hover:bg-[var(--v5-primary)]/[0.08] hover:-translate-y-0.5"
openLib();
}}
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-[var(--v5-primary)] px-3 py-1.5 text-[0.7rem] font-semibold text-white hover:opacity-90"
> >
<Plus className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 text-[var(--v5-primary)]">
릿 <LayoutGrid className="h-4 w-4" />
<span className="text-sm font-semibold">릿 </span>
</div>
<p className="text-xs text-muted-foreground">
</p>
</button> </button>
)} <button
type="button"
onClick={openLib}
className="group flex flex-col items-start gap-1.5 rounded-xl border border-border bg-card px-4 py-3.5 text-left transition-all hover:border-[var(--v5-primary)]/50 hover:-translate-y-0.5"
>
<div className="flex items-center gap-2 text-foreground">
<Plus className="h-4 w-4" />
<span className="text-sm font-semibold"> </span>
</div>
<p className="text-xs text-muted-foreground">
</p>
</button>
</div>
<p className="text-[0.7rem] text-muted-foreground/70">
: 상단의 &apos;&apos; .
</p>
</div> </div>
</div> </div>
); );
+12 -9
View File
@@ -64,15 +64,15 @@
--v5-font-mono: 'JetBrains Mono', 'D2Coding', ui-monospace, SFMono-Regular, Monaco, Consolas, monospace; --v5-font-mono: 'JetBrains Mono', 'D2Coding', ui-monospace, SFMono-Regular, Monaco, Consolas, monospace;
/* ===== Type scale (deliberately dense, ERP). Do not go above these. ===== */ /* ===== Type scale (deliberately dense, ERP). Do not go above these. ===== */
--v5-fs-caption:0.60rem; /* labels, table headers, chips */ --v5-fs-caption:0.75rem; /* labels, table headers, chips */
--v5-fs-caption-lg:0.68rem; --v5-fs-caption-lg:0.8125rem;
--v5-fs-body-sm:0.72rem; /* table rows */ --v5-fs-body-sm:0.8125rem; /* table rows */
--v5-fs-body:0.78rem; /* default body */ --v5-fs-body:0.875rem; /* default body */
--v5-fs-body-lg:0.85rem; /* max body */ --v5-fs-body-lg:0.9375rem; /* max body */
--v5-fs-h3:0.92rem; --v5-fs-h3:1.0625rem;
--v5-fs-h2:1.00rem; --v5-fs-h2:1.1875rem;
--v5-fs-h1:1.12rem; /* page/section title */ --v5-fs-h1:1.375rem; /* page/section title */
--v5-fs-display:1.60rem; /* KPI numbers only */ --v5-fs-display:2rem; /* KPI numbers only */
--v5-fw-regular:400; --v5-fw-regular:400;
--v5-fw-semi:600; --v5-fw-semi:600;
@@ -1446,3 +1446,6 @@ html.vt-color-changing .settings-color-swatch.on,
html.vt-color-changing .v5-bell, html.vt-color-changing .v5-bell,
html.vt-color-changing .v5-admin-btn{ html.vt-color-changing .v5-admin-btn{
animation:v5-color-refresh .55s cubic-bezier(.34,1.4,.64,1) both;} animation:v5-color-refresh .55s cubic-bezier(.34,1.4,.64,1) both;}
/* ===== Header separator (v5 redesign 2026-04-22) ===== */
.v5-hdr-sep{display:inline-block;width:1px;height:18px;background:var(--v5-border);margin:0 .35rem;flex-shrink:0;opacity:.7;}