import { promises as fs } from "node:fs"; import path from "node:path"; const root = process.cwd(); const failures = []; async function read(relativePath) { return fs.readFile(path.join(root, relativePath), "utf8"); } function forbid(relativePath, source, pattern, message) { const match = source.match(pattern); if (match) failures.push(`${relativePath}: ${message} (${JSON.stringify(match[0])})`); } function countRawColors(source) { return source.match(/#[0-9a-f]{3,8}|rgba?\([^)]*\)/gi)?.length ?? 0; } const shellPath = "src/components/shell/shell.css"; const surfacePath = "src/components/ui/ui.css"; const tabsPath = "src/components/ui/Tabs.tsx"; const settingsPath = "src/pages/settings/settings.css"; const sessionPath = "src/pages/Session.tsx"; const learnerHomePath = "src/pages/LearnerHome.tsx"; const adminPath = "src/pages/Admin.tsx"; const personaStudioPath = "src/pages/PersonaStudio.tsx"; const personaViewModelPath = "src/lib/personaViewModel.ts"; const appPath = "src/App.tsx"; const designDocPath = "../../docs/DESIGN_CONCEPT.md"; const shell = await read(shellPath); const surface = await read(surfacePath); const tabs = await read(tabsPath); const settings = await read(settingsPath); const session = await read(sessionPath); const learnerHome = await read(learnerHomePath); const admin = await read(adminPath); const personaStudio = await read(personaStudioPath); const personaViewModel = await read(personaViewModelPath); const app = await read(appPath); const designDoc = await read(designDocPath); // 페이지/아트 전용 CSS는 집중 화면의 국소 팔레트를 소유할 수 있다. 다만 전역 // 토큰으로 승격하지 않은 raw color는 이 기준선 이상 늘어나면 실패시켜 예외가 // 2026-08-18: 전수 토큰화 완료. 모든 페이지와 컴포넌트 CSS에서 raw color가 0개로 정화됨. const rawColorBudgets = { "src/pages/session/session.css": 0, "src/pages/session-review/session-review.css": 0, "src/pages/learner-home.css": 0, "src/components/auth/auth-shell.css": 0, "src/pages/login/login.css": 0, "src/components/avatar/client-avatar.css": 0, "src/pages/avatar-expression-lab.css": 0, "src/pages/avatar-preview.css": 0, "src/pages/pending-approval.css": 0, }; for (const [relativePath, budget] of Object.entries(rawColorBudgets)) { const count = countRawColors(await read(relativePath)); if (count > budget) { failures.push( `${relativePath}: 국소 raw color 예산 초과 (${count}/${budget}). 기존 의미 토큰 또는 페이지 예외 토큰으로 묶어야 합니다`, ); } } forbid( shellPath, shell, /\.(?:lh-|sr-|pf-|ad-|ps-|sx-|vg-set__)/, "AppShell SSOT가 페이지 전용 클래스를 스타일하면 안 됩니다", ); forbid( appPath, app, /^import\s+\w+\s+from\s+["']\.\/pages\//m, "역할별 페이지는 초기 번들에 정적 import하면 안 됩니다", ); forbid( appPath, app, /minHeight:\s*["']100vh["']/, "전체 높이 화면은 모바일 viewport 안정성을 위해 100dvh를 사용해야 합니다", ); forbid( shellPath, shell, /!important/, "공통 셸 권위는 !important가 아니라 소유권으로 유지해야 합니다", ); forbid( surfacePath, surface, /!important/, "Surface primitive는 !important에 의존하면 안 됩니다", ); forbid( surfacePath, surface, /#[0-9a-f]{3,8}|rgba?\(/i, "공통 UI 색은 tokens.css 의미 토큰으로만 표현해야 합니다", ); forbid( tabsPath, tabs, /surfaceClassName|vg-surface/, "Tabs behavior primitive는 콘텐츠 Surface를 만들면 안 됩니다", ); for (const required of [ 'role="tablist"', 'role="tab"', "aria-controls", "aria-labelledby", "tabIndex", '"ArrowRight"', '"Home"', '"End"', ]) { if (!tabs.includes(required)) { failures.push(`${tabsPath}: Tabs 접근성 계약 누락 (${required})`); } } forbid( shellPath, shell, /#[0-9a-f]{3,8}|rgba?\(/i, "공통 셸 색은 tokens.css 의미 토큰으로만 표현해야 합니다", ); forbid( sessionPath, session, /#[0-9a-f]{3,8}|rgba?\(/i, "세션 화면의 아바타 팔레트는 personaViewModel SSOT를 우회하면 안 됩니다", ); forbid( learnerHomePath, learnerHome, /#[0-9a-f]{3,8}|rgba?\(/i, "학습자 화면의 아바타 팔레트는 personaViewModel SSOT를 우회하면 안 됩니다", ); for (const [relativePath, source, className, owner] of [ [adminPath, admin, "vgops-tabs", "관리자 탭 목록"], [personaStudioPath, personaStudio, "ps-tabs", "페르소나 제작 탭 목록"], [adminPath, admin, "vgops-approval", "가입 승인 연속 목록 행"], [adminPath, admin, "vgops-ticket", "지원 티켓 연속 목록 행"], ]) { forbid( relativePath, source, new RegExp( `surfaceClassName\\(["'](?:[^"']*\\s)?${className}(?:\\s[^"']*)?["']`, ), `${owner}은 콘텐츠 Surface가 아니라 공통 컨트롤 또는 외곽 목록 프레임이 소유해야 합니다`, ); } forbid( settingsPath, settings, /body\[data-page=["']settings["']\][^{]*(?:\.vg-topbar|\.vg-nav|\.vg-main|\.vg-shell)/, "설정 페이지 CSS가 공통 앱 크롬을 재정의하면 안 됩니다", ); for (const required of [ ".vg-surface.vg-surface--panel", ".vg-surface.vg-surface--inset", ".vg-surface.vg-surface--interactive", "var(--glass-surface)", "var(--glass-border)", ]) { if (!surface.includes(required)) { failures.push(`${surfacePath}: Surface SSOT 필수 계약 누락 (${required})`); } } for (const required of [ "PENDING_PERSONA_AVATAR_APPEARANCE", "personaAvatarAppearance", "personaBaselineExpression", ]) { if (!personaViewModel.includes(required)) { failures.push( `${personaViewModelPath}: 페르소나 시각 SSOT 필수 계약 누락 (${required})`, ); } } for (const required of [ "### 0.3 구현 소유권 계약", "DESIGN_VARIANCE 4 / MOTION_INTENSITY 3 / VISUAL_DENSITY 6", "apps/web/src/styles/tokens.css", "apps/web/src/components/ui/", "apps/web/src/components/shell/", ]) { if (!designDoc.includes(required)) { failures.push( `${designDocPath}: 디자인 방법론 SSOT 필수 계약 누락 (${required})`, ); } } for (const page of [ "Login", "Onboarding", "PendingApproval", "LearnerHome", "AvatarExpressionLab", "AvatarPreview", "Session", "SessionReview", "Professor", "PersonaStudio", "Admin", "AdminAi", "Settings", ]) { if (!app.includes(`lazy(() => import("./pages/${page}"))`)) { failures.push(`${appPath}: 라우트 지연 로딩 계약 누락 (${page})`); } } if (!app.includes("}>")) { failures.push( `${appPath}: 지연 라우트의 공통 Suspense 로딩 경계가 필요합니다`, ); } const legacyThemeHook = path.join(root, "src/pages/settings/useTheme.ts"); if ( await fs .stat(legacyThemeHook) .then(() => true) .catch(() => false) ) { failures.push( "src/pages/settings/useTheme.ts: 페이지 전용 테마 store를 다시 만들면 안 됩니다", ); } if (failures.length > 0) { console.error( "디자인 SSOT 검사 실패\n" + failures.map((item) => `- ${item}`).join("\n"), ); process.exit(1); } console.log( "디자인 SSOT 검사 통과: 방법론, AppShell, Theme, Surface, route bundle 소유권이 분리되어 있습니다.", );