@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.
- package/README.md +8 -1
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Button.d.ts.map +1 -1
- package/dist/Card.d.ts.map +1 -1
- package/dist/Chip.d.ts.map +1 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/Lightbox.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/SectionLabel.d.ts +9 -2
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/Select.d.ts.map +1 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +392 -14
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/looks-of.d.ts +54 -0
- package/dist/eslint/looks-of.d.ts.map +1 -0
- package/dist/eslint/no-copied-look.d.ts +3 -0
- package/dist/eslint/no-copied-look.d.ts.map +1 -0
- package/dist/gates/index.js +1 -1
- package/dist/gates/index.js.map +2 -2
- package/dist/index.js +171 -133
- package/dist/index.js.map +3 -3
- package/dist/looks.d.ts +34 -0
- package/dist/looks.d.ts.map +1 -0
- package/dist/registry/app-3ALZE6XO.js +10 -0
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/{build-LGFCCOLR.js → build-NDXMVFQ5.js} +3 -3
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-XCBN2EKW.js → chunk-3WJ7ZZ7Z.js} +8 -4
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +7 -0
- package/dist/registry/{chunk-E4JNV7PC.js → chunk-PZO4BVV3.js} +2 -1
- package/dist/registry/chunk-PZO4BVV3.js.map +7 -0
- package/dist/registry/chunk-XVII7P6G.js +595 -0
- package/dist/registry/chunk-XVII7P6G.js.map +7 -0
- package/dist/registry/cli.js +3 -3
- package/dist/registry/index.js +3 -3
- package/dist/registry/schema.d.ts +8 -0
- package/dist/registry/schema.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +347 -90
- package/src/AttachmentCard.tsx +2 -0
- package/src/Button.tsx +1 -0
- package/src/Card.stories.tsx +2 -2
- package/src/Card.tsx +1 -0
- package/src/Chip.tsx +2 -1
- package/src/ChipInput.tsx +4 -2
- package/src/CommandPalette.tsx +2 -0
- package/src/ContainerHeader.stories.tsx +2 -1
- package/src/DialogShell.tsx +1 -0
- package/src/Divider.stories.tsx +12 -10
- package/src/Lightbox.tsx +1 -0
- package/src/Link.stories.tsx +5 -4
- package/src/Menu.tsx +3 -1
- package/src/NavItem.tsx +1 -0
- package/src/PersonTrigger.tsx +1 -0
- package/src/Reaction.tsx +3 -1
- package/src/ReactionPicker.stories.tsx +3 -2
- package/src/ScrollArea.stories.tsx +13 -12
- package/src/SearchInput.tsx +5 -4
- package/src/SectionLabel.mdx +4 -2
- package/src/SectionLabel.stories.tsx +13 -0
- package/src/SectionLabel.tsx +19 -2
- package/src/Select.tsx +6 -4
- package/src/TextInput.tsx +6 -6
- package/src/Textarea.tsx +6 -4
- package/src/Tooltip.stories.tsx +3 -2
- package/src/Tooltip.tsx +2 -1
- package/src/eslint/index.test.ts +12 -8
- package/src/eslint/index.ts +13 -5
- package/src/eslint/looks-of.ts +302 -0
- package/src/eslint/no-copied-look.test.ts +155 -0
- package/src/eslint/no-copied-look.ts +203 -0
- package/src/eslint/no-restyled-part.ts +1 -1
- package/src/gates/app-checks.ts +1 -1
- package/src/gates/create-app.test.ts +1 -1
- package/src/gates/gates.test.ts +1 -1
- package/src/looks.ts +42 -0
- package/src/registry/app.ts +3 -0
- package/src/registry/build.ts +8 -0
- package/src/registry/registry.test.ts +1 -1
- package/src/registry/schema.ts +9 -0
- package/dist/registry/app-FXN4EI34.js +0 -10
- package/dist/registry/chunk-E4JNV7PC.js.map +0 -7
- package/dist/registry/chunk-NJN4MQAM.js +0 -397
- package/dist/registry/chunk-NJN4MQAM.js.map +0 -7
- package/dist/registry/chunk-XCBN2EKW.js.map +0 -7
- /package/dist/registry/{app-FXN4EI34.js.map → app-3ALZE6XO.js.map} +0 -0
- /package/dist/registry/{build-LGFCCOLR.js.map → build-NDXMVFQ5.js.map} +0 -0
package/src/Tooltip.stories.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
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('
|
|
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>
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
expect(
|
|
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
|
})
|
package/src/eslint/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
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)
|
|
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.
|
|
101
|
-
*
|
|
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
|
-
|
|
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
|
+
})
|