diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx
index f3be9b1..2e0418a 100644
--- a/src/frontend/src/App.tsx
+++ b/src/frontend/src/App.tsx
@@ -1,5 +1,6 @@
import { Navigate, Outlet, Route, Routes, useNavigate } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
+import { landingPathFor } from './lib/roleTrack';
import { LoginPage } from './screens/login/LoginPage';
import { OtpSetupPage } from './screens/login/OtpSetupPage';
import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage';
@@ -99,12 +100,16 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
}
/**
- * 루트/미매칭 분기 — 인증=홈 리다이렉트, 미인증=SCR-T0 3-트랙 공개 관문 직접 렌더
+ * 루트/미매칭 분기 — 인증=역할별 랜딩 리다이렉트, 미인증=SCR-T0 3-트랙 공개 관문 직접 렌더
* (design.md §3C-0: 미인증 `/`는 대외 유입 관문. 2026-07-12 소유자 지적 반영).
+ * 인증 시 랜딩은 completeLogin 과 동일 규칙(plan_role_routing §2-1) — ops·business=/home(회귀 0),
+ * admin→/admin, agency→/contractor/dashboard, visitor→/visitor. 로그인 직후·`/`·catch-all 일관.
*/
function RootGate() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
- if (isAuthenticated) return ;
+ const user = useAuthStore((s) => s.user);
+ const workspaces = useAuthStore((s) => s.workspaces);
+ if (isAuthenticated) return ;
return ;
}
diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx
index 9ef9f8e..9345619 100644
--- a/src/frontend/src/components/layout/AppShell.tsx
+++ b/src/frontend/src/components/layout/AppShell.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
+import type { Track } from '../../lib/roleTrack';
import { useThemeStore } from '../../store/themeStore';
import { useMdiStore } from '../../store/mdiStore';
import { useUiStore, isMobileViewport } from '../../store/uiStore';
@@ -55,6 +56,8 @@ interface NavItem {
scoped?: boolean;
/** true → 클릭 불가한 소그룹 구분 헤더(시스템관리 20항목 정리용). */
section?: boolean;
+ /** 노출 허용 트랙(미지정=전 트랙). plan §2-3 항목 단위 필터(예: 플로어플랜=agency 숨김). */
+ tracks?: Track[];
}
interface NavGroupDef {
id: string;
@@ -64,6 +67,17 @@ interface NavGroupDef {
items: NavItem[];
/** 관리자 전용 그룹 — useIsAdmin() 시에만 노출. */
admin?: boolean;
+ /** 노출 허용 트랙(미지정=전 트랙). plan §2-3 그룹 노출 매트릭스. */
+ tracks?: Track[];
+}
+
+/** plan §2-3 노출 매트릭스 — 트랙 상수(가독성용). system 은 admin 플래그로 별도 게이트. */
+const BIZ_OPS_ADMIN: Track[] = ['business', 'ops', 'admin'];
+const WORK_TRACKS: Track[] = ['business', 'ops', 'agency', 'admin'];
+
+/** tracks 미지정(전 트랙 허용) 또는 현재 트랙 포함 시 노출. */
+function trackAllows(tracks: Track[] | undefined, active: Track): boolean {
+ return !tracks || tracks.includes(active);
}
/** 홈 — 로그인 후 랜딩(단일 최상위 링크, 아코디언 밖). */
@@ -80,6 +94,7 @@ const GROUPS: NavGroupDef[] = [
label: '전시 운영',
labelKey: 'shell.menu.groups.ops',
Icon: IconOperations,
+ tracks: BIZ_OPS_ADMIN, // agency·visitor ✕
items: [
{ key: 'dashboard', label: '대시보드', labelKey: 'shell.menu.items.dashboard', Icon: IconDashboard, scoped: true },
{ key: 'exhibitors', label: '참가업체 관리', labelKey: 'shell.menu.items.exhibitors', Icon: IconExhibitors, scoped: true },
@@ -94,8 +109,10 @@ const GROUPS: NavGroupDef[] = [
label: '설계·시공',
labelKey: 'shell.menu.groups.design',
Icon: IconFloorplan,
+ tracks: WORK_TRACKS, // business·ops·admin ● + agency 부분(항목 단위)
items: [
- { key: 'floorplan', label: '플로어플랜 스튜디오', labelKey: 'shell.menu.items.floorplan', Icon: IconFloorplan, scoped: true },
+ // 플로어플랜 편집은 agency 숨김(주최자·홀매니저·관리자 전용).
+ { key: 'floorplan', label: '플로어플랜 스튜디오', labelKey: 'shell.menu.items.floorplan', Icon: IconFloorplan, scoped: true, tracks: BIZ_OPS_ADMIN },
{ key: 'documents', label: '서류·마일스톤', labelKey: 'shell.menu.items.documents', Icon: IconDocument, to: '/docs' },
{ key: 'auctions', label: '공사 옥션', labelKey: 'shell.menu.items.auctions', Icon: IconSettlement, to: '/auctions' },
{ key: 'contractor', label: '수주 부스', labelKey: 'shell.menu.items.contractor', Icon: IconExhibitors, to: '/contractor/dashboard' },
@@ -107,6 +124,7 @@ const GROUPS: NavGroupDef[] = [
label: '관람객·마케팅',
labelKey: 'shell.menu.groups.visitor',
Icon: IconUsers,
+ tracks: BIZ_OPS_ADMIN, // agency·visitor ✕
items: [
{ key: 'visitors', label: '관람객', labelKey: 'shell.menu.items.visitors', Icon: IconUsers, to: '/visitors' },
{ key: 'checkin', label: '체크인 데스크', labelKey: 'shell.menu.items.checkin', Icon: IconCheckCircle, to: '/visitors/checkin' },
@@ -121,6 +139,7 @@ const GROUPS: NavGroupDef[] = [
label: '정산·분석',
labelKey: 'shell.menu.groups.finance',
Icon: IconAnalytics,
+ tracks: BIZ_OPS_ADMIN, // agency·visitor ✕
items: [
{ key: 'settlement', label: '정산', labelKey: 'shell.menu.items.settlement', Icon: IconSettlement, to: '/settlement' },
{ key: 'analytics', label: '경영분석', labelKey: 'shell.menu.items.analytics', Icon: IconAnalytics, to: '/analytics' },
@@ -131,6 +150,7 @@ const GROUPS: NavGroupDef[] = [
label: '업무 공통',
labelKey: 'shell.menu.groups.work',
Icon: IconDocument,
+ tracks: WORK_TRACKS, // 전 업무 트랙 ● (visitor ✕)
items: [
{ key: 'worklog', label: '업무일지', labelKey: 'shell.menu.items.worklog', Icon: IconDocument, to: '/work/worklog' },
{ key: 'work-schedule', label: '일정', labelKey: 'shell.menu.items.workSchedule', Icon: IconCalendar, to: '/work/schedule' },
@@ -232,6 +252,7 @@ export function AppShell() {
const openTab = useMdiStore((s) => s.openTab);
const resolvedEventId = useResolvedEventId();
const isAdmin = useIsAdmin();
+ const activeTrack = useAuthStore((s) => s.activeTrack());
const isDark = themeResolved === 'dark';
const { t } = useTranslation();
@@ -274,7 +295,10 @@ export function AppShell() {
closeOnMobile();
}
- const visibleGroups = GROUPS.filter((g) => !g.admin || isAdmin);
+ // 관리자 그룹은 isAdmin 게이트 유지 + 트랙 노출 매트릭스(plan §2-3). 미지정 그룹=전 트랙.
+ const visibleGroups = GROUPS.filter(
+ (g) => (!g.admin || isAdmin) && trackAllows(g.tracks, activeTrack),
+ );
return (
@@ -343,7 +367,9 @@ export function AppShell() {
{expanded && (
- {group.items.map((item) =>
+ {group.items
+ .filter((item) => trackAllows(item.tracks, activeTrack))
+ .map((item) =>
item.section ? (
// 소그룹 구분 헤더(클릭 불가) — 시스템관리 20항목 정리(WISE 구분선 패턴).
-
diff --git a/src/frontend/src/components/ui/ai-image.css b/src/frontend/src/components/ui/ai-image.css
index 3749cb4..5014c5c 100644
--- a/src/frontend/src/components/ui/ai-image.css
+++ b/src/frontend/src/components/ui/ai-image.css
@@ -89,7 +89,7 @@
.kx-aiimg__shot,
.kx-aiimg__ver {
background: rgba(16, 24, 40, 0.72);
- color: var(--color-white);
+ color: var(--color-on-accent);
border-radius: var(--radius-pill);
padding: 2px 8px;
font-size: 11px;
@@ -97,7 +97,7 @@
}
.kx-aiimg__sample {
background: rgba(109, 74, 255, 0.9);
- color: var(--color-white);
+ color: var(--color-on-accent);
border-radius: var(--radius-pill);
padding: 2px 8px;
font-size: 11px;
@@ -111,7 +111,7 @@
align-items: center;
gap: 4px;
background: var(--color-ai-accent);
- color: var(--color-white);
+ color: var(--color-on-accent);
border: none;
border-radius: var(--radius-sm);
padding: 6px 12px;
diff --git a/src/frontend/src/components/ui/button.css b/src/frontend/src/components/ui/button.css
index 60230d8..a996f04 100644
--- a/src/frontend/src/components/ui/button.css
+++ b/src/frontend/src/components/ui/button.css
@@ -27,7 +27,7 @@
.kx-btn--primary {
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-btn--primary:not(:disabled):hover {
background: var(--color-primary-700);
@@ -44,7 +44,7 @@
.kx-btn--ai {
background: var(--color-ai-accent);
- color: var(--color-white);
+ color: var(--color-on-accent);
box-shadow: 0 0 12px var(--ai-glow);
}
.kx-btn--ai:not(:disabled):hover {
diff --git a/src/frontend/src/components/ui/compare-slider.css b/src/frontend/src/components/ui/compare-slider.css
index 3108cd5..a7258bd 100644
--- a/src/frontend/src/components/ui/compare-slider.css
+++ b/src/frontend/src/components/ui/compare-slider.css
@@ -30,7 +30,7 @@
border-radius: var(--radius-pill);
font-size: var(--fs-caption);
font-weight: 600;
- color: var(--color-white);
+ color: var(--color-on-accent);
background: rgba(16, 24, 40, 0.72);
pointer-events: none;
}
diff --git a/src/frontend/src/lib/roleTrack.ts b/src/frontend/src/lib/roleTrack.ts
new file mode 100644
index 0000000..1ef3296
--- /dev/null
+++ b/src/frontend/src/lib/roleTrack.ts
@@ -0,0 +1,70 @@
+/*
+ * 역할별 랜딩·트랙 판정 (단일 출처) — _workspace/plan_role_routing.md §1 로직 그대로.
+ * 웹·모바일 공통 규칙. 순수 함수 + authStore 파생 셀렉터(activeTrack)에서 재사용.
+ *
+ * 우선순위: admin > ops > business > agency > visitor.
+ * 회귀 0 원칙: ops·business 랜딩은 기존 `/home` 유지, admin·agency·visitor 만 신규 분기.
+ */
+import type { AuthUser, EventRole, WorkspaceDto } from '../api/types';
+
+export type Track = 'visitor' | 'business' | 'agency' | 'ops' | 'admin';
+
+/** 워크스페이스 역할 → 트랙(스코프 판정용). */
+export function trackOf(role: EventRole | null | undefined): Track {
+ switch (role) {
+ case 'ORGANIZER':
+ case 'EXHIBITOR':
+ return 'business';
+ case 'CONTRACTOR':
+ return 'agency';
+ case 'HALL_MANAGER':
+ return 'ops';
+ default:
+ return 'visitor';
+ }
+}
+
+/**
+ * 로그인 랜딩 트랙 결정(primaryTrack 우선순위). plan §1 resolveLandingTrack 이식.
+ * 판정 불가(예외) 시 안전 폴백은 호출부에서 처리(랜딩 경로 /home).
+ */
+export function resolveLandingTrack(
+ user: AuthUser | null | undefined,
+ workspaces: WorkspaceDto[],
+): Track {
+ const role = (user?.roleCode ?? '').toUpperCase();
+ const ws = workspaces ?? [];
+ if (role === 'ADMIN') return 'admin';
+ if (role === 'MANAGER' || ws.some((w) => w.myRole === 'HALL_MANAGER')) return 'ops';
+ if (ws.some((w) => w.myRole === 'ORGANIZER' || w.myRole === 'EXHIBITOR')) return 'business';
+ if (ws.some((w) => w.myRole === 'CONTRACTOR')) return 'agency';
+ return 'visitor';
+}
+
+/** 트랙 → 로그인 직후 랜딩 라우트. ops·business=/home(회귀 0), admin·agency·visitor 만 신규. */
+export function landingPathForTrack(track: Track): string {
+ switch (track) {
+ case 'admin':
+ return '/admin';
+ case 'agency':
+ return '/contractor/dashboard';
+ case 'visitor':
+ return '/visitor';
+ case 'ops':
+ case 'business':
+ default:
+ return '/home';
+ }
+}
+
+/** 사용자·워크스페이스로부터 랜딩 라우트 계산(예외 시 /home 폴백). */
+export function landingPathFor(
+ user: AuthUser | null | undefined,
+ workspaces: WorkspaceDto[],
+): string {
+ try {
+ return landingPathForTrack(resolveLandingTrack(user, workspaces));
+ } catch {
+ return '/home';
+ }
+}
diff --git a/src/frontend/src/screens/admin/admin-modules.css b/src/frontend/src/screens/admin/admin-modules.css
index ce023fa..ca4697a 100644
--- a/src/frontend/src/screens/admin/admin-modules.css
+++ b/src/frontend/src/screens/admin/admin-modules.css
@@ -390,8 +390,8 @@
color: var(--color-neutral-500);
z-index: 1;
}
-.kx-adm-wiz__step.is-done .kx-adm-wiz__dot { background: var(--color-success); color: var(--color-white); }
-.kx-adm-wiz__step.is-current .kx-adm-wiz__dot { background: var(--color-primary-600); color: var(--color-white); }
+.kx-adm-wiz__step.is-done .kx-adm-wiz__dot { background: var(--color-success); color: var(--color-on-accent); }
+.kx-adm-wiz__step.is-current .kx-adm-wiz__dot { background: var(--color-primary-600); color: var(--color-on-accent); }
.kx-adm-wiz__body { display: flex; flex-direction: column; gap: 2px; padding-top: 3px; }
.kx-adm-wiz__label { font-size: var(--fs-body); font-weight: 600; color: var(--color-neutral-900); }
.kx-adm-wiz__step.is-todo .kx-adm-wiz__label { color: var(--color-neutral-500); }
diff --git a/src/frontend/src/screens/appqr/appqr.css b/src/frontend/src/screens/appqr/appqr.css
index c45fa92..b65ad18 100644
--- a/src/frontend/src/screens/appqr/appqr.css
+++ b/src/frontend/src/screens/appqr/appqr.css
@@ -82,7 +82,7 @@
.kx-appqr__ver {
font-size: var(--fs-caption);
font-weight: 700;
- color: var(--color-white);
+ color: var(--color-on-accent);
background: var(--color-primary-600);
border-radius: var(--radius-pill);
padding: 2px 10px;
diff --git a/src/frontend/src/screens/approval/review-details.css b/src/frontend/src/screens/approval/review-details.css
index 9b52723..7f33a05 100644
--- a/src/frontend/src/screens/approval/review-details.css
+++ b/src/frontend/src/screens/approval/review-details.css
@@ -102,7 +102,7 @@
}
.kx-rev__tabs button.is-active {
background: rgba(0, 102, 179, 0.35);
- color: var(--color-white);
+ color: var(--color-on-accent);
border: 1px solid rgba(0, 102, 179, 0.5);
}
.kx-rev__overlay-toggle {
@@ -112,7 +112,7 @@
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
border: 1px solid var(--color-ai-accent);
- color: var(--color-white);
+ color: var(--color-on-accent);
border-radius: var(--radius-sm);
padding: 7px 12px;
font-size: var(--fs-caption);
@@ -182,7 +182,7 @@
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--color-error);
- color: var(--color-white);
+ color: var(--color-on-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -206,7 +206,7 @@
.kx-rev__zoom button {
border: none;
background: transparent;
- color: var(--color-white);
+ color: var(--color-on-accent);
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
diff --git a/src/frontend/src/screens/auction/auction.css b/src/frontend/src/screens/auction/auction.css
index 5cb14a5..aff7569 100644
--- a/src/frontend/src/screens/auction/auction.css
+++ b/src/frontend/src/screens/auction/auction.css
@@ -232,7 +232,7 @@
}
.kx-auc-open__head {
background: var(--color-primary-700);
- color: var(--color-white);
+ color: var(--color-on-accent);
padding: var(--space-4);
}
.kx-auc-open__head h2 {
@@ -631,7 +631,7 @@
}
.kx-rank__row--me .kx-rank__badge {
background: var(--color-ai-accent);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-rank__main {
flex: 1;
@@ -797,7 +797,7 @@
margin-bottom: 6px;
font-size: 10px;
font-weight: 700;
- color: var(--color-white);
+ color: var(--color-on-accent);
background: var(--color-primary-700);
padding: 2px 8px;
border-radius: var(--radius-pill);
@@ -902,7 +902,7 @@
height: 44px;
border-radius: var(--radius-lg);
background: var(--color-primary-700);
- color: var(--color-white);
+ color: var(--color-on-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -1008,7 +1008,7 @@
gap: var(--space-2);
padding: 12px 16px;
background: var(--color-neutral-900);
- color: var(--color-white);
+ color: var(--color-on-accent);
border-radius: var(--radius-lg);
font-size: var(--fs-body);
box-shadow: var(--shadow-level2);
diff --git a/src/frontend/src/screens/cms/cms.css b/src/frontend/src/screens/cms/cms.css
index a7361fa..a332e77 100644
--- a/src/frontend/src/screens/cms/cms.css
+++ b/src/frontend/src/screens/cms/cms.css
@@ -470,7 +470,7 @@
.kx-cms-step.is-done .kx-cms-step__dot {
background: var(--color-success);
border-color: var(--color-success);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-cms-step.is-current {
color: var(--color-primary-700);
@@ -688,7 +688,7 @@
height: 28px;
border-radius: var(--radius-sm);
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -707,7 +707,7 @@
display: flex;
align-items: center;
padding: var(--space-6);
- color: var(--color-white);
+ color: var(--color-on-accent);
background:
linear-gradient(90deg, rgba(0, 76, 134, 0.92), rgba(0, 76, 134, 0.35)),
linear-gradient(135deg, var(--color-primary-700), var(--color-canvas-bg));
@@ -754,7 +754,7 @@
}
.kx-mb-btn--ghost {
background: transparent;
- color: var(--color-white);
+ color: var(--color-on-accent);
border-color: rgba(255, 255, 255, 0.4);
}
.kx-mb-section {
@@ -807,7 +807,7 @@
padding: 2px 10px;
border-radius: var(--radius-pill);
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
font-size: 10px;
font-weight: 700;
}
diff --git a/src/frontend/src/screens/compliance/compliance-report.css b/src/frontend/src/screens/compliance/compliance-report.css
index 4489efd..446358e 100644
--- a/src/frontend/src/screens/compliance/compliance-report.css
+++ b/src/frontend/src/screens/compliance/compliance-report.css
@@ -134,7 +134,7 @@
font-weight: 700;
flex-shrink: 0;
background: var(--color-error);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-comp__item.is-warn .kx-comp__pin {
background: var(--color-violation-warn);
@@ -233,7 +233,7 @@
right: 6%;
font-size: 11px;
font-weight: 700;
- color: var(--color-white);
+ color: var(--color-on-accent);
background: var(--color-error);
padding: 3px 8px;
border-radius: var(--radius-sm);
diff --git a/src/frontend/src/screens/contractor/contractor.css b/src/frontend/src/screens/contractor/contractor.css
index e88817f..04ae70f 100644
--- a/src/frontend/src/screens/contractor/contractor.css
+++ b/src/frontend/src/screens/contractor/contractor.css
@@ -154,11 +154,11 @@
}
.kx-step--done .kx-step__dot {
background: var(--color-primary-700);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-step--current .kx-step__dot {
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
box-shadow: 0 0 0 4px var(--color-primary-100);
}
.kx-step__label {
diff --git a/src/frontend/src/screens/dashboard/dashboard.css b/src/frontend/src/screens/dashboard/dashboard.css
index 9a3e46e..4658453 100644
--- a/src/frontend/src/screens/dashboard/dashboard.css
+++ b/src/frontend/src/screens/dashboard/dashboard.css
@@ -27,7 +27,7 @@
padding: 8px 18px;
border-radius: var(--radius-pill);
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
font-size: var(--fs-h2);
font-weight: 700;
}
@@ -79,7 +79,7 @@
justify-content: center;
font-size: 13px;
font-weight: 700;
- color: var(--color-white);
+ color: var(--color-on-accent);
background: var(--color-neutral-200);
}
.kx-timeline__node.is-done .kx-timeline__dot {
diff --git a/src/frontend/src/screens/exhibitor/exhibitor-home.css b/src/frontend/src/screens/exhibitor/exhibitor-home.css
index 3a06f1c..3a065da 100644
--- a/src/frontend/src/screens/exhibitor/exhibitor-home.css
+++ b/src/frontend/src/screens/exhibitor/exhibitor-home.css
@@ -109,7 +109,7 @@
.kx-stepper__item.is-done .kx-stepper__dot {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-stepper__item.is-current .kx-stepper__dot {
border-color: var(--color-primary-600);
diff --git a/src/frontend/src/screens/floorplan/editor.css b/src/frontend/src/screens/floorplan/editor.css
index 0282215..e95e703 100644
--- a/src/frontend/src/screens/floorplan/editor.css
+++ b/src/frontend/src/screens/floorplan/editor.css
@@ -363,7 +363,7 @@
width: 18px;
height: 18px;
border-radius: 50%;
- color: var(--color-white);
+ color: var(--color-on-accent);
font-size: 11px;
font-weight: 700;
display: flex;
diff --git a/src/frontend/src/screens/floorplan/layout-comparison.css b/src/frontend/src/screens/floorplan/layout-comparison.css
index 5fe7239..c781ceb 100644
--- a/src/frontend/src/screens/floorplan/layout-comparison.css
+++ b/src/frontend/src/screens/floorplan/layout-comparison.css
@@ -71,7 +71,7 @@
height: 24px;
border-radius: 50%;
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/frontend/src/screens/gallery/gallery.css b/src/frontend/src/screens/gallery/gallery.css
index df18871..613f037 100644
--- a/src/frontend/src/screens/gallery/gallery.css
+++ b/src/frontend/src/screens/gallery/gallery.css
@@ -63,7 +63,7 @@
.kx-gallery__chip.is-active {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
/* 마소너리(간이 — CSS columns) */
diff --git a/src/frontend/src/screens/hallassign/hallassign.css b/src/frontend/src/screens/hallassign/hallassign.css
index 449a05a..58ca34e 100644
--- a/src/frontend/src/screens/hallassign/hallassign.css
+++ b/src/frontend/src/screens/hallassign/hallassign.css
@@ -366,7 +366,7 @@
left: 50%;
transform: translateX(-50%);
background: var(--color-neutral-900);
- color: var(--color-white);
+ color: var(--color-on-accent);
padding: 10px 18px;
border-radius: var(--radius-pill);
font-size: var(--fs-caption);
diff --git a/src/frontend/src/screens/home/home.css b/src/frontend/src/screens/home/home.css
index 67029da..9e6f169 100644
--- a/src/frontend/src/screens/home/home.css
+++ b/src/frontend/src/screens/home/home.css
@@ -1132,7 +1132,7 @@
height: 40px;
border-radius: var(--radius-sm);
background: var(--color-ai-accent);
- color: var(--color-white);
+ color: var(--color-on-accent);
flex: 0 0 auto;
}
.kx-home__aihub-heading {
@@ -1239,7 +1239,7 @@
padding: 1px 5px;
border-radius: var(--radius-pill);
background: var(--color-ai-accent);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-home__aihub-card-desc {
font-size: var(--fs-caption);
diff --git a/src/frontend/src/screens/login/LoginPage.tsx b/src/frontend/src/screens/login/LoginPage.tsx
index a618ec6..b7de685 100644
--- a/src/frontend/src/screens/login/LoginPage.tsx
+++ b/src/frontend/src/screens/login/LoginPage.tsx
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { authApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
+import { landingPathFor } from '../../lib/roleTrack';
import { Button } from '../../components/ui/Button';
import { LanguageSwitcher } from '../../components/ui/LanguageSwitcher';
import { InviteDialog } from './InviteDialog';
@@ -48,14 +49,15 @@ export function LoginPage() {
}
}, []);
- // 최종 로그인 확정(공통) — 토큰 적용 후 메인 홈(SCR-HOME)으로 랜딩(v2.1.2).
- // 회원가입 직후 흐름도 로그인 성공 경로를 공유하므로 동일하게 /home 으로 랜딩한다.
+ // 최종 로그인 확정(공통) — 토큰 적용 후 역할별 메인으로 랜딩(plan_role_routing §2-1).
+ // ops·business=/home(회귀 0), admin→/admin, agency→/contractor/dashboard, visitor→/visitor.
+ // 판정 불가 시 안전 폴백 /home. 회원가입 직후 흐름도 동일 경로를 공유한다.
function completeLogin(res: LoginResponse) {
if (remember) localStorage.setItem(REMEMBER_KEY, email.trim());
else localStorage.removeItem(REMEMBER_KEY);
applyLogin(res);
setOtpChallenge(null);
- navigate('/home');
+ navigate(landingPathFor(res.user, res.workspaces));
}
async function handleLogin(e: React.FormEvent) {
diff --git a/src/frontend/src/screens/login/login.css b/src/frontend/src/screens/login/login.css
index a014fd3..6d83216 100644
--- a/src/frontend/src/screens/login/login.css
+++ b/src/frontend/src/screens/login/login.css
@@ -103,7 +103,7 @@
backdrop-filter: blur(4px);
}
.kx-login__headline {
- color: var(--color-white);
+ color: var(--color-on-accent);
font-size: var(--fs-display);
line-height: var(--lh-display);
font-weight: 700;
@@ -149,7 +149,7 @@
height: 36px;
border-radius: var(--radius-sm);
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -321,7 +321,7 @@
height: 56px;
border-radius: var(--radius-pill);
background: var(--color-success, #16a34a);
- color: var(--color-white);
+ color: var(--color-on-accent);
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/frontend/src/screens/marketing/marketing.css b/src/frontend/src/screens/marketing/marketing.css
index 70090f7..86d43a1 100644
--- a/src/frontend/src/screens/marketing/marketing.css
+++ b/src/frontend/src/screens/marketing/marketing.css
@@ -661,7 +661,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-mkt__check.is-done {
background: var(--color-primary-600);
diff --git a/src/frontend/src/screens/mobile/mobile.css b/src/frontend/src/screens/mobile/mobile.css
index 1bbd5e8..6253371 100644
--- a/src/frontend/src/screens/mobile/mobile.css
+++ b/src/frontend/src/screens/mobile/mobile.css
@@ -312,7 +312,7 @@
border: none;
border-radius: var(--radius-lg);
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
font-size: var(--fs-h3);
font-weight: 700;
cursor: pointer;
@@ -488,7 +488,7 @@
}
.kx-m__verdict button.is-fail {
background: var(--color-error);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-m__icard-evidence {
display: flex;
diff --git a/src/frontend/src/screens/ops/ops.css b/src/frontend/src/screens/ops/ops.css
index a46bf9a..34851a4 100644
--- a/src/frontend/src/screens/ops/ops.css
+++ b/src/frontend/src/screens/ops/ops.css
@@ -126,7 +126,7 @@
right: var(--space-3);
font-size: 11px;
font-weight: 700;
- color: var(--color-white);
+ color: var(--color-on-accent);
background: var(--color-error);
padding: 2px 8px;
border-radius: var(--radius-pill);
diff --git a/src/frontend/src/screens/public/public.css b/src/frontend/src/screens/public/public.css
index befbdf7..870b087 100644
--- a/src/frontend/src/screens/public/public.css
+++ b/src/frontend/src/screens/public/public.css
@@ -76,14 +76,14 @@
}
.kxp-btn--primary {
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-btn--primary:hover {
background: var(--color-primary-700);
}
.kxp-btn--ghost {
background: rgba(255, 255, 255, 0.14);
- color: var(--color-white);
+ color: var(--color-on-accent);
border-color: rgba(255, 255, 255, 0.35);
backdrop-filter: blur(6px);
}
@@ -106,7 +106,7 @@
}
.kxp-btn--danger:hover {
background: var(--color-error);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-btn--lg {
padding: 16px 28px;
@@ -145,7 +145,7 @@
padding: 4px 10px;
border-radius: var(--radius-sm);
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-dday--warn {
background: var(--color-warning);
@@ -435,7 +435,7 @@
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
- color: var(--color-white);
+ color: var(--color-on-accent);
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 6px 14px;
@@ -445,7 +445,7 @@
font-size: clamp(30px, 6vw, 52px);
line-height: 1.12;
font-weight: 800;
- color: var(--color-white);
+ color: var(--color-on-accent);
letter-spacing: -0.02em;
margin: 0;
}
@@ -472,7 +472,7 @@
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-lg);
padding: 10px 14px;
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-hero__metacard small {
display: block;
@@ -908,7 +908,7 @@
.kxp-footer__logo {
font-size: var(--fs-h2);
font-weight: 800;
- color: var(--color-white);
+ color: var(--color-on-accent);
margin-bottom: 12px;
}
.kxp-footer__desc {
@@ -928,7 +928,7 @@
}
.kxp-footer__col h4 {
font-size: var(--fs-body);
- color: var(--color-white);
+ color: var(--color-on-accent);
margin: 0 0 4px;
}
.kxp-footer__col a,
@@ -937,7 +937,7 @@
color: rgba(255, 255, 255, 0.6);
}
.kxp-footer__col a:hover {
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-footer__bottom {
border-top: 1px solid rgba(255, 255, 255, 0.1);
@@ -1147,7 +1147,7 @@
}
.kxp-daytab.is-active {
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
border-color: var(--color-primary-600);
}
/* 연사 카드 */
@@ -1299,7 +1299,7 @@
.kxp-fp__chip.is-active {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-fp__list {
flex: 1;
@@ -1430,7 +1430,7 @@
right: 3px;
font-size: 9px;
background: var(--color-error);
- color: var(--color-white);
+ color: var(--color-on-accent);
padding: 0 4px;
border-radius: 3px;
}
@@ -1708,7 +1708,7 @@
.kxp-step.is-done .kxp-step__dot {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-step__label {
font-size: var(--fs-caption);
@@ -1780,7 +1780,7 @@
}
.kxp-typecard.is-active .kxp-typecard__ic {
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
border-color: var(--color-primary-600);
}
.kxp-typecard h3 {
@@ -1812,7 +1812,7 @@
.kxp-interest.is-active {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-regnav {
display: flex;
@@ -1847,7 +1847,7 @@
background: var(--color-primary-600);
border-radius: 24px;
padding: 24px;
- color: var(--color-white);
+ color: var(--color-on-accent);
text-align: left;
}
.kxp-badge__top {
@@ -2029,7 +2029,7 @@
}
.kxp-cta-banner {
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
text-align: center;
padding: 64px 16px;
}
@@ -3250,7 +3250,7 @@
max-width: 860px;
margin: 0 auto;
text-align: center;
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-vhero__title {
font-size: clamp(28px, 5.2vw, 46px);
@@ -3258,7 +3258,7 @@
line-height: 1.18;
letter-spacing: -0.025em;
margin: 8px 0 12px;
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kxp-vhero__lead {
font-size: clamp(15px, 2.2vw, 19px);
@@ -3321,7 +3321,7 @@
border: 0;
border-radius: 999px;
background: var(--color-ai-accent);
- color: var(--color-white);
+ color: var(--color-on-accent);
cursor: pointer;
transition: background 0.15s ease;
}
@@ -3347,7 +3347,7 @@
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.14);
- color: var(--color-white);
+ color: var(--color-on-accent);
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -3488,7 +3488,7 @@
object-fit: cover;
}
.kxp-ai__evfallback {
- color: var(--color-white);
+ color: var(--color-on-accent);
font-weight: 800;
font-size: 15px;
}
@@ -3907,7 +3907,7 @@ a.kxp-ai__cite:hover {
height: 28px;
border-radius: 50%;
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
font-weight: 800;
font-size: 14px;
}
diff --git a/src/frontend/src/screens/schedule/schedule.css b/src/frontend/src/screens/schedule/schedule.css
index 26d7e1f..5cf75d6 100644
--- a/src/frontend/src/screens/schedule/schedule.css
+++ b/src/frontend/src/screens/schedule/schedule.css
@@ -210,7 +210,7 @@
}
.kx-cal__cell.is-today {
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
font-weight: 700;
}
.kx-cal__cell.is-today.has-event::after {
diff --git a/src/frontend/src/screens/styleguide/styleguide.css b/src/frontend/src/screens/styleguide/styleguide.css
index d70db58..ec74789 100644
--- a/src/frontend/src/screens/styleguide/styleguide.css
+++ b/src/frontend/src/screens/styleguide/styleguide.css
@@ -244,7 +244,7 @@
.kx-sg__toast {
display: inline-block;
background: var(--color-neutral-900);
- color: var(--color-white);
+ color: var(--color-on-accent);
font-size: var(--fs-body);
padding: 10px var(--space-4);
border-radius: var(--radius-sm);
diff --git a/src/frontend/src/screens/utility/utility-order.css b/src/frontend/src/screens/utility/utility-order.css
index 127f2b1..7d1c6a6 100644
--- a/src/frontend/src/screens/utility/utility-order.css
+++ b/src/frontend/src/screens/utility/utility-order.css
@@ -63,7 +63,7 @@
.kx-uord__wizard li.is-done::before {
content: '✓';
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-uord__wizard li.is-current {
color: var(--color-primary-700);
@@ -71,7 +71,7 @@
}
.kx-uord__wizard li.is-current::before {
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-uord__body {
diff --git a/src/frontend/src/screens/utility/wiring.css b/src/frontend/src/screens/utility/wiring.css
index e439d91..675dcb2 100644
--- a/src/frontend/src/screens/utility/wiring.css
+++ b/src/frontend/src/screens/utility/wiring.css
@@ -46,7 +46,7 @@
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
.kx-wire__legend-item::before {
content: '';
diff --git a/src/frontend/src/screens/visitor/visitor.css b/src/frontend/src/screens/visitor/visitor.css
index aea781c..52ea10d 100644
--- a/src/frontend/src/screens/visitor/visitor.css
+++ b/src/frontend/src/screens/visitor/visitor.css
@@ -248,7 +248,7 @@
.kx-badgecard__top {
width: 100%;
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.14em;
@@ -280,7 +280,7 @@
.kx-badgecard__tag {
font-size: 7px;
font-weight: 800;
- color: var(--color-white);
+ color: var(--color-on-accent);
background: var(--color-ai-accent);
padding: 2px 5px;
border-radius: 3px;
@@ -490,7 +490,7 @@
.kx-gauge__num {
font-size: 11px;
font-weight: 700;
- color: var(--color-white);
+ color: var(--color-on-accent);
background: var(--color-primary-600);
border-radius: var(--radius-pill);
padding: 1px 8px;
diff --git a/src/frontend/src/store/authStore.ts b/src/frontend/src/store/authStore.ts
index ad8504a..9312b2b 100644
--- a/src/frontend/src/store/authStore.ts
+++ b/src/frontend/src/store/authStore.ts
@@ -5,6 +5,7 @@
import { create } from 'zustand';
import { getAccessToken, setAccessToken } from '../api/client';
import type { AuthUser, EventRole, LoginResponse, WorkspaceDto } from '../api/types';
+import { resolveLandingTrack, trackOf, type Track } from '../lib/roleTrack';
interface AuthState {
user: AuthUser | null;
@@ -18,6 +19,8 @@ interface AuthState {
roleForCurrent: () => EventRole | null;
// 전역 역할(app_user.role_code — 'ADMIN' | 'MANAGER' | …). 없으면 null. 관리자 판정(AdminGuard) 등에 사용.
globalRole: () => string | null;
+ // 반응형 활성 트랙(메뉴/탭 스코프). 행사 선택 시 해당 역할 트랙, 아니면 랜딩 트랙(plan §1).
+ activeTrack: () => Track;
}
export const useAuthStore = create((set, get) => ({
@@ -50,4 +53,14 @@ export const useAuthStore = create((set, get) => ({
roleForCurrent: () => get().currentWorkspace()?.myRole ?? null,
globalRole: () => get().user?.roleCode ?? null,
+
+ activeTrack: () => {
+ const { user, workspaces, currentEventId } = get();
+ // 행사 선택 시 그 역할 트랙 우선(단 현재 워크스페이스 해소 가능한 경우).
+ if (currentEventId) {
+ const ws = workspaces.find((w) => w.eventId === currentEventId);
+ if (ws) return trackOf(ws.myRole);
+ }
+ return resolveLandingTrack(user, workspaces);
+ },
}));
diff --git a/src/frontend/src/styles/global.css b/src/frontend/src/styles/global.css
index 4be39bc..59a0575 100644
--- a/src/frontend/src/styles/global.css
+++ b/src/frontend/src/styles/global.css
@@ -220,12 +220,12 @@ textarea {
padding: 8px 16px;
border-radius: var(--radius-sm);
background: var(--color-primary-600);
- color: var(--color-white);
+ color: var(--color-on-accent);
font-size: var(--fs-body);
font-weight: 600;
transition: top 0.15s ease;
}
.kx-skip-link:focus-visible {
top: var(--space-3);
- color: var(--color-white);
+ color: var(--color-on-accent);
}
diff --git a/src/frontend/src/styles/tokens.css b/src/frontend/src/styles/tokens.css
index e1a0eb7..e35dbbe 100644
--- a/src/frontend/src/styles/tokens.css
+++ b/src/frontend/src/styles/tokens.css
@@ -25,6 +25,7 @@
--color-neutral-100: #f2f4f7;
--color-neutral-050: #f9fafb; /* 앱 배경 */
--color-white: #ffffff;
+ --color-on-accent: #ffffff; /* 색상(파랑/빨강 등) 배경 위 텍스트 — 다크에서도 항상 흰색 유지(--color-white 는 다크에서 서피스로 반전되므로 버튼/배지 글자엔 이 토큰 사용) */
--color-canvas-bg: #1c2536; /* 에디터 캔버스 다크 서피스 */
--color-canvas-line: #2f3b52; /* 캔버스 위 보조 라인 */