@teamblind-chorus/ui 1.0.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 (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/agents/AGENTS.md +143 -0
  4. package/agents/DESIGN.md +1311 -0
  5. package/agents/LOVABLE.md +472 -0
  6. package/agents/anti-patterns.md +533 -0
  7. package/agents/catalog.md +232 -0
  8. package/agents/components/avatar-rail/avatar-rail.family.json +46 -0
  9. package/agents/components/avatar-rail/avatar-rail.md +103 -0
  10. package/agents/components/avatar-rail/avatar-rail.spec.json +160 -0
  11. package/agents/components/badge/badge.family.json +45 -0
  12. package/agents/components/badge/badge.md +10 -0
  13. package/agents/components/badge/role.md +100 -0
  14. package/agents/components/badge/role.spec.json +75 -0
  15. package/agents/components/badge/update.md +132 -0
  16. package/agents/components/badge/update.spec.json +114 -0
  17. package/agents/components/banner/banner.family.json +28 -0
  18. package/agents/components/banner/banner.md +136 -0
  19. package/agents/components/banner/banner.spec.json +136 -0
  20. package/agents/components/bottom-sheet/bottom-sheet.family.json +29 -0
  21. package/agents/components/bottom-sheet/bottom-sheet.md +176 -0
  22. package/agents/components/bottom-sheet/bottom-sheet.spec.json +168 -0
  23. package/agents/components/bubble/bubble.family.json +29 -0
  24. package/agents/components/bubble/bubble.md +134 -0
  25. package/agents/components/bubble/bubble.spec.json +91 -0
  26. package/agents/components/button/button.family.json +76 -0
  27. package/agents/components/button/button.md +31 -0
  28. package/agents/components/button/check.md +138 -0
  29. package/agents/components/button/check.spec.json +161 -0
  30. package/agents/components/button/fab.md +161 -0
  31. package/agents/components/button/fab.spec.json +106 -0
  32. package/agents/components/button/icon.md +141 -0
  33. package/agents/components/button/icon.spec.json +164 -0
  34. package/agents/components/button/standard.md +219 -0
  35. package/agents/components/button/standard.spec.json +205 -0
  36. package/agents/components/button/text.md +186 -0
  37. package/agents/components/button/text.spec.json +215 -0
  38. package/agents/components/button/toggle.md +108 -0
  39. package/agents/components/button/toggle.spec.json +124 -0
  40. package/agents/components/button/toolbar.md +189 -0
  41. package/agents/components/button/toolbar.spec.json +109 -0
  42. package/agents/components/carousel/carousel.family.json +41 -0
  43. package/agents/components/carousel/carousel.md +40 -0
  44. package/agents/components/carousel/post.md +148 -0
  45. package/agents/components/carousel/post.spec.json +229 -0
  46. package/agents/components/carousel/profile.md +184 -0
  47. package/agents/components/carousel/profile.spec.json +219 -0
  48. package/agents/components/chip/chip.family.json +37 -0
  49. package/agents/components/chip/chip.md +10 -0
  50. package/agents/components/chip/filter.md +212 -0
  51. package/agents/components/chip/filter.spec.json +124 -0
  52. package/agents/components/chip/tag.md +137 -0
  53. package/agents/components/chip/tag.spec.json +104 -0
  54. package/agents/components/dialog/dialog.family.json +29 -0
  55. package/agents/components/dialog/dialog.md +113 -0
  56. package/agents/components/dialog/dialog.spec.json +156 -0
  57. package/agents/components/directory-list/directory-list.family.json +46 -0
  58. package/agents/components/directory-list/directory-list.md +87 -0
  59. package/agents/components/directory-list/directory-list.spec.json +104 -0
  60. package/agents/components/divider/divider.family.json +28 -0
  61. package/agents/components/divider/divider.md +78 -0
  62. package/agents/components/divider/divider.spec.json +51 -0
  63. package/agents/components/feed/ad.md +108 -0
  64. package/agents/components/feed/ad.spec.json +187 -0
  65. package/agents/components/feed/feed.family.json +48 -0
  66. package/agents/components/feed/feed.md +30 -0
  67. package/agents/components/feed/post.md +240 -0
  68. package/agents/components/feed/post.spec.json +361 -0
  69. package/agents/components/form-field/form-field.family.json +50 -0
  70. package/agents/components/form-field/form-field.md +11 -0
  71. package/agents/components/form-field/input.md +198 -0
  72. package/agents/components/form-field/input.spec.json +202 -0
  73. package/agents/components/form-field/search.md +81 -0
  74. package/agents/components/form-field/search.spec.json +135 -0
  75. package/agents/components/form-field/select.md +101 -0
  76. package/agents/components/form-field/select.spec.json +194 -0
  77. package/agents/components/form-field/textarea.md +89 -0
  78. package/agents/components/form-field/textarea.spec.json +176 -0
  79. package/agents/components/header/header.family.json +43 -0
  80. package/agents/components/header/header.md +18 -0
  81. package/agents/components/header/main.md +101 -0
  82. package/agents/components/header/main.spec.json +117 -0
  83. package/agents/components/header/sub.md +129 -0
  84. package/agents/components/header/sub.spec.json +81 -0
  85. package/agents/components/list/accordion.md +183 -0
  86. package/agents/components/list/accordion.spec.json +201 -0
  87. package/agents/components/list/entry.md +280 -0
  88. package/agents/components/list/entry.spec.json +237 -0
  89. package/agents/components/list/list.family.json +75 -0
  90. package/agents/components/list/list.md +24 -0
  91. package/agents/components/list/radio.md +144 -0
  92. package/agents/components/list/radio.spec.json +186 -0
  93. package/agents/components/list/standard.md +262 -0
  94. package/agents/components/list/standard.spec.json +221 -0
  95. package/agents/components/metadata/compact.md +69 -0
  96. package/agents/components/metadata/compact.spec.json +69 -0
  97. package/agents/components/metadata/metadata.family.json +42 -0
  98. package/agents/components/metadata/metadata.md +26 -0
  99. package/agents/components/metadata/standard.md +104 -0
  100. package/agents/components/metadata/standard.spec.json +152 -0
  101. package/agents/components/nav-card/nav-card.family.json +29 -0
  102. package/agents/components/nav-card/nav-card.md +179 -0
  103. package/agents/components/nav-card/nav-card.spec.json +161 -0
  104. package/agents/components/nav-list/nav-list.family.json +46 -0
  105. package/agents/components/nav-list/nav-list.md +91 -0
  106. package/agents/components/nav-list/nav-list.spec.json +107 -0
  107. package/agents/components/navigation-bar/main.md +201 -0
  108. package/agents/components/navigation-bar/main.spec.json +109 -0
  109. package/agents/components/navigation-bar/navigation-bar.family.json +44 -0
  110. package/agents/components/navigation-bar/navigation-bar.md +21 -0
  111. package/agents/components/navigation-bar/search.md +96 -0
  112. package/agents/components/navigation-bar/search.spec.json +142 -0
  113. package/agents/components/navigation-bar/sub.md +174 -0
  114. package/agents/components/navigation-bar/sub.spec.json +123 -0
  115. package/agents/components/page-shell/page-shell.family.json +22 -0
  116. package/agents/components/page-shell/page-shell.md +51 -0
  117. package/agents/components/profile-header/profile-header.family.json +29 -0
  118. package/agents/components/profile-header/profile-header.md +149 -0
  119. package/agents/components/profile-header/profile-header.spec.json +200 -0
  120. package/agents/components/progress/progress.family.json +27 -0
  121. package/agents/components/progress/progress.md +38 -0
  122. package/agents/components/progress/progress.spec.json +67 -0
  123. package/agents/components/side-sheet/side-sheet.family.json +30 -0
  124. package/agents/components/side-sheet/side-sheet.md +154 -0
  125. package/agents/components/side-sheet/side-sheet.spec.json +109 -0
  126. package/agents/components/skeleton/skeleton.family.json +28 -0
  127. package/agents/components/skeleton/skeleton.md +123 -0
  128. package/agents/components/skeleton/skeleton.spec.json +73 -0
  129. package/agents/components/status-tag/status-tag.family.json +26 -0
  130. package/agents/components/status-tag/status-tag.md +114 -0
  131. package/agents/components/status-tag/status-tag.spec.json +69 -0
  132. package/agents/components/suggestion-list/suggestion-list.family.json +46 -0
  133. package/agents/components/suggestion-list/suggestion-list.md +91 -0
  134. package/agents/components/suggestion-list/suggestion-list.spec.json +178 -0
  135. package/agents/components/switch/switch.family.json +27 -0
  136. package/agents/components/switch/switch.md +114 -0
  137. package/agents/components/switch/switch.spec.json +123 -0
  138. package/agents/components/tab-bar/tab-bar.family.json +27 -0
  139. package/agents/components/tab-bar/tab-bar.md +178 -0
  140. package/agents/components/tab-bar/tab-bar.spec.json +184 -0
  141. package/agents/components/tabs/rounded.md +150 -0
  142. package/agents/components/tabs/rounded.spec.json +140 -0
  143. package/agents/components/tabs/segmented.md +114 -0
  144. package/agents/components/tabs/segmented.spec.json +100 -0
  145. package/agents/components/tabs/tabs.family.json +59 -0
  146. package/agents/components/tabs/tabs.md +18 -0
  147. package/agents/components/tabs/underline.md +147 -0
  148. package/agents/components/tabs/underline.spec.json +139 -0
  149. package/agents/components/thumbnail/thumbnail.family.json +28 -0
  150. package/agents/components/thumbnail/thumbnail.md +152 -0
  151. package/agents/components/thumbnail/thumbnail.spec.json +172 -0
  152. package/agents/components/toast/toast.family.json +28 -0
  153. package/agents/components/toast/toast.md +133 -0
  154. package/agents/components/toast/toast.spec.json +89 -0
  155. package/agents/components/tooltip/tooltip.family.json +29 -0
  156. package/agents/components/tooltip/tooltip.md +139 -0
  157. package/agents/components/tooltip/tooltip.spec.json +110 -0
  158. package/agents/compose.md +240 -0
  159. package/agents/icons.json +831 -0
  160. package/agents/images.md +66 -0
  161. package/agents/manifest.json +87 -0
  162. package/agents/patterns/README.md +59 -0
  163. package/agents/patterns/actions.md +50 -0
  164. package/agents/patterns/browsing.md +52 -0
  165. package/agents/patterns/communications.md +56 -0
  166. package/agents/patterns/layout.md +72 -0
  167. package/agents/patterns/modals.md +50 -0
  168. package/agents/patterns/visual.md +55 -0
  169. package/agents/reconstruct.md +55 -0
  170. package/agents/scoped-adoption.md +111 -0
  171. package/agents/tokens.usage.json +1657 -0
  172. package/agents/usage.json +422 -0
  173. package/dist/icons/index.cjs +1332 -0
  174. package/dist/icons/index.cjs.map +1 -0
  175. package/dist/icons/index.d.cts +228 -0
  176. package/dist/icons/index.d.ts +228 -0
  177. package/dist/icons/index.js +1114 -0
  178. package/dist/icons/index.js.map +1 -0
  179. package/dist/index.cjs +5905 -0
  180. package/dist/index.cjs.map +1 -0
  181. package/dist/index.d.cts +896 -0
  182. package/dist/index.d.ts +896 -0
  183. package/dist/index.js +5847 -0
  184. package/dist/index.js.map +1 -0
  185. package/dist/styles.css +5765 -0
  186. package/eslint/README.md +79 -0
  187. package/eslint/index.js +78 -0
  188. package/eslint/rules.js +472 -0
  189. package/eslint/test.mjs +135 -0
  190. package/package.json +96 -0
  191. package/placeholder.png +0 -0
@@ -0,0 +1,79 @@
1
+ # `@teamblind-chorus/ui/eslint` — Chorus lint preset
2
+
3
+ The **deterministic backstop for "Chorus First."** LOVABLE.md and the other agent
4
+ guides are prose — they can be skipped, truncated past the paste cap, or diluted
5
+ as a chat grows, after which the model drifts back to its shadcn/Tailwind prior.
6
+ This preset runs in the **consumer project's own toolchain**, so it flags
7
+ design-system violations regardless of whether the agent read or remembered the
8
+ guide. An AI builder's lint-error fix loop reads these errors and self-corrects.
9
+
10
+ ## What it catches
11
+
12
+ | Rule | Severity | Catches | "Chorus First" failure it guards |
13
+ | :--- | :--- | :--- | :--- |
14
+ | `chorus/no-raw-hex` | error | `'#1A1A1A'`, `#fff` in any string (style, className, props) | ignoring color tokens |
15
+ | `chorus/no-tailwind-color` | error | `bg-white`, `text-black`, `border-gray-200`, `hover:bg-blue-500` in `className` | ignoring color tokens |
16
+ | `chorus/no-raw-cta` | error | raw `<button>`; `<a>` used as an action (`onClick` / no `href`) | escaping to non-Chorus |
17
+ | `chorus/no-offscale-space` | warn | `fontSize: 13`, `gap: 6`, `padding: '10px 12px'`, `borderRadius: 6` in inline `style` | ignoring spacing/typo/radius tokens |
18
+ | `no-restricted-imports` (built-in) | error | `@/components/ui/*` (shadcn), `@/components/chorus/*` (legacy mirror) | escaping to non-Chorus |
19
+
20
+ Rules report only on **statically-known** values — runtime-computed expressions
21
+ (`var(...)`, `calc(...)`, identifiers, conditionals) are left alone, so false
22
+ positives stay near zero.
23
+
24
+ ## Setup
25
+
26
+ Requires ESLint 9 (flat config).
27
+
28
+ ```bash
29
+ npm install -D eslint
30
+ ```
31
+
32
+ ### JavaScript / JSX projects (turnkey)
33
+
34
+ ```js
35
+ // eslint.config.js
36
+ import chorus from "@teamblind-chorus/ui/eslint";
37
+
38
+ export default [
39
+ ...chorus,
40
+ ];
41
+ ```
42
+
43
+ ### TypeScript / TSX projects
44
+
45
+ The preset is parser-agnostic — layer its rules onto your TypeScript parser so
46
+ they also run on `.ts` / `.tsx`:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import tseslint from "typescript-eslint";
51
+ import { plugin as chorus, recommendedRules, restrictedImports } from "@teamblind-chorus/ui/eslint";
52
+
53
+ export default [
54
+ ...tseslint.configs.recommended,
55
+ {
56
+ files: ["**/*.{ts,tsx}"],
57
+ plugins: { chorus },
58
+ rules: {
59
+ ...recommendedRules,
60
+ "no-restricted-imports": restrictedImports,
61
+ },
62
+ },
63
+ ];
64
+ ```
65
+
66
+ ## Escape hatch
67
+
68
+ These are hard rules, not preferences — a hit means regenerate, not suppress.
69
+ For the rare legitimate exception (a real external `<a href>`, an intentional
70
+ non-token literal authorized in `DESIGN.md § Adapting Chorus`), disable on the
71
+ single line and say why:
72
+
73
+ ```jsx
74
+ {/* eslint-disable-next-line chorus/no-offscale-space -- intrinsic slot geometry per DESIGN.md */}
75
+ <div style={{ width: 48 }} />
76
+ ```
77
+
78
+ If a value has no token and isn't an authorized exception, that's a **Chorus
79
+ gap** to report — not a license to disable the rule.
@@ -0,0 +1,78 @@
1
+ // Chorus ESLint preset — the deterministic backstop for "Chorus First".
2
+ //
3
+ // Prose guardrails in LOVABLE.md can be skipped, truncated, or diluted as a
4
+ // session grows; this preset runs in the consumer project's own toolchain, so
5
+ // it catches design-system violations whether or not the agent read (or
6
+ // remembered) the guide. An AI builder's lint-error fix loop sees these errors
7
+ // and self-corrects.
8
+ //
9
+ // Flat config (ESLint 9+). Two ways to consume:
10
+ //
11
+ // // eslint.config.js — JavaScript / JSX projects (turnkey)
12
+ // import chorus from "@teamblind-chorus/ui/eslint";
13
+ // export default [ ...chorus ];
14
+ //
15
+ // // eslint.config.js — TypeScript / TSX projects (layer onto your TS parser)
16
+ // import tseslint from "typescript-eslint";
17
+ // import { plugin as chorus, recommendedRules } from "@teamblind-chorus/ui/eslint";
18
+ // export default [
19
+ // ...tseslint.configs.recommended,
20
+ // { files: ["**/*.{ts,tsx}"], plugins: { chorus }, rules: recommendedRules },
21
+ // ];
22
+
23
+ import { rules } from "./rules.js";
24
+
25
+ export const plugin = {
26
+ meta: { name: "@teamblind-chorus/ui/eslint", version: "0.1.0" },
27
+ rules,
28
+ };
29
+
30
+ // The Chorus rules. Color/import/CTA/link violations are hard errors (they
31
+ // produce visibly off-brand or broken UI); off-scale spacing and full-bleed
32
+ // wrapping are warnings — real, but the lowest-severity drift and the highest
33
+ // false-positive risk (spatial heuristics over statically-known padding).
34
+ export const recommendedRules = {
35
+ "chorus/no-raw-hex": "error",
36
+ "chorus/no-tailwind-color": "error",
37
+ "chorus/no-raw-cta": "error",
38
+ "chorus/no-offscale-space": "warn",
39
+ "chorus/no-fullbleed-wrapper": "warn",
40
+ "chorus/no-redundant-link": "error",
41
+ "chorus/no-reinvented-family": "warn",
42
+ };
43
+
44
+ // Escaping to shadcn / the removed legacy mirror is caught by a built-in rule —
45
+ // no custom AST needed.
46
+ export const restrictedImports = [
47
+ "error",
48
+ {
49
+ patterns: [
50
+ {
51
+ group: ["@/components/ui", "@/components/ui/*"],
52
+ message: "shadcn UI is forbidden under Chorus First — import the equivalent from @teamblind-chorus/ui.",
53
+ },
54
+ {
55
+ group: ["@/components/chorus", "@/components/chorus/*"],
56
+ message: "The legacy Chorus mirror was removed — import from the @teamblind-chorus/ui package.",
57
+ },
58
+ ],
59
+ },
60
+ ];
61
+
62
+ const recommended = {
63
+ name: "chorus/recommended",
64
+ files: ["**/*.{js,jsx,mjs,cjs}"],
65
+ languageOptions: {
66
+ ecmaVersion: "latest",
67
+ sourceType: "module",
68
+ parserOptions: { ecmaFeatures: { jsx: true } },
69
+ },
70
+ plugins: { chorus: plugin },
71
+ rules: {
72
+ ...recommendedRules,
73
+ "no-restricted-imports": restrictedImports,
74
+ },
75
+ };
76
+
77
+ // Default export: a flat-config array. Spread it into eslint.config.js.
78
+ export default [recommended];
@@ -0,0 +1,472 @@
1
+ // Custom ESLint rules that enforce Chorus design-system invariants in CONSUMER
2
+ // code (the app a Lovable/AI agent generates against @teamblind-chorus/ui). These are
3
+ // the deterministic backstop for the four "Chorus First" failure modes that
4
+ // prose guardrails in LOVABLE.md cannot guarantee:
5
+ //
6
+ // 1. escaping to non-Chorus → no-shadcn-import (built-in), no-raw-cta
7
+ // 2. ignoring tokens (color) → no-raw-hex, no-tailwind-color
8
+ // 3. ignoring tokens (spacing/typo) → no-offscale-space
9
+ //
10
+ // Plain ESM, no build step. AST-based (ESTree + JSX) so they work under espree
11
+ // (.js/.jsx) and typescript-eslint (.ts/.tsx) alike — any parser that emits the
12
+ // standard JSX nodes. Rules report only on statically-known violations to keep
13
+ // false positives near zero; runtime-computed values are left alone.
14
+
15
+ const HEX_COLOR = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/;
16
+
17
+ // Tailwind color utilities: <prefix>-<palette>-<shade> | <prefix>-{white,black,…}
18
+ // Matches `bg-white`, `text-black`, `border-gray-200`, and state/responsive
19
+ // variants like `hover:bg-blue-500`, `dark:text-white/80`.
20
+ const TW_PALETTE =
21
+ 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
22
+ const TW_PREFIX =
23
+ 'bg|text|border|ring|ring-offset|from|via|to|fill|stroke|divide|outline|decoration|caret|accent|placeholder|shadow';
24
+ const TW_COLOR = new RegExp(
25
+ `(?:^|[\\s:])(?:${TW_PREFIX})-(?:(?:${TW_PALETTE})-\\d{2,3}|white|black|transparent|current|inherit)(?:\\/\\d{1,3})?\\b`,
26
+ );
27
+
28
+ // Style props where every value must resolve to a token (var/calc) or a
29
+ // structural keyword. A raw number or px/rem string here is off-scale drift.
30
+ const TOKEN_ONLY_PROPS = new Set([
31
+ 'gap', 'rowGap', 'columnGap',
32
+ 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
33
+ 'paddingInline', 'paddingBlock', 'paddingInlineStart', 'paddingInlineEnd',
34
+ 'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
35
+ 'marginInline', 'marginBlock',
36
+ 'fontSize', 'lineHeight', 'letterSpacing', 'fontWeight',
37
+ 'borderRadius', 'borderWidth',
38
+ ]);
39
+
40
+ const STRUCTURAL_VALUE = /^(0|auto|100%|inherit|initial|unset|none|normal)$/;
41
+
42
+ function stringIsTokenLike(raw) {
43
+ const s = String(raw).trim();
44
+ if (STRUCTURAL_VALUE.test(s)) return true;
45
+ if (s.includes('var(') || s.includes('calc(') || s.includes('clamp(')) return true;
46
+ if (/^-?\d+(\.\d+)?%$/.test(s)) return true; // bare percentage
47
+ return false;
48
+ }
49
+
50
+ // Collect every statically-known string from a JSX attribute value:
51
+ // plain string literals, the static quasis of template literals, and string
52
+ // arguments to helpers like clsx()/cn()/classNames(). Dynamic parts are skipped.
53
+ function collectStaticStrings(node, out) {
54
+ if (!node) return;
55
+ switch (node.type) {
56
+ case 'Literal':
57
+ if (typeof node.value === 'string') out.push(node.value);
58
+ break;
59
+ case 'TemplateLiteral':
60
+ for (const q of node.quasis) out.push(q.value.cooked ?? q.value.raw);
61
+ for (const ex of node.expressions) collectStaticStrings(ex, out);
62
+ break;
63
+ case 'JSXExpressionContainer':
64
+ collectStaticStrings(node.expression, out);
65
+ break;
66
+ case 'CallExpression':
67
+ for (const arg of node.arguments) collectStaticStrings(arg, out);
68
+ break;
69
+ case 'ArrayExpression':
70
+ for (const el of node.elements) collectStaticStrings(el, out);
71
+ break;
72
+ case 'ObjectExpression':
73
+ // clsx({ 'bg-white': cond }) — keys carry the class names
74
+ for (const prop of node.properties) {
75
+ if (prop.type === 'Property' && !prop.computed) {
76
+ if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') out.push(prop.key.value);
77
+ else if (prop.key.type === 'Identifier') out.push(prop.key.name);
78
+ }
79
+ }
80
+ break;
81
+ case 'ConditionalExpression':
82
+ collectStaticStrings(node.consequent, out);
83
+ collectStaticStrings(node.alternate, out);
84
+ break;
85
+ case 'LogicalExpression':
86
+ collectStaticStrings(node.left, out);
87
+ collectStaticStrings(node.right, out);
88
+ break;
89
+ default:
90
+ break;
91
+ }
92
+ }
93
+
94
+ function jsxAttrName(attr) {
95
+ return attr && attr.type === 'JSXAttribute' && attr.name && attr.name.type === 'JSXIdentifier'
96
+ ? attr.name.name
97
+ : null;
98
+ }
99
+
100
+ // Chorus families that own their page-rail inline padding internally
101
+ // (layoutInset="full-bleed"). Wrapping one in a padded element, passing it
102
+ // inline padding, or making the whole card a navigation link all re-pay the
103
+ // page gutter and misalign the component with sibling rails (NavigationBar /
104
+ // TabBar / section headings). Mirrors the runtime useFullBleedGuard.
105
+ const FULL_BLEED = new Set([
106
+ 'Feed', 'FeedGroup', 'FeedAd', 'Section', 'Carousel', 'PostCarousel', 'ProfileCarousel',
107
+ 'NavigationBar', 'TabBar', 'BottomNav', 'Tabs', 'List', 'NavList',
108
+ 'DirectoryList', 'SuggestionList', 'AvatarRail', 'ProfileHeader', 'SubHeader',
109
+ ]);
110
+
111
+ // Generic HTML containers that, when padded around a full-bleed child, cause
112
+ // the double-pay. Chorus full-bleed hosts (the Uppercase names above) are the
113
+ // documented embedded-composition parents and are intentionally NOT flagged.
114
+ const HTML_CONTAINERS = new Set([
115
+ 'div', 'section', 'main', 'article', 'span', 'header', 'footer', 'aside', 'nav', 'form', 'ul', 'ol', 'li',
116
+ ]);
117
+
118
+ // Inline-axis padding only — padding-block / py-* do not touch the page rail.
119
+ const INLINE_PAD_STYLE = new Set([
120
+ 'padding', 'paddingInline', 'paddingLeft', 'paddingRight', 'paddingInlineStart', 'paddingInlineEnd',
121
+ ]);
122
+ const INLINE_MARGIN_STYLE = new Set([
123
+ 'margin', 'marginInline', 'marginLeft', 'marginRight', 'marginInlineStart', 'marginInlineEnd',
124
+ ]);
125
+ // px-* / pl-* / pr-* / ps-* / pe-* / p-* (inline or shorthand), non-zero value.
126
+ const TW_INLINE_PAD = /(?:^|\s)p(?:x|l|r|s|e)?-(?!0(?:\s|$|\/))/;
127
+
128
+ function getOpeningName(opening) {
129
+ return opening && opening.name && opening.name.type === 'JSXIdentifier' ? opening.name.name : null;
130
+ }
131
+
132
+ function getStyleObject(opening) {
133
+ for (const attr of opening.attributes) {
134
+ if (jsxAttrName(attr) === 'style') {
135
+ const v = attr.value;
136
+ if (v && v.type === 'JSXExpressionContainer' && v.expression.type === 'ObjectExpression') {
137
+ return v.expression;
138
+ }
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+
144
+ function getClassNameStrings(opening) {
145
+ const out = [];
146
+ for (const attr of opening.attributes) {
147
+ const n = jsxAttrName(attr);
148
+ if (n === 'className' || n === 'class') collectStaticStrings(attr.value, out);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // A style prop in `nameSet` is "set" unless it's an explicit zero / none.
154
+ function objHasNonzeroProp(obj, nameSet) {
155
+ for (const prop of obj.properties) {
156
+ if (prop.type !== 'Property' || prop.computed) continue;
157
+ const key = prop.key.type === 'Identifier' ? prop.key.name
158
+ : prop.key.type === 'Literal' ? String(prop.key.value) : null;
159
+ if (!key || !nameSet.has(key)) continue;
160
+ const v = prop.value;
161
+ if (v.type === 'Literal' && (v.value === 0 || v.value === '0' || v.value === 'none')) continue;
162
+ return true;
163
+ }
164
+ return false;
165
+ }
166
+
167
+ // The negative-margin opt-out idiom that legitimately negates a bounded
168
+ // surface's padding (marginInline: calc(-1 * ...) / a leading '-').
169
+ function styleHasNegativeInlineMargin(obj) {
170
+ if (!obj) return false;
171
+ for (const prop of obj.properties) {
172
+ if (prop.type !== 'Property' || prop.computed) continue;
173
+ const key = prop.key.type === 'Identifier' ? prop.key.name
174
+ : prop.key.type === 'Literal' ? String(prop.key.value) : null;
175
+ if (!key || !INLINE_MARGIN_STYLE.has(key)) continue;
176
+ const raw = prop.value.type === 'Literal' ? String(prop.value.value)
177
+ : prop.value.type === 'TemplateLiteral'
178
+ ? prop.value.quasis.map((q) => q.value.cooked ?? q.value.raw).join('')
179
+ : '';
180
+ if (raw.includes('-') || raw.includes('calc(')) return true;
181
+ }
182
+ return false;
183
+ }
184
+
185
+ // Reinvented-family heuristics. An identity header / author row / entity row
186
+ // hand-built from primitives matches a family's anatomy and should BE the
187
+ // family (ProfileHeader / Metadata / List entry).
188
+ const HEADING_TAGS = new Set(['h1', 'h2', 'h3']);
189
+ const PLAIN_BLOCK_TAGS = new Set(['div', 'section', 'header', 'article', 'a', 'li']);
190
+ const IDENTITY_LEADING = new Set(['Thumbnail', 'Avatar']);
191
+ const META_SEPARATORS = new Set(['·', '•', '・', '∙']);
192
+
193
+ // Does `node`'s JSX subtree contain an element whose tag is in `names`?
194
+ function subtreeHasComponent(node, names) {
195
+ for (const c of node.children ?? []) {
196
+ if (c.type === 'JSXElement') {
197
+ if (names.has(getOpeningName(c.openingElement))) return true;
198
+ if (subtreeHasComponent(c, names)) return true;
199
+ } else if (c.type === 'JSXFragment') {
200
+ if (subtreeHasComponent(c, names)) return true;
201
+ }
202
+ }
203
+ return false;
204
+ }
205
+
206
+ export const rules = {
207
+ // ───────────────────────────────────────────────────────────────────────
208
+ 'no-raw-hex': {
209
+ meta: {
210
+ type: 'problem',
211
+ docs: { description: 'Disallow raw hex colors; every color must resolve to a Chorus token (var(--sys-*) / var(--ref-*)).' },
212
+ schema: [],
213
+ messages: {
214
+ rawHex: 'Raw hex color "{{ hex }}" — use a Chorus token: var(--sys-color-*) (or var(--ref-*) if no semantic alias). See LOVABLE.md § Token strictness.',
215
+ },
216
+ },
217
+ create(context) {
218
+ function check(node, str) {
219
+ const m = HEX_COLOR.exec(str);
220
+ if (m) context.report({ node, messageId: 'rawHex', data: { hex: m[0] } });
221
+ }
222
+ return {
223
+ Literal(node) {
224
+ if (typeof node.value === 'string') check(node, node.value);
225
+ },
226
+ TemplateElement(node) {
227
+ check(node, node.value.cooked ?? node.value.raw ?? '');
228
+ },
229
+ };
230
+ },
231
+ },
232
+
233
+ // ───────────────────────────────────────────────────────────────────────
234
+ 'no-tailwind-color': {
235
+ meta: {
236
+ type: 'problem',
237
+ docs: { description: 'Disallow Tailwind color utilities in className; color comes from Chorus tokens via styles.css, not utility classes.' },
238
+ schema: [],
239
+ messages: {
240
+ twColor: 'Tailwind color utility "{{ cls }}" — Chorus color comes from tokens, not utilities. Drop it; surfaces/text inherit from @teamblind-chorus/tokens. See anti-patterns.md.',
241
+ },
242
+ },
243
+ create(context) {
244
+ return {
245
+ JSXAttribute(node) {
246
+ const name = jsxAttrName(node);
247
+ if (name !== 'className' && name !== 'class') return;
248
+ const strings = [];
249
+ collectStaticStrings(node.value, strings);
250
+ for (const s of strings) {
251
+ const m = TW_COLOR.exec(s);
252
+ if (m) {
253
+ context.report({ node, messageId: 'twColor', data: { cls: m[0].trim() } });
254
+ break;
255
+ }
256
+ }
257
+ },
258
+ };
259
+ },
260
+ },
261
+
262
+ // ───────────────────────────────────────────────────────────────────────
263
+ 'no-raw-cta': {
264
+ meta: {
265
+ type: 'problem',
266
+ docs: { description: 'Disallow raw <button>, and <a> used as an action; commits go through the @teamblind-chorus/ui Button family.' },
267
+ schema: [],
268
+ messages: {
269
+ rawButton: 'Raw <button> — use <Button> from @teamblind-chorus/ui (variant standard/text/icon/fab). See compose.md § CTAs.',
270
+ anchorAsButton: '<a> used as an action (onClick / no href) — use <Button variant="text" appearance="accent"> from @teamblind-chorus/ui.',
271
+ },
272
+ },
273
+ create(context) {
274
+ return {
275
+ JSXOpeningElement(node) {
276
+ if (node.name.type !== 'JSXIdentifier') return;
277
+ const tag = node.name.name;
278
+ if (tag === 'button') {
279
+ context.report({ node, messageId: 'rawButton' });
280
+ return;
281
+ }
282
+ if (tag === 'a') {
283
+ let hasOnClick = false;
284
+ let hasHref = false;
285
+ for (const attr of node.attributes) {
286
+ const n = jsxAttrName(attr);
287
+ if (n === 'onClick') hasOnClick = true;
288
+ if (n === 'href') hasHref = true;
289
+ }
290
+ // Flag only anchors behaving like buttons; leave real navigation links.
291
+ if (hasOnClick || !hasHref) context.report({ node, messageId: 'anchorAsButton' });
292
+ }
293
+ },
294
+ };
295
+ },
296
+ },
297
+
298
+ // ───────────────────────────────────────────────────────────────────────
299
+ 'no-offscale-space': {
300
+ meta: {
301
+ type: 'suggestion',
302
+ docs: { description: 'Disallow off-scale numeric/px spacing, typography, and radius in inline style; values must resolve to sys.layout.* / sys.radius.* / sys.typo.* tokens.' },
303
+ schema: [],
304
+ messages: {
305
+ offScale: 'Off-scale "{{ prop }}: {{ value }}" — resolve to a Chorus token (e.g. var(--sys-layout-*), var(--sys-radius-*)). For typography use className="sys-typo-<role>-<rung>". See compose.md § raw → token map.',
306
+ },
307
+ },
308
+ create(context) {
309
+ function checkObject(obj) {
310
+ for (const prop of obj.properties) {
311
+ if (prop.type !== 'Property' || prop.computed) continue;
312
+ const key = prop.key.type === 'Identifier' ? prop.key.name
313
+ : prop.key.type === 'Literal' ? String(prop.key.value) : null;
314
+ if (!key || !TOKEN_ONLY_PROPS.has(key)) continue;
315
+ const v = prop.value;
316
+ if (v.type === 'Literal') {
317
+ if (typeof v.value === 'number' && v.value !== 0) {
318
+ context.report({ node: v, messageId: 'offScale', data: { prop: key, value: String(v.value) } });
319
+ } else if (typeof v.value === 'string' && !stringIsTokenLike(v.value)) {
320
+ context.report({ node: v, messageId: 'offScale', data: { prop: key, value: v.value } });
321
+ }
322
+ } else if (v.type === 'TemplateLiteral') {
323
+ const raw = v.quasis.map((q) => q.value.cooked ?? q.value.raw).join('');
324
+ if (!stringIsTokenLike(raw)) {
325
+ context.report({ node: v, messageId: 'offScale', data: { prop: key, value: raw || '…' } });
326
+ }
327
+ }
328
+ }
329
+ }
330
+ return {
331
+ JSXAttribute(node) {
332
+ if (jsxAttrName(node) !== 'style') return;
333
+ const val = node.value;
334
+ if (val && val.type === 'JSXExpressionContainer' && val.expression.type === 'ObjectExpression') {
335
+ checkObject(val.expression);
336
+ }
337
+ },
338
+ };
339
+ },
340
+ },
341
+
342
+ // ───────────────────────────────────────────────────────────────────────
343
+ 'no-fullbleed-wrapper': {
344
+ meta: {
345
+ type: 'suggestion',
346
+ docs: { description: 'Disallow wrapping a full-bleed Chorus component in a padded element, or passing it inline padding; the page rail is paid once and a full-bleed child reaches the container edges.' },
347
+ schema: [],
348
+ messages: {
349
+ directPadding: '<{{ name }}> is full-bleed and owns its page-rail padding internally — drop the inline padding (paddingInline / padding / px-* / pl-* / pr-*) on it. A full-bleed child reaches its container edges; the gutter is paid once at the shell. See LOVABLE.md §★ Layout-Type & Padding Contract.',
350
+ wrappedInPadding: '<{{ name }}> (full-bleed) wrapped in a padded <{{ wrapper }}> — the page rail is double-paid, so section headings, list-row leading content, and feed author blocks land at different rails. Remove the wrapper\'s inline padding (the shell pays the gutter once), or — inside a bounded surface — apply the negative-margin opt-out on <{{ name }}>. See anti-patterns.md § Rail self-diagnostic.',
351
+ },
352
+ },
353
+ create(context) {
354
+ return {
355
+ JSXElement(node) {
356
+ const opening = node.openingElement;
357
+ const name = getOpeningName(opening);
358
+ if (!name || !FULL_BLEED.has(name)) return;
359
+
360
+ // (a) inline padding on the full-bleed component itself.
361
+ const ownStyle = getStyleObject(opening);
362
+ const ownClasses = getClassNameStrings(opening);
363
+ if ((ownStyle && objHasNonzeroProp(ownStyle, INLINE_PAD_STYLE)) ||
364
+ ownClasses.some((s) => TW_INLINE_PAD.test(s))) {
365
+ context.report({ node: opening, messageId: 'directPadding', data: { name } });
366
+ return;
367
+ }
368
+
369
+ // (b) wrapped in a padded plain HTML container (gutter double-paid),
370
+ // unless this instance opts out with a negative inline margin.
371
+ const parent = node.parent;
372
+ if (parent && parent.type === 'JSXElement') {
373
+ const pName = getOpeningName(parent.openingElement);
374
+ if (pName && HTML_CONTAINERS.has(pName)) {
375
+ const pStyle = getStyleObject(parent.openingElement);
376
+ const pClasses = getClassNameStrings(parent.openingElement);
377
+ const padded = (pStyle && objHasNonzeroProp(pStyle, INLINE_PAD_STYLE)) ||
378
+ pClasses.some((s) => TW_INLINE_PAD.test(s));
379
+ if (padded && !styleHasNegativeInlineMargin(ownStyle)) {
380
+ context.report({ node: opening, messageId: 'wrappedInPadding', data: { name, wrapper: pName } });
381
+ }
382
+ }
383
+ }
384
+ },
385
+ };
386
+ },
387
+ },
388
+
389
+ // ───────────────────────────────────────────────────────────────────────
390
+ 'no-redundant-link': {
391
+ meta: {
392
+ type: 'problem',
393
+ docs: { description: 'Disallow wrapping a full-bleed Chorus component in a navigation <Link>/<a>; pass the component\'s own onClick instead — an outer link re-pays the page rail as a gutter and nests the card\'s own anchors.' },
394
+ schema: [],
395
+ messages: {
396
+ redundantLink: '<{{ name }}> wrapped in <{{ tag }}> for navigation — pass <{{ name }}>\'s own onClick (e.g. router.push) instead. An outer link re-pays the page rail as a gutter (misaligning it with NavigationBar / TabBar) and nests the card\'s own anchors (channel link / citation / mention), which breaks DOM parsing. See feed/post.spec.json § forbidden.',
397
+ },
398
+ },
399
+ create(context) {
400
+ return {
401
+ JSXElement(node) {
402
+ const opening = node.openingElement;
403
+ const tag = getOpeningName(opening);
404
+ if (tag !== 'Link' && tag !== 'a') return;
405
+ // Only real navigation links: <Link>, or <a href>. A bare <a onClick>
406
+ // is already covered by no-raw-cta.
407
+ if (tag === 'a' && !opening.attributes.some((at) => jsxAttrName(at) === 'href')) return;
408
+
409
+ for (const child of node.children) {
410
+ if (child.type !== 'JSXElement') continue;
411
+ const cName = getOpeningName(child.openingElement);
412
+ if (cName && FULL_BLEED.has(cName)) {
413
+ context.report({ node: opening, messageId: 'redundantLink', data: { name: cName, tag } });
414
+ break;
415
+ }
416
+ }
417
+ },
418
+ };
419
+ },
420
+ },
421
+
422
+ // ───────────────────────────────────────────────────────────────────────
423
+ 'no-reinvented-family': {
424
+ meta: {
425
+ type: 'suggestion',
426
+ docs: { description: 'Flag identity headers / meta rows hand-assembled from primitives (Thumbnail + heading, or a manual middot separator) — use the ProfileHeader / Metadata / List families instead of reinventing them.' },
427
+ schema: [],
428
+ messages: {
429
+ reinventedIdentity: 'Identity block hand-assembled from primitives (Thumbnail/Avatar + heading) — use a Chorus family: <ProfileHeader> for a profile/channel header, <Metadata> for a card-head author row, or <List variant="entry"> for an entity row. Resolve the exact usage in usage.json; never reinvent a family from token primitives. See anti-patterns.md §18.',
430
+ manualMetaSeparator: 'Hand-rolled "{{ sep }}" separator between meta spans — the <Metadata> family owns this row (name · subhandle · timestamp, middot-separated). Use <Metadata> instead of assembling it from spans. See anti-patterns.md §18.',
431
+ },
432
+ },
433
+ create(context) {
434
+ return {
435
+ JSXElement(node) {
436
+ const tag = getOpeningName(node.openingElement);
437
+ if (!tag || !HEADING_TAGS.has(tag)) return;
438
+ // Walk up a few levels: if a nearby plain container also holds a
439
+ // Thumbnail/Avatar, this is a reinvented identity block. Stop at any
440
+ // Chorus (Uppercase) ancestor — a heading legitimately inside a
441
+ // family is not a reinvention.
442
+ let cur = node.parent;
443
+ for (let hops = 0; cur && hops < 4; hops += 1) {
444
+ if (cur.type === 'JSXElement') {
445
+ const pName = getOpeningName(cur.openingElement);
446
+ if (pName && /^[A-Z]/.test(pName)) return;
447
+ if (pName && PLAIN_BLOCK_TAGS.has(pName) && subtreeHasComponent(cur, IDENTITY_LEADING)) {
448
+ context.report({ node, messageId: 'reinventedIdentity' });
449
+ return;
450
+ }
451
+ }
452
+ cur = cur.parent;
453
+ }
454
+ },
455
+ JSXText(node) {
456
+ const t = (node.value ?? '').trim();
457
+ if (!META_SEPARATORS.has(t)) return;
458
+ const parent = node.parent;
459
+ if (!parent || !parent.children) return;
460
+ // Only a separator *between* sibling elements, not middot inside a
461
+ // single text run (e.g. "공개 · 팔로우" stays one JSXText, untrimmed-equal).
462
+ const elementSiblings = parent.children.filter((c) => c.type === 'JSXElement').length;
463
+ if (elementSiblings >= 2) {
464
+ context.report({ node, messageId: 'manualMetaSeparator', data: { sep: t } });
465
+ }
466
+ },
467
+ };
468
+ },
469
+ },
470
+ };
471
+
472
+ export default rules;