sellmate-design-system-react 2.2.0 → 3.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.
Files changed (113) hide show
  1. package/AGENTS.md +661 -0
  2. package/README.md +85 -0
  3. package/dist/components/SActionModal/README.md +41 -0
  4. package/dist/components/SBadge/README.md +25 -0
  5. package/dist/components/SBarcodeInput/README.md +64 -0
  6. package/dist/components/SButton/README.md +49 -0
  7. package/dist/components/SCalendar/README.md +42 -0
  8. package/dist/components/SCallout/README.md +29 -0
  9. package/dist/components/SCallout/SCallout.cjs +76 -1
  10. package/dist/components/SCallout/SCallout.cjs.map +1 -1
  11. package/dist/components/SCallout/SCallout.d.ts +6 -2
  12. package/dist/components/SCallout/SCallout.js +76 -1
  13. package/dist/components/SCallout/SCallout.js.map +1 -1
  14. package/dist/components/SCard/README.md +12 -0
  15. package/dist/components/SCheckbox/README.md +44 -0
  16. package/dist/components/SChip/README.md +57 -0
  17. package/dist/components/SChipInput/README.md +77 -0
  18. package/dist/components/SCircleProgress/README.md +35 -0
  19. package/dist/components/SConfirmModal/README.md +61 -0
  20. package/dist/components/SDatePicker/README.md +62 -0
  21. package/dist/components/SDateRangePicker/README.md +64 -0
  22. package/dist/components/SDivider/README.md +25 -0
  23. package/dist/components/SDraggableItem/README.md +40 -0
  24. package/dist/components/SDraggableItem/SDraggableItem.cjs +3079 -0
  25. package/dist/components/SDraggableItem/SDraggableItem.cjs.map +1 -0
  26. package/dist/components/SDraggableItem/SDraggableItem.d.ts +33 -0
  27. package/dist/components/SDraggableItem/SDraggableItem.js +3077 -0
  28. package/dist/components/SDraggableItem/SDraggableItem.js.map +1 -0
  29. package/dist/components/SDraggableItem/index.d.ts +1 -0
  30. package/dist/components/SDropdownButton/README.md +50 -0
  31. package/dist/components/SExpansionItem/README.md +40 -0
  32. package/dist/components/SExpansionItem/SExpansionItem.cjs +6 -15
  33. package/dist/components/SExpansionItem/SExpansionItem.cjs.map +1 -1
  34. package/dist/components/SExpansionItem/SExpansionItem.js +6 -15
  35. package/dist/components/SExpansionItem/SExpansionItem.js.map +1 -1
  36. package/dist/components/SField/README.md +91 -0
  37. package/dist/components/SFilePicker/README.md +72 -0
  38. package/dist/components/SForm/README.md +31 -0
  39. package/dist/components/SGhostButton/README.md +76 -0
  40. package/dist/components/SGnb/README.md +56 -0
  41. package/dist/components/SGuide/README.md +34 -0
  42. package/dist/components/SIcon/README.md +91 -0
  43. package/dist/components/SInput/README.md +66 -0
  44. package/dist/components/SKeyValueTable/README.md +59 -0
  45. package/dist/components/SLayout/README.md +42 -0
  46. package/dist/components/SLinearProgress/README.md +17 -0
  47. package/dist/components/SList/README.md +14 -0
  48. package/dist/components/SList/SList.cjs +38 -0
  49. package/dist/components/SList/SList.cjs.map +1 -0
  50. package/dist/components/SList/SList.d.ts +10 -0
  51. package/dist/components/SList/SList.js +36 -0
  52. package/dist/components/SList/SList.js.map +1 -0
  53. package/dist/components/SList/index.d.ts +1 -0
  54. package/dist/components/SListItem/README.md +19 -0
  55. package/dist/components/SListItem/SListItem.cjs +83 -0
  56. package/dist/components/SListItem/SListItem.cjs.map +1 -0
  57. package/dist/components/SListItem/SListItem.d.ts +26 -0
  58. package/dist/components/SListItem/SListItem.js +81 -0
  59. package/dist/components/SListItem/SListItem.js.map +1 -0
  60. package/dist/components/SListItem/index.d.ts +1 -0
  61. package/dist/components/SLoadingContainer/README.md +29 -0
  62. package/dist/components/SLoadingModal/README.md +52 -0
  63. package/dist/components/SModal/README.md +192 -0
  64. package/dist/components/SModalContainer/README.md +49 -0
  65. package/dist/components/SNumberInput/README.md +74 -0
  66. package/dist/components/SPage/README.md +25 -0
  67. package/dist/components/SPagination/README.md +40 -0
  68. package/dist/components/SPopover/README.md +54 -0
  69. package/dist/components/SPopup/README.md +38 -0
  70. package/dist/components/SPortal/README.md +51 -0
  71. package/dist/components/SRadio/README.md +57 -0
  72. package/dist/components/SRadioButton/README.md +24 -0
  73. package/dist/components/SScrollArea/README.md +16 -0
  74. package/dist/components/SSectionHeaderCard/README.md +51 -0
  75. package/dist/components/SSectionHeaderCard/SSectionHeaderCard.cjs +3414 -0
  76. package/dist/components/SSectionHeaderCard/SSectionHeaderCard.cjs.map +1 -0
  77. package/dist/components/SSectionHeaderCard/SSectionHeaderCard.d.ts +32 -0
  78. package/dist/components/SSectionHeaderCard/SSectionHeaderCard.js +3392 -0
  79. package/dist/components/SSectionHeaderCard/SSectionHeaderCard.js.map +1 -0
  80. package/dist/components/SSectionHeaderCard/index.d.ts +1 -0
  81. package/dist/components/SSelect/README.md +79 -0
  82. package/dist/components/SStepper/README.md +30 -0
  83. package/dist/components/SSwitch/README.md +24 -0
  84. package/dist/components/STable/README.md +82 -0
  85. package/dist/components/STableBar/README.md +28 -0
  86. package/dist/components/STabs/README.md +37 -0
  87. package/dist/components/STag/README.md +44 -0
  88. package/dist/components/STextLink/README.md +48 -0
  89. package/dist/components/STextarea/README.md +60 -0
  90. package/dist/components/STimePicker/README.md +65 -0
  91. package/dist/components/STimeRangePicker/README.md +63 -0
  92. package/dist/components/SToast/README.md +70 -0
  93. package/dist/components/SToggle/README.md +22 -0
  94. package/dist/components/STooltip/README.md +57 -0
  95. package/dist/index.cjs +815 -419
  96. package/dist/index.cjs.map +1 -1
  97. package/dist/index.d.ts +4 -0
  98. package/dist/index.js +815 -423
  99. package/dist/index.js.map +1 -1
  100. package/dist/llms.txt +3496 -0
  101. package/dist/styles.css +545 -19
  102. package/dist/theme.css +22 -0
  103. package/eslint/index.mjs +66 -0
  104. package/eslint/lib/class-names.mjs +119 -0
  105. package/eslint/lib/table-column.mjs +53 -0
  106. package/eslint/rules/no-arbitrary-class.mjs +91 -0
  107. package/eslint/rules/no-off-scale-spacing.mjs +67 -0
  108. package/eslint/rules/no-raw-html-control.mjs +117 -0
  109. package/eslint/rules/prefer-typo-preset.mjs +73 -0
  110. package/eslint/rules/require-locale-number.mjs +78 -0
  111. package/eslint/rules/table-numeric-align.mjs +93 -0
  112. package/eslint/scale.gen.mjs +16 -0
  113. package/package.json +9 -4
