Initial commit: HMDesignSystem baseline

This commit is contained in:
shkim4 committed 2026-10-08 16:58:09 +09:00
commit b6a0a61103
486 files changed
+68432

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { execFileSync } from "node:child_process"
import { existsSync } from "node:fs"
import { build } from "vite"
const root = fileURLToPath(new URL("../", import.meta.url))
const name = process.argv[2]
if (!["ui", "center-graphic"].includes(name)) throw new Error("Expected ui or center-graphic")
const packageRoot = path.join(root, "packages", name)
const output = path.join(packageRoot, "dist")
const manifest = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"))
const externals = Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies })
// Only this package's generated output is removed. Library builds never copy root public assets.
await rm(output, { recursive: true, force: true })
await build({
configFile: false,
root: packageRoot,
publicDir: false,
build: {
target: "es2022",
outDir: output,
minify: false,
lib: {
entry: name === "ui"
? { index: path.join(packageRoot, "src/index.ts"), "theme/index": path.join(packageRoot, "src/theme/index.ts") }
: { index: path.join(packageRoot, "src/index.ts"), reference: path.join(packageRoot, "src/reference.ts") },
formats: ["es"],
fileName: (_format, entry) => `${entry}.js`,
cssFileName: "style",
},
rolldownOptions: {
external: id => externals.some(dep => id === dep || id.startsWith(`${dep}/`)),
onwarn(warning, warn) {
// The UI component entrypoint retains the directive below; source directives are redundant.
if (name === "ui" && warning.code === "MODULE_LEVEL_DIRECTIVE") return
warn(warning)
},
},
},
})
execFileSync(process.execPath, [path.join(root, "node_modules/typescript/bin/tsc"), "-p", path.join(packageRoot, "tsconfig.build.json")], { stdio: "inherit" })
// Declarations describe APIs, not stylesheet loading; explicit .js specifiers also support NodeNext.
async function finishDeclarations(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name)
if (entry.isDirectory()) await finishDeclarations(file)
else if (entry.name.endsWith(".d.ts")) {
const text = (await readFile(file, "utf8"))
.replace(/^import\s+["'][^"']+\.css["'];?\s*$/gm, "")
.replace(/(from\s+["']|import\(["'])(\.\.?\/[^"']+)(["'])/g, (_match, before, specifier, after) => {
const target = path.resolve(directory, specifier)
const resolved = existsSync(`${target}.d.ts`) ? specifier
: existsSync(path.join(target, "index.d.ts")) ? `${specifier}/index` : specifier
return `${before}${resolved.endsWith(".js") ? resolved : `${resolved}.js`}${after}`
})
await writeFile(file, text)
}
}
}
await finishDeclarations(output)
if (name === "ui") {
const entry = path.join(output, "index.js")
await writeFile(entry, `"use client";\n${await readFile(entry, "utf8")}`)
await mkdir(path.join(output, "theme"), { recursive: true })
for (const entry of await readdir(path.join(packageRoot, "src/theme"))) {
if (entry.endsWith(".css")) await cp(path.join(packageRoot, "src/theme", entry), path.join(output, "theme", entry))
}
await mkdir(path.join(output, "vendor"), { recursive: true })
// Preserve the exact upstream CSS rather than shipping its CLI dependency graph to consumers.
await cp(path.join(root, "node_modules/shadcn/dist/tailwind.css"), path.join(output, "vendor/shadcn-tailwind.css"))
await cp(path.join(root, "node_modules/shadcn/LICENSE.md"), path.join(output, "vendor/shadcn-LICENSE.md"))
const tailwind = (await readFile(path.join(packageRoot, "src/tailwind.css"), "utf8"))
.replace('"shadcn/tailwind.css"', '"./vendor/shadcn-tailwind.css"')
.replace(/@source "\.\/components";\r?\n@source "\.\/patterns";/, '@source "./index.js";')
await writeFile(path.join(output, "tailwind.css"), tailwind)
await mkdir(path.join(output, "fonts"), { recursive: true })
for (const file of ["NotoSansKR-variable.ttf", "NotoSansKR-OFL.txt"]) {
await cp(path.join(root, "public/fonts", file), path.join(output, "fonts", file))
}
const theme = (await readFile(path.join(packageRoot, "src/theme.css"), "utf8"))
.replace('/fonts/NotoSansKR-variable.ttf', './fonts/NotoSansKR-variable.ttf')
await writeFile(path.join(output, "theme.css"), theme)
await cp(path.join(packageRoot, "src/patterns/dashboard-status/dashboard-status.css"), path.join(output, "dashboard-status.css"))
} else {
// Preserve CENTER's existing automatic component CSS behavior for both public entrypoints.
for (const entry of ["index.js", "reference.js"]) {
const file = path.join(output, entry)
await writeFile(file, `import "./style.css";\n${await readFile(file, "utf8")}`)
}
}
+115
View File
@@ -0,0 +1,115 @@
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
const root = process.cwd();
const out = path.join(root, 'HMDesignSystem-AI-Reference-Test');
const zip = path.join(root, 'HMDesignSystem-AI-Reference-Test.zip');
fs.rmSync(out, { recursive: true, force: true });
fs.rmSync(zip, { force: true });
fs.mkdirSync(out, { recursive: true });
const copy = (from, to, options = {}) => {
const src = path.join(root, from);
if (!fs.existsSync(src)) return false;
const dest = path.join(out, to);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.cpSync(src, dest, { recursive: true, force: true, ...options });
return true;
};
// Preserve repository-relative paths for source references and imports.
copy('packages/ui/src', 'source/packages/ui/src');
copy('packages/ui/package.json', 'source/packages/ui/package.json');
copy('packages/ui/tsconfig.json', 'source/packages/ui/tsconfig.json');
copy('packages/ui/tsconfig.build.json', 'source/packages/ui/tsconfig.build.json');
copy('packages/ui/components.json', 'source/packages/ui/components.json');
copy('packages/center-graphic/src', 'source/packages/center-graphic/src');
copy('packages/center-graphic/package.json', 'source/packages/center-graphic/package.json');
copy('packages/center-graphic/tsconfig.json', 'source/packages/center-graphic/tsconfig.json');
copy('packages/center-graphic/tsconfig.build.json', 'source/packages/center-graphic/tsconfig.build.json');
copy('.storybook', 'storybook/config');
copy('src', 'storybook/source');
copy('storybook-static', 'storybook/static');
copy('dist', 'packages/app-build');
copy('dist-dashboard', 'packages/dashboard-export-build');
copy('packages/ui/dist', 'packages/ui-dist');
copy('packages/center-graphic/dist', 'packages/center-graphic-dist');
copy('theme-dist', 'theme/generated');
copy('themes', 'theme/source');
copy('packages/ui/src/theme.css', 'theme/source/packages-ui-theme.css');
copy('packages/ui/src/theme', 'theme/source/packages-ui-theme');
copy('docs', 'docs/source');
copy('AGENTS.md', 'docs/AGENTS.md');
copy('CLAUDE.md', 'docs/CLAUDE.md');
copy('README.md', 'docs/README.md');
copy('package.json', 'docs/root-package.json');
copy('package-lock.json', 'docs/package-lock.json');
copy('src/exports', 'examples/react-and-html/src/exports');
copy('src/exports/dashboard', 'examples/react-and-html/src/exports/dashboard');
copy('index.html', 'examples/react-and-html/index.html');
copy('vite.dashboard-export.config.ts', 'examples/react-and-html/vite.dashboard-export.config.ts');
copy('scripts/finalize-dashboard-export.mjs', 'examples/react-and-html/finalize-dashboard-export.mjs');
copy('public/fonts', 'assets/fonts');
copy('packages/center-graphic/src/assets', 'assets/center-graphic');
const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { recursive: true }).filter((p) => fs.statSync(path.join(dir, p)).isFile()).length : 0;
const countFiles = (rel, suffix) => {
const dir = path.join(root, rel);
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { recursive: true }).filter((p) => String(p).endsWith(suffix)).length;
};
const publicComponentFiles = countFiles('packages/ui/src/components', '.tsx');
const patternFiles = countFiles('packages/ui/src/patterns', '.tsx');
const storyFiles = ['.stories.tsx', '.stories.ts', '.stories.jsx', '.stories.js'].reduce((n, s) => n + countFiles('src', s), 0);
const generatedAt = new Date().toISOString();
const startHere = `# HM Design System AI Reference
이 ZIP은 빌드 패키지만이 아니라 HM Design System의 실제 구현·Storybook·Theme·문서를 함께 참조하기 위한 테스트 배포본입니다.
## AI 참조 순서
1. \`docs/AGENTS.md\`와 \`docs/README.md\`를 읽습니다.
2. \`docs/source/components\`, \`docs/source/patterns\`, \`docs/source/guidelines\`에서 사용 규칙을 확인합니다.
3. \`source/packages/ui/src\`와 \`source/packages/center-graphic/src\`의 실제 TSX 구현을 재사용합니다.
4. \`storybook/source\`의 Story에서 상태·조합·레이아웃을 확인합니다.
5. \`theme/source\` 및 \`theme/generated\`의 Token/CSS Custom Properties를 사용합니다.
6. \`packages/ui-dist\`와 declarations는 소비자 Export 형태를 확인할 때 사용합니다.
임의의 색상·spacing·radius·Typography 값을 추가하거나 디자인을 재해석하지 않습니다. 신규 화면은 기존 공개 Component/Pattern과 Theme 계약을 우선 재사용합니다.
## 구조
- \`source/\`: 원본 공개 패키지 구현 및 내부 의존 코드
- \`storybook/\`: Storybook 설정, Story/Pattern/Foundation 소스, 정적 Storybook
- \`packages/\`: 기존 빌드 산출물 및 TypeScript declarations
- \`theme/\`: Token 원본, Generated CSS, Runtime Theme 자료
- \`docs/\`: 원본 사용 규칙, AI 공통 가이드, 컴포넌트·패턴 문서
- \`examples/\`: React/HTML export 관련 예제 및 계약
- \`assets/\`: Font와 Center Graphic asset
## 재현 범위
React는 기존 공개 패키지를 직접 import하는 방식을 우선합니다. HTML은 \`examples/react-and-html\`의 기존 export와 Runtime 계약을 먼저 확인합니다. 이 배포본 자체에서 무료 AI에 의한 신규 화면 생성이나 브라우저 시각 검증은 수행하지 않았습니다.
`;
fs.writeFileSync(path.join(out, 'AI_START_HERE.md'), startHere, 'utf8');
const manifest = {
package: 'HMDesignSystem-AI-Reference-Test', generatedAt,
source: { publicComponentSourceFiles: publicComponentFiles, centerGraphicSourceFiles: files(path.join(out, 'source/packages/center-graphic/src')) },
storybook: { storyFiles, sourceFiles: files(path.join(out, 'storybook/source')), staticIncluded: fs.existsSync(path.join(out, 'storybook/static')) },
patterns: { patternSourceFiles: patternFiles },
theme: { sourceIncluded: fs.existsSync(path.join(out, 'theme/source')), generatedIncluded: fs.existsSync(path.join(out, 'theme/generated')) },
assets: { fontFiles: files(path.join(out, 'assets/fonts')), centerGraphicAssetFiles: files(path.join(out, 'assets/center-graphic')) },
verification: { aiReproduction: 'NOT_RUN', browserVisualVerification: 'NOT_RUN', sourceMutation: 'NOT_RUN (packaging-only)' }
};
fs.writeFileSync(path.join(out, 'MANIFEST.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
execFileSync('tar', ['-a', '-c', '-f', zip, '-C', root, path.basename(out)], { stdio: 'inherit' });
console.log(JSON.stringify({ out, zip, manifest }, null, 2));
+17
View File
@@ -0,0 +1,17 @@
import { readFile, writeFile } from "node:fs/promises"
const path = "dist-dashboard/index.html"
const html = await readFile(path, "utf8")
const finalized = html.replace(
'<script type="module" crossorigin src="./assets/dashboard-export.js"></script>',
'<script defer src="./assets/dashboard-export.js"></script>',
).replace(
/(<link\s+rel="stylesheet")\s+crossorigin(\s+href="\.\/assets\/[^\"]+\.css")/,
"$1$2",
)
if (finalized === html) {
throw new Error(`Dashboard export script tag was not found in ${path}`)
}
await writeFile(path, finalized)
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { build } from 'esbuild';
const args = new Map();
for (let i = 2; i < process.argv.length; i += 1) {
const value = process.argv[i];
if (value.startsWith('--')) args.set(value.slice(2), process.argv[i + 1]?.startsWith('--') ? true : process.argv[++i]);
}
const input = args.get('input');
const output = args.get('output');
const screens = Number(args.get('screens') || 1);
const root = process.cwd();
const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(4).toString('hex')}`;
const reportDir = output ? path.resolve(output) : path.resolve(root, '_local', 'h2-preview', runId);
const failurePath = path.join(reportDir, 'verification-failure.json');
const rel = (p) => path.relative(root, p) || '.';
const fail = (code, message, details = {}) => {
fs.mkdirSync(reportDir, { recursive: true });
const report = { h2: 'H-2', status: 'FAIL', code, message, details, createdAt: new Date().toISOString(), input: input ? path.resolve(input) : null };
fs.writeFileSync(failurePath, JSON.stringify(report, null, 2) + '\n');
console.error(JSON.stringify(report, null, 2));
process.exitCode = 1;
};
if (!input) fail('INPUT_REQUIRED', 'Use --input with a .tsx/.jsx entry file or a directory containing one.');
else {
const inputPath = path.resolve(input);
const stat = fs.existsSync(inputPath) && fs.statSync(inputPath);
if (!stat) fail('INPUT_NOT_FOUND', `Input does not exist: ${inputPath}`);
else {
const entry = stat.isDirectory() ? ['App.tsx', 'index.tsx', 'main.tsx', 'App.jsx', 'index.jsx', 'main.jsx'].map((x) => path.join(inputPath, x)).find(fs.existsSync) : inputPath;
if (!entry || !/\.(tsx|jsx)$/.test(entry)) fail('INVALID_INPUT', 'Input must resolve to an independent React TSX/JSX entry.');
else if (path.resolve(reportDir) === path.resolve(root, 'dist') || path.resolve(reportDir) === path.resolve(root, 'storybook-static')) fail('PROTECTED_OUTPUT', 'Shared dist and Storybook output are not valid H-2 output paths.');
else if (fs.existsSync(path.join(reportDir, 'preview.html')) || fs.existsSync(path.join(reportDir, 'verification.json'))) fail('OUTPUT_EXISTS', 'Refusing to overwrite an existing H-2 output. Choose a new --output directory.');
else {
const before = requireGitStatus();
const outputBefore = fs.existsSync(reportDir) ? fs.readdirSync(reportDir) : [];
try {
fs.mkdirSync(reportDir, { recursive: true });
const temp = path.join(reportDir, '.bundle.js');
await build({ entryPoints: [entry], outfile: temp, bundle: true, format: 'iife', platform: 'browser', target: 'es2020', sourcemap: false, logLevel: 'silent', absWorkingDir: path.dirname(entry) });
const js = fs.readFileSync(temp, 'utf8');
fs.rmSync(temp, { force: true });
const source = fs.readFileSync(entry, 'utf8');
const urls = [...source.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
const external = urls.filter((u) => /^(https?:|\/\/)/i.test(u));
const unresolved = urls.filter((u) => !/^(data:|#|https?:|\/\/|\/)/i.test(u) && !fs.existsSync(path.resolve(path.dirname(entry), u.split('#')[0])));
if (external.length || unresolved.length) throw new Error(`External or unresolved asset references: ${JSON.stringify({ external, unresolved })}`);
const html = `<!doctype html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>H-2 Preview</title><style>html,body,#root{margin:0;min-height:100%;width:100%;}body{background:#fff;} </style></head><body><div id="root"></div><script>${js}\n//# sourceURL=h2-preview.js</script></body></html>`;
fs.writeFileSync(path.join(reportDir, 'preview.html'), html);
const verification = { h2: 'H-2', status: 'PASS', input: path.resolve(entry), output: path.join(reportDir, 'preview.html'), runtime: 'REACT_TSX', screens, checks: { isolatedBuild: true, singlePreviewHtml: true, fileUrlReady: true, externalUrls: external, unresolvedRelativePaths: unresolved, console: 'NOT_RUN', runtime: 'NOT_RUN', network: 'NOT_RUN', captures: 'NOT_RUN', protectedOutputsUnchanged: true, preexistingChanges: before, postRunChanges: requireGitStatus(), outputBefore, outputAfter: fs.readdirSync(reportDir) }, note: 'Browser/runtime/capture checks remain NOT_RUN until a browser runner executes them; NOT_RUN is not PASS.' };
fs.writeFileSync(path.join(reportDir, 'verification.json'), JSON.stringify(verification, null, 2) + '\n');
console.log(JSON.stringify({ status: 'PASS', preview: path.join(reportDir, 'preview.html'), verification: path.join(reportDir, 'verification.json') }, null, 2));
} catch (error) { fail('BUILD_OR_ASSET_CHECK_FAILED', error.message, { entry: rel(entry), output: reportDir }); }
}
}
}
function requireGitStatus() { try { return execFileSync('git', ['status', '--short'], { encoding: 'utf8' }).split(/\r?\n/).filter(Boolean); } catch { return ['UNAVAILABLE']; } }
@@ -0,0 +1,66 @@
// Portable test launcher: built-in modules only; never invokes npm or a global Node.
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { spawn } = require('node:child_process');
const root = __dirname;
const types = { '.html':'text/html; charset=utf-8', '.js':'text/javascript; charset=utf-8', '.mjs':'text/javascript; charset=utf-8', '.css':'text/css; charset=utf-8', '.json':'application/json; charset=utf-8', '.svg':'image/svg+xml', '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.gif':'image/gif', '.webp':'image/webp', '.ico':'image/x-icon', '.ttf':'font/ttf', '.woff':'font/woff', '.woff2':'font/woff2', '.txt':'text/plain; charset=utf-8' };
const server = http.createServer((req, res) => {
if (!['GET','HEAD'].includes(req.method)) { res.writeHead(405); res.end(); return; }
let pathname;
try { pathname = decodeURIComponent(new URL(req.url, 'http://127.0.0.1').pathname); }
catch { res.writeHead(400); res.end(); return; }
// Dashboard is a production export and must be served from its own URL root.
const dashboard = pathname === '/dashboard' || pathname.startsWith('/dashboard/');
const dashboardPath = dashboard ? (pathname === '/dashboard' ? '/dashboard/' : pathname) : pathname;
// Source Reference/static example paths keep their repository-relative imports.
const reference = ['/docs/','/packages/','/src/','/public/'].some(prefix => pathname.startsWith(prefix));
const base = dashboard ? path.join(root, 'dist-dashboard') : (reference ? root : path.join(root, 'storybook-static'));
const relativePath = dashboard ? dashboardPath.slice('/dashboard'.length) || '/' : (pathname === '/favicon.ico' ? '/favicon.svg' : pathname);
let file = path.resolve(base, '.' + relativePath);
const relative = path.relative(base, file);
if (relative.startsWith('..') || path.isAbsolute(relative) || pathname.includes('\\')) { res.writeHead(403); res.end(); return; }
fs.stat(file, (error, stat) => {
if (!error && stat.isDirectory()) file = path.join(file, 'index.html');
fs.stat(file, (error, stat) => {
if (error || !stat.isFile()) { res.writeHead(404); res.end('Not found'); return; }
res.writeHead(200, { 'Content-Type': types[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Content-Length':stat.size, 'Cache-Control':'no-store', 'X-Content-Type-Options':'nosniff' });
if (req.method === 'HEAD') { res.end(); return; }
const stream = fs.createReadStream(file);
stream.on('error', () => res.destroy());
stream.pipe(res);
});
});
});
let port = 6007;
let browserOpened = false;
function openDefaultBrowser(url) {
if (browserOpened) return;
browserOpened = true;
const command = process.env.ComSpec || 'cmd.exe';
const browser = spawn(command, ['/c', 'start', '', url], {
detached: true,
stdio: 'ignore',
windowsHide: true
});
browser.on('error', error => {
console.error(`Browser auto-open failed: ${error.message}`);
console.error(`Open this address manually: ${url}`);
});
browser.unref();
}
server.on('error', error => {
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
if (port === 0) { console.error(error.message); process.exitCode = 1; return; }
port = port < 6023 ? port + 1 : 0;
server.listen(port, '127.0.0.1');
} else { console.error(error.message); process.exitCode = 1; }
});
server.on('listening', () => {
const url = `http://127.0.0.1:${server.address().port}/`;
console.log(`HM Design System Portable Test\nNode ${process.version} (${process.arch})\nStorybook: ${url}\nDashboard: ${url}dashboard/\nStatic status example: ${url}docs/patterns/dashboard-status-static.html\nPress Ctrl+C to stop this preview.\n`);
openDefaultBrowser(url);
});
process.on('SIGINT', () => server.close(() => process.exit(0)));
process.on('SIGTERM', () => server.close(() => process.exit(0)));
server.listen(port, '127.0.0.1');
@@ -0,0 +1,15 @@
@echo off
setlocal
chcp 65001 >nul
title HM Design System - Portable Test Preview
if not exist "%~dp0runtime\node\node.exe" (
echo ERROR: Bundled runtime is missing. Extract the complete ZIP first.
pause
exit /b 1
)
echo.
echo The actual Storybook and Dashboard addresses will be printed by the server below.
echo.
"%~dp0runtime\node\node.exe" "%~dp0preview-server.cjs"
if errorlevel 1 pause
endlocal
+17
View File
@@ -0,0 +1,17 @@
import { mkdirSync, readdirSync, writeFileSync, unlinkSync } from "node:fs"
import { read, repositoryRoot, validateDocument } from "./theme-contract.mjs"
// Independent Theme artifact. Does not build UI/Graphic or generate package CSS.
// Only top-level themes/*.json are published; historic drafts stay out.
const directory = new URL("../themes/", import.meta.url)
const themes = readdirSync(directory).filter(file => file.endsWith(".json")).sort()
if (!themes.includes("default.json")) throw Error("Missing themes/default.json")
const documents = themes.map(file => [file, validateDocument(JSON.parse(read(`themes/${file}`)), file === "default.json")])
const output = new URL("theme-dist/themes/", repositoryRoot)
mkdirSync(output, { recursive: true })
// Only generated top-level JSON artifacts in this explicit workspace output.
for (const file of readdirSync(output).filter(file => file.endsWith(".json") && !themes.includes(file))) unlinkSync(new URL(file, output))
for (const [file, theme] of documents) {
writeFileSync(new URL(file, output), JSON.stringify(theme, null, 2) + "\n")
}
console.log(`Validated/exported ${themes.length} independent Theme artifacts to theme-dist/themes/`)
+25
View File
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs"
export const repositoryRoot = new URL("../", import.meta.url)
export const read = path => readFileSync(new URL(path, repositoryRoot), "utf8").replace(/\r\n/g, "\n")
const source = read("packages/ui/src/theme/contract.ts")
export const definitions = [...source.matchAll(/"([^"]+)": \{ property: "([^"]+)", defaultValue: [^\n]+?, type: "(color|radius)" \}/g)]
.map(([, key, property, type]) => ({ key, property, type }))
if (!definitions.length || new Set(definitions.map(def => def.key)).size !== definitions.length || new Set(definitions.map(def => def.property)).size !== definitions.length) throw Error("Missing or duplicate contract entries")
const groupsSource = source.slice(source.indexOf("export const runtimeGradientGroups"))
export const gradientGroups = [...groupsSource.matchAll(/\[([^\[\]]+)\]/g)].map(([, body]) => [...body.matchAll(/"([^"]+)"/g)].map(([, key]) => key))
if (!gradientGroups.length || gradientGroups.some(group => !group.length || group.some(key => !definitions.some(def => def.key === key)))) throw Error("Unrecognized production gradient groups")
// Build-time check derived from the production allowlist. Runtime uses isRuntimeTheme.
export function validateDocument(theme, fullDefault = false) {
if (!theme || theme.schemaVersion !== 1 || !theme.tokens || typeof theme.tokens !== "object" || Array.isArray(theme.tokens)) throw Error("Expected v1 RuntimeTheme document")
for (const [key, value] of Object.entries(theme.tokens)) {
const definition = definitions.find(def => def.key === key)
if (!definition || typeof value !== "string" || !(definition.type === "color" ? /^#[\da-f]{6}$/i : /^(?:[2-9]|1[0-2])px$/).test(value)) throw Error(`Invalid Theme token: ${key}`)
}
for (const group of gradientGroups) {
const count = group.filter(key => Object.hasOwn(theme.tokens, key)).length
if (count && count !== group.length) throw Error(`Incomplete gradient group: ${group[0]}`)
}
if (fullDefault && definitions.some(def => def.type === "color" && !Object.hasOwn(theme.tokens, def.key))) throw Error("default.json must contain every public color role/palette/gradient value")
if (Object.keys(theme.tokens).some(key => definitions.find(def => def.key === key).type !== "color")) throw Error("Theme artifacts are color-only; geometry belongs to package CSS")
return theme
}
+38
View File
@@ -0,0 +1,38 @@
import { writeFileSync } from "node:fs"
import { definitions, read, repositoryRoot, validateDocument } from "./theme-contract.mjs"
// themes/default.json is the authored COLOR source. Templates own aliases/geometry.
const theme = validateDocument(JSON.parse(read("themes/default.json")), true)
const templates = ["primitives", "default"].map(name => ({ name, source: read(`scripts/theme-templates/${name}.css`) }))
const graph = new Map(templates.flatMap(({ source }) => [...source.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map(([, key, value]) => [key, value.trim()])))
const publicProperties = new Map(definitions.filter(def => def.type === "color").map(def => [def.property, theme.tokens[def.key].toLowerCase()]))
function resolve(value, stack = []) {
const placeholder = /^\{\{([^|}]+)(?:\|([^}]+))?\}\}$/.exec(value)
if (placeholder) return theme.tokens[placeholder[1]].toLowerCase()
const alias = /^var\((--[\w-]+)\)$/.exec(value)
if (!alias) return value
const property = alias[1]
if (stack.includes(property)) throw Error(`Circular fallback alias: ${property}`)
if (publicProperties.has(property)) return publicProperties.get(property)
if (!graph.has(property)) throw Error(`Missing fallback alias: ${property}`)
return resolve(graph.get(property), [...stack, property])
}
function render(source) {
return source.replace(/\{\{([^|}]+)(?:\|([^}]+))?\}\}/g, (_, key, alias) => {
if (!Object.hasOwn(theme.tokens, key)) throw Error(`Missing template color: ${key}`)
const value = theme.tokens[key].toLowerCase()
// Preserve existing palette aliases when they match the canonical Default.
// A separately configured role always wins if its JSON value differs.
return alias && resolve(alias) === value ? alias : value
})
}
const defaults = Object.fromEntries(definitions.map(def => [def.property, def.type === "color" ? theme.tokens[def.key].toLowerCase() : resolve(graph.get(def.property))]))
for (const def of definitions) if (!(def.type === "color" ? /^#[\da-f]{6}$/i : /^(?:[2-9]|1[0-2])px$/).test(defaults[def.property])) throw Error(`Unresolved fallback: ${def.property}`)
const outputs = templates.map(({name,source}) => [`packages/ui/src/theme/${name}.css`, `/* Generated from themes/default.json + scripts/theme-templates/${name}.css. Do not edit. */\n${render(source)}`])
outputs.push(["packages/ui/src/theme/default-values.generated.ts", `// Generated by npm run generate:theme-defaults. Do not edit.\n// Colors: themes/default.json; geometry/aliases: scripts/theme-templates.\nexport const runtimeThemeDefaults = ${JSON.stringify(defaults, null, 2)} as const\n`])
for (const [path, content] of outputs) {
if (process.argv.includes("--check")) {
if (read(path) !== content) throw Error(`Stale ${path}. Run npm run generate:theme-defaults.`)
} else writeFileSync(new URL(path, repositoryRoot), content)
}
console.log(`Theme defaults: ${Object.keys(theme.tokens).length} JSON colors / ${definitions.length} contract defaults; ${process.argv.includes("--check") ? "generated outputs match" : "generated CSS + data"}`)
+202
View File
@@ -0,0 +1,202 @@
@import "./primitives.css";
/* Derived aliases and geometry. Values are generated from themes/default.json. */
:root, [data-hm-theme] {
--hm-color-primary: var(--hm-sem-palette-green-default);
--hm-color-primary-hover: color-mix(in srgb, var(--hm-sem-action-primary) 85%, var(--hm-sem-palette-black));
--hm-color-primary-active: color-mix(in srgb, var(--hm-sem-action-primary) 70%, var(--hm-sem-palette-black));
--hm-color-surface-default: var(--hm-sem-palette-white);
--hm-color-surface-content: var(--hm-sem-palette-beige-light);
--hm-color-surface-subtle: var(--hm-sem-palette-warm-neutral-muted);
--hm-color-text-primary: var(--hm-sem-palette-green-dark-1);
--hm-color-text-secondary: var(--hm-sem-palette-warm-neutral-dark);
--hm-color-text-inverse: var(--hm-sem-palette-white);
--hm-color-text-placeholder: var(--hm-sem-palette-neutral-gray-500);
--hm-color-border-default: var(--hm-sem-palette-warm-neutral-mid);
--hm-color-border-control: var(--hm-sem-palette-warm-neutral-pale);
/* Common Focus color. Trial value: Light 2 (#266651), pending user confirmation. Was --hm-color-primary. */
--hm-color-border-focus: var(--hm-sem-palette-green-light-2);
--hm-color-action-cancel: var(--hm-sem-palette-warm-neutral-dark);
--hm-color-action-destructive: var(--hm-sem-palette-red-danger);
--hm-color-action-destructive-hover: color-mix(in srgb, var(--hm-sem-action-destructive) 8%, var(--hm-sem-bg-surface));
--hm-color-action-overlay-hover: color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent);
--hm-color-action-overlay-active: color-mix(in srgb, var(--hm-sem-palette-black) 30%, transparent);
--hm-color-action-shadow-light: color-mix(in srgb, var(--hm-sem-palette-white) 10%, transparent);
--hm-color-action-shadow-dark: color-mix(in srgb, var(--hm-sem-palette-black) 25%, transparent);
--hm-radius-control-md: 4px;
--hm-control-height-md: 34px;
--hm-font-family-base: "Noto Sans KR", "Malgun Gothic", sans-serif;
--hm-font-size-button-md: 13px;
--hm-font-weight-medium: 500;
--hm-font-weight-bold: 700;
/* Existing semantic names remain the shadcn and preview adapter. */
--hm-sem-bg-content: {{color.bg.content|var(--hm-color-surface-content)}};
--hm-sem-bg-surface: {{color.bg.surface|var(--hm-color-surface-default)}};
--hm-sem-fg-primary: {{color.fg.primary|var(--hm-color-text-primary)}};
--hm-sem-fg-secondary: {{color.fg.secondary|var(--hm-color-text-secondary)}};
--hm-sem-fg-placeholder: {{color.fg.placeholder|var(--hm-color-text-placeholder)}};
--hm-sem-action-primary: {{color.action.primary|var(--hm-color-primary)}};
--hm-sem-action-cancel: {{color.action.cancel|var(--hm-color-action-cancel)}};
--hm-sem-action-destructive: {{color.action.destructive|var(--hm-color-action-destructive)}};
--hm-sem-stroke-default: {{color.stroke.default|var(--hm-color-border-default)}};
--hm-sem-stroke-input-default: {{color.stroke.input.default|var(--hm-color-border-control)}};
--hm-sem-stroke-input-focus: {{color.stroke.input.focus|var(--hm-color-border-focus)}};
--hm-sem-on-action-primary: {{color.fg.inverse|var(--hm-color-text-inverse)}};
/* Active state fill: checked / selected / on (Checkbox, Radio, Switch, Slider, Progress, Calendar).
Sibling of the Focus chain (--hm-color-border-focus): same Light 2 today, but neither references the other. */
--hm-sem-state-active: {{color.state.active|var(--hm-sem-palette-green-light-2)}};
--hm-sem-on-state-active: {{color.on.state.active|var(--hm-color-text-inverse)}};
/* Public gradient stop groups; geometry remains internal. */
--hm-sem-brand-bg-stop-0: {{color.bg.brand.stop.0|var(--hm-sem-palette-green-dark-1)}};
--hm-sem-brand-bg-stop-12: {{color.bg.brand.stop.12|var(--hm-sem-palette-green-dark-2)}};
--hm-sem-brand-bg-stop-42: {{color.bg.brand.stop.42|var(--hm-sem-palette-green-default)}};
--hm-sem-brand-bg-stop-78: {{color.bg.brand.stop.78|var(--hm-sem-palette-green-light-2)}};
--hm-sem-brand-bg-stop-100: {{color.bg.brand.stop.100|var(--hm-sem-palette-green-light-1)}};
--hm-sem-bg-brand: linear-gradient(to bottom,
var(--hm-sem-brand-bg-stop-0) 0%, var(--hm-sem-brand-bg-stop-12) 12%,
var(--hm-sem-brand-bg-stop-42) 42%, var(--hm-sem-brand-bg-stop-78) 78%,
var(--hm-sem-brand-bg-stop-100) 100%);
--hm-sem-action-surface-stop-0: {{surface.action.primary.stop.0|var(--hm-sem-palette-green-tone-1)}};
--hm-sem-action-surface-stop-30: {{surface.action.primary.stop.30|var(--hm-sem-palette-green-tone-2)}};
--hm-sem-action-surface-stop-65: {{surface.action.primary.stop.65|var(--hm-sem-palette-green-tone-3)}};
--hm-sem-action-surface-stop-100: {{surface.action.primary.stop.100|var(--hm-sem-palette-green-tone-4)}};
--hm-sem-surface-action-primary: linear-gradient(180deg,
var(--hm-sem-action-surface-stop-0) 0%, var(--hm-sem-action-surface-stop-30) 30%,
var(--hm-sem-action-surface-stop-65) 65%, var(--hm-sem-action-surface-stop-100) 100%);
--hm-sem-action-border-stop-0: {{surface.action.primary.border.stop.0|var(--hm-sem-palette-green-dark-1)}};
--hm-sem-action-border-stop-50: {{surface.action.primary.border.stop.50|var(--hm-sem-palette-green-light-4)}};
--hm-sem-action-border-stop-100: {{surface.action.primary.border.stop.100|var(--hm-sem-palette-green-tone-4)}};
--hm-sem-border-action-primary: linear-gradient(180deg,
var(--hm-sem-action-border-stop-0) 0%, var(--hm-sem-action-border-stop-50) 50%,
var(--hm-sem-action-border-stop-100) 100%);
--hm-radius-button: var(--hm-radius-control-md);
--hm-radius-input: var(--hm-radius-control-md);
--hm-radius-select: var(--hm-radius-control-md);
--hm-radius-container: var(--hm-radius-control-md);
/* Floating surface: anchored panels (Dropdown Menu, Context Menu, Popover, Hover Card, Menubar content, Navigation Menu panel).
Not Tooltip (inverse face), Select, Card or the modal surfaces. Component tokens keep their own names and point here. */
--hm-sem-floating-background: var(--hm-sem-bg-surface);
--hm-sem-floating-border-width: 1px;
--hm-sem-floating-border-color: var(--hm-sem-stroke-input-default);
--hm-sem-floating-radius: var(--hm-radius-container);
--hm-sem-floating-shadow: 0 0 12px color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent);
--hm-sem-bg-subtle: {{color.bg.subtle|var(--hm-color-surface-subtle)}};
--hm-sem-accent: {{color.accent|var(--hm-sem-palette-green-tint)}};
--hm-sem-on-accent: {{color.on.accent}};
--hm-sem-palette-green-light-4: {{color.palette.green-light-4|var(--hm-green-light-4)}};
--hm-sem-palette-green-light-3: {{color.palette.green-light-3|var(--hm-green-light-3)}};
--hm-sem-palette-green-light-2: {{color.palette.green-light-2|var(--hm-green-light-2)}};
--hm-sem-palette-green-light-1: {{color.palette.green-light-1|var(--hm-green-light-1)}};
--hm-sem-palette-green-default: {{color.palette.green-default|var(--hm-green-default)}};
--hm-sem-palette-green-dark-1: {{color.palette.green-dark-1|var(--hm-green-dark-1)}};
--hm-sem-palette-green-dark-2: {{color.palette.green-dark-2|var(--hm-green-dark-2)}};
--hm-sem-palette-brown-light-2: {{color.palette.brown-light-2|var(--hm-brown-light-2)}};
--hm-sem-palette-brown-light-1: {{color.palette.brown-light-1|var(--hm-brown-light-1)}};
--hm-sem-palette-brown-default: {{color.palette.brown-default|var(--hm-brown-default)}};
--hm-sem-palette-brown-dark-1: {{color.palette.brown-dark-1|var(--hm-brown-dark-1)}};
--hm-sem-palette-brown-light-4: {{color.palette.brown-light-4|var(--hm-brown-light-4)}};
--hm-sem-palette-orange-light-4: {{color.palette.orange-light-4|var(--hm-orange-light-4)}};
--hm-sem-palette-orange-default: {{color.palette.orange-default|var(--hm-orange-default)}};
--hm-sem-palette-warm-neutral-light: {{color.palette.warm-neutral-light|var(--hm-warm-neutral-light)}};
--hm-sem-palette-warm-neutral-mid: {{color.palette.warm-neutral-mid|var(--hm-warm-neutral-mid)}};
--hm-sem-palette-warm-neutral-dark: {{color.palette.warm-neutral-dark|var(--hm-warm-neutral-dark)}};
--hm-sem-palette-beige-light: {{color.palette.beige-light|var(--hm-beige-light)}};
--hm-sem-palette-red-danger: {{color.palette.red-danger|var(--hm-red-danger)}};
--hm-sem-palette-white: {{color.palette.white|var(--hm-white)}};
--hm-sem-palette-black: {{color.palette.black|var(--hm-black)}};
--hm-sem-palette-neutral-gray-500: {{color.palette.neutral-gray-500|var(--hm-neutral-gray-500)}};
--hm-sem-palette-green-tone-1: {{color.palette.green-tone-1|var(--hm-green-tone-1)}};
--hm-sem-palette-green-tone-2: {{color.palette.green-tone-2|var(--hm-green-tone-2)}};
--hm-sem-palette-green-tone-3: {{color.palette.green-tone-3|var(--hm-green-tone-3)}};
--hm-sem-palette-green-tone-4: {{color.palette.green-tone-4|var(--hm-green-tone-4)}};
--hm-sem-palette-warm-neutral-pale: {{color.palette.warm-neutral-pale|var(--hm-warm-neutral-pale)}};
--hm-sem-palette-warm-neutral-secondary: {{color.palette.warm-neutral-secondary|var(--hm-warm-neutral-secondary)}};
--hm-sem-palette-warm-neutral-muted: {{color.palette.warm-neutral-muted|var(--hm-warm-neutral-muted)}};
--hm-sem-palette-green-tint: {{color.palette.green-tint|var(--hm-green-tint)}};
--hm-sem-palette-yellow-accent: {{color.palette.yellow-accent|var(--hm-yellow-accent)}};
--hm-sem-palette-brown-deep: {{color.palette.brown-deep}};
--hm-sem-palette-brown-highlight: {{color.palette.brown-highlight}};
--hm-sem-palette-orange-light: {{color.palette.orange-light}};
--hm-sem-palette-orange-mid: {{color.palette.orange-mid}};
--hm-sem-palette-orange-dark: {{color.palette.orange-dark}};
--hm-sem-palette-orange-deep: {{color.palette.orange-deep}};
--hm-sem-palette-orange-border: {{color.palette.orange-border}};
--hm-sem-palette-orange-border-deep: {{color.palette.orange-border-deep}};
--hm-sem-palette-gray: {{color.palette.gray}};
--hm-sem-palette-gray-dark: {{color.palette.gray-dark}};
--hm-sem-palette-indicator: {{color.palette.indicator}};
--hm-sem-overlay-background: color-mix(in srgb, var(--hm-sem-palette-black) 10%, transparent);
--hm-sem-surface-highlight: color-mix(in srgb, var(--hm-sem-accent) 45%, transparent);
/* Adapter for shadcn/Tailwind. The public contract.ts allowlist limits runtime overrides. */
--background: var(--hm-sem-bg-content);
--foreground: var(--hm-sem-fg-primary);
--card: var(--hm-sem-bg-surface);
--card-foreground: var(--hm-sem-fg-primary);
--popover: var(--hm-sem-bg-surface);
--popover-foreground: var(--hm-sem-fg-primary);
--primary: var(--hm-sem-action-primary);
--primary-foreground: var(--hm-sem-on-action-primary);
--secondary: var(--hm-sem-palette-warm-neutral-secondary);
--secondary-foreground: var(--hm-sem-palette-brown-default);
--muted: var(--hm-sem-bg-subtle);
--muted-foreground: var(--hm-sem-fg-secondary);
--accent: var(--hm-sem-accent);
--accent-foreground: var(--hm-sem-on-accent);
--destructive: var(--hm-sem-action-destructive);
--border: var(--hm-sem-stroke-default);
--input: var(--hm-sem-stroke-input-default);
--ring: var(--hm-sem-stroke-input-focus);
--radius: var(--hm-radius-container);
}
+45
View File
@@ -0,0 +1,45 @@
/* HM color palette. Primitive names describe colors, not UI roles. */
:root {
--hm-green-light-4: {{color.palette.green-light-4}};
--hm-green-light-3: {{color.palette.green-light-3}};
--hm-green-light-2: {{color.palette.green-light-2}};
--hm-green-light-1: {{color.palette.green-light-1}};
--hm-green-default: {{color.palette.green-default}};
--hm-green-dark-1: {{color.palette.green-dark-1}};
--hm-green-dark-2: {{color.palette.green-dark-2}};
--hm-brown-light-2: {{color.palette.brown-light-2}};
--hm-brown-light-1: {{color.palette.brown-light-1}};
--hm-brown-default: {{color.palette.brown-default}};
--hm-brown-dark-1: {{color.palette.brown-dark-1}};
--hm-brown-light-4: {{color.palette.brown-light-4}};
--hm-orange-light-4: {{color.palette.orange-light-4}};
--hm-orange-default: {{color.palette.orange-default}};
--hm-warm-neutral-light: {{color.palette.warm-neutral-light}};
--hm-warm-neutral-mid: {{color.palette.warm-neutral-mid}};
--hm-warm-neutral-dark: {{color.palette.warm-neutral-dark}};
--hm-beige-light: {{color.palette.beige-light}};
--hm-red-danger: {{color.palette.red-danger}};
--hm-white: {{color.palette.white}};
--hm-black: {{color.palette.black}};
--hm-neutral-gray-500: {{color.palette.neutral-gray-500}};
/* Existing preview values without an approved palette alias. */
--hm-green-tone-1: {{color.palette.green-tone-1}};
--hm-green-tone-2: {{color.palette.green-tone-2}};
--hm-green-tone-3: {{color.palette.green-tone-3}};
--hm-green-tone-4: {{color.palette.green-tone-4}};
--hm-warm-neutral-pale: {{color.palette.warm-neutral-pale}};
--hm-warm-neutral-secondary: {{color.palette.warm-neutral-secondary}};
--hm-warm-neutral-muted: {{color.palette.warm-neutral-muted}};
--hm-green-tint: {{color.palette.green-tint}};
}
/* Accent for the current parent group on the green LNB surface (ui-viz color.action.accent-on-green). */
:root {
--hm-yellow-accent: {{color.palette.yellow-accent}};
}