@estiva-app/ui 0.33.0 → 0.34.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 (98) hide show
  1. package/README.md +8 -1
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Button.d.ts.map +1 -1
  4. package/dist/Card.d.ts.map +1 -1
  5. package/dist/Chip.d.ts.map +1 -1
  6. package/dist/ChipInput.d.ts.map +1 -1
  7. package/dist/CommandPalette.d.ts.map +1 -1
  8. package/dist/DialogShell.d.ts.map +1 -1
  9. package/dist/Lightbox.d.ts.map +1 -1
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/NavItem.d.ts.map +1 -1
  12. package/dist/PersonTrigger.d.ts.map +1 -1
  13. package/dist/Reaction.d.ts.map +1 -1
  14. package/dist/SearchInput.d.ts.map +1 -1
  15. package/dist/SectionLabel.d.ts +9 -2
  16. package/dist/SectionLabel.d.ts.map +1 -1
  17. package/dist/Select.d.ts.map +1 -1
  18. package/dist/TextInput.d.ts.map +1 -1
  19. package/dist/Textarea.d.ts.map +1 -1
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/eslint/index.d.ts +2 -0
  22. package/dist/eslint/index.d.ts.map +1 -1
  23. package/dist/eslint/index.js +392 -14
  24. package/dist/eslint/index.js.map +4 -4
  25. package/dist/eslint/looks-of.d.ts +54 -0
  26. package/dist/eslint/looks-of.d.ts.map +1 -0
  27. package/dist/eslint/no-copied-look.d.ts +3 -0
  28. package/dist/eslint/no-copied-look.d.ts.map +1 -0
  29. package/dist/gates/index.js +1 -1
  30. package/dist/gates/index.js.map +2 -2
  31. package/dist/index.js +171 -133
  32. package/dist/index.js.map +3 -3
  33. package/dist/looks.d.ts +34 -0
  34. package/dist/looks.d.ts.map +1 -0
  35. package/dist/registry/app-3ALZE6XO.js +10 -0
  36. package/dist/registry/app.d.ts.map +1 -1
  37. package/dist/registry/{build-LGFCCOLR.js → build-NDXMVFQ5.js} +3 -3
  38. package/dist/registry/build.d.ts.map +1 -1
  39. package/dist/registry/{chunk-XCBN2EKW.js → chunk-3WJ7ZZ7Z.js} +8 -4
  40. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +7 -0
  41. package/dist/registry/{chunk-E4JNV7PC.js → chunk-PZO4BVV3.js} +2 -1
  42. package/dist/registry/chunk-PZO4BVV3.js.map +7 -0
  43. package/dist/registry/chunk-XVII7P6G.js +595 -0
  44. package/dist/registry/chunk-XVII7P6G.js.map +7 -0
  45. package/dist/registry/cli.js +3 -3
  46. package/dist/registry/index.js +3 -3
  47. package/dist/registry/schema.d.ts +8 -0
  48. package/dist/registry/schema.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/registry.json +347 -90
  51. package/src/AttachmentCard.tsx +2 -0
  52. package/src/Button.tsx +1 -0
  53. package/src/Card.stories.tsx +2 -2
  54. package/src/Card.tsx +1 -0
  55. package/src/Chip.tsx +2 -1
  56. package/src/ChipInput.tsx +4 -2
  57. package/src/CommandPalette.tsx +2 -0
  58. package/src/ContainerHeader.stories.tsx +2 -1
  59. package/src/DialogShell.tsx +1 -0
  60. package/src/Divider.stories.tsx +12 -10
  61. package/src/Lightbox.tsx +1 -0
  62. package/src/Link.stories.tsx +5 -4
  63. package/src/Menu.tsx +3 -1
  64. package/src/NavItem.tsx +1 -0
  65. package/src/PersonTrigger.tsx +1 -0
  66. package/src/Reaction.tsx +3 -1
  67. package/src/ReactionPicker.stories.tsx +3 -2
  68. package/src/ScrollArea.stories.tsx +13 -12
  69. package/src/SearchInput.tsx +5 -4
  70. package/src/SectionLabel.mdx +4 -2
  71. package/src/SectionLabel.stories.tsx +13 -0
  72. package/src/SectionLabel.tsx +19 -2
  73. package/src/Select.tsx +6 -4
  74. package/src/TextInput.tsx +6 -6
  75. package/src/Textarea.tsx +6 -4
  76. package/src/Tooltip.stories.tsx +3 -2
  77. package/src/Tooltip.tsx +2 -1
  78. package/src/eslint/index.test.ts +12 -8
  79. package/src/eslint/index.ts +13 -5
  80. package/src/eslint/looks-of.ts +302 -0
  81. package/src/eslint/no-copied-look.test.ts +155 -0
  82. package/src/eslint/no-copied-look.ts +203 -0
  83. package/src/eslint/no-restyled-part.ts +1 -1
  84. package/src/gates/app-checks.ts +1 -1
  85. package/src/gates/create-app.test.ts +1 -1
  86. package/src/gates/gates.test.ts +1 -1
  87. package/src/looks.ts +42 -0
  88. package/src/registry/app.ts +3 -0
  89. package/src/registry/build.ts +8 -0
  90. package/src/registry/registry.test.ts +1 -1
  91. package/src/registry/schema.ts +9 -0
  92. package/dist/registry/app-FXN4EI34.js +0 -10
  93. package/dist/registry/chunk-E4JNV7PC.js.map +0 -7
  94. package/dist/registry/chunk-NJN4MQAM.js +0 -397
  95. package/dist/registry/chunk-NJN4MQAM.js.map +0 -7
  96. package/dist/registry/chunk-XCBN2EKW.js.map +0 -7
  97. /package/dist/registry/{app-FXN4EI34.js.map → app-3ALZE6XO.js.map} +0 -0
  98. /package/dist/registry/{build-LGFCCOLR.js.map → build-NDXMVFQ5.js.map} +0 -0