package/dist/theme.css CHANGED
@@ -1344,6 +1344,8 @@
1344
1344
  --cmp-gnb-rail-item-radius: 8px;
1345
1345
  --cmp-gnb-rail-item-icon: 24px;
1346
1346
  --cmp-gnb-rail-item-width: 44px;
1347
+ --cmp-list-group-gap: 8px;
1348
+ --cmp-list-group-padding: 16px;
1347
1349
  --cmp-listItem-paddingX: 16px;
1348
1350
  --cmp-listItem-paddingY-normal: 8px;
1349
1351
  --cmp-listItem-paddingY-dense: 4px;
@@ -1417,6 +1419,18 @@
1417
1419
  --cmp-sectionHeaderCard-required-icon: 16px;
1418
1420
  --cmp-sectionHeaderCard-subtitle-color: #555555;
1419
1421
  --cmp-sectionHeaderCard-body-bg: #FFFFFF;
1422
+ --cmp-tree-item-bg-default: #FFFFFF;
1423
+ --cmp-tree-item-bg-hover: #F9F9F9;
1424
+ --cmp-tree-item-border-default: #E1E1E1;
1425
+ --cmp-tree-item-border-width-default: 1px;
1426
+ --cmp-tree-item-paddingX: 16px;
1427
+ --cmp-tree-item-paddingY: 4px;
1428
+ --cmp-tree-item-rowGap: 8px;
1429
+ --cmp-tree-item-contentGap: 12px;
1430
+ --cmp-tree-item-text-default: #222222;
1431
+ --cmp-tree-item-text-selected: #0075FF;
1432
+ --cmp-tree-item-arrow-icon: 12px;
1433
+ --cmp-tree-connector-line-color: #AAAAAA;
1420
1434
  }
1421
1435
 
1422
1436
  /* 전역 스크롤바 — overflow(auto/scroll)로 스크롤이 생기는 모든 요소에 SScrollArea 기본 스크롤바를 자동 적용.
@@ -1425,6 +1439,14 @@
1425
1439
  Chrome 121+/WebKit 는 scrollbar-width·scrollbar-color 계산값이 auto 가 아니면 ::-webkit-scrollbar-* 를 통째로
1426
1440
  무시하므로, 표준 속성은 webkit 유사요소가 없는 브라우저(Firefox)에서만 켠다. */
