@tide-tds/foundation 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/dist/cli.js ADDED
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import process from "node:process";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ // src/color.ts
7
+ const color = {
8
+ white: "#ffffff",
9
+ black: "#000000",
10
+ blue: {
11
+ 50: "#f0f6fe",
12
+ 100: "#ddeafc",
13
+ 200: "#c3dafb",
14
+ 300: "#9bc3fd",
15
+ 400: "#65a1f9",
16
+ 500: "#3183f5",
17
+ 600: "#006ae2",
18
+ 700: "#005ac2",
19
+ 800: "#0049a1",
20
+ 900: "#093f85",
21
+ 950: "#0d2850"
22
+ },
23
+ gray: {
24
+ 50: "#f5f5f6",
25
+ 100: "#e8e8ea",
26
+ 200: "#d7d8da",
27
+ 300: "#bec1c4",
28
+ 400: "#9da1a6",
29
+ 500: "#82878e",
30
+ 600: "#6b7179",
31
+ 700: "#5b6068",
32
+ 800: "#4a4e54",
33
+ 900: "#3f4247",
34
+ 950: "#28292c"
35
+ },
36
+ green: {
37
+ 50: "#f0f7f1",
38
+ 100: "#deeee0",
39
+ 200: "#c4e1c9",
40
+ 300: "#9bd0a5",
41
+ 400: "#60b775",
42
+ 500: "#15a14c",
43
+ 600: "#00873d",
44
+ 700: "#007433",
45
+ 800: "#005f28",
46
+ 900: "#005121",
47
+ 950: "#073315"
48
+ },
49
+ amber: {
50
+ 50: "#faf4ed",
51
+ 100: "#f3e7d7",
52
+ 200: "#ead5b8",
53
+ 300: "#e0ba85",
54
+ 400: "#ce9437",
55
+ 500: "#b47900",
56
+ 600: "#976500",
57
+ 700: "#815600",
58
+ 800: "#6a4600",
59
+ 900: "#5b3b00",
60
+ 950: "#3a2400"
61
+ },
62
+ red: {
63
+ 50: "#fff2f0",
64
+ 100: "#fde1dd",
65
+ 200: "#fccac4",
66
+ 300: "#fca79d",
67
+ 400: "#f47368",
68
+ 500: "#ea3e39",
69
+ 600: "#d30016",
70
+ 700: "#b60011",
71
+ 800: "#96000c",
72
+ 900: "#7d0e10",
73
+ 950: "#4c120f"
74
+ },
75
+ cyan: {
76
+ 50: "#eff7fa",
77
+ 100: "#dcecf2",
78
+ 200: "#c0deea",
79
+ 300: "#93cae0",
80
+ 400: "#51aed0",
81
+ 500: "#0094bc",
82
+ 600: "#007c9e",
83
+ 700: "#006a87",
84
+ 800: "#00576f",
85
+ 900: "#004a5f",
86
+ 950: "#002f3d"
87
+ }
88
+ };
89
+ /**
90
+ * Primitive 이름을 정본 hex로 바꾼다.
91
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
92
+ */
93
+ function primitiveHex(name) {
94
+ if (name === "white" || name === "black") return color[name];
95
+ const index = name.lastIndexOf("-");
96
+ const family = name.slice(0, index);
97
+ const step = Number(name.slice(index + 1));
98
+ return color[family][step];
99
+ }
100
+
101
+ // src/color-slot.ts
102
+ /**
103
+ * 색 계열 변수 묶음의 슬롯. CSS에서는 `--tide-<색 이름>-<슬롯>`과 `--tide-accent-<슬롯>`으로 나간다.
104
+ */
105
+ const colorSlots = [
106
+ "solid",
107
+ "solid-hover",
108
+ "solid-active",
109
+ "subtle",
110
+ "subtle-hover",
111
+ "subtle-active",
112
+ "text",
113
+ "border",
114
+ "on-solid"
115
+ ];
116
+ /**
117
+ * 슬롯 매핑 규칙.
118
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
119
+ */
120
+ const slotRule = {
121
+ solid: {
122
+ light: 600,
123
+ dark: 600
124
+ },
125
+ "solid-hover": {
126
+ light: 700,
127
+ dark: 700
128
+ },
129
+ "solid-active": {
130
+ light: 800,
131
+ dark: 800
132
+ },
133
+ subtle: {
134
+ light: 50,
135
+ dark: 950
136
+ },
137
+ "subtle-hover": {
138
+ light: 100,
139
+ dark: 900
140
+ },
141
+ "subtle-active": {
142
+ light: 200,
143
+ dark: 800
144
+ },
145
+ text: {
146
+ light: 800,
147
+ dark: 200
148
+ },
149
+ border: {
150
+ light: 600,
151
+ dark: 400
152
+ },
153
+ "on-solid": {
154
+ light: "white",
155
+ dark: "white"
156
+ }
157
+ };
158
+ /**
159
+ * Tide 계열별 덮어쓸 값.
160
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
161
+ */
162
+ const familyOverrides = {};
163
+ /**
164
+ * 규칙에 덮어쓸 값을 얹은 슬롯별 단계.
165
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
166
+ */
167
+ function resolveSlotSteps(...overrides) {
168
+ return Object.fromEntries(colorSlots.map((slot) => {
169
+ return [slot, overrides.reduce((current, override) => ({
170
+ ...current,
171
+ ...override?.[slot]
172
+ }), slotRule[slot])];
173
+ }));
174
+ }
175
+ /**
176
+ * 슬롯 값을 Primitive 이름으로 바꾼다. 예: (`blue`, 600) → `blue-600`, (`blue`, `white`) → `white`
177
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
178
+ */
179
+ function slotPrimitive(family, step) {
180
+ return typeof step === "number" ? `${family}-${step}` : step;
181
+ }
182
+
183
+ // src/semantic.ts
184
+ /**
185
+ * Role이 참조하는 Primitive 계열.
186
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
187
+ */
188
+ const roleFamily = {
189
+ neutral: "gray",
190
+ primary: "blue",
191
+ success: "green",
192
+ warning: "amber",
193
+ danger: "red",
194
+ info: "cyan"
195
+ };
196
+ /**
197
+ * Role별로 계열 규칙에서 덮어쓸 값.
198
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
199
+ */
200
+ const roleOverrides = { neutral: {
201
+ subtle: { dark: 900 },
202
+ "subtle-hover": { dark: 800 },
203
+ "subtle-active": { dark: 700 }
204
+ } };
205
+ /**
206
+ * 역할 Token 대신 공통 Semantic Token을 쓰는 Role 슬롯. on-solid는 모든 Role이 `text-on-solid`를 쓴다.
207
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
208
+ */
209
+ const roleSharedSlots = { neutral: {
210
+ text: "text-default",
211
+ border: "border-strong"
212
+ } };
213
+ const roles$1 = Object.keys(roleFamily);
214
+ const backgroundSlots = colorSlots.filter((slot) => slot !== "text" && slot !== "border" && slot !== "on-solid");
215
+ const accentRoles = roles$1.filter((role) => !(role in roleSharedSlots));
216
+ /** Role 슬롯이 가리키는 Primitive. 계열 규칙 → 계열 덮어쓸 값 → Role 덮어쓸 값 순서로 얹는다. */
217
+ function roleSlotColor(role, slot) {
218
+ const family = roleFamily[role];
219
+ const steps = resolveSlotSteps(familyOverrides[family], roleOverrides[role])[slot];
220
+ return {
221
+ light: slotPrimitive(family, steps.light),
222
+ dark: slotPrimitive(family, steps.dark)
223
+ };
224
+ }
225
+ const commonBackground = {
226
+ canvas: {
227
+ light: "white",
228
+ dark: "gray-950"
229
+ },
230
+ surface: {
231
+ light: "white",
232
+ dark: "gray-950"
233
+ },
234
+ raised: {
235
+ light: "white",
236
+ dark: "gray-900"
237
+ },
238
+ disabled: {
239
+ light: "gray-100",
240
+ dark: "gray-900"
241
+ },
242
+ overlay: {
243
+ light: {
244
+ primitive: "gray-950",
245
+ alpha: .5
246
+ },
247
+ dark: {
248
+ primitive: "gray-950",
249
+ alpha: .7
250
+ }
251
+ }
252
+ };
253
+ const commonText = {
254
+ default: {
255
+ light: "gray-900",
256
+ dark: "gray-50"
257
+ },
258
+ muted: {
259
+ light: "gray-600",
260
+ dark: "gray-300"
261
+ },
262
+ disabled: {
263
+ light: "gray-400",
264
+ dark: "gray-600"
265
+ },
266
+ "on-solid": {
267
+ light: "white",
268
+ dark: "white"
269
+ }
270
+ };
271
+ const commonBorder = {
272
+ default: {
273
+ light: "gray-200",
274
+ dark: "gray-800"
275
+ },
276
+ strong: {
277
+ light: "gray-500",
278
+ dark: "gray-400"
279
+ },
280
+ raised: {
281
+ light: "transparent",
282
+ dark: "gray-800"
283
+ }
284
+ };
285
+ const commonOutline = { focus: {
286
+ light: "blue-600",
287
+ dark: "blue-400"
288
+ } };
289
+ const semantic = {
290
+ background: {
291
+ ...commonBackground,
292
+ ...Object.fromEntries(roles$1.flatMap((role) => backgroundSlots.map((slot) => [`${role}-${slot}`, roleSlotColor(role, slot)])))
293
+ },
294
+ text: {
295
+ ...commonText,
296
+ ...Object.fromEntries(accentRoles.map((role) => [role, roleSlotColor(role, "text")]))
297
+ },
298
+ border: {
299
+ ...commonBorder,
300
+ ...Object.fromEntries(accentRoles.map((role) => [role, roleSlotColor(role, "border")]))
301
+ },
302
+ outline: commonOutline
303
+ };
304
+ const semanticPrefix = {
305
+ background: "bg",
306
+ text: "text",
307
+ border: "border",
308
+ outline: "outline"
309
+ };
310
+ /**
311
+ * 모든 Semantic Token(namespace, Utility class 이름, Theme별 값).
312
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
313
+ */
314
+ function semanticEntries() {
315
+ return Object.keys(semantic).flatMap((namespace) => Object.entries(semantic[namespace]).map(([key, value]) => ({
316
+ namespace,
317
+ key,
318
+ className: `${semanticPrefix[namespace]}-${key}`,
319
+ value
320
+ })));
321
+ }
322
+ /**
323
+ * 값을 hex로 바꾼다. alpha가 있으면 8자리 hex(`#rrggbbaa`)다. `transparent`는 `#00000000`이다.
324
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
325
+ */
326
+ function colorValueHex(value) {
327
+ if (value === "transparent") return "#00000000";
328
+ if (typeof value === "string") return primitiveHex(value);
329
+ const alpha = Math.round(value.alpha * 255).toString(16).padStart(2, "0");
330
+ return `${primitiveHex(value.primitive)}${alpha}`;
331
+ }
332
+ /**
333
+ * Semantic Token이 Theme에서 가리키는 hex.
334
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
335
+ */
336
+ function semanticHex(name, theme) {
337
+ const entry = semanticEntries().find((candidate) => candidate.className === name);
338
+ if (!entry) throw new Error(`Semantic Token이 아니다: ${name}`);
339
+ return colorValueHex(entry.value[theme]);
340
+ }
341
+
342
+ // src/color-name.ts
343
+ /**
344
+ * Role 이름 목록(neutral, primary, success, warning, danger, info 순서).
345
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
346
+ */
347
+ const roleNames = Object.keys(roleFamily);
348
+ /**
349
+ * Tide 색 계열 목록(`color`의 11단계 계열 순서).
350
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
351
+ */
352
+ const colorFamilyNames = Object.keys(color).filter((name) => typeof color[name] !== "string");
353
+ /**
354
+ * Tide 색 이름 목록. Role 다음에 색 계열이 온다.
355
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
356
+ */
357
+ const tideColorNames = [...roleNames, ...colorFamilyNames];
358
+ /**
359
+ * Tide Role인지 확인한다. 앱 Role은 Role Token이 없어서 false다.
360
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
361
+ */
362
+ function isRole(name) {
363
+ return Object.hasOwn(roleFamily, name);
364
+ }
365
+ /**
366
+ * Role 슬롯이 가리키는 Semantic Token. 예: (`primary`, `solid`) → `bg-primary-solid`, (`neutral`, `text`) → `text-default`
367
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
368
+ */
369
+ function roleSlotToken(role, slot) {
370
+ if (slot === "on-solid") return "text-on-solid";
371
+ if (slot === "text" || slot === "border") return roleSharedSlots[role]?.[slot] ?? `${slot}-${role}`;
372
+ return `bg-${role}-${slot}`;
373
+ }
374
+
375
+ // src/color-css.ts
376
+ /** Primitive 변수 이름. 예: `--tide-color-blue-600` */
377
+ function primitiveVar(name) {
378
+ return `--tide-color-${name}`;
379
+ }
380
+ /** 계열 변수 이름. 예: `--tide-blue-solid` */
381
+ function familyVar(family, slot) {
382
+ return `--tide-${family}-${slot}`;
383
+ }
384
+ /** accent 슬롯 변수 이름. 예: `--tide-accent-solid` */
385
+ function accentVar(slot) {
386
+ return `--tide-accent-${slot}`;
387
+ }
388
+ /** Semantic Token 변수 이름. 예: `--tide-bg-primary-solid` */
389
+ function semanticVar(className) {
390
+ return `--tide-${className}`;
391
+ }
392
+ /** 슬롯 값의 CSS 표현. 계열 단계는 Primitive 변수를, white·black은 Tide Primitive 변수를 가리킨다. */
393
+ function slotCss(family, step) {
394
+ return `var(${primitiveVar(slotPrimitive(family, step))})`;
395
+ }
396
+ /**
397
+ * 계열 하나의 Theme 블록 선언(9줄).
398
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
399
+ */
400
+ function familyThemeDeclarations(family, steps, theme) {
401
+ return colorSlots.map((slot) => ` ${familyVar(family, slot)}: ${slotCss(family, steps[slot][theme])};`);
402
+ }
403
+ /** accent 슬롯에 변수를 꽂는 선언 */
404
+ function accentDeclarations(source) {
405
+ return colorSlots.map((slot) => ` ${accentVar(slot)}: var(${source(slot)});`);
406
+ }
407
+ /**
408
+ * `[data-color="<색 이름>"]` 규칙 하나. Role은 Role Token을, 계열은 계열 변수를 꽂는다.
409
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
410
+ */
411
+ function dataColorRule(name) {
412
+ const source = isRole(name) ? (slot) => semanticVar(roleSlotToken(name, slot)) : (slot) => familyVar(name, slot);
413
+ return [
414
+ `[data-color="${name}"] {`,
415
+ ...accentDeclarations(source),
416
+ "}"
417
+ ].join("\n");
418
+ }
419
+ const roleAliases = new Map(roleNames.flatMap((role) => colorSlots.map((slot) => [roleSlotToken(role, slot), slot]).filter(([token]) => token.includes(`-${role}`)).map(([token, slot]) => [token, {
420
+ family: roleFamily[role],
421
+ slot
422
+ }])));
423
+ /**
424
+ * accent Utility class 목록. 예: `bg-accent-solid` → `--tide-accent-solid`, `text-accent` → `--tide-accent-text`
425
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
426
+ */
427
+ const accentUtilities = [
428
+ ...colorSlots.filter((slot) => slot !== "text" && slot !== "border" && slot !== "on-solid").map((slot) => ({
429
+ namespace: "background",
430
+ key: `accent-${slot}`,
431
+ slot
432
+ })),
433
+ {
434
+ namespace: "text",
435
+ key: "accent",
436
+ slot: "text"
437
+ },
438
+ {
439
+ namespace: "text",
440
+ key: "accent-on-solid",
441
+ slot: "on-solid"
442
+ },
443
+ {
444
+ namespace: "border",
445
+ key: "accent",
446
+ slot: "border"
447
+ }
448
+ ];
449
+
450
+ // src/contrast.ts
451
+ const surfaces = [
452
+ "bg-canvas",
453
+ "bg-surface",
454
+ "bg-raised"
455
+ ];
456
+ const roles = [
457
+ "primary",
458
+ "success",
459
+ "warning",
460
+ "danger",
461
+ "info"
462
+ ];
463
+ const allRoles = ["neutral", ...roles];
464
+ const subtleStates = [
465
+ "subtle",
466
+ "subtle-hover",
467
+ "subtle-active"
468
+ ];
469
+ const solidStates = [
470
+ "solid",
471
+ "solid-hover",
472
+ "solid-active"
473
+ ];
474
+ function pairs(foregrounds, backgrounds, minRatio) {
475
+ return foregrounds.flatMap((foreground) => backgrounds.map((background) => ({
476
+ foreground,
477
+ background,
478
+ minRatio
479
+ })));
480
+ }
481
+ const contrastPairs = [
482
+ ...pairs(["text-default", "text-muted"], surfaces, 4.5),
483
+ ...pairs(["text-default"], subtleStates.map((state) => `bg-neutral-${state}`), 4.5),
484
+ ...roles.flatMap((role) => pairs([`text-${role}`], [...surfaces, ...subtleStates.map((state) => `bg-${role}-${state}`)], 4.5)),
485
+ ...pairs(["text-on-solid"], allRoles.flatMap((role) => solidStates.map((state) => `bg-${role}-${state}`)), 4.5),
486
+ ...pairs([
487
+ "border-strong",
488
+ ...roles.map((role) => `border-${role}`),
489
+ "outline-focus"
490
+ ], surfaces, 3)
491
+ ];
492
+ /**
493
+ * 색 이름 하나의 대비 쌍 규칙.
494
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
495
+ */
496
+ const colorContrastRules = [
497
+ ...[...surfaces, ...subtleStates].map((background) => ({
498
+ foreground: "text",
499
+ background,
500
+ minRatio: 4.5
501
+ })),
502
+ ...solidStates.map((background) => ({
503
+ foreground: "on-solid",
504
+ background,
505
+ minRatio: 4.5
506
+ })),
507
+ ...surfaces.map((background) => ({
508
+ foreground: "border",
509
+ background,
510
+ minRatio: 3
511
+ }))
512
+ ];
513
+ /**
514
+ * 색 이름 하나의 슬롯 hex로 대비 쌍 규칙을 light·dark에서 검사한다. 표면 층은 Tide Token 값을 쓴다.
515
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
516
+ */
517
+ function checkColorContrast(slotHex) {
518
+ return ["light", "dark"].flatMap((theme) => colorContrastRules.map((rule) => {
519
+ const background = rule.background.startsWith("bg-") ? semanticHex(rule.background, theme) : slotHex[theme][rule.background];
520
+ const ratio = contrastRatio(slotHex[theme][rule.foreground], background);
521
+ return {
522
+ ...rule,
523
+ theme,
524
+ ratio,
525
+ pass: ratio >= rule.minRatio
526
+ };
527
+ }));
528
+ }
529
+ /** WCAG 2.x 상대 휘도. `#rrggbb`만 받는다. */
530
+ function relativeLuminance(hex) {
531
+ const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
532
+ if (!match) throw new Error(`불투명한 6자리 hex가 아니다: ${hex}`);
533
+ const [r, g, b] = match.slice(1).map((channel) => {
534
+ const value = Number.parseInt(channel, 16) / 255;
535
+ return value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4;
536
+ });
537
+ return .2126 * r + .7152 * g + .0722 * b;
538
+ }
539
+ /**
540
+ * 두 색의 WCAG 2.x 대비비(1~21). 순서는 상관없다.
541
+ * 반투명 색(8자리 hex)은 받지 않는다. 대비 검사에서 overlay와 shadow는 제외한다.
542
+ * @internal `@tide-tds/foundation/internal` 전용. 앱 개발자용 공개 계약이 아니다(문서 앱·빌드 스크립트·테스트용).
543
+ */
544
+ function contrastRatio(foreground, background) {
545
+ const a = relativeLuminance(foreground);
546
+ const b = relativeLuminance(background);
547
+ return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
548
+ }
549
+
550
+ // src/color-family.ts
551
+ /**
552
+ * `@tide-tds/foundation/color-family`: 앱이 자기 브랜드 색 계열을 더하는 API.
553
+ *
554
+ * 앱은 `defineColorFamily`로 11단계 스케일을 정의하고, `tide-color build <파일>`로 CSS를 만든다.
555
+ * CSS는 Tide 계열과 같은 매핑 규칙으로 light·dark 계열 변수와 `[data-color="<이름>"]` 규칙을 담는다.
556
+ * @packageDocumentation
557
+ */
558
+ const steps = Object.keys(color.blue).map(Number);
559
+ const reservedNames = /* @__PURE__ */ new Set([
560
+ ...tideColorNames,
561
+ "accent",
562
+ "white",
563
+ "black",
564
+ "color",
565
+ "bg",
566
+ "text",
567
+ "border",
568
+ "outline",
569
+ "font",
570
+ "rounded",
571
+ "shadow",
572
+ "duration",
573
+ "ease",
574
+ "z",
575
+ "focus"
576
+ ]);
577
+ function isSlotStep(value) {
578
+ return value === "white" || value === "black" || steps.includes(value);
579
+ }
580
+ /**
581
+ * 앱 브랜드 색 계열을 정의한다. 이름, 스케일, 덮어쓸 값이 올바른지 확인하고 그대로 돌려준다.
582
+ *
583
+ * ```ts
584
+ * // brand.ts
585
+ * import { defineColorFamily } from "@tide-tds/foundation/color-family";
586
+ *
587
+ * export default defineColorFamily({
588
+ * name: "brand",
589
+ * scale: { 50: "#f5f3ff", 100: "#ede9fe", ... 950: "#2e1065" },
590
+ * });
591
+ * ```
592
+ *
593
+ * @throws 이름이 Tide 색 이름과 겹치거나, 스케일 단계가 빠졌거나, hex가 `#rrggbb`가 아니면
594
+ */
595
+ function defineColorFamily(input) {
596
+ const { name, scale, overrides = {} } = input;
597
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) throw new Error(`색 이름은 소문자로 시작하는 kebab-case여야 한다: "${name}"`);
598
+ if (reservedNames.has(name)) throw new Error(`"${name}"은 Tide가 쓰는 이름이라 앱 색 계열 이름으로 쓸 수 없다.`);
599
+ for (const step of steps) {
600
+ const hex = scale[step];
601
+ if (typeof hex !== "string" || !/^#[0-9a-f]{6}$/i.test(hex)) throw new Error(`${name}-${step}: #rrggbb hex가 아니다(${String(hex)})`);
602
+ }
603
+ const extraSteps = Object.keys(scale).filter((step) => !steps.includes(Number(step)));
604
+ if (extraSteps.length > 0) throw new Error(`${name}: 스케일에 없는 단계가 있다(${extraSteps.join(", ")})`);
605
+ for (const [slot, rule] of Object.entries(overrides)) {
606
+ if (!colorSlots.includes(slot)) throw new Error(`${name}: 없는 슬롯을 덮어썼다(${slot})`);
607
+ for (const [theme, step] of Object.entries(rule ?? {})) if (theme !== "light" && theme !== "dark" || !isSlotStep(step)) throw new Error(`${name}: ${slot}.${theme} 값이 단계(50~950, white, black)가 아니다`);
608
+ }
609
+ return {
610
+ name,
611
+ scale: Object.fromEntries(steps.map((step) => [step, scale[step].toLowerCase()])),
612
+ overrides
613
+ };
614
+ }
615
+ /** 앱 계열의 슬롯별 단계(규칙 + 덮어쓸 값) */
616
+ function slotSteps(family) {
617
+ return resolveSlotSteps(family.overrides);
618
+ }
619
+ /** 앱 계열의 Theme별 슬롯 hex */
620
+ function colorFamilySlotHex(family) {
621
+ const resolved = slotSteps(family);
622
+ const hex = (step) => typeof step === "number" ? family.scale[step] : color[step];
623
+ return {
624
+ light: Object.fromEntries(colorSlots.map((slot) => [slot, hex(resolved[slot].light)])),
625
+ dark: Object.fromEntries(colorSlots.map((slot) => [slot, hex(resolved[slot].dark)]))
626
+ };
627
+ }
628
+ /** 앱 계열을 Tide와 같은 대비 쌍 규칙으로 light·dark에서 검사한다. 미달이면 `pass`가 false다. */
629
+ function checkColorFamily(family) {
630
+ return checkColorContrast(colorFamilySlotHex(family));
631
+ }
632
+ /**
633
+ * 앱 계열의 CSS. Primitive 변수, light·dark 계열 변수, `[data-color]` 규칙을 담는다.
634
+ * Tide CSS(`@tide-tds/components/styles.css` 또는 foundation CSS) 다음에 불러온다.
635
+ */
636
+ function colorFamilyCss(families) {
637
+ const themeBlock = (theme) => families.flatMap((family) => familyThemeDeclarations(family.name, slotSteps(family), theme));
638
+ return [
639
+ ":root {",
640
+ ...families.flatMap((family) => steps.map((step) => ` ${primitiveVar(slotPrimitive(family.name, step))}: ${family.scale[step]};`)),
641
+ "}",
642
+ "",
643
+ ":root,\n[data-theme=\"light\"] {",
644
+ ...themeBlock("light"),
645
+ "}",
646
+ "",
647
+ "[data-theme=\"dark\"] {",
648
+ ...themeBlock("dark"),
649
+ "}",
650
+ "",
651
+ ...families.flatMap((family) => [dataColorRule(family.name), ""])
652
+ ].join("\n");
653
+ }
654
+
655
+ // src/cli.ts
656
+ const usage = "사용법: tide-color build <색 계열 파일(.ts, .js)> > <출력 CSS>";
657
+ async function loadFamilies(file) {
658
+ const exported = (await import(pathToFileURL(resolve(file)).href)).default;
659
+ if (exported === void 0) throw new Error(`${file}에 default export가 없다. defineColorFamily 결과를 default로 내보낸다.`);
660
+ const families = (Array.isArray(exported) ? exported : [exported]).map((family) => defineColorFamily(family));
661
+ const names = families.map((family) => family.name);
662
+ const duplicated = names.filter((name, index) => names.indexOf(name) !== index);
663
+ if (duplicated.length > 0) throw new Error(`같은 색 이름이 두 번 나온다: ${duplicated.join(", ")}`);
664
+ return families;
665
+ }
666
+ async function build(file) {
667
+ const families = await loadFamilies(file);
668
+ let failed = 0;
669
+ for (const family of families) {
670
+ const results = checkColorFamily(family);
671
+ const failures = results.filter((result) => !result.pass);
672
+ failed += failures.length;
673
+ for (const failure of failures) process.stderr.write(`대비 미달 ${family.name} ${failure.theme}: ${failure.foreground} / ${failure.background} ${failure.ratio.toFixed(2)}:1 < ${failure.minRatio}:1\n`);
674
+ process.stderr.write(`${family.name}: 대비 쌍 ${results.length}개 중 ${results.length - failures.length}개 통과\n`);
675
+ }
676
+ if (failed > 0) {
677
+ process.stderr.write("대비 기준에 못 미쳐 CSS를 만들지 않았다. 스케일을 고치거나 overrides로 슬롯 단계를 바꾼다.\n");
678
+ return 1;
679
+ }
680
+ const header = `/* tide-color가 ${file}에서 만들었다. 직접 고치지 않는다. Tide CSS 다음에 불러온다. */\n`;
681
+ process.stdout.write(header + colorFamilyCss(families));
682
+ return 0;
683
+ }
684
+ async function main(args) {
685
+ const [command, file, ...rest] = args;
686
+ if (command !== "build" || !file || rest.length > 0) {
687
+ process.stderr.write(`${usage}\n`);
688
+ return 2;
689
+ }
690
+ try {
691
+ return await build(file);
692
+ } catch (error) {
693
+ process.stderr.write(`tide-color: ${error instanceof Error ? error.message : String(error)}\n`);
694
+ return 1;
695
+ }
696
+ }
697
+ process.exitCode = await main(process.argv.slice(2));
698
+
699
+ export { };