@@ -3,6 +3,7 @@ import { IconArchive, IconPin, IconTrash } from '@tabler/icons-react'
3
3
  import { Button } from './Button'
4
4
  import { IconButton } from './IconButton'
5
5
  import { Tooltip, WithTooltip } from './Tooltip'
6
+ import { Card } from './Card'
6
7
 
7
8
  const meta = {
8
9
  title: 'Primitives/Tooltip',
@@ -99,7 +100,7 @@ export const OnADisabledControl: Story = {
99
100
  export const InAToolbar: Story = {
100
101
  parameters: { controls: { disable: true } },
101
102
  render: () => (
102
- <div className="flex items-center gap-1 rounded-lg border border-border-default bg-bg-elevated p-1">
103
+ <Card fill="elevated" className="flex items-center gap-1 p-1">
103
104
  <IconButton aria-label="Pin" tooltip="Pin">
104
105
  <IconPin size={16} stroke={1.5} />
105
106
  </IconButton>
@@ -109,6 +110,6 @@ export const InAToolbar: Story = {
109
110
  <IconButton aria-label="Delete" tooltip="Delete" tooltipShortcut="Del">
110
111
  <IconTrash size={16} stroke={1.5} />
111
112
  </IconButton>
112
- </div>
113
+ </Card>
113
114
  ),
114
115
  }
package/src/Tooltip.tsx CHANGED
@@ -2,6 +2,7 @@ import type { ComponentPropsWithRef, ReactElement, ReactNode } from 'react'
2
2
  import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
3
3
  import { cn } from './cn'
4
4
  import { Kbd } from './Kbd'
5
+ import { FLOATING_SURFACE_CLASSES } from './looks'
5
6
 
6
7
  /**
7
8
  * Peek's Tooltip and WithTooltip (2026-08-28) — the same 30px elevated pill,
@@ -72,7 +73,7 @@ export interface TooltipProps extends Omit<ComponentPropsWithRef<'div'>, 'childr
72
73
  */
73
74
  export function Tooltip({ label, shortcut, className, ...props }: TooltipProps) {
74
75
  return (
75
- <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg min-h-[30px] max-w-80 flex items-center justify-center gap-1.5 px-2 py-1 shadow-lg', className)} {...props}>
76
+ <div role="tooltip" className={cn(FLOATING_SURFACE_CLASSES, 'min-h-[30px] max-w-80 flex items-center justify-center gap-1.5 px-2 py-1', className)} {...props}>
76
77
  <span className="min-w-0 text-caption text-text-primary break-words">{label}</span>
77
78
  {shortcut && <Kbd>{shortcut}</Kbd>}
78
79
  </div>
@@ -40,6 +40,7 @@ describe('the plugin object', () => {
40
40
  'no-handmade-header',
41
41
  'no-handmade-empty-state',
42
42
  'no-native-title',
43
+ 'no-copied-look',
43
44
  'raw-element-outside-a-wrapper',
44
45
  'no-hand-rolled-behaviour',
45
46
  'component-has-a-page',
@@ -57,11 +58,12 @@ describe('the plugin object', () => {
57
58
  * change it deliberately, here.
58
59
  */
59
60
  it('gives an app only the app rules, as errors, under estiva/', () => {
60
- for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
61
- expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
62
- expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error', 'estiva/no-native-title': 'error' })
63
- }
64
- expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title'])
61
+ const blocking = { 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error', 'estiva/no-native-title': 'error' }
62
+ for (const config of [estiva.configs.recommended, estiva.configs.strict]) expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
63
+ // The copied look warns in recommended (Katerina, 13 September), and strict makes it an error.
64
+ expect(estiva.configs.recommended.rules).toEqual({ ...blocking, 'estiva/no-copied-look': 'warn' })
65
+ expect(estiva.configs.strict.rules).toEqual({ ...blocking, 'estiva/no-copied-look': 'error' })
66
+ expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look'])
65
67
  })
66
68
 
67
69
  /**
@@ -82,9 +84,10 @@ describe('the plugin object', () => {
82
84
  'estiva/no-handmade-header': 'error',
83
85
  'estiva/no-handmade-empty-state': 'error',
84
86
  'estiva/no-native-title': 'error',
87
+ 'estiva/no-copied-look': 'warn',
85
88
  })
86
89
  expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {}))
87
- for (const id of PACKAGE_RULE_IDS.filter((id) => !['estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title'].includes(id))) {
90
+ for (const id of PACKAGE_RULE_IDS.filter((id) => !['estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look'].includes(id))) {
88
91
  expect(estiva.configs.recommended.rules?.[id]).toBeUndefined()
89
92
  expect(estiva.configs.strict.rules?.[id]).toBeUndefined()
90
93
  }
@@ -151,8 +154,8 @@ describe('countGates', () => {
151
154
  it('counts an error, and an escape only when the lint reports escapes', async () => {
152
155
  const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
153
156
  const none = { errors: 0, warnings: 0, escapes: 0 }
154
- expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none })
155
- expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none })
157
+ expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
158
+ expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
156
159
  })
157
160
 
158
161
  it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
@@ -176,6 +179,7 @@ describe('countGates', () => {
176
179
  'estiva/no-handmade-header': { errors: 0, warnings: 0, escapes: 0 },
177
180
  'estiva/no-handmade-empty-state': { errors: 0, warnings: 0, escapes: 0 },
178
181
  'estiva/no-native-title': { errors: 0, warnings: 0, escapes: 0 },
182
+ 'estiva/no-copied-look': { errors: 0, warnings: 0, escapes: 0 },
179
183
  },
180
184
  disabled: [],
181
185
  })
@@ -22,6 +22,7 @@ import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
22
22
  import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
23
23
  import { noHandmadeEmptyState } from './no-handmade-empty-state'
24
24
  import { noHandmadeHeader } from './no-handmade-header'
25
+ import { noCopiedLook } from './no-copied-look'
25
26
  import { noNativeTitle } from './no-native-title'
26
27
  import { noRawElement } from './no-raw-element'
27
28
  import { noRebuiltBehaviour } from './no-rebuilt-behaviour'
@@ -32,6 +33,7 @@ export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings
32
33
  export { OWNED_BEHAVIOURS, type OwnedBehaviour } from './no-rebuilt-behaviour'
33
34
  export { PART_LOOK_PROPS, PLACEMENT } from './no-restyled-part'
34
35
  export { PARTS_WITH_A_TITLE } from './no-native-title'
36
+ export { MIN_COVER, MIN_SHARED } from './looks-of'
35
37
 
36
38
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
37
39
 
@@ -51,6 +53,7 @@ const appRules = {
51
53
  'no-handmade-header': noHandmadeHeader,
52
54
  'no-handmade-empty-state': noHandmadeEmptyState,
53
55
  'no-native-title': noNativeTitle,
56
+ 'no-copied-look': noCopiedLook,
54
57
  }
55
58
 
56
59
  /**
@@ -65,9 +68,10 @@ const appRules = {
65
68
  * app's version, UIG-7), and an app has no `.mdx` pages at all. `index.test.ts` holds the
66
69
  * apps' list to exactly the app rules.
67
70
  *
68
- * Four rules are in both sets: `no-restyled-part` (UIG-9), and the
71
+ * Five rules are in both sets: `no-restyled-part` (UIG-9), and the
69
72
  * fingerprints `no-handmade-header` (UIG-22), `no-handmade-empty-state`
70
- * (UIG-23) and `no-native-title` (UIG-24). The package restyles none of its own parts either (Katerina, 17
73
+ * (UIG-23), `no-native-title` (UIG-24) and `no-copied-look` (UIG-25, a
74
+ * warning). The package restyles none of its own parts either (Katerina, 17
71
75
  * September), and "my review isn't enough" (23 September).
72
76
  */
73
77
  const packageRules = {
@@ -79,6 +83,7 @@ const packageRules = {
79
83
  'no-handmade-header': noHandmadeHeader,
80
84
  'no-handmade-empty-state': noHandmadeEmptyState,
81
85
  'no-native-title': noNativeTitle,
86
+ 'no-copied-look': noCopiedLook,
82
87
  }
83
88
 
84
89
  const rules = { ...appRules, ...packageRules }
@@ -97,8 +102,8 @@ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGI
97
102
  /**
98
103
  * `recommended` switches every **app** rule on at the level it was ruled at: an
99
104
  * error blocks, a warning is reported and never blocks. `strict` makes every app
100
- * rule an error. With every app rule an error, the two are the same today; they part
101
- * when the first warning-level rule arrives (UIG-25).
105
+ * rule an error. They part at the first warning-level rule, `no-copied-look`
106
+ * (UIG-25): `recommended` warns on it, `strict` blocks on it.
102
107
  *
103
108
  * `package` is the inward set (UIG-5), which only this package runs.
104
109
  */
@@ -112,6 +117,8 @@ plugin.configs.recommended = {
112
117
  [`${PLUGIN_KEY}/no-handmade-header`]: 'error',
113
118
  [`${PLUGIN_KEY}/no-handmade-empty-state`]: 'error',
114
119
  [`${PLUGIN_KEY}/no-native-title`]: 'error',
120
+ // A warning, never an error (Katerina, 13 September): likeness is a judgement.
121
+ [`${PLUGIN_KEY}/no-copied-look`]: 'warn',
115
122
  },
116
123
  }
117
124
  plugin.configs.strict = {
@@ -122,7 +129,8 @@ plugin.configs.strict = {
122
129
  plugin.configs.package = {
123
130
  name: '@estiva-app/ui/package',
124
131
  plugins: { [PLUGIN_KEY]: plugin },
125
- rules: Object.fromEntries(PACKAGE_RULE_IDS.map((id) => [id, 'error'])),
132
+ // Every inward rule blocks, but the copied look, which warns here too (UIG-25).
133
+ rules: Object.fromEntries(PACKAGE_RULE_IDS.map((id) => [id, id === `${PLUGIN_KEY}/no-copied-look` ? 'warn' : 'error'])),
126
134
  }
127
135
 
128
136
  export default plugin
@@ -0,0 +1,302 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, resolve as resolvePath } from 'node:path'
3
+ import ts from 'typescript'
4
+
5
+ /**
6
+ * What a component looks like, read from its source: every class list it
7
+ * writes (UIG-25). Shared by the catalogue, which records each part's looks in
8
+ * `registry.json`, and by `estiva/no-copied-look`, which compares a class list
9
+ * typed by hand against them — so the two always mean the same thing by "look".
10
+ *
11
+ * A class list is read wherever it is written: a `className` (or any
12
+ * `…ClassName`), through `cn()`/`clsx()`, a ternary, a `&&`, a template, a
13
+ * `const` in the file, or a class map indexed by a key; and every string that
14
+ * is a class list on its own. A constant imported from another file is not
15
+ * followed: a look written once and imported is not a copy.
16
+ *
17
+ * Only the **look words** count — colour, text, border, corner, shadow.
18
+ * Placement (where it sits, how much room it takes) is everyone's and says
19
+ * nothing about which part a list is copied from.
20
+ */
21
+
22
+ /** Where a thing sits and how much room it takes: never part of a look (the same list as `no-restyled-part`'s PLACEMENT). */
23
+ const PLACEMENT = [
24
+ /^m[xytrblse]?-/,
25
+ /^p[xytrblse]?-/,
26
+ /^(?:w|h|size|min-w|min-h|max-w|max-h)-/,
27
+ /^(?:hidden|block|inline|inline-block|inline-flex|inline-grid|flex|grid|contents|flow-root)$/,
28
+ /^(?:grow|shrink)$|^(?:flex|basis|grow|shrink|order|gap|gap-x|gap-y|space-x|space-y|col|row|grid-cols|grid-rows|grid-flow|auto-cols|auto-rows)-/,
29
+ /^(?:self|justify|justify-items|justify-self|items|place-content|place-items|place-self)-|^content-(?!\[)/,
30
+ /^(?:static|relative|absolute|fixed|sticky)$|^(?:inset|inset-x|inset-y|top|right|bottom|left|start|end|z)-/,
31
+ /^(?:group|peer)(?:\/[\w-]+)?$/,
32
+ ]
33
+ const bare = (token: string) => token.replace(/^(?:[^:\s[\]]+:)+/, '')
34
+ export const isPlacementWord = (token: string) => PLACEMENT.some((pattern) => pattern.test(bare(token)))
35
+
36
+ /** A word that could be a Tailwind class: lower case, dashes, variants, arbitrary values. */
37
+ const CLASSY = /^!?(?:[a-z0-9-]+:|\[[^\]\s]+\]:)*-?[a-z@][a-z0-9-]*(?:-\[[^\]\s]+\]|\/[\w.[\]-]+|-[\w.]+)*$|^\[[^\]\s]+\]$/
38
+ const words = (text: string) => text.split(/\s+/).filter(Boolean)
39
+ const CLASS_FUNCTIONS = new Set(['cn', 'clsx', 'cx', 'twMerge', 'classNames'])
40
+ const CLASS_PROP = /^(?:className|[a-z][A-Za-z]*ClassName)$/
41
+
42
+ /** A string is a class list on its own when every word could be a class, and two at least have a dash or a variant. */
43
+ function isClassList(text: string): boolean {
44
+ const list = words(text)
45
+ return list.length >= 3 && list.every((w) => CLASSY.test(w)) && list.filter((w) => /[-:[]/.test(w)).length >= 2
46
+ }
47
+
48
+ export interface ClassUnit {
49
+ /** Every class of the list, placement included, as written. */
50
+ tokens: string[]
51
+ /** Its look words only. */
52
+ look: string[]
53
+ /** Offsets in the file, for a report. */
54
+ start: number
55
+ end: number
56
+ /** A `className` (resolved through the file), or a string that is a class list on its own. */
57
+ via: 'attribute' | 'literal'
58
+ /** Where an escape for it is read: the element a `className` is on, or the string itself. */
59
+ anchor: number
60
+ /** The value of an exported `…_CLASSES` constant: a shared look, where it is written once. */
61
+ shared: boolean
62
+ }
63
+
64
+ const CAP = 24
65
+
66
+ /**
67
+ * Every class list a file writes. `follow` resolves a name the file imports,
68
+ * for a part's own look: what it draws includes a look it takes from a shared
69
+ * file. A list being checked is never followed — using a shared look is not
70
+ * copying it.
71
+ */
72
+ export function classUnits(source: ts.SourceFile, follow?: (name: string) => ts.Expression | undefined): ClassUnit[] {
73
+ const consts = new Map<string, ts.Expression>()
74
+ const collect = (node: ts.Node) => {
75
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isVariableDeclarationList(node.parent) && node.parent.flags & ts.NodeFlags.Const) {
76
+ consts.set(node.name.text, node.initializer)
77
+ }
78
+ ts.forEachChild(node, collect)
79
+ }
80
+ collect(source)
81
+
82
+ const product = (lists: string[][][]): string[][] => {
83
+ let out: string[][] = [[]]
84
+ for (const alternatives of lists) {
85
+ const next: string[][] = []
86
+ for (const a of out) for (const b of alternatives) if (next.length < CAP) next.push([...a, ...b])
87
+ out = next
88
+ }
89
+ return out
90
+ }
91
+ const resolve = (node: ts.Node | undefined, depth = 0): string[][] => {
92
+ if (!node || depth > 8) return [[]]
93
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [words(node.text)]
94
+ if (ts.isTemplateExpression(node)) return product([[words(node.head.text)], ...node.templateSpans.flatMap((span) => [resolve(span.expression, depth + 1), [words(span.literal.text)]])])
95
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node)) return resolve(node.expression, depth + 1)
96
+ if (ts.isJsxExpression(node)) return resolve(node.expression, depth + 1)
97
+ if (ts.isCallExpression(node)) return ts.isIdentifier(node.expression) && CLASS_FUNCTIONS.has(node.expression.text) ? product(node.arguments.map((a) => resolve(a, depth + 1))) : [[]]
98
+ if (ts.isConditionalExpression(node)) return [...resolve(node.whenTrue, depth + 1), ...resolve(node.whenFalse, depth + 1)].slice(0, CAP)
99
+ if (ts.isBinaryExpression(node)) {
100
+ // Off first: a look is the part at rest, with each option taken one at a time, never all at once.
101
+ if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) return [[], ...resolve(node.right, depth + 1)]
102
+ if (node.operatorToken.kind === ts.SyntaxKind.BarBarToken || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) return [...resolve(node.left, depth + 1), ...resolve(node.right, depth + 1)].slice(0, CAP)
103
+ return [[]]
104
+ }
105
+ if (ts.isArrayLiteralExpression(node)) return product(node.elements.map((e) => resolve(e, depth + 1)))
106
+ if (ts.isObjectLiteralExpression(node)) return [[], node.properties.flatMap((p) => (p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? words(p.name.text) : []))]
107
+ if (ts.isIdentifier(node)) {
108
+ const init = consts.get(node.text) ?? follow?.(node.text)
109
+ return init ? resolve(init, depth + 1) : [[]]
110
+ }
111
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
112
+ let target: ts.Expression | undefined = node.expression
113
+ if (ts.isIdentifier(target)) target = consts.get(target.text) ?? follow?.(target.text)
114
+ while (target && (ts.isAsExpression(target) || ts.isSatisfiesExpression(target) || ts.isParenthesizedExpression(target))) target = target.expression
115
+ if (!target || !ts.isObjectLiteralExpression(target)) return [[]]
116
+ const key = ts.isPropertyAccessExpression(node) ? node.name.text : ts.isStringLiteral(node.argumentExpression) ? node.argumentExpression.text : null
117
+ return target.properties
118
+ .filter((p): p is ts.PropertyAssignment => ts.isPropertyAssignment(p) && (key === null || (!!p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === key)))
119
+ .flatMap((p) => resolve(p.initializer, depth + 1))
120
+ .slice(0, CAP)
121
+ }
122
+ return [[]]
123
+ }
124
+
125
+ const units: ClassUnit[] = []
126
+ const seen = new Set<string>()
127
+ const sharedAt = (node: ts.Node) => {
128
+ for (let at: ts.Node | undefined = node; at; at = at.parent) {
129
+ if (ts.isVariableDeclaration(at)) return ts.isIdentifier(at.name) && /_CLASSES$/.test(at.name.text) && !!at.parent?.parent && ts.isVariableStatement(at.parent.parent) && !!at.parent.parent.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
130
+ }
131
+ return false
132
+ }
133
+ // A string's escape is read above the line it starts: the widest thing that starts on that line.
134
+ const lineStart = (node: ts.Node) => {
135
+ const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line
136
+ let at = node
137
+ while (at.parent && !ts.isSourceFile(at.parent) && source.getLineAndCharacterOfPosition(at.parent.getStart(source)).line === line) at = at.parent
138
+ return at.getStart(source)
139
+ }
140
+ const add = (tokens: string[], node: ts.Node, via: ClassUnit['via'], anchor = via === 'literal' ? lineStart(node) : node.getStart(source)) => {
141
+ const list = [...new Set(tokens.filter((t) => CLASSY.test(t)))]
142
+ const look = list.filter((t) => !isPlacementWord(t))
143
+ if (look.length === 0) return
144
+ const key = `${via}:${node.getStart(source)}:${[...list].sort().join(' ')}`
145
+ if (seen.has(key)) return
146
+ seen.add(key)
147
+ units.push({ tokens: list, look, start: node.getStart(source), end: node.getEnd(), via, anchor, shared: via === 'literal' && sharedAt(node) })
148
+ }
149
+ const visit = (node: ts.Node) => {
150
+ if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && CLASS_PROP.test(node.name.text) && node.initializer) {
151
+ // An escape goes on the line above the element, as it does for every rule of the plugin.
152
+ const element = node.parent.parent
153
+ for (const alternative of resolve(node.initializer)) add(alternative, node, 'attribute', element.getStart(source))
154
+ }
155
+ if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && isClassList(node.text)) add(words(node.text), node, 'literal')
156
+ ts.forEachChild(node, visit)
157
+ }
158
+ visit(source)
159
+ return units
160
+ }
161
+
162
+ /** The function components a file declares, by name, with where each one's body is. */
163
+ export function componentsOf(source: ts.SourceFile): Map<string, { start: number; end: number }> {
164
+ const found = new Map<string, { start: number; end: number }>()
165
+ const fn = (node: ts.Expression | undefined): ts.Node | undefined => {
166
+ let current = node
167
+ while (current && ts.isCallExpression(current) && current.arguments.length) current = current.arguments[0]
168
+ return current && (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) ? current : undefined
169
+ }
170
+ for (const statement of source.statements) {
171
+ if (ts.isFunctionDeclaration(statement) && statement.name && /^[A-Z]/.test(statement.name.text)) found.set(statement.name.text, { start: statement.getStart(source), end: statement.getEnd() })
172
+ if (ts.isVariableStatement(statement)) {
173
+ for (const declaration of statement.declarationList.declarations) {
174
+ const body = fn(declaration.initializer)
175
+ if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && body) found.set(declaration.name.text, { start: body.getStart(source), end: body.getEnd() })
176
+ }
177
+ }
178
+ }
179
+ return found
180
+ }
181
+
182
+ /** A look is worth comparing when it has this many look words at least: fewer is a text style, not a part. */
183
+ export const MIN_LOOK_WORDS = 4
184
+
185
+ /**
186
+ * Each named component's looks: its class lists' look words, one space-joined
187
+ * string per list, those with `MIN_LOOK_WORDS` or more. A list written outside
188
+ * every component of the file (a top-level constant, a class map) belongs to
189
+ * every component in it.
190
+ */
191
+ export function looksOf(source: ts.SourceFile, names?: Iterable<string>): Map<string, string[]> {
192
+ const components = componentsOf(source)
193
+ const units = classUnits(source, importedConstants(source)).filter((u) => u.look.length >= MIN_LOOK_WORDS)
194
+ const inside = (u: ClassUnit, r: { start: number; end: number }) => u.start >= r.start && u.start < r.end
195
+ const out = new Map<string, string[]>()
196
+ for (const name of names ?? components.keys()) {
197
+ const range = components.get(name)
198
+ const mine = units.filter((u) => (range ? inside(u, range) : false) || ![...components.values()].some((r) => inside(u, r)))
199
+ out.set(name, [...new Set(mine.map((u) => u.look.join(' ')))])
200
+ }
201
+ return out
202
+ }
203
+
204
+ /** Read and parse a file for looks. */
205
+ export function parseForLooks(fileName: string, text: string): ts.SourceFile {
206
+ return ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, fileName.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
207
+ }
208
+
209
+ /** How much of a look a class list must share to be a copy of it (UIG-25, Katerina 24 September). */
210
+ export const MIN_SHARED = 4
211
+ export const MIN_COVER = 0.8
212
+
213
+ /** The look words a list shares with a look, when it is a copy of it; otherwise null. */
214
+ export function copies(list: string[], look: string[]): string[] | null {
215
+ const mine = new Set(list)
216
+ const shared = look.filter((word) => mine.has(word))
217
+ if (shared.length < MIN_SHARED) return null
218
+ return shared.length / Math.min(list.length, look.length) >= MIN_COVER ? shared : null
219
+ }
220
+
221
+ /** The file a relative or `@/` import names, from the file that imports it. */
222
+ function importTarget(fromFile: string, specifier: string): string | null {
223
+ let base: string
224
+ if (specifier.startsWith('.')) base = resolvePath(dirname(fromFile), specifier)
225
+ else if (specifier.startsWith('@/')) {
226
+ let dir = dirname(resolvePath(fromFile))
227
+ while (!existsSync(join(dir, 'package.json')) && dirname(dir) !== dir) dir = dirname(dir)
228
+ base = join(dir, 'src', specifier.slice(2))
229
+ } else return null
230
+ for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) if (existsSync(candidate)) return candidate
231
+ return null
232
+ }
233
+
234
+ const constantsCache = new Map<string, Map<string, ts.Expression>>()
235
+ /** A file's top-level `const`s, by name. */
236
+ function constantsOf(file: string): Map<string, ts.Expression> {
237
+ const cached = constantsCache.get(file)
238
+ if (cached) return cached
239
+ const found = new Map<string, ts.Expression>()
240
+ const source = parseForLooks(file, readFileSync(file, 'utf8'))
241
+ for (const statement of source.statements) {
242
+ if (!ts.isVariableStatement(statement)) continue
243
+ for (const d of statement.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer) found.set(d.name.text, d.initializer)
244
+ }
245
+ constantsCache.set(file, found)
246
+ return found
247
+ }
248
+
249
+ /** A resolver for the class constants a file imports from its own repo: `FIELD_BOX_CLASSES` from `./looks`. */
250
+ export function importedConstants(source: ts.SourceFile): (name: string) => ts.Expression | undefined {
251
+ const imported = new Map<string, { file: string; name: string }>()
252
+ for (const statement of source.statements) {
253
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue
254
+ const bindings = statement.importClause?.namedBindings
255
+ if (!bindings || !ts.isNamedImports(bindings)) continue
256
+ const target = importTarget(source.fileName, statement.moduleSpecifier.text)
257
+ if (!target) continue
258
+ for (const element of bindings.elements) {
259
+ const local = element.name.text
260
+ if (/_CLASSES$/.test(element.propertyName?.text ?? local)) imported.set(local, { file: target, name: element.propertyName?.text ?? local })
261
+ }
262
+ }
263
+ return (name) => {
264
+ const from = imported.get(name)
265
+ if (!from) return undefined
266
+ try {
267
+ return constantsOf(from.file).get(from.name)
268
+ } catch {
269
+ return undefined
270
+ }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * The shared looks a file declares: each exported `…_CLASSES` constant, a string
276
+ * or a map of strings, by name (`FIELD_SIZE_CLASSES.small`). A hand-typed copy of
277
+ * one is told to use it.
278
+ */
279
+ export function sharedLooksOf(source: ts.SourceFile): Map<string, string[]> {
280
+ const out = new Map<string, string[]>()
281
+ const lookWords = (text: string) => words(text).filter((w) => CLASSY.test(w) && !isPlacementWord(w))
282
+ for (const statement of source.statements) {
283
+ if (!ts.isVariableStatement(statement) || !statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue
284
+ for (const d of statement.declarationList.declarations) {
285
+ if (!ts.isIdentifier(d.name) || !/_CLASSES$/.test(d.name.text) || !d.initializer) continue
286
+ let init: ts.Expression = d.initializer
287
+ while (ts.isAsExpression(init) || ts.isSatisfiesExpression(init) || ts.isParenthesizedExpression(init)) init = init.expression
288
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
289
+ const look = lookWords(init.text)
290
+ if (look.length >= MIN_LOOK_WORDS) out.set(d.name.text, [look.join(' ')])
291
+ } else if (ts.isObjectLiteralExpression(init)) {
292
+ for (const p of init.properties) {
293
+ if (!ts.isPropertyAssignment(p) || !p.name || !(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) continue
294
+ if (!(ts.isStringLiteral(p.initializer) || ts.isNoSubstitutionTemplateLiteral(p.initializer))) continue
295
+ const look = lookWords(p.initializer.text)
296
+ if (look.length >= MIN_LOOK_WORDS) out.set(`${d.name.text}.${p.name.text}`, [look.join(' ')])
297
+ }
298
+ }
299
+ }
300
+ }
301
+ return out
302
+ }
@@ -0,0 +1,155 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { ESLint, RuleTester } from 'eslint'
5
+ import { parser } from 'typescript-eslint'
6
+ import { describe, expect, it } from 'vitest'
7
+ import { copies, looksOf, parseForLooks } from './looks-of'
8
+ import { noCopiedLook } from './no-copied-look'
9
+ import estiva from './index'
10
+
11
+ RuleTester.describe = describe
12
+ RuleTester.it = it
13
+ RuleTester.itOnly = it.only
14
+
15
+ const tester = new RuleTester({
16
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
17
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
18
+ })
19
+
20
+ /**
21
+ * Two small repos on disk: an app, with the package's catalogue installed beside
22
+ * it (`node_modules/@estiva-app/ui/registry.json`, its `looks`), and a copy of
23
+ * the package's own layout. The looks are the ones the parts have today.
24
+ */
25
+ const root = mkdtempSync(join(tmpdir(), 'no-copied-look-'))
26
+ const write = (path: string, text: string) => {
27
+ mkdirSync(dirname(join(root, path)), { recursive: true })
28
+ writeFileSync(join(root, path), text)
29
+ }
30
+ const SECTION_LABEL = 'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-secondary'
31
+ const registry = (entries: { name: string; sourceFile: string; looks: string[] }[]) => JSON.stringify({ schemaVersion: 2, entries })
32
+ write('app/package.json', '{ "name": "app" }')
33
+ write(
34
+ 'app/node_modules/@estiva-app/ui/registry.json',
35
+ registry([
36
+ { name: 'SectionLabel', sourceFile: 'src/SectionLabel.tsx', looks: [SECTION_LABEL] },
37
+ { name: 'Card', sourceFile: 'src/Card.tsx', looks: ['rounded-lg border bg-bg-surface border-border-default'] },
38
+ { name: 'Avatar', sourceFile: 'src/Avatar.tsx', looks: ['rounded-full bg-bg-inset text-text-secondary font-medium'] },
39
+ ]),
40
+ )
41
+ // The app's own reusable part, and its wrapper of the package's Avatar.
42
+ write('app/src/components/ui/MetaText.tsx', "export function MetaText({ children }: { children: string }) {\n return <span className=\"text-caption text-text-muted whitespace-nowrap signal:font-mono signal:text-small signal:tracking-wide signal:tabular-nums\">{children}</span>\n}\n")
43
+ write('app/src/components/ui/Avatar.tsx', "import { Avatar as UiAvatar } from '@estiva-app/ui'\nexport function Avatar(props: { name: string }) {\n return <UiAvatar {...props} />\n}\n")
44
+ write('pkg/package.json', '{ "name": "@estiva-app/ui" }')
45
+ write('pkg/src/TextInput.tsx', "export function TextInput() {\n return <input className=\"bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg px-3 py-2 text-input-value text-text-primary\" />\n}\n")
46
+ write('app/src/components/ui/looks.ts', "export const WRITING_BOX_CLASSES = 'relative bg-bg-inset border border-border-default rounded-lg p-3 signal:focus-within:border-border-focus signal:focus-within:shadow-focus-ring'\n")
47
+ write('pkg/src/looks.ts', "export const FIELD_BOX_CLASSES = 'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg'\n")
48
+
49
+ const app = join(root, 'app/src/components/Probe.tsx')
50
+ const appWrapper = join(root, 'app/src/components/ui/Avatar.tsx')
51
+ const pkg = join(root, 'pkg/src/Textarea.tsx')
52
+ const probe = (jsx: string) => `export function Probe() {\n return (\n ${jsx}\n )\n}\n`
53
+
54
+ tester.run('no-copied-look', noCopiedLook, {
55
+ valid: [
56
+ { name: 'placement only: where it sits is everyone’s', filename: app, code: probe('<div className="flex items-center gap-2 px-3 py-2 min-w-0 shrink-0" />') },
57
+ { name: 'three look words: a text style, not a part', filename: app, code: probe('<span className="text-caption text-text-muted truncate">x</span>') },
58
+ { name: 'the part used, not copied', filename: app, code: `import { SectionLabel } from '@estiva-app/ui'\n${probe('<SectionLabel tone="secondary">Files</SectionLabel>')}` },
59
+ {
60
+ name: 'a wrapper of the package’s part, named like it (Peek’s Avatar): its own look is the part’s, never a copy',
61
+ filename: appWrapper,
62
+ code: "import { Avatar as UiAvatar } from '@estiva-app/ui'\nexport function Avatar() {\n return <span className=\"rounded-full bg-bg-inset text-text-secondary font-medium\"><UiAvatar /></span>\n}\n",
63
+ },
64
+ { name: 'a look written once in the package and imported is not a copy', filename: pkg, code: `import { FIELD_BOX_CLASSES } from './looks'\n${probe('<textarea className={FIELD_BOX_CLASSES} />')}` },
65
+ {
66
+ name: 'an escape with its reason',
67
+ filename: app,
68
+ code: probe(`<>\n {/* @estiva-escape: recorded in GATES.md, a label strip Katerina left as it is */}\n <span className="${SECTION_LABEL}">x</span>\n </>`),
69
+ },
70
+ ],
71
+ invalid: [
72
+ {
73
+ name: 'the package’s SectionLabel, typed again in an app (the ticket’s ForeignObjectWidget case, in today’s words)',
74
+ filename: app,
75
+ code: probe(`<span className="min-w-0 ${SECTION_LABEL}">Assignee</span>`),
76
+ errors: [{ messageId: 'usePart' }],
77
+ },
78
+ {
79
+ name: 'the same look, the words in another order and one class more',
80
+ filename: app,
81
+ code: probe('<span className="text-text-secondary signal:tracking-widest signal:uppercase text-h5 signal:text-small signal:font-mono leading-3 shrink-0">x</span>'),
82
+ errors: [{ messageId: 'usePart' }],
83
+ },
84
+ {
85
+ name: 'a card’s frame in a const, used through cn(): reported once, at the const',
86
+ filename: app,
87
+ code: `import { cn } from '@/lib/utils'\nconst FRAME = 'rounded-lg border border-border-default bg-bg-surface'\n${probe('<div className={cn(FRAME, "p-3")} />')}`,
88
+ errors: [{ messageId: 'usePart', line: 2 }],
89
+ },
90
+ {
91
+ name: 'the app’s own part, typed again: covered because it is exported from src, with no list kept',
92
+ filename: app,
93
+ code: probe('<span className="text-caption text-text-muted whitespace-nowrap shrink-0 signal:font-mono signal:text-small signal:tracking-wide signal:tabular-nums">9:14</span>'),
94
+ errors: [{ messageId: 'usePart' }],
95
+ },
96
+ {
97
+ name: 'the package copying itself: Textarea typing TextInput’s field (the ticket’s TextInput ≈ Textarea)',
98
+ filename: pkg,
99
+ code: probe('<textarea className="bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg px-3 py-2 text-input-value text-text-primary" />'),
100
+ errors: [{ messageId: 'shareIt' }],
101
+ },
102
+ {
103
+ name: 'a shared look typed again in the package: told to use it',
104
+ filename: pkg,
105
+ code: probe('<div className="bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg" />'),
106
+ errors: [{ messageId: 'useShared' }],
107
+ },
108
+ {
109
+ name: 'an app’s own shared look typed again: told to use it (Peek’s writing box)',
110
+ filename: app,
111
+ code: probe('<div className="relative bg-bg-inset border border-border-default rounded-lg p-3 signal:focus-within:border-border-focus signal:focus-within:shadow-focus-ring" />'),
112
+ errors: [{ messageId: 'useShared' }],
113
+ },
114
+ ],
115
+ })
116
+
117
+ describe('looks', () => {
118
+ it('reads a part at rest with one option at a time, never every option at once', () => {
119
+ const source = parseForLooks('Card.tsx', "const FILL = { surface: 'bg-bg-surface border-border-default', inset: 'bg-bg-inset border-border-subtle' }\nexport function Card({ fill, selected }: { fill: 'surface' | 'inset'; selected: boolean }) {\n return <div className={cn('rounded-lg border', FILL[fill], selected && 'bg-bg-selected border-accent-primary')} />\n}\n")
120
+ const looks = looksOf(source, ['Card']).get('Card') ?? []
121
+ expect(looks).toContain('rounded-lg border bg-bg-surface border-border-default')
122
+ expect(looks).toContain('rounded-lg border bg-bg-inset border-border-subtle')
123
+ expect(looks.some((l) => l.includes('bg-bg-surface') && l.includes('bg-bg-inset'))).toBe(false)
124
+ })
125
+
126
+ it('states its line: four look words, covering 80% of the shorter list', () => {
127
+ expect(copies(['a-1', 'b-1', 'c-1', 'd-1'], ['a-1', 'b-1', 'c-1', 'd-1', 'e-1'])).toHaveLength(4)
128
+ expect(copies(['a-1', 'b-1', 'c-1'], ['a-1', 'b-1', 'c-1'])).toBeNull()
129
+ expect(copies(['a-1', 'b-1', 'c-1', 'd-1', 'x-1', 'y-1'], ['a-1', 'b-1', 'c-1', 'd-1', 'e-1', 'f-1'])).toBeNull()
130
+ })
131
+ })
132
+
133
+ describe('a new part is covered the day it is added', () => {
134
+ it('reads the installed catalogue, so a part the package adds is compared without touching the rule', async () => {
135
+ const lint = async () => {
136
+ const eslint = new ESLint({
137
+ cwd: join(root, 'app'),
138
+ overrideConfigFile: true,
139
+ overrideConfig: [{ files: ['**/*.tsx'], languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } }, ...estiva.configs.recommended }],
140
+ })
141
+ const [result] = await eslint.lintText(probe('<div className="rounded-full bg-accent-wash text-accent-primary font-semibold ring-1 ring-accent-outline">3</div>'), { filePath: app })
142
+ return result.messages.filter((m) => m.ruleId === 'estiva/no-copied-look')
143
+ }
144
+ expect(await lint()).toHaveLength(0)
145
+ write(
146
+ 'app/node_modules/@estiva-app/ui/registry.json',
147
+ registry([{ name: 'CountBadge', sourceFile: 'src/CountBadge.tsx', looks: ['rounded-full bg-accent-wash text-accent-primary font-semibold ring-1 ring-accent-outline'] }]),
148
+ )
149
+ const found = await lint()
150
+ expect(found).toHaveLength(1)
151
+ // A warning, never an error (Katerina, 13 September).
152
+ expect(found[0].severity).toBe(1)
153
+ expect(found[0].message).toContain('CountBadge')
154
+ })
155
+ })