@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,70 @@
1
+ import { relativeTo } from "./paths.mjs";
2
+
3
+ // Tailwind utilities that take a color. A class such as hover:bg-eds-bg-brand-primary/80 is one token here.
4
+ const colorUtility = "(?:text|bg|border|ring|outline|fill|stroke|decoration|divide|from|to|via|accent|caret|shadow|placeholder|inset-ring)";
5
+ const variantPrefix = "(?:[a-z0-9-]+:)*";
6
+ const boundaryBefore = "(?<![\\w-])";
7
+ const boundaryAfter = "(?![\\w-])";
8
+ const opacity = "(?:/[0-9]{1,3})?";
9
+
10
+ const edsColorClass = new RegExp(boundaryBefore + variantPrefix + colorUtility + "-eds-([a-z0-9-]+)" + opacity + boundaryAfter, "g");
11
+ const edsTypographyClass = new RegExp(boundaryBefore + variantPrefix + "eds-typography-([a-z0-9-]+)" + boundaryAfter, "g");
12
+ const rawColorClass = new RegExp(
13
+ boundaryBefore + variantPrefix + colorUtility +
14
+ "-(?:(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]{2,3}|white|black|\\[(?:#|rgba?\\(|hsla?\\(|oklch\\(|oklab\\(|color\\()[^\\]]*\\])" +
15
+ opacity + boundaryAfter,
16
+ "g",
17
+ );
18
+
19
+ // The public class names are derived exactly like eds sync derives the Tailwind aliases from tokens.json.
20
+ export function publicClassNames(manifest) {
21
+ const colors = new Set();
22
+ const typography = new Set();
23
+ for (const name of Object.keys(manifest.tokens ?? {})) {
24
+ if (name.startsWith("color-")) {
25
+ colors.add("eds-" + name.slice("color-".length));
26
+ continue;
27
+ }
28
+ const match = name.match(/^typography-(.+)-(?:fontFamily|fontWeight|fontSize|lineHeight|letterSpacing)$/);
29
+ if (match) {
30
+ typography.add(match[1]);
31
+ }
32
+ }
33
+ return { colors, typography };
34
+ }
35
+
36
+ // files: [{ path, contents }] as readSources() lists them (the generated eds-tokens.css is not among them).
37
+ // unknown: eds-* classes that no public token backs (always a mistake, Tailwind silently drops them).
38
+ // raw: Tailwind palette, white/black and arbitrary color classes that bypass the EDS tokens (informational).
39
+ export function scanClassNames({ consumerRoot, manifest, files }) {
40
+ const { colors, typography } = publicClassNames(manifest);
41
+ const unknown = [];
42
+ const raw = [];
43
+ for (const file of files) {
44
+ const relativePath = relativeTo(consumerRoot, file.path);
45
+ file.contents.split(/\r?\n/).forEach((line, index) => {
46
+ const location = relativePath + ":" + (index + 1);
47
+ for (const match of line.matchAll(edsColorClass)) {
48
+ // "bg-eds-bg-" + name is composed at runtime; the scan cannot judge it.
49
+ if (match[1].endsWith("-")) {
50
+ continue;
51
+ }
52
+ if (!colors.has("eds-" + match[1])) {
53
+ unknown.push({ location, className: match[0] });
54
+ }
55
+ }
56
+ for (const match of line.matchAll(edsTypographyClass)) {
57
+ if (match[1].endsWith("-")) {
58
+ continue;
59
+ }
60
+ if (!typography.has(match[1])) {
61
+ unknown.push({ location, className: match[0] });
62
+ }
63
+ }
64
+ for (const match of line.matchAll(rawColorClass)) {
65
+ raw.push({ location, className: match[0] });
66
+ }
67
+ });
68
+ }
69
+ return { unknown, raw };
70
+ }
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ consumerConfigField,
5
+ findTailwindRoot,
6
+ planCssRootImports,
7
+ planLegacyJsImportCleanup,
8
+ readSources,
9
+ tailwindRootConfigKey,
10
+ tailwindRootEnvironmentVariable,
11
+ } from "./css-root.mjs";
12
+ import { planTailwind } from "./generate-tailwind.mjs";
13
+ import { createExitError, detectPackageManager, generatedCssRelativePath, normalizeEol, resolvePackage, toPosix } from "./paths.mjs";
14
+ import { writeConsumerPackageJson } from "./register-scripts.mjs";
15
+ import { planAgentContext, planClaudeImport, planGeminiImport } from "./update-agent-context.mjs";
16
+
17
+ export const syncTargets = new Set(["tailwind", "agents"]);
18
+ export const noTailwindNote =
19
+ "No Tailwind root CSS found (no @import \"tailwindcss\"); skipped " + toPosix(generatedCssRelativePath) +
20
+ " and the CSS imports. Set " + tailwindRootEnvironmentVariable + "=<file> once to wire Tailwind; sync records it in package.json " +
21
+ consumerConfigField + "." + tailwindRootConfigKey + ".";
22
+
23
+ export function collectPlans({ consumerRoot, env = process.env }) {
24
+ const pkg = resolvePackage(consumerRoot);
25
+ const packageManager = detectPackageManager(consumerRoot, env);
26
+ const sources = readSources(consumerRoot);
27
+ const root = findTailwindRoot(consumerRoot, env, sources, packageManager);
28
+ // No Tailwind root: the Tailwind wiring is skipped entirely (no generated CSS, no root edits).
29
+ const tailwind = root === null ? null : planTailwind({ consumerRoot, pkg });
30
+ const cssRoot = root === null ? null : planCssRootImports({ consumerRoot, root, sources });
31
+ const legacyImports = root === null ? [] : planLegacyJsImportCleanup({ consumerRoot });
32
+ const errors = [];
33
+ let agents = null;
34
+ try {
35
+ agents = planAgentContext({ consumerRoot, pkg, packageManager });
36
+ } catch (error) {
37
+ errors.push(error);
38
+ }
39
+ const claude = planClaudeImport({ consumerRoot });
40
+ const gemini = planGeminiImport({ consumerRoot });
41
+ return { consumerRoot, pkg, packageManager, sources, root, tailwind, cssRoot, legacyImports, agents, claude, gemini, errors };
42
+ }
43
+
44
+ export function planSync({ consumerRoot, env = process.env, only = null }) {
45
+ if (only !== null && !syncTargets.has(only)) {
46
+ throw createExitError("Unknown --only target: " + only + " (use tailwind or agents).", 2);
47
+ }
48
+ const plans = collectPlans({ consumerRoot, env });
49
+ if (plans.errors.length > 0) {
50
+ throw plans.errors[0];
51
+ }
52
+
53
+ const writes = [];
54
+ if ((only === null || only === "tailwind") && plans.root !== null) {
55
+ writes.push(plans.tailwind, plans.cssRoot, ...plans.legacyImports);
56
+ }
57
+ if (only === null || only === "agents") {
58
+ writes.push(plans.agents);
59
+ if (plans.claude) {
60
+ writes.push(plans.claude);
61
+ }
62
+ if (plans.gemini) {
63
+ writes.push(plans.gemini);
64
+ }
65
+ }
66
+ // Like doctor, an EOL-only difference (an autocrlf checkout of the LF generated CSS) is already synced.
67
+ return { ...plans, only, writes: writes.filter((write) => normalizeEol(write.before) !== normalizeEol(write.after)) };
68
+ }
69
+
70
+ // EDS_TAILWIND_ROOT is a one-time choice: sync records it as package.json "eds": { "tailwindRoot" } so
71
+ // doctor and the pretypecheck/prebuild hooks resolve the same root without the environment variable.
72
+ function persistTailwindRoot(plan, { dryRun, log }) {
73
+ if (plan.root?.source !== "env" || plan.only === "agents") {
74
+ return false;
75
+ }
76
+ const packagePath = path.join(plan.consumerRoot, "package.json");
77
+ const source = fs.readFileSync(packagePath, "utf8");
78
+ const consumerPackage = JSON.parse(source);
79
+ const existing = consumerPackage[consumerConfigField];
80
+ if (existing !== undefined && (typeof existing !== "object" || existing === null || Array.isArray(existing))) {
81
+ log("[EDS] package.json " + consumerConfigField + " is not an object; " + tailwindRootEnvironmentVariable + " was not recorded there.");
82
+ return false;
83
+ }
84
+ const value = plan.root.relativePath;
85
+ const label = consumerConfigField + "." + tailwindRootConfigKey + " = " + JSON.stringify(value);
86
+ if (existing?.[tailwindRootConfigKey] === value) {
87
+ return false;
88
+ }
89
+ if (dryRun) {
90
+ log("[EDS] would write " + label + " to package.json");
91
+ return false;
92
+ }
93
+ consumerPackage[consumerConfigField] = { ...(existing ?? {}), [tailwindRootConfigKey]: value };
94
+ writeConsumerPackageJson(packagePath, source, consumerPackage);
95
+ log("[EDS] wrote " + label + " to package.json; doctor, sync and the hooks now use this Tailwind root without " + tailwindRootEnvironmentVariable + ".");
96
+ return true;
97
+ }
98
+
99
+ export function applySync(plan, { dryRun = false, log = console.log } = {}) {
100
+ const version = "@spectrabrain/eds@" + plan.pkg.version;
101
+ if (plan.root === null && plan.only !== "agents") {
102
+ log("[EDS] " + noTailwindNote);
103
+ } else if (plan.root?.warning) {
104
+ log("[EDS] " + plan.root.warning);
105
+ }
106
+ persistTailwindRoot(plan, { dryRun, log });
107
+ if (plan.writes.length === 0) {
108
+ log("[EDS] Already synced with " + version + ".");
109
+ return [];
110
+ }
111
+ for (const write of plan.writes) {
112
+ if (dryRun) {
113
+ log("[EDS] would write " + write.relativePath);
114
+ continue;
115
+ }
116
+ fs.mkdirSync(path.dirname(write.path), { recursive: true });
117
+ fs.writeFileSync(write.path, write.after);
118
+ log("[EDS] wrote " + write.relativePath);
119
+ }
120
+ if (!dryRun) {
121
+ log(
122
+ "[EDS] Synced with " + version + ". Review the diff (AGENTS.md, CSS imports, " + toPosix(generatedCssRelativePath) +
123
+ ") before committing; the AGENTS.md block lists the rules.",
124
+ );
125
+ }
126
+ return plan.writes.map((write) => write.relativePath);
127
+ }
128
+
129
+ export function runSync({ consumerRoot, env = process.env, only = null, dryRun = false, log = console.log }) {
130
+ const plan = planSync({ consumerRoot, env, only });
131
+ applySync(plan, { dryRun, log });
132
+ return 0;
133
+ }
@@ -0,0 +1,87 @@
1
+ import path from "node:path";
2
+ import { applyBlock, locateBlock, maskFencedBlocks, readBlockVersion, readTemplateBlock, renderBlock } from "./managed-block.mjs";
3
+ import { detectEol, readTextIfExists, realpathOrNull, relativeTo, withEol } from "./paths.mjs";
4
+
5
+ export const claudeBeginMarker = "<!-- EDS:CLAUDE:BEGIN -->";
6
+ export const claudeEndMarker = "<!-- EDS:CLAUDE:END -->";
7
+ export const claudeImportLine = "@AGENTS.md";
8
+ export const geminiBeginMarker = "<!-- EDS:GEMINI:BEGIN -->";
9
+ export const geminiEndMarker = "<!-- EDS:GEMINI:END -->";
10
+ // Gemini CLI documents its import syntax with an explicit relative path.
11
+ export const geminiImportLine = "@./AGENTS.md";
12
+
13
+ // Claude Code and Gemini CLI accept `@AGENTS.md` and `@./AGENTS.md` as the same import and ignore fenced code blocks.
14
+ const importPattern = /^[ \t]*@(?:\.\/)?AGENTS\.md[ \t]*$/m;
15
+
16
+ export function planAgentContext({ consumerRoot, pkg, packageManager = null }) {
17
+ const filePath = path.join(consumerRoot, "AGENTS.md");
18
+ const before = readTextIfExists(filePath);
19
+ // The block is rendered with the target file's dominant line ending; a new file uses LF.
20
+ const eol = before === null ? "\n" : detectEol(before);
21
+ const block = withEol(renderBlock(readTemplateBlock(pkg.packageRoot), pkg.version, packageManager), eol);
22
+ const located = before === null ? null : locateBlock(before);
23
+ return {
24
+ label: "agents",
25
+ path: filePath,
26
+ relativePath: relativeTo(consumerRoot, filePath),
27
+ before,
28
+ after: applyBlock(before, block, eol),
29
+ block,
30
+ existingBlock: located?.block ?? null,
31
+ existingVersion: located ? readBlockVersion(located.block) : null,
32
+ };
33
+ }
34
+
35
+ function hasImport(contents, beginMarker, endMarker) {
36
+ const visible = maskFencedBlocks(contents);
37
+ if (visible.includes(beginMarker) && visible.includes(endMarker)) {
38
+ return true;
39
+ }
40
+ return importPattern.test(visible);
41
+ }
42
+
43
+ // Returns null when the file does not exist, or when it and AGENTS.md are the same file (a symlink in either
44
+ // direction): the managed block already lives in that file and an import would point at itself.
45
+ function planImportFile({ consumerRoot, fileName, label, beginMarker, endMarker, importLine }) {
46
+ const filePath = path.join(consumerRoot, fileName);
47
+ const before = readTextIfExists(filePath);
48
+ if (before === null) {
49
+ return null;
50
+ }
51
+ const realPath = realpathOrNull(filePath);
52
+ const agentsRealPath = realpathOrNull(path.join(consumerRoot, "AGENTS.md"));
53
+ if (realPath !== null && realPath === agentsRealPath) {
54
+ return null;
55
+ }
56
+ const eol = detectEol(before);
57
+ const block = [beginMarker, importLine, endMarker].join(eol);
58
+ const trimmed = before.replace(/\s*$/, "");
59
+ const after = hasImport(before, beginMarker, endMarker)
60
+ ? before
61
+ : (trimmed ? trimmed + eol + eol + block + eol : block + eol);
62
+ return { label, path: filePath, relativePath: relativeTo(consumerRoot, filePath), before, after };
63
+ }
64
+
65
+ // Claude Code reads CLAUDE.md; the import makes it follow the AGENTS.md managed block.
66
+ export function planClaudeImport({ consumerRoot }) {
67
+ return planImportFile({
68
+ consumerRoot,
69
+ fileName: "CLAUDE.md",
70
+ label: "claude",
71
+ beginMarker: claudeBeginMarker,
72
+ endMarker: claudeEndMarker,
73
+ importLine: claudeImportLine,
74
+ });
75
+ }
76
+
77
+ // Gemini CLI reads GEMINI.md by default and AGENTS.md only with a context.fileName setting, so it gets the same import.
78
+ export function planGeminiImport({ consumerRoot }) {
79
+ return planImportFile({
80
+ consumerRoot,
81
+ fileName: "GEMINI.md",
82
+ label: "gemini",
83
+ beginMarker: geminiBeginMarker,
84
+ endMarker: geminiEndMarker,
85
+ importLine: geminiImportLine,
86
+ });
87
+ }
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { registerConsumerScripts } from "./consumer/register-scripts.mjs";
5
+
6
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
7
+ const consumerRoot = process.env.INIT_CWD;
8
+
9
+ if (!consumerRoot || path.resolve(consumerRoot) === packageRoot) {
10
+ process.exit(0);
11
+ }
12
+
13
+ const consumerPackagePath = path.join(consumerRoot, "package.json");
14
+ if (!fs.existsSync(consumerPackagePath)) {
15
+ console.log("[EVA Design System] Consumer package.json was not found; run the documented setup manually.");
16
+ process.exit(0);
17
+ }
18
+
19
+ function realpathOrNull(filePath) {
20
+ try {
21
+ return fs.realpathSync(filePath);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ const installedPath = realpathOrNull(path.join(consumerRoot, "node_modules", "@spectrabrain", "eds"));
28
+ if (installedPath === null || installedPath !== realpathOrNull(packageRoot)) {
29
+ console.log("[EVA Design System] Skipping consumer setup: this install is not the consumer's node_modules/@spectrabrain/eds. Run npx @spectrabrain/eds init from the consumer root if needed.");
30
+ process.exit(0);
31
+ }
32
+
33
+ // pnpm rewrites the consumer package.json after dependency lifecycle scripts, so edits made here are lost.
34
+ if (String(process.env.npm_config_user_agent ?? "").startsWith("pnpm/")) {
35
+ console.log("[EVA Design System] pnpm discards package.json edits made during install; run pnpm exec eds init to register the EDS scripts.");
36
+ process.exit(0);
37
+ }
38
+
39
+ let consumerPackage;
40
+ try {
41
+ consumerPackage = JSON.parse(fs.readFileSync(consumerPackagePath, "utf8"));
42
+ } catch (error) {
43
+ console.log("[EVA Design System] Consumer package.json could not be parsed (" + error.message + "); run npx @spectrabrain/eds init after fixing it.");
44
+ process.exit(0);
45
+ }
46
+
47
+ // A workspaces root hoists the package but is not the app that uses it: registering the scripts and hooks
48
+ // there would make the root build look for src/styles/eds-tokens.css and AGENTS.md in the wrong place.
49
+ if (consumerPackage.workspaces !== undefined) {
50
+ console.log("[EVA Design System] Skipping consumer setup: package.json declares workspaces. Run npx eds init inside the app package that depends on @spectrabrain/eds.");
51
+ process.exit(0);
52
+ }
53
+
54
+ // The install must never fail because of this setup step; anything unexpected is reported and left to eds doctor.
55
+ try {
56
+ registerConsumerScripts(consumerRoot);
57
+ } catch (error) {
58
+ console.log("[EVA Design System] Consumer setup was skipped (" + (error instanceof Error ? error.message : String(error)) + "). Run npx eds init to register the EDS scripts.");
59
+ }
package/dist/base.css ADDED
@@ -0,0 +1,4 @@
1
+ /* This file is generated by scripts/build-tokens.ts. Do not edit directly. */
2
+ :where(html) {
3
+ font-family: var(--eds-typography-body1-fontFamily);
4
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  이 문서는 `Button` 디렉토리의 기준 문서입니다. `Button`의 개요·사용 맥락·공개 API·접근성 계약을 기록합니다. Button을 사용하기 전에는 이 문서를 먼저 확인합니다.
4
4
 
5
- 패키지 설치와 공개 경로는 루트 [README.md](../../../README.md)를, 소비처 AI의 공통 규칙은 루트 [AI.md](../../../AI.md)를 참고합니다. 루트 `AI.md`에는 컴포넌트별 정책을 중복해서 기록하지 않습니다.
5
+ 패키지 설치와 공개 경로는 루트 [README.md](../../../README.md)를, 소비처 AI의 필수 규칙은 루트 [QUICKSTART.md](../../../QUICKSTART.md)를, 공개 범위와 금지 사례는 루트 [AI.md](../../../AI.md)를 참고합니다. 루트 문서에는 컴포넌트별 정책을 중복해서 기록하지 않습니다.
6
6
 
7
7
  ## 개요
8
8
 
@@ -10,6 +10,21 @@
10
10
 
11
11
  탐색 목적의 링크는 `neutral/link` 조합으로 표현하고, 상태 변경·폼 제출·명령 실행은 native `<button>`으로 표현합니다. `Button`은 현재 React 웹과 light theme만 지원합니다.
12
12
 
13
+ 화면에서 반복되는 행동 맥락은 다음처럼 선택합니다.
14
+
15
+ | 행동 맥락 | 우선 검토할 조합 | 설명 |
16
+ | --- | --- | --- |
17
+ | 생성·저장·관리·확인처럼 화면의 핵심 행동 | `brand/solid` | 사용자가 완료해야 할 대표 행동을 강조합니다. 한 화면에 하나를 권장합니다. |
18
+ | 검색·조회·일반적인 주요 실행 | `neutral/solid` | 브랜드 핵심 행동보다 낮은 주목도로 주요 액션을 표현합니다. |
19
+ | 취소·닫기·수정 같은 보조 작업 | `neutral/outline` | 주 행동과 시각적 위계를 분리합니다. |
20
+ | 초기화·갱신·되돌리기처럼 부담이 낮은 반복 행동 | `neutral/weak` | CTA를 보조하면서 반복 가능한 행동을 표현합니다. 되돌리기 어려운 초기화는 `critical/weak`입니다. |
21
+ | 되돌리기 어려운 삭제·초기화 | `critical/weak` | 위험을 분명히 전달하고 가능하면 확인 절차와 함께 사용합니다. |
22
+ | 비동기 저장·확인 처리 | 선택한 조합과 `loading` | 중복 실행을 막고 라벨·아이콘 공간과 accessible name을 유지합니다. |
23
+
24
+ 기존 화면에서 다른 조합을 사용하고 있더라도 새 코드에서는 행동의 의미에 맞는 위계를 먼저 검토합니다. 보조 행동은 한 조합으로 고정합니다. 취소·닫기·수정은 `neutral/outline`, 초기화·갱신·되돌리기처럼 부담이 낮은 반복 행동은 `neutral/weak`입니다. `neutral/link`는 페이지나 콘텐츠로 이동하는 탐색에만 사용합니다.
25
+
26
+ 표 행 조작이나 pagination처럼 공간이 좁은 곳에서는 `iconOnly`를 사용할 수 있습니다. 아이콘만으로 의미가 충분한지 확인하고, 필요한 경우 비어 있지 않은 `aria-label`을 지정합니다. 아이콘은 생성·수정·삭제·외부 이동·다운로드처럼 행동의 목적이나 방향을 보조할 때만 사용합니다.
27
+
13
28
  ## 사용 맥락
14
29
 
15
30
  ### Size
@@ -53,8 +68,8 @@ Variant는 같은 Role 안에서 시각적 위계를 정의합니다.
53
68
  | `brand/solid` | 서비스의 핵심 행동에 사용합니다. 한 화면에 하나를 권장합니다. |
54
69
  | `brand/outline` | `brand/solid`보다 낮은 위계의 브랜드 보조 행동에 사용합니다. `neutral/outline`과 조합할 수 있으며 같은 액션 그룹에서 `brand/solid`와 함께 사용하지 않는 것을 권장합니다. |
55
70
  | `neutral/solid` | `brand/solid` 다음 위계의 일반적인 주요 CTA에 사용합니다. 한 화면에 하나를 권장합니다. |
56
- | `neutral/weak` | CTA를 제외한 일반적인 보조 행동에 사용하며, CTA와 함께 제공할 때 보조 위계를 표현합니다. |
57
- | `neutral/outline` | 시각적 부담을 낮춘 대안이나 보조 행동에 사용합니다. `neutral/solid`과 같은 액션 그룹에서 함께 사용하지 않으며, `brand/outline`과 조합할 수 있습니다. |
71
+ | `neutral/weak` | 초기화·갱신·되돌리기처럼 부담이 낮은 반복 행동에 사용합니다. 취소·닫기·수정은 `neutral/outline`을 사용합니다. |
72
+ | `neutral/outline` | 취소·닫기·수정처럼 주 행동과 위계를 분리하는 보조 행동에 사용합니다. `neutral/solid`과 같은 액션 그룹에서 함께 사용하지 않으며, `brand/outline`과 조합할 수 있습니다. |
58
73
  | `neutral/ghost` | 툴바·헤더·옵션 메뉴처럼 주변 UI와의 간섭을 줄여야 하는 보조 행동에 사용합니다. |
59
74
  | `neutral/link` | 다른 페이지·콘텐츠로 이동하거나 관련 정보를 확인하는 탐색성 행동에 사용합니다. 상태 변경이나 일반 CTA에는 사용하지 않습니다. `href`가 필요하며 `<a>`로 렌더링합니다. |
60
75
  | `critical/weak` | 삭제·초기화처럼 되돌리기 어려운 행동에 사용합니다. 위험을 분명히 전달하며 가능하면 확인 대화상자나 Alert Dialog와 함께 사용합니다. |
@@ -98,9 +113,9 @@ Variant는 같은 Role 안에서 시각적 위계를 정의합니다.
98
113
  | --- | --- |
99
114
  | `children` | `ReactNode` label입니다. 렌더링 가능한 내용이 하나 이상 필요하며 `startIcon`·`endIcon`과 조합할 수 있습니다. |
100
115
  | `icon` | icon-only용 단일 custom `ReactElement`입니다. children이나 start/end icon과 함께 사용하지 않습니다. |
101
- | `iconName` | icon-only용 `ButtonIconName` lucide 이름입니다. |
116
+ | `iconName` | icon-only용 `ButtonIconName` lucide 이름입니다. 승인된 아이콘 목록의 이름만 허용합니다. |
102
117
  | `startIcon` / `endIcon` | label 앞·뒤의 단일 custom `ReactElement` 슬롯입니다. |
103
- | `startIconName` / `endIconName` | label 앞·뒤의 `ButtonIconName` lucide 이름 슬롯입니다. |
118
+ | `startIconName` / `endIconName` | label 앞·뒤의 `ButtonIconName` lucide 이름 슬롯입니다. 승인된 아이콘 목록의 이름만 허용합니다. |
104
119
  | `aria-label` | 선택적인 `string`입니다. icon-only의 accessible name을 제공할 때 지정하며, 필수로 강제하지 않습니다. |
105
120
  | `disabled` | `boolean`입니다. native button은 `disabled`, link는 href 제거·`aria-disabled="true"`·`tabIndex={-1}`를 적용합니다. |
106
121
  | `loading` | `boolean`입니다. label·아이콘 공간을 유지한 progress circle을 표시하고 상호작용을 차단합니다. `aria-busy="true"`를 적용합니다. |
@@ -114,6 +129,10 @@ Variant는 같은 Role 안에서 시각적 위계를 정의합니다.
114
129
 
115
130
  `startIcon`, `endIcon`, `icon`은 단일 `ReactElement` 슬롯입니다. lucide 아이콘은 `startIconName`, `endIconName`, `iconName`으로 PascalCase 전체 이름을 지정합니다. 이름 prop은 `ButtonIconName` 자동완성을 제공하며 custom ReactElement와 같은 슬롯에 함께 지정할 수 없습니다.
116
131
 
132
+ `ButtonIconName`은 EVA 화면에서 실제로 사용하는 lucide 아이콘을 모은 승인된 아이콘 목록입니다. 목록은 `lucide-react`의 canonical 이름만 포함하며 `AlertCircle`처럼 deprecated된 alias는 허용하지 않습니다. 승인된 이름 전체는 설치된 패키지의 공개 `spec.json`의 `api.iconNames`와 내부 Storybook 아이콘 카탈로그에서 확인합니다. 이름으로 지정한 아이콘은 동기적으로 렌더링하며 SSR에서도 SVG를 바로 출력합니다. 목록에 없는 이름은 TypeScript 오류로 막고, 런타임에 전달되면 render 오류로 처리합니다.
133
+
134
+ 승인된 아이콘 목록 밖의 아이콘이 필요하면 `icon`, `startIcon`, `endIcon`에 `ReactElement`로 전달합니다. 같은 아이콘이 두 화면 이상에서 반복되면 승인된 아이콘 목록에 추가하는 PR을 엽니다. 슬롯 아이콘을 `lucide-react`에서 직접 import할 때는 소비처 `package.json`에 `lucide-react`를 EDS와 같은 `1.45.0`으로 선언합니다. pnpm처럼 엄격한 node_modules에서는 선언 없이 resolve되지 않고, 버전이 다르면 아이콘 코드가 두 벌 번들됩니다. 슬롯 아이콘의 크기는 Button이 맞추므로 stroke 두께만 lucide 기본값 `2`로 두면 이름으로 지정한 아이콘과 같아 보입니다.
135
+
117
136
  이름으로 지정한 lucide 아이콘은 `lucide-react`의 기본 시각 설정을 사용합니다. Button은 아이콘 영역의 크기만 조정하며, `color`, `size`, `strokeWidth`, `strokeLinecap`, `strokeLinejoin`을 소비처 prop으로 노출하거나 덮어쓰지 않습니다. `aria-hidden`과 `focusable`은 Button 접근성 contract에 따라 내부에서 설정합니다.
118
137
 
119
138
  - children만 있으면 `textOnly`입니다.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "specVersion": 1,
3
- "packageVersion": "0.0.4",
3
+ "packageVersion": "0.1.0",
4
4
  "name": "Button",
5
5
  "purpose": "사용자가 즉시 실행할 수 있는 행동과 탐색을 표현하는 인터랙티브 요소입니다.",
6
6
  "usageContexts": [
@@ -9,6 +9,14 @@
9
9
  "brand는 화면에서 가장 중요한 행동을 강조할 때 사용합니다.",
10
10
  "neutral은 일반적인 액션과 탐색에 사용합니다.",
11
11
  "critical은 삭제·초기화처럼 되돌리기 어려운 행동에 사용합니다.",
12
+ "생성·저장·관리·확인처럼 화면의 핵심 행동에는 brand/solid를 먼저 검토합니다.",
13
+ "검색·조회·일반적인 주요 실행에는 neutral/solid를 먼저 검토합니다.",
14
+ "취소·닫기·수정 같은 보조 작업에는 neutral/outline을 사용합니다.",
15
+ "초기화·갱신·되돌리기처럼 부담이 낮은 반복 행동에는 neutral/weak를 사용합니다.",
16
+ "되돌리기 어려운 삭제·초기화는 critical/weak와 확인 절차를 먼저 검토합니다.",
17
+ "비동기 저장·확인에는 loading을 사용해 중복 실행을 막고 라벨 공간을 유지합니다.",
18
+ "표 행 조작·pagination처럼 공간이 좁은 곳의 icon-only는 accessible name을 확인합니다.",
19
+ "icon은 생성·수정·삭제·외부 이동·다운로드처럼 행동의 목적이나 방향을 보조할 때 사용합니다.",
12
20
  "icon-only의 접근성 이름을 제공하려면 aria-label을 지정합니다. aria-label은 필수로 강제하지 않습니다."
13
21
  ],
14
22
  "unsupportedContexts": [
@@ -157,6 +165,55 @@
157
165
  "iconBoth": "children과 앞·뒤 아이콘을 모두 제공합니다.",
158
166
  "iconOnly": "children 없이 icon 또는 iconName을 제공합니다. 접근성 이름이 필요하면 aria-label을 지정합니다."
159
167
  },
168
+ "iconNames": [
169
+ "ArrowLeft",
170
+ "ArrowRight",
171
+ "Check",
172
+ "ChevronDown",
173
+ "ChevronLeft",
174
+ "ChevronRight",
175
+ "ChevronUp",
176
+ "ChevronsUpDown",
177
+ "Circle",
178
+ "CircleAlert",
179
+ "CircleCheckBig",
180
+ "CirclePause",
181
+ "CirclePlay",
182
+ "CircleQuestionMark",
183
+ "ClockAlert",
184
+ "Copy",
185
+ "Eye",
186
+ "EyeOff",
187
+ "FaceNeutral",
188
+ "FileDown",
189
+ "FilePenLine",
190
+ "FileX",
191
+ "FolderClock",
192
+ "Fullscreen",
193
+ "ImageOff",
194
+ "ImagePlus",
195
+ "Info",
196
+ "Key",
197
+ "Languages",
198
+ "Layers2",
199
+ "List",
200
+ "LoaderCircle",
201
+ "LockOpen",
202
+ "Mail",
203
+ "Megaphone",
204
+ "Minimize",
205
+ "Minus",
206
+ "Moon",
207
+ "Paperclip",
208
+ "Plus",
209
+ "RotateCw",
210
+ "Search",
211
+ "Sun",
212
+ "ThumbsUp",
213
+ "Trash",
214
+ "VectorSquare",
215
+ "X"
216
+ ],
160
217
  "typeExports": [
161
218
  "ButtonProps",
162
219
  "ButtonComponent",
@@ -245,9 +245,7 @@
245
245
  display: inline-flex;
246
246
  align-items: center;
247
247
  justify-content: center;
248
- flex: 0 0 auto;
249
248
  appearance: none;
250
- margin: 0;
251
249
  border: 0;
252
250
  font: inherit;
253
251
  white-space: nowrap;