vignette/apps/web/scripts/check-design-ssot.mjs

248 lines
7.2 KiB
JavaScript

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("<Suspense fallback={<BootScreen />}>")) {
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 소유권이 분리되어 있습니다.",
);