1427
1441
  @layer base {
1442
+ /* 전역 기본 텍스트 색 — 본문은 grey_95(= fg.primary). 요소마다 색을 지정하지 않아도
1443
+ 브라우저 기본 검정(#000)이 아니라 디자인 시스템 색으로 렌더된다.
1444
+ 보조·비활성 텍스트만 text-fg-tertiary(grey_65)로 내려 쓴다.
1445
+ base 레이어라 소비 앱의 text-* 유틸리티(utilities)가 언제든 덮어쓴다. */
1446
+ body {
1447
+ color: var(--color-fg-primary);
1448
+ }
1449
+
1428
1450
  *::-webkit-scrollbar {
1429
1451
  width: 12px;
1430
1452
  height: 12px;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * sellmate-design-system-react — 소비 앱용 ESLint 플러그인.
3
+ *
4
+ * AGENTS.md 의 규칙 중 정적으로 검출 가능한 것을 린트로 강제한다.
5
+ * 사용법은 README 의 "ESLint 설정" 절 참조.
6
+ */
7
+ import noRawHtmlControl from "./rules/no-raw-html-control.mjs";
8
+ import noArbitraryClass from "./rules/no-arbitrary-class.mjs";
9
+ import noOffScaleSpacing from "./rules/no-off-scale-spacing.mjs";
10
+ import preferTypoPreset from "./rules/prefer-typo-preset.mjs";
11
+ import tableNumericAlign from "./rules/table-numeric-align.mjs";
12
+ import requireLocaleNumber from "./rules/require-locale-number.mjs";
13
+
14
+ const PLUGIN_NAME = "sellmate";
15
+
16
+ const plugin = {
17
+ meta: { name: "eslint-plugin-sellmate" },
18
+ rules: {
19
+ "no-raw-html-control": noRawHtmlControl,
20
+ "no-arbitrary-class": noArbitraryClass,
21
+ "no-off-scale-spacing": noOffScaleSpacing,
22
+ "prefer-typo-preset": preferTypoPreset,
23
+ "table-numeric-align": tableNumericAlign,
24
+ "require-locale-number": requireLocaleNumber,
25
+ },
26
+ };
27
+
28
+ /** 기본 프리셋 — JSX/TSX 파일에 적용 */
29
+ const recommended = [
30
+ {
31
+ // 컬럼 정의를 .ts 파일로 분리하는 경우가 있어 tsx 뿐 아니라 ts 도 포함한다
32
+ files: ["**/*.{js,jsx,ts,tsx}"],
33
+ plugins: { [PLUGIN_NAME]: plugin },
34
+ rules: {
35
+ // 스타일이 조용히 사라지거나 디자인 시스템을 우회하는 경우 → error
36
+ [`${PLUGIN_NAME}/no-raw-html-control`]: "error",
37
+ [`${PLUGIN_NAME}/no-arbitrary-class`]: "error",
38
+ [`${PLUGIN_NAME}/no-off-scale-spacing`]: "error",
39
+ [`${PLUGIN_NAME}/table-numeric-align`]: "error",
40
+ [`${PLUGIN_NAME}/require-locale-number`]: "error",
41
+ // 대체 프리셋 판단에 여지가 있어 warn
42
+ [`${PLUGIN_NAME}/prefer-typo-preset`]: "warn",
43
+ },
44
+ },
45
+ ];
46
+
47
+ /**
48
+ * 전 규칙 error + ul·ol·li·svg·label 까지 검사.
49
+ * 정당한 사용처(로고 SVG, 시맨틱 목록 등)가 있는 요소도 잡히므로
50
+ * 예외는 `allow` 로 열어 쓰는 것을 전제로 한다. 신규 프로젝트용.
51
+ */
52
+ const strict = [
53
+ {
54
+ ...recommended[0],
55
+ rules: {
56
+ ...recommended[0].rules,
57
+ [`${PLUGIN_NAME}/no-raw-html-control`]: ["error", { strict: true }],
58
+ [`${PLUGIN_NAME}/prefer-typo-preset`]: "error",
59
+ },
60
+ },
61
+ ];
62
+
63
+ plugin.configs = { recommended, strict };
64
+
65
+ export default plugin;
66
+ export const { rules, configs } = plugin;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * className / class 속성에서 클래스 토큰을 추출하는 공용 헬퍼.
3
+ *
4
+ * 지원 형태:
5
+ * className="a b" → Literal
6
+ * className={`a ${x} b`} → TemplateLiteral (정적 quasi 부분만)
7
+ * className={cn('a', cond && 'b')} → clsx/cn/classnames 호출의 문자열 인자
8
+ * className={'a b'} → JSXExpressionContainer > Literal
9
+ *
10
+ * 동적으로 조립되는 부분(변수·삼항의 식별자 등)은 정적 분석 대상이 아니므로 건너뛴다.
11
+ */
12
+
13
+ const CLASS_ATTRS = new Set(["className", "class"]);
14
+ const CLASS_HELPERS = new Set(["cn", "clsx", "classNames", "classnames", "cva", "twMerge"]);
15
+
16
+ /** 문자열을 공백 기준 클래스 토큰으로 나눈다 (variant 접두어는 유지) */
17
+ export function tokenize(value) {
18
+ return String(value)
19
+ .split(/\s+/)
20
+ .filter(Boolean);
21
+ }
22
+
23
+ /**
24
+ * `hover:md:gap-12` → `gap-12` — variant 접두어를 벗겨 유틸리티 본체만 남긴다.
25
+ * 임의 값 안의 `:`(예: `[length:var(--x)]`)는 벗기지 않는다.
26
+ */
27
+ export function stripVariants(token) {
28
+ let rest = token;
29
+ for (;;) {
30
+ const idx = indexOfTopLevelColon(rest);
31
+ if (idx === -1) return rest;
32
+ rest = rest.slice(idx + 1);
33
+ }
34
+ }
35
+
36
+ /** 대괄호 밖에 있는 첫 `:` 위치 (없으면 -1) */
37
+ function indexOfTopLevelColon(s) {
38
+ let depth = 0;
39
+ for (let i = 0; i < s.length; i++) {
40
+ const c = s[i];
41
+ if (c === "[") depth++;
42
+ else if (c === "]") depth--;
43
+ else if (c === ":" && depth === 0) return i;
44
+ }
45
+ return -1;
46
+ }
47
+
48
+ /** `!gap-12` / `-mt-8` 같은 접두 기호 제거 */
49
+ export function stripModifiers(token) {
50
+ return token.replace(/^!/, "").replace(/^-/, "");
51
+ }
52
+
53
+ /**
54
+ * JSXAttribute 노드에서 정적으로 알 수 있는 클래스 토큰을 모두 수집한다.
55
+ * @returns {Array<{ token: string, node: import('estree').Node }>}
56
+ */
57
+ export function collectClassTokens(attrNode) {
58
+ if (attrNode.type !== "JSXAttribute") return [];
59
+ const name = attrNode.name?.name;
60
+ if (!CLASS_ATTRS.has(name)) return [];
61
+
62
+ const out = [];
63
+ const push = (raw, node) => {
64
+ for (const token of tokenize(raw)) out.push({ token, node });
65
+ };
66
+
67
+ const visit = (node) => {
68
+ if (!node) return;
69
+ switch (node.type) {
70
+ case "Literal":
71
+ if (typeof node.value === "string") push(node.value, node);
72
+ break;
73
+ case "TemplateLiteral":
74
+ // 정적 부분만 본다. 보간(${}) 안쪽은 아래 표현식 방문으로 따로 처리.
75
+ for (const q of node.quasis) push(q.value.cooked ?? "", node);
76
+ for (const e of node.expressions) visit(e);
77
+ break;
78
+ case "JSXExpressionContainer":
79
+ visit(node.expression);
80
+ break;
81
+ case "CallExpression": {
82
+ const callee = node.callee;
83
+ const fnName = callee?.type === "Identifier" ? callee.name : callee?.property?.name;
84
+ if (fnName && CLASS_HELPERS.has(fnName)) {
85
+ for (const arg of node.arguments) visit(arg);
86
+ }
87
+ break;
88
+ }
89
+ case "ConditionalExpression":
90
+ visit(node.consequent);
91
+ visit(node.alternate);
92
+ break;
93
+ case "LogicalExpression":
94
+ visit(node.left);
95
+ visit(node.right);
96
+ break;
97
+ case "ArrayExpression":
98
+ for (const el of node.elements) visit(el);
99
+ break;
100
+ case "ObjectExpression":
101
+ // clsx({ 'gap-12': cond }) 형태 — 키가 클래스다
102
+ for (const prop of node.properties) {
103
+ if (prop.type === "Property" && prop.key) {
104
+ if (prop.key.type === "Literal" && typeof prop.key.value === "string") {
105
+ push(prop.key.value, prop.key);
106
+ } else if (prop.key.type === "Identifier" && !prop.computed) {
107
+ push(prop.key.name, prop.key);
108
+ }
109
+ }
110
+ }
111
+ break;
112
+ default:
113
+ break;
114
+ }
115
+ };
116
+
117
+ visit(attrNode.value);
118
+ return out;
119
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * STableColumn 형태 판별과 "숫자 컬럼" 판정을 공유하는 헬퍼.
3
+ * table-numeric-align · require-locale-number 두 규칙이 함께 쓴다.
4
+ */
5
+
6
+ /**
7
+ * 오른쪽 정렬 대상 — 숫자가 들어가는 모든 컬럼.
8
+ * 식별자(주문번호·상품코드)도 숫자라 자릿수를 맞춰야 하므로 포함한다.
9
+ */
10
+ export const NUMERIC_LABEL =
11
+ /금액|가격|단가|판매가|원가|비용|수수료|배송비|할인|합계|총액|잔액|포인트|적립|수량|재고|개수|건수|횟수|비율|퍼센트|평점|점수|무게|용량|길이|면적|번호|코드|순번|순위|나이|연차/;
12
+
13
+ /**
14
+ * 하이픈 등 서식이 들어간 문자열 — 숫자가 아니므로 정렬·서식 규칙 모두 대상이 아니다.
15
+ * (전화번호 010-1234-5678, 사업자번호 123-45-67890)
16
+ */
17
+ export const IDENTIFIER_ONLY = /전화|휴대폰|연락처|사업자|주민|계좌|우편/;
18
+
19
+ /** format 본문에 숫자 서식이 있으면 숫자 컬럼으로 본다 */
20
+ export const NUMERIC_FORMAT = /toLocaleString|toFixed|Number\s*\(|parseInt|parseFloat/;
21
+
22
+ /** 객체 리터럴에서 프로퍼티 노드를 이름으로 찾는다 */
23
+ export function prop(objectNode, name) {
24
+ return objectNode.properties.find(
25
+ (p) =>
26
+ p.type === "Property" &&
27
+ !p.computed &&
28
+ ((p.key.type === "Identifier" && p.key.name === name) ||
29
+ (p.key.type === "Literal" && p.key.value === name)),
30
+ );
31
+ }
32
+
33
+ /** 문자열 리터럴 값 (아니면 undefined) */
34
+ export function literalString(node) {
35
+ return node?.value?.type === "Literal" && typeof node.value.value === "string"
36
+ ? node.value.value
37
+ : undefined;
38
+ }
39
+
40
+ /**
41
+ * STableColumn 형태인지 — name·label·field 셋을 모두 가진 객체만 컬럼으로 본다.
42
+ * @returns {{ nameProp, labelProp, label: string } | null}
43
+ */
44
+ export function asColumn(node) {
45
+ const nameProp = prop(node, "name");
46
+ const labelProp = prop(node, "label");
47
+ if (!nameProp || !labelProp || !prop(node, "field")) return null;
48
+
49
+ const label = literalString(labelProp);
50
+ if (label === undefined) return null; // 동적 label 은 판단하지 않는다
51
+
52
+ return { nameProp, labelProp, label, columnName: literalString(nameProp) };
53
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * **디자인 토큰이 있는 속성**에 임의 값을 쓰는 것을 막는다 — `text-[14px]`, `bg-[#eee]` 등.
3
+ *
4
+ * 반대로, 디자인 시스템이 토큰화하지 않는 **앱 고유 레이아웃 치수**(`w-[280px]`,
5
+ * `max-w-[1200px]`, `grid-cols-[200px_1fr]`)는 정당한 사용이므로 막지 않는다.
6
+ * 토큰을 var() 로 참조하는 형태(`h-[var(--sys-size-...)]`)도 허용한다.
7
+ *
8
+ * AGENTS.md §1-2.
9
+ */
10
+ import { collectClassTokens, stripVariants, stripModifiers } from "../lib/class-names.mjs";
11
+
12
+ /**
13
+ * 토큰이 존재하는 속성 접두어 — 여기에 임의 값을 쓰면 디자인 시스템을 우회하는 것이다.
14
+ * 색·타이포·간격·모서리 4계열.
15
+ */
16
+ const TOKEN_BACKED = [
17
+ // 색 (text- 는 색·폰트크기 양쪽 모두 토큰이 있어 어느 쪽이든 대상)
18
+ "text", "bg", "border", "outline", "ring", "shadow", "fill", "stroke",
19
+ "divide", "accent", "caret", "decoration", "from", "via", "to",
20
+ // 타이포
21
+ "font", "leading", "tracking",
22
+ // 간격
23
+ "gap", "gap-x", "gap-y", "space-x", "space-y",
24
+ "p", "px", "py", "pt", "pr", "pb", "pl", "ps", "pe",
25
+ "m", "mx", "my", "mt", "mr", "mb", "ml", "ms", "me",
26
+ // 모서리
27
+ "rounded", "rounded-t", "rounded-r", "rounded-b", "rounded-l",
28
+ "rounded-tl", "rounded-tr", "rounded-br", "rounded-bl",
29
+ ];
30
+
31
+ /** 대괄호 안이 토큰 참조인지 — `var(--x)` 또는 `length:var(--x)` 같은 데이터타입 접두어 허용 */
32
+ function isTokenReference(inner) {
33
+ const body = inner.includes(":") ? inner.slice(inner.indexOf(":") + 1) : inner;
34
+ return /^var\(--[\w-]+\)$/.test(body.trim());
35
+ }
36
+
37
+ /** `bg-[#eee]` → `bg` 처럼 임의 값 앞의 유틸리티 접두어를 뽑는다 */
38
+ function prefixOf(util) {
39
+ const idx = util.indexOf("[");
40
+ return idx <= 0 ? "" : util.slice(0, idx - 1); // 대괄호 바로 앞의 `-` 제외
41
+ }
42
+
43
+ export default {
44
+ meta: {
45
+ type: "problem",
46
+ docs: {
47
+ description: "색·타이포·간격·모서리에 임의 값 금지 — 토큰 유틸리티 또는 var(--token) 참조 사용",
48
+ },
49
+ schema: [
50
+ {
51
+ type: "object",
52
+ properties: {
53
+ /** 예외로 허용할 클래스 (완전 일치) */
54
+ allow: { type: "array", items: { type: "string" }, uniqueItems: true },
55
+ /** 검사 대상에서 뺄 유틸리티 접두어 (예: ["shadow"]) */
56
+ ignorePrefixes: { type: "array", items: { type: "string" }, uniqueItems: true },
57
+ },
58
+ additionalProperties: false,
59
+ },
60
+ ],
61
+ messages: {
62
+ arbitrary:
63
+ "`{{token}}` — {{prefix}} 는 디자인 토큰이 있는 속성이라 임의 값을 쓸 수 없습니다. 토큰 유틸리티를 쓰거나 var(--token) 으로 참조하세요. (AGENTS.md §1-2)",
64
+ },
65
+ },
66
+
67
+ create(context) {
68
+ const allow = new Set(context.options[0]?.allow ?? []);
69
+ const ignored = new Set(context.options[0]?.ignorePrefixes ?? []);
70
+ const targets = TOKEN_BACKED.filter((p) => !ignored.has(p));
71
+
72
+ return {
73
+ JSXAttribute(node) {
74
+ for (const { token, node: valueNode } of collectClassTokens(node)) {
75
+ if (allow.has(token)) continue;
76
+
77
+ const util = stripModifiers(stripVariants(token));
78
+ const match = util.match(/\[([^\]]*)\]/);
79
+ if (!match) continue;
80
+ if (isTokenReference(match[1])) continue;
81
+
82
+ // 토큰이 없는 속성(w·max-w·grid-cols·top …)의 임의 값은 앱 고유 치수이므로 허용
83
+ const prefix = prefixOf(util);
84
+ if (!targets.includes(prefix)) continue;
85
+
86
+ context.report({ node: valueNode, messageId: "arbitrary", data: { token, prefix } });
87
+ }
88
+ },
89
+ };
90
+ },
91
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 간격 유틸리티가 토큰 스케일 밖의 값을 쓰는 것을 막는다 — `gap-13`, `p-15` 등.
3
+ *
4
+ * Tailwind v4 는 `--spacing-*` 테마 키에 있는 값만 유틸리티로 생성하므로,
5
+ * 스케일 밖 숫자는 **에러 없이 조용히 아무 스타일도 적용되지 않는다.**
6
+ * 눈으로는 "간격이 왜 안 먹지" 로만 보이기 때문에 린트로 잡는다.
7
+ *
8
+ * AGENTS.md §2-2.
9
+ */
10
+ import { collectClassTokens, stripVariants, stripModifiers } from "../lib/class-names.mjs";
11
+ import { SPACING } from "../scale.gen.mjs";
12
+
13
+ /** 값이 순수하게 간격 스케일에서 오는 유틸리티 접두어만 대상으로 한다 */
14
+ const SPACING_PREFIXES = [
15
+ "gap-x", "gap-y", "gap",
16
+ "space-x", "space-y",
17
+ "px", "py", "pt", "pr", "pb", "pl", "ps", "pe", "p",
18
+ "mx", "my", "mt", "mr", "mb", "ml", "ms", "me", "m",
19
+ ];
20
+
21
+ const ALLOWED = new Set([0, ...SPACING]);
22
+
23
+ export default {
24
+ meta: {
25
+ type: "problem",
26
+ docs: {
27
+ description: "간격 유틸리티는 토큰 스케일 값만 사용",
28
+ },
29
+ schema: [],
30
+ messages: {
31
+ offScale:
32
+ "`{{token}}` 의 {{value}} 는 간격 스케일에 없어 스타일이 적용되지 않습니다. 가까운 값: {{nearest}}. (AGENTS.md §2-2)",
33
+ },
34
+ },
35
+
36
+ create(context) {
37
+ return {
38
+ JSXAttribute(node) {
39
+ for (const { token, node: valueNode } of collectClassTokens(node)) {
40
+ const util = stripModifiers(stripVariants(token));
41
+
42
+ const prefix = SPACING_PREFIXES.find((p) => util.startsWith(`${p}-`));
43
+ if (!prefix) continue;
44
+
45
+ const raw = util.slice(prefix.length + 1);
46
+ // 숫자가 아닌 값(auto, px, full, [var(--x)] 등)은 이 규칙 대상이 아니다
47
+ if (!/^\d+$/.test(raw)) continue;
48
+
49
+ const value = Number(raw);
50
+ if (ALLOWED.has(value)) continue;
51
+
52
+ const nearest = [...ALLOWED]
53
+ .sort((a, b) => Math.abs(a - value) - Math.abs(b - value))
54
+ .slice(0, 2)
55
+ .sort((a, b) => a - b)
56
+ .join(" / ");
57
+
58
+ context.report({
59
+ node: valueNode,
60
+ messageId: "offScale",
61
+ data: { token, value: String(value), nearest },
62
+ });
63
+ }
64
+ },
65
+ };
66
+ },
67
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * 디자인 시스템에 대응 컴포넌트가 있는 생 HTML 요소 사용을 막는다.
3
+ * AGENTS.md §1-1 과 같은 목록을 강제한다.
4
+ */
5
+
6
+ /**
7
+ * 기본 대상 — 대응 컴포넌트가 명확해 오탐 여지가 거의 없는 요소.
8
+ */
9
+ const REPLACEMENTS = {
10
+ button: "SButton / SGhostButton / SDropdownButton / STextLink",
11
+ input: "SInput / SNumberInput / SCheckbox / SRadio / SFilePicker",
12
+ select: "SSelect",
13
+ textarea: "STextarea",
14
+ table: "STable / SKeyValueTable",
15
+ form: "SForm",
16
+ dialog: "SModal.create(...) / SModal.confirm(...) / SPopup",
17
+ hr: "SDivider",
18
+ details: "SExpansionItem",
19
+ summary: "SExpansionItem",
20
+ progress: "SLinearProgress / SCircleProgress",
21
+ };
22
+
23
+ /**
24
+ * strict 옵션에서만 검사하는 요소 — 정당한 사용처가 함께 있어 기본에서는 뺀다.
25
+ * - svg : 로고·일러스트·차트
26
+ * - ul/li : 본문 안의 시맨틱 목록, 브레드크럼
27
+ * - label : DS 밖 컨트롤의 접근성 레이블
28
+ */
29
+ const STRICT_REPLACEMENTS = {
30
+ ul: "SList + SListItem",
31
+ ol: "SList + SListItem",
32
+ li: "SListItem",
33
+ svg: "SIcon",
34
+ label: "SField 의 label prop",
35
+ };
36
+
37
+ /** 시각 요소가 없어 대체 컴포넌트가 없는 input 타입 */
38
+ const EXEMPT_INPUT_TYPES = new Set(["hidden"]);
39
+
40
+ /** JSX 속성에서 리터럴 문자열 값을 읽는다 (동적 값이면 undefined) */
41
+ function literalAttr(node, name) {
42
+ const attr = node.attributes.find(
43
+ (a) => a.type === "JSXAttribute" && a.name?.name === name,
44
+ );
45
+ const v = attr?.value;
46
+ if (v?.type === "Literal" && typeof v.value === "string") return v.value;
47
+ return undefined;
48
+ }
49
+
50
+ /** 전역 함수 → 권장 대체 */
51
+ const GLOBAL_CALLS = {
52
+ alert: "SToast / SModal.confirm(...)",
53
+ confirm: "SModal.confirm(...)",
54
+ };
55
+
56
+ export default {
57
+ meta: {
58
+ type: "problem",
59
+ docs: {
60
+ description: "디자인 시스템 컴포넌트가 있는 생 HTML 컨트롤 사용 금지",
61
+ },
62
+ schema: [
63
+ {
64
+ type: "object",
65
+ properties: {
66
+ /** 예외로 허용할 요소명 (예: 로고용 svg) */
67
+ allow: { type: "array", items: { type: "string" }, uniqueItems: true },
68
+ /** ul·ol·li·svg·label 까지 검사 (정당한 사용처가 있어 기본은 off) */
69
+ strict: { type: "boolean" },
70
+ },
71
+ additionalProperties: false,
72
+ },
73
+ ],
74
+ messages: {
75
+ rawElement: "<{{name}}> 대신 {{replacement}} 를 사용하세요. (AGENTS.md §1-1)",
76
+ rawCall: "{{name}}() 대신 {{replacement}} 를 사용하세요. (AGENTS.md §1-1)",
77
+ },
78
+ },
79
+
80
+ create(context) {
81
+ const options = context.options[0] ?? {};
82
+ const allow = new Set(options.allow ?? []);
83
+ const replacements = options.strict
84
+ ? { ...REPLACEMENTS, ...STRICT_REPLACEMENTS }
85
+ : REPLACEMENTS;
86
+
87
+ return {
88
+ JSXOpeningElement(node) {
89
+ if (node.name.type !== "JSXIdentifier") return;
90
+ const name = node.name.name;
91
+ // 컴포넌트(대문자 시작)는 대상이 아니다
92
+ if (name[0] !== name[0].toLowerCase()) return;
93
+ if (allow.has(name)) return;
94
+
95
+ const replacement = replacements[name];
96
+ if (!replacement) return;
97
+
98
+ // <input type="hidden"> 처럼 시각 요소가 없는 경우는 대체 컴포넌트가 없다
99
+ if (name === "input" && EXEMPT_INPUT_TYPES.has(literalAttr(node, "type"))) return;
100
+
101
+ context.report({ node: node.name, messageId: "rawElement", data: { name, replacement } });
102
+ },
103
+
104
+ CallExpression(node) {
105
+ if (node.callee.type !== "Identifier") return;
106
+ const name = node.callee.name;
107
+ const replacement = GLOBAL_CALLS[name];
108
+ if (!replacement) return;
109
+ // 지역 선언(사용자가 만든 confirm 등)은 제외
110
+ if (context.sourceCode.getScope(node).references.some((r) => r.identifier === node.callee && r.resolved)) {
111
+ return;
112
+ }
113
+ context.report({ node, messageId: "rawCall", data: { name, replacement } });
114
+ },
115
+ };
116
+ },
117
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 폰트 크기·굵기를 낱개 유틸리티로 조립하는 것을 막고 `typo-*` 프리셋 사용을 유도한다.
3
+ * `text-14 font-bold` 대신 `typo-body-md-bold`.
4
+ *
5
+ * AGENTS.md §1-3 / §2-1.
6
+ */
7
+ import { collectClassTokens, stripVariants, stripModifiers } from "../lib/class-names.mjs";
8
+ import { TEXT_SIZES, TYPO_PRESETS } from "../scale.gen.mjs";
9
+
10
+ const FONT_WEIGHTS = new Set([
11
+ "font-thin", "font-extralight", "font-light", "font-normal", "font-medium",
12
+ "font-semibold", "font-bold", "font-extrabold", "font-black",
13
+ ]);
14
+
15
+ const TEXT_SIZE_SET = new Set(TEXT_SIZES.map(String));
16
+
17
+ /** 자주 쓰는 조합에 대한 추천 프리셋 (없으면 일반 안내) */
18
+ function suggest(size) {
19
+ const byRole = {
20
+ 12: "typo-body-sm-*",
21
+ 14: "typo-body-md-* / typo-heading-sm",
22
+ 16: "typo-body-lg-* / typo-heading-md",
23
+ 18: "typo-heading-lg",
24
+ 11: "typo-body-xs-default",
25
+ };
26
+ return byRole[size] ?? "typo-*";
27
+ }
28
+
29
+ export default {
30
+ meta: {
31
+ type: "suggestion",
32
+ docs: {
33
+ description: "폰트 크기·굵기 낱개 조합 대신 typo-* 프리셋 사용",
34
+ },
35
+ schema: [],
36
+ messages: {
37
+ fontSize:
38
+ "`{{token}}` 대신 타이포그래피 프리셋을 쓰세요 — {{suggestion}}. (AGENTS.md §2-1)",
39
+ fontWeight:
40
+ "`{{token}}` 을 단독으로 쓰지 말고 굵기가 포함된 typo-* 프리셋을 쓰세요. (AGENTS.md §2-1)",
41
+ },
42
+ },
43
+
44
+ create(context) {
45
+ return {
46
+ JSXAttribute(node) {
47
+ const tokens = collectClassTokens(node);
48
+ // 이미 프리셋을 쓰고 있으면 굵기 보조 유틸리티는 문제 삼지 않는다
49
+ const hasPreset = tokens.some(({ token }) =>
50
+ TYPO_PRESETS.includes(stripModifiers(stripVariants(token))),
51
+ );
52
+
53
+ for (const { token, node: valueNode } of tokens) {
54
+ const util = stripModifiers(stripVariants(token));
55
+
56
+ const sizeMatch = util.match(/^text-(\d+)$/);
57
+ if (sizeMatch && TEXT_SIZE_SET.has(sizeMatch[1])) {
58
+ context.report({
59
+ node: valueNode,
60
+ messageId: "fontSize",
61
+ data: { token, suggestion: suggest(Number(sizeMatch[1])) },
62
+ });
63
+ continue;
64
+ }
65
+
66
+ if (!hasPreset && FONT_WEIGHTS.has(util)) {
67
+ context.report({ node: valueNode, messageId: "fontWeight", data: { token } });
68
+ }
69
+ }
70
+ },
71
+ };
72
+ },
73
+ };