@spectrabrain/eds 0.0.4 → 0.1.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.
@@ -0,0 +1,71 @@
1
+ # EVA semantic token 사용 맥락
2
+
3
+ 이 문서는 `@spectrabrain/eds`의 공개 semantic token을 소비처의 화면 맥락에 맞춰 선택하기 위한 보조 안내입니다. 필수 읽기는 package root의 `QUICKSTART.md`이며, 이 문서는 token을 직접 고를 때 읽습니다. 정확한 이름과 값은 설치된 버전의 `dist/tokens.json`(`@spectrabrain/eds/tokens.json`)과 `@spectrabrain/eds/tokens`를 기준으로 합니다.
4
+
5
+ ## 색상 역할
6
+
7
+ | 역할 | 적용 대상 | Tailwind class 패턴 | 선택 기준 |
8
+ | --- | --- | --- | --- |
9
+ | `fg` | 제목, 본문, placeholder, 아이콘, inverted content | `text-eds-fg-*` | 콘텐츠가 전달하는 의미와 대비를 먼저 결정합니다. |
10
+ | `bg` | 페이지, 카드, 입력, 버튼, 상태 surface | `bg-eds-bg-*` | surface의 위계와 interaction state를 결정합니다. |
11
+ | `stroke` | border, divider, focus ring | `border-eds-stroke-*` | 경계의 중요도와 keyboard focus 여부를 결정합니다. |
12
+
13
+ 역할을 고른 뒤 `brand`, `neutral`, `critical`, `informative`, `positive`, `warning`의 의미와 `primary`, `weak`, `disabled`, `hover`, `focus` 상태를 맞춥니다. 색상 취향만으로 역할을 고르지 않습니다.
14
+
15
+ ### 행동 맥락 예시
16
+
17
+ | 화면 맥락 | 우선 검토할 표현 | 이유 |
18
+ | --- | --- | --- |
19
+ | 생성·저장·관리·확인 같은 화면의 핵심 행동 | `brand`의 primary 표현 | 사용자가 완료해야 할 대표 행동을 분명히 합니다. |
20
+ | 검색·조회·일반적인 주요 실행 | `neutral`의 primary 표현 | 핵심 행동보다 낮은 브랜드 주목도로 일반 행동을 표현합니다. |
21
+ | 취소·닫기·수정 같은 보조 작업 | `neutral`의 outline 표현 | 주 행동과 시각적 위계를 분리합니다. |
22
+ | 초기화·갱신·되돌리기처럼 부담이 낮은 반복 행동 | `neutral`의 weak 표현 | 주 행동을 보조하는 반복 가능한 행동을 낮은 위계로 표현합니다. |
23
+ | 되돌리기 어려운 삭제·초기화 | `critical`의 weak 표현 | 위험을 분명히 하고 확인 절차와 함께 사용합니다. |
24
+ | 비동기 처리 중 | 컴포넌트의 `loading` contract | 라벨 공간과 접근성 이름을 유지하면서 중복 실행을 막습니다. |
25
+
26
+ 기존 화면에서 다른 역할이 사용되고 있더라도 새 코드에서는 행동 의미에 맞는 공개 역할을 먼저 검토합니다. exact token이 없으면 `fg/bg/stroke` 역할, 상태, 행동 의미, `neutral` 순서로 가까운 공개 token을 선택합니다.
27
+
28
+ ## Typography
29
+
30
+ text style은 다섯 속성을 함께 적용하는 preset입니다. `fontFamily`, `fontWeight`, `fontSize`, `lineHeight`, `letterSpacing`을 각각 임의로 조합하지 않습니다.
31
+
32
+ Tailwind utility는 `eds-typography-*` 패턴으로 사용하며, 설치된 manifest에 있는 preset 이름만 선택합니다. `npm run sync:eds`가 생성하는 `@theme inline`에는 `--font-sans: var(--eds-typography-body1-fontFamily);`가 포함되므로 Tailwind의 `font-sans`와 기본 sans font도 Pretendard가 됩니다. 이 font family는 `@spectrabrain/eds/fonts.css`가 선언합니다.
33
+
34
+ | 순서 | class 패턴 | 권장 맥락 |
35
+ | --- | --- | --- |
36
+ | Display | `eds-typography-display1`, `eds-typography-display2` | 제품의 큰 메시지와 페이지 진입점입니다. |
37
+ | Heading | `eds-typography-heading1` … `eds-typography-heading4` | 페이지·섹션·그룹의 구조를 나타냅니다. |
38
+ | Title | `eds-typography-title1` … `eds-typography-title4` | 카드·행·대화상자의 핵심 제목과 control label입니다. |
39
+ | Body | `eds-typography-body1` … `eds-typography-body4` | 설명, 입력 안내, 일반 본문입니다. |
40
+ | Caption | `eds-typography-caption1` … `eds-typography-caption3` | 보조 설명, 메타데이터, 짧은 상태 안내입니다. |
41
+
42
+ 한글과 영문이 함께 있는 화면에서도 실제 Pretendard preset을 그대로 사용합니다. 문장의 중요도와 정보 구조를 먼저 판단하고 글자 크기만 임의로 조정하지 않습니다.
43
+
44
+ ## Tailwind className 예시
45
+
46
+ ```tsx
47
+ <section className="bg-eds-bg-neutral-primary text-eds-fg-neutral">
48
+ <h2 className="eds-typography-heading2 text-eds-fg-neutral">
49
+ 카메라 그룹
50
+ </h2>
51
+ <p className="eds-typography-body2 text-eds-fg-neutral-secondary">
52
+ 등록된 장치를 관리합니다.
53
+ </p>
54
+ <div className="border border-eds-stroke-neutral-secondary" />
55
+ </section>
56
+ ```
57
+
58
+ 새 UI는 위처럼 `className`으로 조합합니다. 컴포넌트별 규칙을 `index.css`에 추가하거나 공개 token의 값을 복사하지 않습니다.
59
+
60
+ ## Spacing과 radius
61
+
62
+ 현재 공개 semantic token에는 소비처용 spacing·radius alias가 없습니다. 소비처 Tailwind의 기본 scale을 사용하고, 기존 scale로 표현할 수 없는 요구가 있을 때만 디자인 시스템 담당자에게 목적과 대체 가능성을 요청합니다.
63
+
64
+ ## 확인 목록
65
+
66
+ - `@spectrabrain/eds/styles.css`가 Tailwind root CSS에서 한 번만 import되어 있습니다. 개별 파일로 구성했다면 `@spectrabrain/eds/tokens.css`, `@spectrabrain/eds/components.css`, `@spectrabrain/eds/fonts.css`가 함께 import되어 있고 `@spectrabrain/eds/base.css`는 선택입니다. EDS text style이 참조하는 font family는 EDS `fonts.css`가 선언하므로 다른 출처의 Pretendard로 대신하지 않습니다.
67
+ - 생성된 `eds-tokens.css`가 설치된 `dist/tokens.json`의 공개 이름만 참조합니다.
68
+ - 색상은 `fg/bg/stroke` 역할과 상태를 설명할 수 있습니다.
69
+ - typography preset은 다섯 속성을 함께 적용합니다.
70
+ - 새 컴포넌트가 EDS component를 대체 구현하고 있지 않습니다.
71
+ - raw 색상·typography·임의 CSS variable이 새 코드에 없습니다.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ import { runDoctor } from "./doctor.mjs";
3
+ import { runInit } from "./init.mjs";
4
+ import { runSync } from "./sync.mjs";
5
+
6
+ const usage = [
7
+ "Usage: eds <command> [options]",
8
+ "",
9
+ "Commands:",
10
+ " init Register sync:eds, eds:doctor and doctor hooks in package.json, approve the postinstall",
11
+ " for the detected package manager (npm 12+ writes allowScripts; others print steps), then run sync",
12
+ " sync Generate src/styles/eds-tokens.css, wire the Tailwind root CSS and update AGENTS.md",
13
+ " and the @AGENTS.md import in CLAUDE.md and GEMINI.md when they exist (without a Tailwind root only those change)",
14
+ " doctor Check whether sync is up to date, the EDS font is imported and every eds-* class in the sources",
15
+ " is a public token (exit 1 when something is off)",
16
+ "",
17
+ "Options:",
18
+ " --only tailwind|agents sync: apply only that part",
19
+ " --dry-run Print the files sync or init would write without writing them",
20
+ " --no-sync init: register scripts only",
21
+ " --no-allow-scripts init: do not write allowScripts to package.json on npm 12+",
22
+ " --print-ci github|gitlab init: print a CI job that runs npx eds doctor, then exit",
23
+ " --warn doctor: report findings but exit 0 (same as EDS_DOCTOR=warn)",
24
+ " --scan doctor: also list Tailwind palette and arbitrary color classes that bypass the EDS tokens",
25
+ " --help Show this message",
26
+ "",
27
+ "Environment:",
28
+ " EDS_TAILWIND_ROOT=src/index.css Tailwind root CSS when it cannot be detected (needed once: sync records",
29
+ " it in package.json \"eds\": { \"tailwindRoot\" }, which doctor and the hooks reuse)",
30
+ " EDS_DOCTOR=warn|off Downgrade or silence doctor failures",
31
+ ].join("\n");
32
+
33
+ function parseArguments(argv) {
34
+ const [command, ...rest] = argv;
35
+ const options = { only: null, dryRun: false, noSync: false, allowScripts: true, printCi: null, warn: false, scan: false, help: false };
36
+ for (let index = 0; index < rest.length; index += 1) {
37
+ const argument = rest[index];
38
+ if (argument === "--dry-run") {
39
+ options.dryRun = true;
40
+ } else if (argument === "--no-sync") {
41
+ options.noSync = true;
42
+ } else if (argument === "--no-allow-scripts") {
43
+ options.allowScripts = false;
44
+ } else if (argument === "--warn") {
45
+ options.warn = true;
46
+ } else if (argument === "--scan") {
47
+ options.scan = true;
48
+ } else if (argument === "--help" || argument === "-h") {
49
+ options.help = true;
50
+ } else if (argument === "--only") {
51
+ index += 1;
52
+ options.only = rest[index] ?? "";
53
+ } else if (argument.startsWith("--only=")) {
54
+ options.only = argument.slice("--only=".length);
55
+ } else if (argument === "--print-ci") {
56
+ index += 1;
57
+ options.printCi = rest[index] ?? "";
58
+ } else if (argument.startsWith("--print-ci=")) {
59
+ options.printCi = argument.slice("--print-ci=".length);
60
+ } else {
61
+ throw new Error("Unknown option: " + argument);
62
+ }
63
+ }
64
+ return { command, options };
65
+ }
66
+
67
+ async function main() {
68
+ const consumerRoot = process.cwd();
69
+ let parsed;
70
+ try {
71
+ parsed = parseArguments(process.argv.slice(2));
72
+ } catch (error) {
73
+ console.error(error.message + "\n\n" + usage);
74
+ return 2;
75
+ }
76
+ const { command, options } = parsed;
77
+
78
+ if (command === "--help" || command === "-h" || options.help) {
79
+ console.log(usage);
80
+ return 0;
81
+ }
82
+ if (command === "sync") {
83
+ return runSync({ consumerRoot, only: options.only, dryRun: options.dryRun });
84
+ }
85
+ if (command === "doctor") {
86
+ return runDoctor({ consumerRoot, warn: options.warn, scan: options.scan });
87
+ }
88
+ if (command === "init") {
89
+ return runInit({
90
+ consumerRoot,
91
+ noSync: options.noSync,
92
+ dryRun: options.dryRun,
93
+ allowScripts: options.allowScripts,
94
+ printCi: options.printCi,
95
+ });
96
+ }
97
+
98
+ console.error((command ? "Unknown command: " + command + "\n\n" : "") + usage);
99
+ return 2;
100
+ }
101
+
102
+ try {
103
+ process.exitCode = await main();
104
+ } catch (error) {
105
+ console.error("[EDS] " + (error instanceof Error ? error.message : String(error)));
106
+ process.exitCode = typeof error?.exitCode === "number" ? error.exitCode : 1;
107
+ }
@@ -0,0 +1,239 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ createExitError,
5
+ detectEol,
6
+ detectPackageManager,
7
+ generatedCssRelativePath,
8
+ listCssFiles,
9
+ listHtmlFiles,
10
+ listScriptFiles,
11
+ readPackageJson,
12
+ relativeImportPath,
13
+ relativeTo,
14
+ runnerCommand,
15
+ toPosix,
16
+ } from "./paths.mjs";
17
+
18
+ export const tailwindRootEnvironmentVariable = "EDS_TAILWIND_ROOT";
19
+ // Consumer package.json: "eds": { "tailwindRoot": "src/index.css" }. Written by sync when the override is used.
20
+ export const consumerConfigField = "eds";
21
+ export const tailwindRootConfigKey = "tailwindRoot";
22
+ export const stylesImportLine = '@import "@spectrabrain/eds/styles.css";';
23
+ export const fontsImportLine = '@import "@spectrabrain/eds/fonts.css";';
24
+
25
+ // Accepts the canonical `@import "tailwindcss";`, the granular `tailwindcss/theme.css` and
26
+ // `tailwindcss/utilities.css` entries (Preflight opt-out) and the `@import url("tailwindcss")` form.
27
+ const tailwindSpecifier = "tailwindcss(?:\\/(?:theme|utilities)(?:\\.css)?)?";
28
+ const tailwindImportSource = "^[ \\t]*@import\\s+(?:url\\(\\s*)?[\"']" + tailwindSpecifier + "[\"']\\s*\\)?[^;]*;[^\\r\\n]*";
29
+ const tailwindImportPattern = new RegExp(tailwindImportSource, "m");
30
+ const tailwindImportPatternGlobal = new RegExp(tailwindImportSource, "gm");
31
+ const cssImportPattern = /^[ \t]*@import\s+(?:url\()?["']([^"']+)["']/gm;
32
+ const scriptImportPattern = /^[ \t]*import\s+["']([^"']+)["']/gm;
33
+ const legacyGeneratedImportPattern = /^[ \t]*import\s+["'][^"']*eds-tokens\.css["'];?[ \t]*(?:\r?\n|$)/gm;
34
+ const cssCommentPattern = /\/\*[\s\S]*?\*\//g;
35
+ const pretendardPattern = /pretendard/i;
36
+ const packageReferencePattern = /@spectrabrain\/eds\//;
37
+
38
+ // Blanks `/* ... */` comments while keeping every index and line break, so a commented-out import is
39
+ // neither a Tailwind root nor a satisfied EDS import, and match positions still apply to the original.
40
+ export function stripCssComments(contents) {
41
+ return contents.replace(cssCommentPattern, (comment) => comment.replace(/[^\r\n]/g, " "));
42
+ }
43
+
44
+ function collectImports(contents, pattern) {
45
+ return [...contents.matchAll(pattern)].map((match) => match[1]);
46
+ }
47
+
48
+ function hasTailwindImport(contents) {
49
+ return tailwindImportPattern.test(stripCssComments(contents));
50
+ }
51
+
52
+ function readSources(consumerRoot) {
53
+ const generatedPath = path.join(consumerRoot, generatedCssRelativePath);
54
+ const css = listCssFiles(consumerRoot)
55
+ .filter((filePath) => filePath !== generatedPath)
56
+ .map((filePath) => ({ path: filePath, contents: fs.readFileSync(filePath, "utf8") }));
57
+ const scripts = listScriptFiles(consumerRoot)
58
+ .map((filePath) => ({ path: filePath, contents: fs.readFileSync(filePath, "utf8") }));
59
+ const html = listHtmlFiles(consumerRoot)
60
+ .map((filePath) => ({ path: filePath, contents: fs.readFileSync(filePath, "utf8") }));
61
+ return { generatedPath, css, scripts, html };
62
+ }
63
+
64
+ export function readConfiguredTailwindRoot(consumerRoot) {
65
+ const configured = readPackageJson(consumerRoot)?.[consumerConfigField]?.[tailwindRootConfigKey];
66
+ if (configured === undefined || configured === null) {
67
+ return null;
68
+ }
69
+ if (typeof configured !== "string" || configured.trim() === "") {
70
+ throw createExitError("package.json " + consumerConfigField + "." + tailwindRootConfigKey + " must be a relative path string, for example \"src/index.css\".", 2);
71
+ }
72
+ return configured;
73
+ }
74
+
75
+ function resolveExplicitRoot(consumerRoot, relative, { source, label, remedy }) {
76
+ const rootPath = path.resolve(consumerRoot, relative);
77
+ if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isFile()) {
78
+ throw createExitError(label + " does not point to an existing file: " + relative + ". " + remedy, 2);
79
+ }
80
+ const contents = fs.readFileSync(rootPath, "utf8");
81
+ const relativePath = relativeTo(consumerRoot, rootPath);
82
+ return {
83
+ path: rootPath,
84
+ relativePath,
85
+ contents,
86
+ source,
87
+ warning: hasTailwindImport(contents)
88
+ ? null
89
+ : relativePath + ' does not import "tailwindcss"; EDS imports will be inserted at the top of the file.',
90
+ };
91
+ }
92
+
93
+ // Resolution order: EDS_TAILWIND_ROOT, then package.json eds.tailwindRoot, then auto-detection.
94
+ // Returns null when the consumer has no Tailwind root at all: sync then skips the Tailwind wiring.
95
+ // Two or more candidates and an explicit path that does not exist still exit 2.
96
+ export function findTailwindRoot(
97
+ consumerRoot,
98
+ env = process.env,
99
+ sources = readSources(consumerRoot),
100
+ packageManager = detectPackageManager(consumerRoot, env),
101
+ ) {
102
+ const override = env[tailwindRootEnvironmentVariable];
103
+ if (override) {
104
+ return resolveExplicitRoot(consumerRoot, override, {
105
+ source: "env",
106
+ label: tailwindRootEnvironmentVariable,
107
+ remedy: "Point it at the CSS file that imports tailwindcss.",
108
+ });
109
+ }
110
+
111
+ const configured = readConfiguredTailwindRoot(consumerRoot);
112
+ if (configured !== null) {
113
+ return resolveExplicitRoot(consumerRoot, toPosix(configured), {
114
+ source: "config",
115
+ label: "package.json " + consumerConfigField + "." + tailwindRootConfigKey,
116
+ remedy: "Update or remove that key, or run " + tailwindRootEnvironmentVariable + "=<file> " +
117
+ runnerCommand(packageManager, "sync:eds") + " once to record the new path.",
118
+ });
119
+ }
120
+
121
+ const candidates = sources.css.filter((file) => hasTailwindImport(file.contents));
122
+ if (candidates.length === 0) {
123
+ return null;
124
+ }
125
+ if (candidates.length > 1) {
126
+ const found = candidates.map((file) => relativeTo(consumerRoot, file.path));
127
+ throw createExitError(
128
+ "Could not identify one Tailwind root CSS file (found " + found.length + ": " + found.join(", ") + "). " +
129
+ "Set " + tailwindRootEnvironmentVariable + ", for example " +
130
+ tailwindRootEnvironmentVariable + "=src/index.css " + runnerCommand(packageManager, "sync:eds") + ". " +
131
+ "sync records the choice in package.json as \"" + consumerConfigField + "\": { \"" + tailwindRootConfigKey +
132
+ "\": \"src/index.css\" } so doctor and the hooks reuse it.",
133
+ 2,
134
+ );
135
+ }
136
+
137
+ const [root] = candidates;
138
+ return { path: root.path, relativePath: relativeTo(consumerRoot, root.path), contents: root.contents, source: "detected", warning: null };
139
+ }
140
+
141
+ export function importsPackageFile(sources, fileName) {
142
+ const pattern = new RegExp("^@spectrabrain/eds/" + fileName.replace(".", "\\.") + "$");
143
+ const cssHit = sources.css.some((file) => collectImports(stripCssComments(file.contents), cssImportPattern).some((entry) => pattern.test(entry)));
144
+ const scriptHit = sources.scripts.some((file) => collectImports(file.contents, scriptImportPattern).some((entry) => pattern.test(entry)));
145
+ return cssHit || scriptHit;
146
+ }
147
+
148
+ // EDS text styles reference the font family "Pretendard" from fonts.css, so a consumer that opts out of
149
+ // styles.css must import tokens.css, components.css and fonts.css. Returns the import lines still missing.
150
+ export function planPackageImports(sources) {
151
+ const hasStyles = importsPackageFile(sources, "styles.css");
152
+ const hasTokensAndComponents = importsPackageFile(sources, "tokens.css") && importsPackageFile(sources, "components.css");
153
+ const hasFonts = importsPackageFile(sources, "fonts.css");
154
+ if (!hasStyles && !hasTokensAndComponents) {
155
+ return [stylesImportLine];
156
+ }
157
+ if (!hasStyles && !hasFonts) {
158
+ return [fontsImportLine];
159
+ }
160
+ return [];
161
+ }
162
+
163
+ // Files (other than the EDS imports themselves) that mention Pretendard: a CDN link, a second @font-face
164
+ // or a font-family declaration. Reported by doctor as a note only.
165
+ export function findPretendardReferences(consumerRoot, sources) {
166
+ return [...sources.html, ...sources.css]
167
+ .filter((file) => file.contents.split(/\r?\n/).some((line) => pretendardPattern.test(line) && !packageReferencePattern.test(line)))
168
+ .map((file) => relativeTo(consumerRoot, file.path));
169
+ }
170
+
171
+ function importsGeneratedFile(root, generatedPath) {
172
+ return collectImports(stripCssComments(root.contents), cssImportPattern).some((specifier) => {
173
+ if (!specifier.startsWith(".")) {
174
+ return false;
175
+ }
176
+ return path.resolve(path.dirname(root.path), specifier) === generatedPath;
177
+ });
178
+ }
179
+
180
+ // End index of the last Tailwind import line (comments ignored), or null. With granular imports the EDS
181
+ // lines go after the last one so they follow both theme.css and utilities.css.
182
+ function lastTailwindImportEnd(contents) {
183
+ let end = null;
184
+ for (const match of stripCssComments(contents).matchAll(tailwindImportPatternGlobal)) {
185
+ end = match.index + match[0].length;
186
+ }
187
+ return end;
188
+ }
189
+
190
+ export function planCssRootImports({ consumerRoot, root, sources }) {
191
+ const generatedPath = sources.generatedPath;
192
+ const generatedImportLine = '@import "' + relativeImportPath(root.path, generatedPath) + '";';
193
+ const missing = planPackageImports(sources);
194
+ if (!importsGeneratedFile(root, generatedPath)) {
195
+ missing.push(generatedImportLine);
196
+ }
197
+
198
+ const eol = detectEol(root.contents);
199
+ let after = root.contents;
200
+ if (missing.length > 0) {
201
+ const lineEnd = lastTailwindImportEnd(root.contents);
202
+ if (lineEnd !== null) {
203
+ const lineBreak = root.contents.slice(lineEnd, lineEnd + eol.length) === eol ? eol : "";
204
+ const inserted = missing.join(eol);
205
+ after = root.contents.slice(0, lineEnd) + eol + inserted + (lineBreak ? "" : eol) + root.contents.slice(lineEnd);
206
+ } else {
207
+ after = missing.join(eol) + eol + root.contents;
208
+ }
209
+ }
210
+
211
+ return {
212
+ label: "css-root",
213
+ path: root.path,
214
+ relativePath: relativeTo(consumerRoot, root.path),
215
+ before: root.contents,
216
+ after,
217
+ missing,
218
+ };
219
+ }
220
+
221
+ export function planLegacyJsImportCleanup({ consumerRoot }) {
222
+ const sourceDirectory = path.join(consumerRoot, "src");
223
+ return listScriptFiles(sourceDirectory).flatMap((filePath) => {
224
+ const before = fs.readFileSync(filePath, "utf8");
225
+ const after = before.replace(legacyGeneratedImportPattern, "");
226
+ if (after === before) {
227
+ return [];
228
+ }
229
+ return [{
230
+ label: "legacy-js-import",
231
+ path: filePath,
232
+ relativePath: relativeTo(consumerRoot, filePath),
233
+ before,
234
+ after,
235
+ }];
236
+ });
237
+ }
238
+
239
+ export { readSources };
@@ -0,0 +1,178 @@
1
+ import { findPretendardReferences, planPackageImports, stylesImportLine, fontsImportLine } from "./css-root.mjs";
2
+ import { readGeneratedVersion } from "./generate-tailwind.mjs";
3
+ import { legacyVersionLabel } from "./managed-block.mjs";
4
+ import { generatedCssRelativePath, normalizeEol, readJson, readPackageJson, runnerCommand, toPosix } from "./paths.mjs";
5
+ import { doctorCommand, doctorScriptName, isCompatibleCommand, syncCommand, syncScriptName } from "./register-scripts.mjs";
6
+ import { scanClassNames } from "./scan-classes.mjs";
7
+ import { collectPlans, noTailwindNote } from "./sync.mjs";
8
+
9
+ export const doctorEnvironmentVariable = "EDS_DOCTOR";
10
+
11
+ const fontGuidance = {
12
+ [stylesImportLine]: "missing: @spectrabrain/eds/styles.css import in CSS or JS (or tokens.css + components.css + fonts.css); EDS text styles need the bundled Pretendard font",
13
+ [fontsImportLine]: "missing: @spectrabrain/eds/fonts.css import next to tokens.css and components.css; EDS text styles reference the font family \"Pretendard\" from fonts.css",
14
+ };
15
+
16
+ export function collectFindings({ consumerRoot, env = process.env }) {
17
+ const plans = collectPlans({ consumerRoot, env });
18
+ const installed = plans.pkg.version;
19
+ const generatedRelativePath = toPosix(generatedCssRelativePath);
20
+ const findings = [];
21
+ const notes = [];
22
+ let rawColors = null;
23
+
24
+ const scripts = readPackageJson(consumerRoot)?.scripts ?? {};
25
+ for (const [name, command] of [[syncScriptName, syncCommand], [doctorScriptName, doctorCommand]]) {
26
+ if (isCompatibleCommand(scripts[name], command)) {
27
+ continue;
28
+ }
29
+ findings.push(
30
+ scripts[name] === undefined
31
+ ? "scripts: " + name + " is not registered; run npx @spectrabrain/eds init"
32
+ : "scripts: " + name + " runs " + JSON.stringify(scripts[name]) + " instead of \"" + command +
33
+ "\"; rename that script and run npx @spectrabrain/eds init, or make it run \"" + command + "\"",
34
+ );
35
+ }
36
+ // The fix line may only point at sync:eds when that script really runs eds sync.
37
+ const syncRegistered = isCompatibleCommand(scripts[syncScriptName], syncCommand);
38
+
39
+ if (plans.root === null) {
40
+ notes.push(noTailwindNote);
41
+ // Without a Tailwind root, sync cannot insert the imports, so doctor states the requirement directly.
42
+ for (const line of planPackageImports(plans.sources)) {
43
+ findings.push(fontGuidance[line]);
44
+ }
45
+ } else {
46
+ if (plans.tailwind.before === null) {
47
+ findings.push("missing: " + generatedRelativePath);
48
+ } else if (normalizeEol(plans.tailwind.before) !== normalizeEol(plans.tailwind.after)) {
49
+ // Line endings are ignored: an autocrlf checkout of the LF generated file is not stale.
50
+ const generatedBy = readGeneratedVersion(plans.tailwind.before);
51
+ findings.push(
52
+ generatedBy === null
53
+ ? "stale: " + generatedRelativePath + " has no @spectrabrain/eds version header (installed " + installed + ")"
54
+ : generatedBy === installed
55
+ ? "stale: " + generatedRelativePath + " differs from the output of @spectrabrain/eds@" + installed
56
+ : "stale: " + generatedRelativePath + " was generated by @spectrabrain/eds@" + generatedBy + " (installed " + installed + ")",
57
+ );
58
+ }
59
+
60
+ for (const line of plans.cssRoot.missing) {
61
+ findings.push("missing: " + line + " in " + plans.cssRoot.relativePath);
62
+ }
63
+
64
+ for (const write of plans.legacyImports) {
65
+ findings.push("legacy: " + write.relativePath + " imports eds-tokens.css from JavaScript (Tailwind cannot compile it)");
66
+ }
67
+
68
+ // Tailwind drops a class no token backs without a word, so an unknown eds-* name is always a finding.
69
+ const classScan = scanClassNames({
70
+ consumerRoot,
71
+ manifest: readJson(plans.pkg.resolve("tokens.json")),
72
+ files: [...plans.sources.scripts, ...plans.sources.css, ...plans.sources.html],
73
+ });
74
+ for (const hit of classScan.unknown) {
75
+ findings.push("unknown: " + hit.location + " " + hit.className + " is not a public EDS class; check tokens.json or " + generatedRelativePath);
76
+ }
77
+ rawColors = classScan.raw;
78
+ }
79
+
80
+ for (const filePath of findPretendardReferences(consumerRoot, plans.sources)) {
81
+ notes.push(filePath + " references Pretendard outside @spectrabrain/eds; EDS text styles use the bundled font, so the extra font source can be removed.");
82
+ }
83
+
84
+ for (const error of plans.errors) {
85
+ findings.push("invalid: " + error.message);
86
+ }
87
+ if (plans.agents) {
88
+ if (plans.agents.existingBlock === null) {
89
+ findings.push("missing: AGENTS.md managed block");
90
+ } else if (normalizeEol(plans.agents.existingBlock) !== normalizeEol(plans.agents.block)) {
91
+ const blockVersion = plans.agents.existingVersion ?? legacyVersionLabel;
92
+ findings.push(
93
+ blockVersion === installed
94
+ ? "stale: AGENTS.md managed block differs from @spectrabrain/eds@" + installed
95
+ : "stale: AGENTS.md managed block is for " + blockVersion + " (installed " + installed + ")",
96
+ );
97
+ }
98
+ }
99
+
100
+ if (plans.claude && normalizeEol(plans.claude.before) !== normalizeEol(plans.claude.after)) {
101
+ findings.push("missing: @AGENTS.md import in CLAUDE.md");
102
+ }
103
+ if (plans.gemini && normalizeEol(plans.gemini.before) !== normalizeEol(plans.gemini.after)) {
104
+ findings.push("missing: @./AGENTS.md import in GEMINI.md");
105
+ }
106
+
107
+ return { plans, findings, notes, installed, generatedRelativePath, syncRegistered, rawColors };
108
+ }
109
+
110
+ const rawColorLineLimit = 40;
111
+
112
+ // --scan lists the classes that bypass the EDS tokens. It never changes the exit code: legacy code may keep them.
113
+ function logRawColors(rawColors, log) {
114
+ if (rawColors === null) {
115
+ log("[EDS] scan: skipped (no Tailwind root)");
116
+ return;
117
+ }
118
+ if (rawColors.length === 0) {
119
+ log("[EDS] scan: no raw color classes found");
120
+ return;
121
+ }
122
+ const files = new Set(rawColors.map((hit) => hit.location.split(":")[0]));
123
+ log(
124
+ "[EDS] scan: " + rawColors.length + " raw color class" + (rawColors.length === 1 ? "" : "es") + " in " + files.size +
125
+ " file" + (files.size === 1 ? "" : "s") + " bypass the EDS tokens (informational; prefer text-eds-fg-*, bg-eds-bg-*, border-eds-stroke-*)",
126
+ );
127
+ for (const hit of rawColors.slice(0, rawColorLineLimit)) {
128
+ log("[EDS] raw: " + hit.location + " " + hit.className);
129
+ }
130
+ if (rawColors.length > rawColorLineLimit) {
131
+ log("[EDS] raw: … " + (rawColors.length - rawColorLineLimit) + " more");
132
+ }
133
+ }
134
+
135
+ export function runDoctor({ consumerRoot, env = process.env, warn = false, scan = false, log = console.log }) {
136
+ const mode = env[doctorEnvironmentVariable];
137
+ if (mode === "off") {
138
+ return 0;
139
+ }
140
+ const downgrade = warn || mode === "warn";
141
+
142
+ let result;
143
+ try {
144
+ result = collectFindings({ consumerRoot, env });
145
+ } catch (error) {
146
+ log("[EDS] " + error.message);
147
+ if (error.exitCode === 2) {
148
+ return downgrade ? 0 : 2;
149
+ }
150
+ throw error;
151
+ }
152
+
153
+ for (const note of result.notes) {
154
+ log("[EDS] note: " + note);
155
+ }
156
+ if (scan) {
157
+ logRawColors(result.rawColors, log);
158
+ }
159
+
160
+ if (result.findings.length === 0) {
161
+ const syncedPaths = result.plans.root === null
162
+ ? ["AGENTS.md"]
163
+ : [result.plans.root.relativePath, result.generatedRelativePath, "AGENTS.md"];
164
+ log("[EDS] synced with @spectrabrain/eds@" + result.installed + ": " + syncedPaths.join(", "));
165
+ return 0;
166
+ }
167
+
168
+ for (const finding of result.findings) {
169
+ log("[EDS] " + finding);
170
+ }
171
+ const fixCommand = result.syncRegistered ? runnerCommand(result.plans.packageManager, syncScriptName) : "npx eds sync";
172
+ log(
173
+ "[EDS] Run " + fixCommand + " to fix. (" +
174
+ doctorEnvironmentVariable + "=warn or eds doctor --warn downgrades this to a warning.)",
175
+ );
176
+ log("[EDS] Then read the AGENTS.md managed block and QUICKSTART.md in the package root before UI work.");
177
+ return downgrade ? 0 : 1;
178
+ }
@@ -0,0 +1,83 @@
1
+ import path from "node:path";
2
+ import { generatedCssRelativePath, readJson, readTextIfExists, relativeTo } from "./paths.mjs";
3
+
4
+ const propertyNames = [
5
+ "fontFamily",
6
+ "fontWeight",
7
+ "fontSize",
8
+ "lineHeight",
9
+ "letterSpacing",
10
+ ];
11
+
12
+ export const fontSansStyleName = "body1";
13
+ export function renderTailwindCss(manifest, version) {
14
+ const tokens = Object.keys(manifest.tokens ?? {});
15
+ const colorNames = tokens.filter((name) => name.startsWith("color-"));
16
+ const typography = new Map();
17
+
18
+ for (const name of tokens.filter((entry) => entry.startsWith("typography-"))) {
19
+ const match = name.match(/^typography-(.+)-(fontFamily|fontWeight|fontSize|lineHeight|letterSpacing)$/);
20
+ if (!match) {
21
+ throw new Error(`Unsupported typography token name: ${name}`);
22
+ }
23
+
24
+ const [, styleName, propertyName] = match;
25
+ const properties = typography.get(styleName) ?? new Set();
26
+ properties.add(propertyName);
27
+ typography.set(styleName, properties);
28
+ }
29
+
30
+ for (const [styleName, properties] of typography) {
31
+ for (const propertyName of propertyNames) {
32
+ if (!properties.has(propertyName)) {
33
+ throw new Error(`Typography preset ${styleName} is missing ${propertyName}.`);
34
+ }
35
+ }
36
+ }
37
+
38
+ if (!typography.has(fontSansStyleName)) {
39
+ throw new Error(`Typography preset ${fontSansStyleName} is required for --font-sans.`);
40
+ }
41
+
42
+ const colorAliases = colorNames
43
+ .map((name) => ` --color-eds-${name.slice("color-".length)}: var(--eds-${name});`)
44
+ .join("\n");
45
+ // Last line of the @theme block: Tailwind's default font family becomes the EDS body font.
46
+ const fontSans = ` --font-sans: var(--eds-typography-${fontSansStyleName}-fontFamily);`;
47
+ const typographyUtilities = [...typography.keys()].sort().map((styleName) => [
48
+ `@utility eds-typography-${styleName} {`,
49
+ ` font-family: var(--eds-typography-${styleName}-fontFamily);`,
50
+ ` font-weight: var(--eds-typography-${styleName}-fontWeight);`,
51
+ ` font-size: var(--eds-typography-${styleName}-fontSize);`,
52
+ ` line-height: var(--eds-typography-${styleName}-lineHeight);`,
53
+ ` letter-spacing: var(--eds-typography-${styleName}-letterSpacing);`,
54
+ `}`,
55
+ ].join("\n")).join("\n\n");
56
+
57
+ return [
58
+ `/* Generated from @spectrabrain/eds@${version}. Do not edit manually. */`,
59
+ "@theme inline {",
60
+ colorAliases,
61
+ fontSans,
62
+ "}",
63
+ "",
64
+ typographyUtilities,
65
+ "",
66
+ ].join("\n");
67
+ }
68
+
69
+ export function readGeneratedVersion(contents) {
70
+ return contents.match(/^\/\* Generated from @spectrabrain\/eds@(\S+)\. Do not edit manually\. \*\//m)?.[1] ?? null;
71
+ }
72
+
73
+ export function planTailwind({ consumerRoot, pkg }) {
74
+ const manifest = readJson(pkg.resolve("tokens.json"));
75
+ const filePath = path.join(consumerRoot, generatedCssRelativePath);
76
+ return {
77
+ label: "tailwind",
78
+ path: filePath,
79
+ relativePath: relativeTo(consumerRoot, filePath),
80
+ before: readTextIfExists(filePath),
81
+ after: renderTailwindCss(manifest, pkg.version),
82
+ };
83
+ }