kwon-ui 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CATALOG.md +322 -0
- package/CHANGELOG.md +61 -0
- package/LICENSE +4 -0
- package/README.md +71 -2
- package/bin/kwon-ui.mjs +60 -0
- package/check/index.mjs +233 -0
- package/claude/ADOPTING.md +107 -0
- package/claude/APP-CLAUDE.md +15 -0
- package/claude/hook.mjs +46 -0
- package/dist/classes.json +76 -0
- package/dist/kwon-ui.css +523 -0
- package/dist/react/controls.d.ts +20 -0
- package/dist/react/controls.js +19 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +5 -0
- package/dist/react/layout.d.ts +68 -0
- package/dist/react/layout.js +59 -0
- package/dist/react/tone.d.ts +18 -0
- package/dist/react/tone.js +29 -0
- package/dist/react/type.d.ts +29 -0
- package/dist/react/type.js +28 -0
- package/dist/react/util.d.ts +10 -0
- package/dist/react/util.js +4 -0
- package/package.json +62 -4
package/check/index.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// kwon-ui 검사기. 앱 코드가 디자인 시스템을 벗어난 곳을 찾는다.
|
|
2
|
+
// - CSS: 색·글자 크기·굵기·모서리 값을 직접 쓴 곳, 시스템 조각과 토큰을 앱에서 다시 정의한 곳
|
|
3
|
+
// - TSX: style 과 Tailwind 유틸리티로 색·글자를 직접 정한 곳
|
|
4
|
+
// 이미 있는 위반은 기준선(baseline)에 적어 두고, 파일별로 그 수가 늘어날 때만 실패한다.
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import postcss from "postcss";
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
export const RULES = {
|
|
13
|
+
"raw-color": "색 값을 직접 썼습니다. var(--n-…) 토큰을 쓰거나, 앱 고유의 색이면 앱 토큰 파일에서 변수로 정의합니다.",
|
|
14
|
+
"raw-font-size": "글자 크기를 직접 썼습니다. var(--n-fs-title), var(--n-fs-body), var(--n-fs-small) 가운데 하나를 씁니다.",
|
|
15
|
+
"font-weight": "굵기는 400과 500 두 가지입니다.",
|
|
16
|
+
"raw-radius": "모서리 값을 직접 썼습니다. var(--n-radius), var(--n-radius-md), var(--n-radius-pill) 가운데 하나를 씁니다.",
|
|
17
|
+
"system-redefine": "kwon-ui 의 조각을 앱에서 다시 정의했습니다. 고칠 것이 있으면 kwon-ui 에 제안합니다.",
|
|
18
|
+
"reserved-prefix": "n- 으로 시작하는 이름은 kwon-ui 에서만 만듭니다. 앱의 조각은 앱 접두어로 짓습니다.",
|
|
19
|
+
"token-redefine": "--n- 토큰은 kwon-ui 에서만 정합니다.",
|
|
20
|
+
"inline-style": "style 에 색·글자 값을 직접 썼습니다. kwon-ui 조각과 토큰을 씁니다.",
|
|
21
|
+
"tw-arbitrary": "Tailwind 임의 값으로 색·글자·모서리를 정했습니다. kwon-ui 조각과 토큰을 씁니다.",
|
|
22
|
+
"tw-type": "Tailwind 글자 크기·굵기 유틸리티 대신 Title, Body, Small 을 씁니다.",
|
|
23
|
+
"tw-color": "Tailwind 색 유틸리티 대신 kwon-ui 조각과 토큰을 씁니다.",
|
|
24
|
+
};
|
|
25
|
+
// 실패로 치지 않고 수만 알려 주는 것
|
|
26
|
+
export const WARNINGS = {
|
|
27
|
+
"system-override": "kwon-ui 조각을 페이지 문맥에서 덮어썼습니다. 자주 되풀이되면 kwon-ui 에 변형을 제안합니다.",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_CONFIG = {
|
|
31
|
+
roots: ["src"],
|
|
32
|
+
extensions: [".css", ".tsx"],
|
|
33
|
+
exclude: [],
|
|
34
|
+
tokenFiles: [],
|
|
35
|
+
scopes: ["night", "paper"],
|
|
36
|
+
baseline: "kwon-ui.baseline.json",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function loadSystem() {
|
|
40
|
+
const p = join(here, "..", "dist", "classes.json");
|
|
41
|
+
if (!existsSync(p)) throw new Error("dist/classes.json 이 없습니다. kwon-ui 를 먼저 빌드합니다(npm run build).");
|
|
42
|
+
const j = JSON.parse(readFileSync(p, "utf8"));
|
|
43
|
+
return { version: j.version, classes: new Set(j.classes), modifiers: new Set(j.modifiers), tokens: new Set(j.tokens) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function loadConfig(cwd) {
|
|
47
|
+
const p = join(cwd, "kwon-ui.config.json");
|
|
48
|
+
const user = existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : {};
|
|
49
|
+
return { ...DEFAULT_CONFIG, ...user, found: existsSync(p) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const posix = (p) => p.split(sep).join("/");
|
|
53
|
+
const strip = (v) => v.replace(/url\([^)]*\)/gi, "").replace(/(["'])(?:\\.|(?!\1).)*\1/g, "");
|
|
54
|
+
const COLOR = /#[0-9a-fA-F]{3,8}(?![\w-])|(?<![\w-])(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(|(?<![\w-])(?:white|black)(?![\w-])/i;
|
|
55
|
+
const RADIUS_OK = /^(?:0|50%|100%|999px|9999px|inherit|initial|unset|var\(--[\w-]+(?:,[^)]*)?\))$/;
|
|
56
|
+
const WEIGHT_OK = /^(?:400|500|normal|inherit|initial|unset|var\(--[\w-]+(?:,[^)]*)?\))$/;
|
|
57
|
+
const SIZE_KEYWORD = /^(?:inherit|initial|unset|1em|100%|0)$/;
|
|
58
|
+
|
|
59
|
+
/** CSS 한 파일을 검사한다. */
|
|
60
|
+
export function checkCss(source, { file = "", system, config = DEFAULT_CONFIG } = {}) {
|
|
61
|
+
const out = [];
|
|
62
|
+
const add = (rule, node, text) => out.push({ rule, line: node.source?.start?.line ?? 0, text: text.replace(/\s+/g, " ").slice(0, 120) });
|
|
63
|
+
const isTokenFile = config.tokenFiles.map(posix).includes(posix(file));
|
|
64
|
+
const scopes = new Set(config.scopes);
|
|
65
|
+
let root;
|
|
66
|
+
try {
|
|
67
|
+
root = postcss.parse(source, { from: file || undefined });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return [{ rule: "parse-error", line: e.line ?? 0, text: String(e.reason ?? e.message) }];
|
|
70
|
+
}
|
|
71
|
+
const inTheme = (node) => {
|
|
72
|
+
for (let p = node.parent; p; p = p.parent) if (p.type === "atrule" && p.name === "theme") return true;
|
|
73
|
+
return false;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
root.walkDecls((d) => {
|
|
77
|
+
const prop = d.prop.toLowerCase();
|
|
78
|
+
const value = strip(d.value);
|
|
79
|
+
const shown = `${d.prop}: ${d.value}`;
|
|
80
|
+
if (prop.startsWith("--")) {
|
|
81
|
+
if (prop.startsWith("--n-")) add("token-redefine", d, shown);
|
|
82
|
+
// 변수 정의는 앱 토큰 파일과 @theme 안에서만 값을 직접 쓸 수 있다
|
|
83
|
+
if (!isTokenFile && !inTheme(d) && COLOR.test(value)) add("raw-color", d, shown);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (COLOR.test(value) && prop !== "content" && prop !== "font-family") add("raw-color", d, shown);
|
|
87
|
+
if (prop === "font-size" || prop === "font") {
|
|
88
|
+
const rest = value.replace(/var\((?:[^()]|\([^()]*\))*\)/g, "");
|
|
89
|
+
const literal = /\d/.test(rest) && !SIZE_KEYWORD.test(value.trim());
|
|
90
|
+
if (prop === "font" || literal || (!/var\(--/.test(value) && !SIZE_KEYWORD.test(value.trim()))) add("raw-font-size", d, shown);
|
|
91
|
+
}
|
|
92
|
+
if (prop === "font-weight" && !WEIGHT_OK.test(value.trim())) add("font-weight", d, shown);
|
|
93
|
+
if (/^border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius$/.test(prop)) {
|
|
94
|
+
const parts = value.replace(/var\((?:[^()]|\([^()]*\))*\)/g, "var(--x)").split(/[\s/]+/).filter(Boolean);
|
|
95
|
+
if (parts.some((p) => !RADIUS_OK.test(p))) add("raw-radius", d, shown);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (system) {
|
|
100
|
+
root.walkRules((rule) => {
|
|
101
|
+
if (rule.parent?.type === "atrule" && /keyframes$/i.test(rule.parent.name)) return;
|
|
102
|
+
for (const sel of rule.selectors ?? []) {
|
|
103
|
+
const classes = (sel.match(/\.(-?[a-zA-Z_][\w-]*)/g) ?? []).map((c) => c.slice(1));
|
|
104
|
+
const reserved = classes.filter((c) => c.startsWith("n-"));
|
|
105
|
+
if (!reserved.length) continue;
|
|
106
|
+
if (reserved.some((c) => !system.classes.has(c))) {
|
|
107
|
+
add("reserved-prefix", rule, sel);
|
|
108
|
+
} else if (classes.every((c) => c.startsWith("n-") || scopes.has(c) || system.modifiers.has(c))) {
|
|
109
|
+
add("system-redefine", rule, sel);
|
|
110
|
+
} else {
|
|
111
|
+
add("system-override", rule, sel);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const TW_VARIANT = String.raw`(?<![\w-])(?:[a-z0-9-]+:)*`;
|
|
120
|
+
const TW_TYPE = new RegExp(TW_VARIANT + String.raw`(?:text-(?:xs|sm|base|lg|xl|[2-9]xl)|font-(?:thin|extralight|light|semibold|bold|extrabold|black))(?![\w-])`);
|
|
121
|
+
const TW_COLOR = new RegExp(
|
|
122
|
+
TW_VARIANT +
|
|
123
|
+
String.raw`(?:text|bg|border|ring|fill|stroke|from|to|via|decoration|divide|outline|placeholder|caret|accent)-(?:white|black|(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3})(?:\/\d{1,3})?(?![\w-])`,
|
|
124
|
+
);
|
|
125
|
+
const TW_ARBITRARY = new RegExp(TW_VARIANT + String.raw`(text|bg|border|ring|fill|stroke|from|to|via|shadow|outline|decoration|divide|rounded(?:-[a-z]{1,2})?|leading|tracking|font)-\[([^\]]+)\]`, "g");
|
|
126
|
+
const STYLE_KEYS = /(?<![\w-])(color|background|backgroundColor|borderColor|fontSize|fontWeight|borderRadius|letterSpacing|lineHeight|fontFamily)\s*:\s*([^,}\n]+)/g;
|
|
127
|
+
|
|
128
|
+
/** TSX 한 파일을 검사한다(문자열과 style 속성을 정규식으로 훑는다). */
|
|
129
|
+
export function checkTsx(source) {
|
|
130
|
+
const out = [];
|
|
131
|
+
const lines = source.split("\n");
|
|
132
|
+
lines.forEach((line, i) => {
|
|
133
|
+
for (const m of line.matchAll(/(["'`])((?:\\.|(?!\1).)*)\1/g)) {
|
|
134
|
+
const s = m[2];
|
|
135
|
+
if (!s || s.length > 600) continue;
|
|
136
|
+
const type = s.match(TW_TYPE);
|
|
137
|
+
if (type) out.push({ rule: "tw-type", line: i + 1, text: type[0] });
|
|
138
|
+
const color = s.match(TW_COLOR);
|
|
139
|
+
if (color) out.push({ rule: "tw-color", line: i + 1, text: color[0] });
|
|
140
|
+
for (const a of s.matchAll(TW_ARBITRARY)) {
|
|
141
|
+
const [whole, prop, val] = a;
|
|
142
|
+
if (/var\(--/.test(val)) continue; // 토큰을 가리키는 임의 값은 허용
|
|
143
|
+
const colorish = COLOR.test(val);
|
|
144
|
+
const sizeish = /^\d*\.?\d+(?:px|rem|em|%)$/.test(val);
|
|
145
|
+
const isTypeProp = /^(?:rounded|leading|tracking|font)/.test(prop);
|
|
146
|
+
if (isTypeProp || colorish || (prop === "text" && sizeish)) out.push({ rule: "tw-arbitrary", line: i + 1, text: whole });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
for (const m of source.matchAll(/style=\{\{([\s\S]*?)\}\}/g)) {
|
|
151
|
+
const base = source.slice(0, m.index).split("\n").length;
|
|
152
|
+
for (const k of m[1].matchAll(STYLE_KEYS)) {
|
|
153
|
+
const v = k[2].trim();
|
|
154
|
+
const literal = /^["'`\d]/.test(v) && !/var\(--/.test(v);
|
|
155
|
+
if (literal) out.push({ rule: "inline-style", line: base + m[1].slice(0, k.index).split("\n").length - 1, text: `${k[1]}: ${v}`.slice(0, 120) });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function checkFile(abs, { cwd, system, config }) {
|
|
162
|
+
const rel = posix(relative(cwd, abs));
|
|
163
|
+
const source = readFileSync(abs, "utf8");
|
|
164
|
+
const found = abs.endsWith(".css") ? checkCss(source, { file: rel, system, config }) : checkTsx(source);
|
|
165
|
+
return found.map((v) => ({ ...v, file: rel }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 설정의 roots 아래에서 검사할 파일을 모은다. */
|
|
169
|
+
export function collect(cwd, config) {
|
|
170
|
+
const files = [];
|
|
171
|
+
const skip = config.exclude.map(posix);
|
|
172
|
+
const walk = (dir) => {
|
|
173
|
+
for (const name of readdirSync(dir)) {
|
|
174
|
+
if (name === "node_modules" || name.startsWith(".")) continue;
|
|
175
|
+
const abs = join(dir, name);
|
|
176
|
+
const rel = posix(relative(cwd, abs));
|
|
177
|
+
if (skip.some((x) => rel === x || rel.startsWith(x.endsWith("/") ? x : x + "/"))) continue;
|
|
178
|
+
if (statSync(abs).isDirectory()) walk(abs);
|
|
179
|
+
else if (config.extensions.some((e) => name.endsWith(e))) files.push(abs);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
for (const r of config.roots) if (existsSync(join(cwd, r))) walk(join(cwd, r));
|
|
183
|
+
return files.sort();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 위반을 파일과 규칙별 수로 줄인다(경고와 파싱 오류는 빼고 센다). */
|
|
187
|
+
export function summarize(violations) {
|
|
188
|
+
const files = {};
|
|
189
|
+
for (const v of violations) {
|
|
190
|
+
if (!(v.rule in RULES)) continue;
|
|
191
|
+
files[v.file] ??= {};
|
|
192
|
+
files[v.file][v.rule] = (files[v.file][v.rule] ?? 0) + 1;
|
|
193
|
+
}
|
|
194
|
+
return files;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 기준선보다 늘어난 (파일, 규칙)만 고른다. */
|
|
198
|
+
export function overBaseline(counts, baseline) {
|
|
199
|
+
const over = [];
|
|
200
|
+
for (const [file, rules] of Object.entries(counts)) {
|
|
201
|
+
for (const [rule, n] of Object.entries(rules)) {
|
|
202
|
+
const base = baseline?.files?.[file]?.[rule] ?? 0;
|
|
203
|
+
if (n > base) over.push({ file, rule, count: n, base });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return over;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 검사를 돌린다. files 를 주면 그 파일만, 없으면 설정의 roots 전체를 본다.
|
|
211
|
+
* 돌려주는 값: violations(전부), over(기준선보다 늘어난 것), counts, baseline
|
|
212
|
+
*/
|
|
213
|
+
export function run({ cwd = process.cwd(), files, useBaseline = true } = {}) {
|
|
214
|
+
cwd = resolve(cwd);
|
|
215
|
+
const config = loadConfig(cwd);
|
|
216
|
+
const system = loadSystem();
|
|
217
|
+
const targets = (files?.length ? files.map((f) => resolve(cwd, f)) : collect(cwd, config)).filter(
|
|
218
|
+
(f) => existsSync(f) && config.extensions.some((e) => f.endsWith(e)),
|
|
219
|
+
);
|
|
220
|
+
const violations = targets.flatMap((f) => checkFile(f, { cwd, system, config }));
|
|
221
|
+
const counts = summarize(violations);
|
|
222
|
+
const basePath = join(cwd, config.baseline);
|
|
223
|
+
const baseline = useBaseline && existsSync(basePath) ? JSON.parse(readFileSync(basePath, "utf8")) : null;
|
|
224
|
+
return { config, system, targets, violations, counts, baseline, over: overBaseline(counts, baseline) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function writeBaseline(cwd = process.cwd()) {
|
|
228
|
+
cwd = resolve(cwd);
|
|
229
|
+
const r = run({ cwd, useBaseline: false });
|
|
230
|
+
const body = { kwonUi: r.system.version, files: Object.fromEntries(Object.entries(r.counts).sort(([a], [b]) => a.localeCompare(b))) };
|
|
231
|
+
writeFileSync(join(cwd, r.config.baseline), JSON.stringify(body, null, 2) + "\n");
|
|
232
|
+
return { path: r.config.baseline, total: Object.values(r.counts).reduce((s, x) => s + Object.values(x).reduce((a, b) => a + b, 0), 0), files: Object.keys(r.counts).length };
|
|
233
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# 앱에 넣는 순서
|
|
2
|
+
|
|
3
|
+
앱 하나에 kwon-ui를 넣을 때 따르는 순서입니다. 한 번에 한 앱씩, 넣기 전과 후의 화면을 같은 크기로 찍어 대조합니다.
|
|
4
|
+
|
|
5
|
+
## 1. 설치
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install kwon-ui@0.2.0 --save-exact
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
정확한 버전을 고정합니다. 올릴 때는 `CHANGELOG.md`를 읽고 한 앱씩 올립니다.
|
|
12
|
+
|
|
13
|
+
## 2. CSS 불러오기
|
|
14
|
+
|
|
15
|
+
레이아웃에서 앱의 CSS보다 먼저 불러옵니다.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import "kwon-ui/kwon-ui.css";
|
|
19
|
+
import "./globals.css";
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- 앱의 CSS가 `@layer`로 묶여 있고 kwon-ui도 같은 층에 넣어야 하면 CSS에서 `@import "kwon-ui/kwon-ui.css" layer(components);`로 부릅니다.
|
|
23
|
+
- 층 없이 부르면 kwon-ui의 규칙이 Tailwind 유틸리티보다 우선합니다. 그래서 조각에는 margin과 폭이 없습니다.
|
|
24
|
+
- 페이지 맨 바깥에 `.night`가 있어야 합니다.
|
|
25
|
+
- Next.js 16과 Tailwind 4에서 `@import "kwon-ui/kwon-ui.css" layer(components);`가 개발 서버(webpack)와 운영 빌드(Turbopack) 모두에서 되는 것을 확인했습니다(스킬캠퍼스, 2026-11-16). 컴포넌트와 `tone()`도 서버 컴포넌트에서 그대로 불러와 씁니다.
|
|
26
|
+
- 같은 층에 넣으면 특이도와 순서가 그대로 적용됩니다. kwon-ui의 규칙은 `.night .n-…`(클래스 둘)이라, 같은 요소에 붙은 앱의 한 클래스짜리 규칙(`.cp-offer { padding-top: … }`)은 집니다. 앱 쪽을 `.n-sect.cp-offer`처럼 클래스 둘로 씁니다.
|
|
27
|
+
- `.night`는 글자색을 흰색으로 둡니다. 앱의 기본 글자색이 다르면 바깥 요소에서 한 번 정합니다(`.cp.night { color: var(--n-soft); }`).
|
|
28
|
+
- 개발 서버가 떠 있는 채로 브랜치를 바꿔 CSS 파일이 사라졌다 생기면 옛 CSS가 남습니다. 불러오는 CSS 파일을 한 번 저장(touch)하면 다시 만들어집니다.
|
|
29
|
+
|
|
30
|
+
## 3. 앱에 있던 같은 정의 지우기
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx kwon-ui check --report
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`system-redefine`과 `token-redefine`으로 나오는 규칙이 앱에 남아 있는 같은 이름의 정의입니다. 지우고 화면을 대조합니다. 앱의 값이 kwon-ui와 달랐다면 그 차이가 화면에 나타납니다. 의도한 변형이었는지 보고, 필요하면 kwon-ui에 변형을 제안합니다.
|
|
37
|
+
|
|
38
|
+
`reserved-prefix`로 나오는 것은 앱에만 있는 `n-` 이름입니다. 한 번에 바꾸지 않아도 됩니다. 기준선에 두고, 그 파일을 고칠 때 앱 접두어로 바꿉니다.
|
|
39
|
+
|
|
40
|
+
## 4. 설정과 기준선
|
|
41
|
+
|
|
42
|
+
앱 루트에 `kwon-ui.config.json`을 둡니다.
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"roots": ["src"],
|
|
47
|
+
"exclude": [],
|
|
48
|
+
"tokenFiles": ["src/app/tokens.css"]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `roots`: 검사할 폴더. 화면과 상관없는 폴더는 `exclude`에 넣습니다.
|
|
53
|
+
- `tokenFiles`: 앱 고유의 색(강조색 등)을 변수로 정의하는 파일. 색 값을 직접 쓸 수 있는 곳은 여기뿐입니다.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx kwon-ui baseline
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
지금의 위반 수가 `kwon-ui.baseline.json`에 적힙니다. 커밋합니다. 이 수는 줄어들기만 해야 합니다. 위반을 고쳤으면 다시 `baseline`을 돌려 수를 낮춥니다.
|
|
60
|
+
|
|
61
|
+
## 5. 빌드에 걸기
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"scripts": {
|
|
66
|
+
"check:design": "kwon-ui check",
|
|
67
|
+
"prebuild": "npm run check:design"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
위반이 기준선보다 늘면 빌드가 실패합니다. 어떤 도구로 작업해도 똑같이 걸립니다.
|
|
73
|
+
|
|
74
|
+
## 6. Claude Code 훅
|
|
75
|
+
|
|
76
|
+
파일을 저장할 때마다 그 파일만 검사해서, 위반이 늘었으면 내용을 모델에게 바로 돌려줍니다. 작업 공간의 Claude Code 설정(`.claude/settings.json`)에 한 번 겁니다. 아래 모양으로 걸어 실제로 되돌아오는 것을 확인했습니다(2026-11-16).
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"hooks": {
|
|
81
|
+
"PostToolUse": [
|
|
82
|
+
{
|
|
83
|
+
"matcher": "Edit|Write",
|
|
84
|
+
"hooks": [{ "type": "command", "command": "command -v node >/dev/null 2>&1 || exit 0; node /경로/kwon-ui/claude/hook.mjs", "timeout": 20 }]
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
훅은 `kwon-ui.config.json`이 있는 앱 안의 `.css`, `.tsx` 파일만 봅니다. 그 밖의 파일과 오류는 조용히 지나갑니다.
|
|
92
|
+
|
|
93
|
+
## 7. 앱의 CLAUDE.md
|
|
94
|
+
|
|
95
|
+
[APP-CLAUDE.md](APP-CLAUDE.md)의 문구를 앱의 CLAUDE.md에 넣습니다.
|
|
96
|
+
|
|
97
|
+
## 넣기 전과 후를 대조하는 법
|
|
98
|
+
|
|
99
|
+
넣기 전의 화면에서 모든 요소의 자리(x, y, 폭, 높이)와 계산된 스타일을 기록해 두고, 넣은 뒤 같은 방법으로 기록해 요소 순서대로 맞춰 봅니다. 화면 폭은 390, 768, 1024, 1440 네 가지로 잽니다. 같은 화면을 두 번 재서 차이가 0인지 먼저 확인합니다(움직이는 조각은 빼고 잽니다). 남는 차이가 kwon-ui의 값을 받아들인 것뿐이면 끝입니다. 스킬캠퍼스는 이 방법으로 네 페이지를 대조했고, 페이지 길이 차이는 16,000px 가운데 4px 안팎이었습니다.
|
|
100
|
+
|
|
101
|
+
## 넣을 때 알아 둘 차이
|
|
102
|
+
|
|
103
|
+
값의 기준은 kwonandcompany.com입니다. 0.1.0은 계산된 스타일이 홈페이지와 같고, 0.2.0에서 아래 두 가지가 달라졌습니다. 다른 두 제품은 손으로 옮기면서 생긴 작은 차이가 있어, 넣으면 그만큼 바뀝니다.
|
|
104
|
+
|
|
105
|
+
- kwonandcompany.com: 줄 목록의 라벨이 본문 첫 줄의 밑선에 맞춰져 넓은 화면에서 4px 내려갑니다(`items-baseline`을 이미 준 줄은 그대로입니다). 교육 문의의 사진 판(`n-closing`)을 `n-photo`로 바꾸면 1024px 미만에서 최소 높이가 560px → 480px, 제목과 문단 사이가 20px → 16px, 제목의 회색 글자가 50% → 62%가 됩니다. 문단과 버튼 줄 사이는 버튼 줄을 `n-actions`로 두면 40px 그대로입니다. 사진 판 안의 `mt-5`, `mt-10`은 뺍니다.
|
|
106
|
+
- Projects 랜딩: 제목 줄 간격 1.25 → 1.2, 본문 1.55 → 1.5, 알약 버튼 글자 사이 0.01em과 간격 10px, 글자 링크 간격 8px, 구간 머리의 세로 정렬(위 → 가운데). 구간 사이의 선(`n-sect n-rule`)을 `n-sect ruled`로 바꾸면 선이 바깥 여백만큼 짧아져 내용 폭과 같아집니다.
|
|
107
|
+
- 스킬캠퍼스(2026-11-16 공개 네 페이지에 넣음): 변수 이름을 바꿨습니다(`--hi`, `--mid`, `--line` 등 → `--n-hi`, `--n-mid`, `--n-line`). 바뀐 값은 작은 글 줄 간격 1.5 → 1.45, 글자 링크의 줄 높이 1.4 → 1.6, 줄 목록 라벨의 글자 사이 0, 검은 판의 z-index 1입니다. 0.2.0으로 올리면서 앱이 덧붙여 쓰던 규칙(구간 선, 짧은 구간, 줄 목록의 첫 줄 맞춤, 진한 낱말, 사진 판)을 지웠습니다.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# 앱의 CLAUDE.md에 넣는 문구
|
|
2
|
+
|
|
3
|
+
kwon-ui를 넣은 앱의 CLAUDE.md에 아래를 그대로 넣습니다. 짧게 둡니다. 지켜지게 하는 것은 이 문구가 아니라 검사(`kwon-ui check`)와 훅입니다.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
디자인 시스템: kwon-ui
|
|
7
|
+
|
|
8
|
+
'디자인 시스템', '공통 컴포넌트'는 모두 kwon-ui(패키지 kwon-ui)를 뜻한다. 이 앱의 화면은 kwon-ui로만 만든다.
|
|
9
|
+
|
|
10
|
+
- 화면 작업 전에 node_modules/kwon-ui/CATALOG.md 를 읽고, 거기 있는 조각으로 짠다.
|
|
11
|
+
- 색, 글자 크기, 굵기, 모서리 값을 앱 코드에 직접 쓰지 않는다. kwon-ui 의 토큰(var(--n-…))과 조각만 쓴다. 앱 고유의 색은 kwon-ui.config.json 의 tokenFiles 에 적힌 파일에서만 변수로 정의한다.
|
|
12
|
+
- n- 으로 시작하는 이름과 --n- 토큰을 앱에서 만들거나 다시 정의하지 않는다. 앱의 조각은 앱 접두어로 짓는다.
|
|
13
|
+
- 필요한 조각이 없으면 페이지에서 비슷한 것을 새로 만들지 않는다. 멈추고 "kwon-ui 에 추가할 것"을 제안한다. 한 페이지에서만 쓸 조각이면 앱 접두어로 만들고 토큰만 쓴다.
|
|
14
|
+
- 끝내기 전에 npm run check:design 을 통과시킨다. 검사를 끄거나, 기준선을 올리거나, 예외로 넘기지 않는다.
|
|
15
|
+
```
|
package/claude/hook.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code 훅(PostToolUse, Edit·Write)용. 방금 고친 파일 하나를 kwon-ui 검사기로 본다.
|
|
3
|
+
// 표준 입력으로 훅 JSON 을 받는다. 그 파일이 kwon-ui 를 쓰는 앱(kwon-ui.config.json 이 있는 폴더) 안에 있고
|
|
4
|
+
// 위반이 기준선보다 늘었으면 내용을 표준 오류로 내고 종료 코드 2로 끝낸다(모델에게 그대로 돌아간다).
|
|
5
|
+
// 그 밖의 경우(다른 폴더, 다른 종류의 파일, 검사기 오류)는 조용히 0으로 끝낸다. 훅 때문에 작업이 막히면 안 된다.
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join, relative } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
|
|
10
|
+
function read() {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(0, "utf8"));
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const input = read();
|
|
19
|
+
const file = input?.tool_input?.file_path;
|
|
20
|
+
if (!file || !/\.(css|tsx)$/.test(file) || !existsSync(file)) process.exit(0);
|
|
21
|
+
|
|
22
|
+
// 가장 가까운 kwon-ui.config.json 을 찾는다
|
|
23
|
+
let app = dirname(file);
|
|
24
|
+
while (!existsSync(join(app, "kwon-ui.config.json"))) {
|
|
25
|
+
const up = dirname(app);
|
|
26
|
+
if (up === app) process.exit(0);
|
|
27
|
+
app = up;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// 앱에 설치된 판의 검사기를 쓴다(앱마다 kwon-ui 버전이 다를 수 있다)
|
|
32
|
+
const installed = join(app, "node_modules", "kwon-ui", "check", "index.mjs");
|
|
33
|
+
const { run, RULES } = await import(pathToFileURL(existsSync(installed) ? installed : new URL("../check/index.mjs", import.meta.url).pathname).href);
|
|
34
|
+
const r = run({ cwd: app, files: [relative(app, file)] });
|
|
35
|
+
if (!r.over.length) process.exit(0);
|
|
36
|
+
const lines = [`kwon-ui: ${relative(app, file)} 이 디자인 시스템을 벗어났습니다.`];
|
|
37
|
+
for (const o of r.over) {
|
|
38
|
+
lines.push(`[${o.rule}] ${RULES[o.rule]}`);
|
|
39
|
+
for (const v of r.violations.filter((x) => x.rule === o.rule).slice(0, 6)) lines.push(` ${v.line}: ${v.text}`);
|
|
40
|
+
}
|
|
41
|
+
lines.push("조각과 토큰 목록: node_modules/kwon-ui/CATALOG.md. 필요한 조각이 없으면 페이지에서 만들지 말고 kwon-ui 에 추가를 제안합니다.");
|
|
42
|
+
console.error(lines.join("\n"));
|
|
43
|
+
process.exit(2);
|
|
44
|
+
} catch {
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.2.0",
|
|
3
|
+
"scope": "night",
|
|
4
|
+
"classes": [
|
|
5
|
+
"n-actions",
|
|
6
|
+
"n-body",
|
|
7
|
+
"n-card-title",
|
|
8
|
+
"n-cta-band",
|
|
9
|
+
"n-display",
|
|
10
|
+
"n-head",
|
|
11
|
+
"n-link",
|
|
12
|
+
"n-media",
|
|
13
|
+
"n-num",
|
|
14
|
+
"n-page",
|
|
15
|
+
"n-page-head",
|
|
16
|
+
"n-panel",
|
|
17
|
+
"n-photo",
|
|
18
|
+
"n-photo-in",
|
|
19
|
+
"n-pill",
|
|
20
|
+
"n-row",
|
|
21
|
+
"n-row-label",
|
|
22
|
+
"n-rows",
|
|
23
|
+
"n-rule",
|
|
24
|
+
"n-sect",
|
|
25
|
+
"n-small",
|
|
26
|
+
"n-sr",
|
|
27
|
+
"n-timeline",
|
|
28
|
+
"n-title",
|
|
29
|
+
"n-tl-more",
|
|
30
|
+
"n-tl-year",
|
|
31
|
+
"n-wrap"
|
|
32
|
+
],
|
|
33
|
+
"modifiers": [
|
|
34
|
+
"chev",
|
|
35
|
+
"ghost",
|
|
36
|
+
"hi",
|
|
37
|
+
"lo",
|
|
38
|
+
"lo-inline",
|
|
39
|
+
"ruled",
|
|
40
|
+
"slim",
|
|
41
|
+
"wide",
|
|
42
|
+
"wide-label"
|
|
43
|
+
],
|
|
44
|
+
"tokens": [
|
|
45
|
+
"--n-bg",
|
|
46
|
+
"--n-panel",
|
|
47
|
+
"--n-card",
|
|
48
|
+
"--n-card-2",
|
|
49
|
+
"--n-line",
|
|
50
|
+
"--n-hi",
|
|
51
|
+
"--n-soft",
|
|
52
|
+
"--n-mid",
|
|
53
|
+
"--n-dim",
|
|
54
|
+
"--n-btn",
|
|
55
|
+
"--n-btn-ink",
|
|
56
|
+
"--n-btn-hover",
|
|
57
|
+
"--n-ghost",
|
|
58
|
+
"--n-ghost-hover",
|
|
59
|
+
"--n-focus",
|
|
60
|
+
"--n-shade",
|
|
61
|
+
"--n-shade-soft",
|
|
62
|
+
"--n-on-photo",
|
|
63
|
+
"--n-fs-display",
|
|
64
|
+
"--n-fs-title",
|
|
65
|
+
"--n-fs-body",
|
|
66
|
+
"--n-fs-small",
|
|
67
|
+
"--n-fs-control",
|
|
68
|
+
"--n-radius",
|
|
69
|
+
"--n-radius-md",
|
|
70
|
+
"--n-radius-pill",
|
|
71
|
+
"--n-wrap",
|
|
72
|
+
"--n-gutter",
|
|
73
|
+
"--n-edge",
|
|
74
|
+
"--n-font"
|
|
75
|
+
]
|
|
76
|
+
}
|