@estiva-app/ui 0.19.0 → 0.21.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 (112) hide show
  1. package/README.md +38 -2
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Breadcrumb.d.ts.map +1 -1
  4. package/dist/Button.d.ts +6 -1
  5. package/dist/Button.d.ts.map +1 -1
  6. package/dist/Card.d.ts +6 -1
  7. package/dist/Card.d.ts.map +1 -1
  8. package/dist/DialogShell.d.ts.map +1 -1
  9. package/dist/Divider.d.ts +7 -0
  10. package/dist/Divider.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts +12 -2
  12. package/dist/IconButton.d.ts.map +1 -1
  13. package/dist/IdentityMenu.d.ts.map +1 -1
  14. package/dist/Link.d.ts +6 -1
  15. package/dist/Link.d.ts.map +1 -1
  16. package/dist/Menu.d.ts.map +1 -1
  17. package/dist/Popover.d.ts.map +1 -1
  18. package/dist/ReactionPicker.d.ts.map +1 -1
  19. package/dist/SectionLabel.d.ts +6 -1
  20. package/dist/SectionLabel.d.ts.map +1 -1
  21. package/dist/Tooltip.d.ts.map +1 -1
  22. package/dist/eslint/index.d.ts +2 -0
  23. package/dist/eslint/index.d.ts.map +1 -1
  24. package/dist/eslint/index.js +514 -6
  25. package/dist/eslint/index.js.map +4 -4
  26. package/dist/eslint/no-restyled-part.d.ts +81 -0
  27. package/dist/eslint/no-restyled-part.d.ts.map +1 -0
  28. package/dist/gates/app-checks.d.ts +40 -0
  29. package/dist/gates/app-checks.d.ts.map +1 -0
  30. package/dist/gates/chunk-AWO7SZSA.js +477 -0
  31. package/dist/gates/chunk-AWO7SZSA.js.map +7 -0
  32. package/dist/gates/chunk-ZGJ2J5NU.js +754 -0
  33. package/dist/gates/chunk-ZGJ2J5NU.js.map +7 -0
  34. package/dist/gates/cli.d.ts +2 -0
  35. package/dist/gates/cli.d.ts.map +1 -0
  36. package/dist/gates/cli.js +46 -0
  37. package/dist/gates/cli.js.map +7 -0
  38. package/dist/gates/count.d.ts +20 -0
  39. package/dist/gates/count.d.ts.map +1 -0
  40. package/dist/gates/create-app-cli.d.ts +2 -0
  41. package/dist/gates/create-app-cli.d.ts.map +1 -0
  42. package/dist/gates/create-app.d.ts +23 -0
  43. package/dist/gates/create-app.d.ts.map +1 -0
  44. package/dist/gates/create-app.js +30 -0
  45. package/dist/gates/create-app.js.map +7 -0
  46. package/dist/gates/gate-config.d.ts +44 -0
  47. package/dist/gates/gate-config.d.ts.map +1 -0
  48. package/dist/gates/hook.d.ts +18 -0
  49. package/dist/gates/hook.d.ts.map +1 -0
  50. package/dist/gates/index.d.ts +30 -0
  51. package/dist/gates/index.d.ts.map +1 -0
  52. package/dist/gates/index.js +415 -0
  53. package/dist/gates/index.js.map +7 -0
  54. package/dist/gates/status.d.ts +84 -0
  55. package/dist/gates/status.d.ts.map +1 -0
  56. package/dist/gates/token-lint.d.ts +26 -0
  57. package/dist/gates/token-lint.d.ts.map +1 -0
  58. package/dist/index.js +290 -266
  59. package/dist/index.js.map +4 -4
  60. package/package.json +30 -4
  61. package/src/AttachmentCard.tsx +7 -2
  62. package/src/Banner.tsx +2 -2
  63. package/src/Breadcrumb.tsx +9 -4
  64. package/src/Button.mdx +7 -0
  65. package/src/Button.stories.tsx +4 -1
  66. package/src/Button.tsx +10 -2
  67. package/src/Card.mdx +7 -0
  68. package/src/Card.stories.tsx +11 -0
  69. package/src/Card.tsx +8 -0
  70. package/src/CommandPalette.tsx +1 -1
  71. package/src/ConfirmDialog.tsx +2 -2
  72. package/src/DialogShell.tsx +6 -7
  73. package/src/Divider.mdx +3 -0
  74. package/src/Divider.tsx +12 -2
  75. package/src/EditableText.mdx +5 -0
  76. package/src/IconButton.mdx +9 -0
  77. package/src/IconButton.stories.tsx +10 -1
  78. package/src/IconButton.tsx +22 -3
  79. package/src/IdentityMenu.tsx +4 -1
  80. package/src/Link.mdx +6 -0
  81. package/src/Link.stories.tsx +12 -0
  82. package/src/Link.tsx +7 -2
  83. package/src/Menu.stories.tsx +1 -1
  84. package/src/Menu.test.tsx +2 -2
  85. package/src/Menu.tsx +6 -3
  86. package/src/Person.stories.tsx +5 -5
  87. package/src/Popover.test.tsx +2 -2
  88. package/src/Popover.tsx +3 -2
  89. package/src/PreviewCard.tsx +1 -1
  90. package/src/Property.stories.tsx +2 -2
  91. package/src/ReactionPicker.tsx +6 -3
  92. package/src/ScrollArea.stories.tsx +21 -7
  93. package/src/SectionLabel.mdx +3 -2
  94. package/src/SectionLabel.stories.tsx +3 -0
  95. package/src/SectionLabel.tsx +7 -2
  96. package/src/Tooltip.tsx +12 -14
  97. package/src/eslint/index.test.ts +32 -7
  98. package/src/eslint/index.ts +10 -1
  99. package/src/eslint/no-restyled-part.test.ts +301 -0
  100. package/src/eslint/no-restyled-part.ts +647 -0
  101. package/src/gates/app-checks.ts +224 -0
  102. package/src/gates/cli.ts +55 -0
  103. package/src/gates/count.ts +76 -0
  104. package/src/gates/create-app-cli.ts +25 -0
  105. package/src/gates/create-app.test.ts +72 -0
  106. package/src/gates/create-app.ts +797 -0
  107. package/src/gates/gate-config.ts +78 -0
  108. package/src/gates/gates.test.ts +183 -0
  109. package/src/gates/hook.ts +111 -0
  110. package/src/gates/index.ts +30 -0
  111. package/src/gates/status.ts +532 -0
  112. package/src/gates/token-lint.ts +231 -0
@@ -0,0 +1,301 @@
1
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { RuleTester } from 'eslint'
5
+ import ts from 'typescript'
6
+ import { parser } from 'typescript-eslint'
7
+ import { describe, expect, it } from 'vitest'
8
+ import { isPlacement, noRestyledPart, PART_LOOK_PROPS, PLACEMENT } from './no-restyled-part'
9
+
10
+ RuleTester.describe = describe
11
+ RuleTester.it = it
12
+ RuleTester.itOnly = it.only
13
+
14
+ const tester = new RuleTester({
15
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
16
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
17
+ })
18
+
19
+ /**
20
+ * The rule follows imports on disk, so its cases live in two small folders: an
21
+ * app, whose files re-export and wrap the package's parts the ways Peek and Ship
22
+ * do, and a copy of the package's own layout.
23
+ */
24
+ const root = mkdtempSync(join(tmpdir(), 'no-restyled-part-'))
25
+ const write = (path: string, text: string) => {
26
+ mkdirSync(dirname(join(root, path)), { recursive: true })
27
+ writeFileSync(join(root, path), text)
28
+ }
29
+ write('app/package.json', '{ "name": "app" }')
30
+ write('app/src/components/ui/Button.tsx', "export { Button, type ButtonProps } from '@estiva-app/ui'\n")
31
+ write('app/src/components/ui/index.ts', "export * from './Button'\n")
32
+ write('app/src/components/ui/Skeleton.tsx', "import { SkeletonList } from '@estiva-app/ui'\nexport const SkeletonSidebarList = SkeletonList\n")
33
+ write(
34
+ 'app/src/components/ui/EmptyState.tsx',
35
+ "import { EmptyState as UiEmptyState, type EmptyStateProps } from '@estiva-app/ui'\nexport function EmptyState({ message = 'Nothing', ...rest }: EmptyStateProps) {\n return <UiEmptyState message={message} {...rest} />\n}\n",
36
+ )
37
+ write(
38
+ 'app/src/components/ConversationCard.tsx',
39
+ "import { Card, cn } from '@estiva-app/ui'\nexport function ConversationCard({ className, title }: { className?: string; title: string }) {\n return <Card className={cn('relative', className)}>{title}</Card>\n}\n",
40
+ )
41
+ write('app/src/components/Button.tsx', 'export function Button() {\n return null\n}\n')
42
+ write('pkg/package.json', '{ "name": "@estiva-app/ui" }')
43
+ write('pkg/src/index.ts', "export { Link, type LinkProps } from './Link'\nexport { Card } from './Card'\nexport { cn } from './cn'\n")
44
+ write('pkg/src/Link.tsx', 'export function Link() {\n return null\n}\n')
45
+ write('pkg/src/cn.ts', 'export const cn = (...a: string[]) => a.join(" ")\n')
46
+
47
+ const app = join(root, 'app/src/Probe.tsx')
48
+ const pkg = join(root, 'pkg/src/Card.tsx')
49
+ const ui = (imports: string, body: string) => `import { ${imports} } from '@estiva-app/ui'\nexport function Probe() {\n return (\n${body}\n )\n}\n`
50
+ const local = (imports: string, from: string, body: string) => `import { ${imports} } from '${from}'\nexport function Probe() {\n return (\n${body}\n )\n}\n`
51
+ const USE_VARIANT_OR_SIZE = ' Use its `variant` or `size`.'
52
+ const restyled = (classes: string, where: string, use: string) =>
53
+ `${classes} on ${where} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in.${use} A look for what is around it goes on your own element around it.`
54
+ const noProp = (part: string) => ` \`${part}\` has no prop for this yet: ask Katerina, and it gets made in @estiva-app/ui.`
55
+
56
+ tester.run('no-restyled-part', noRestyledPart, {
57
+ valid: [
58
+ // placement, in every form it is written
59
+ { name: 'space, size, flex and grid', filename: app, code: ui('Button', ' <Button className="mt-2 -mx-2 px-3 w-full min-w-0 h-[240px] flex-1 shrink-0 gap-2 col-start-2 row-span-2 self-start">x</Button>') },
60
+ { name: 'shown or hidden, and position', filename: app, code: ui('Card', ' <Card className="hidden md:block relative z-10 inset-0 items-center justify-between" />') },
61
+ { name: 'placement behind a breakpoint, a theme or a state', filename: app, code: ui('Card', ' <Card className="sm:w-1/2 signal:gap-3 data-[open]:flex-col group-hover:flex" />') },
62
+ { name: 'a name for hover draws nothing', filename: app, code: ui('Card', ' <Card className="group group/row" />') },
63
+ { name: 'cn, a condition and a template, all placement', filename: app, code: ui('Button, cn', ' <Button className={cn(\'mt-2\', wide ? \'w-full\' : \'w-auto\', open && `gap-${1}`)}>x</Button>') },
64
+ { name: 'a class the code works out while it runs passes', filename: app, code: ui('Button', ' <Button className={props.className}>x</Button>') },
65
+ { name: 'an inner box’s own class prop, placement', filename: app, code: ui('ScrollArea', ' <ScrollArea className="min-h-0" contentClassName="flex flex-col gap-6 px-6 pb-5" viewportClassName="max-h-[70vh]" />') },
66
+ { name: 'a raw element is not a part', filename: app, code: ui('Button', ' <div className="bg-bg-surface text-h2 rounded-lg" />') },
67
+ { name: 'an app component of the same name is not a part', filename: app, code: local('Button', './components/Button', ' <Button className="text-h2" />') },
68
+ { name: 'a style prop is not a class prop', filename: app, code: ui('Button', ' <Button style={{ width: 20 }} data-class="text-h2">x</Button>') },
69
+ { name: 'EmptyState placed', filename: app, code: ui('EmptyState', ' <EmptyState className="mt-2 flex-1" message="Nothing yet" />') },
70
+ // escapes
71
+ { name: 'escaped, with its reason', filename: app, code: ui('Link', ' // @estiva-escape: the whole row is the link, and no part does that yet\n <Link href="/x" className="after:absolute after:inset-0">x</Link>') },
72
+ // the package itself
73
+ { name: 'inside the package: a name index.ts does not export', filename: pkg, code: local('Base', './Base', ' <Base className="text-h2" />') },
74
+ { name: 'inside the package: a raw element in a part', filename: pkg, code: 'export function Card() {\n return <div className="rounded-lg border" />\n}\n' },
75
+ ],
76
+ invalid: [
77
+ {
78
+ name: 'a colour and a text size, naming the props that carry a look',
79
+ filename: app,
80
+ code: ui('Button', ' <Button className="mt-2 text-text-secondary text-caption">x</Button>'),
81
+ errors: [{ message: restyled('`text-text-secondary` `text-caption`', '`Button`', USE_VARIANT_OR_SIZE) }],
82
+ },
83
+ {
84
+ name: 'a part with no look prop says to ask',
85
+ filename: app,
86
+ code: ui('EditableText', ' <EditableText className="-mx-2 text-h2" value="" placeholder="" label="Title" onCommit={() => true} />'),
87
+ errors: [{ message: restyled('`text-h2`', '`EditableText`', noProp('EditableText')) }],
88
+ },
89
+ {
90
+ name: 'border, corner, shadow, clipping, wrapping and effects',
91
+ filename: app,
92
+ code: ui('ScrollArea', ' <ScrollArea className="overflow-hidden rounded-lg border border-border-default shadow-md truncate transition-colors">x</ScrollArea>'),
93
+ errors: [{ message: restyled('`overflow-hidden` `rounded-lg` `border` `border-border-default` `shadow-md` `truncate` `transition-colors`', '`ScrollArea`', noProp('ScrollArea')) }],
94
+ },
95
+ {
96
+ name: 'a look behind a variant, and an extra layer',
97
+ filename: app,
98
+ code: ui('Link', ' <Link href="/x" className="signal:hover:bg-success-muted after:absolute [&>*]:shrink-0">x</Link>'),
99
+ errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant` or `truncate`.') }],
100
+ },
101
+ {
102
+ name: 'an inner box’s class prop',
103
+ filename: app,
104
+ code: ui('DialogShell', ' <DialogShell title="t" onClose={close} bodyClassName="flex flex-col text-body-2">x</DialogShell>'),
105
+ errors: [{ message: restyled('`text-body-2`', "`DialogShell`'s `bodyClassName`", noProp('DialogShell')) }],
106
+ },
107
+ {
108
+ name: 'cn, both sides of a condition, a template and a clsx object',
109
+ filename: app,
110
+ code: ui('Card, cn', " <Card className={cn('p-3', active ? 'bg-bg-active' : 'text-h5', `flex ${big ? 'rounded-lg' : ''}`, { 'text-text-muted': muted })} />"),
111
+ errors: [{ message: restyled('`bg-bg-active` `text-h5` `rounded-lg` `text-text-muted`', '`Card`', ` Use its ${'`fill`, `hover`, `attention`, `selected`, `active`, `hovered`, `quietUntilHover`, `unreadable` or `clip`'}.`) }],
112
+ },
113
+ {
114
+ name: 'a const in the same file, and a class map read from one',
115
+ filename: app,
116
+ code: "import { Person } from '@estiva-app/ui'\nconst NAME = 'text-caption'\nconst toneStyles = { quiet: 'text-text-muted', loud: { strong: 'font-semibold' } } as const\nexport function Probe({ tone }: { tone: 'quiet' | 'loud' }) {\n return <Person className={`${NAME} ${toneStyles[tone]}`} name=\"x\" />\n}\n",
117
+ errors: [{ message: restyled('`text-caption` `text-text-muted` `font-semibold`', '`Person`', ' Use its `size`.') }],
118
+ },
119
+ {
120
+ name: 'a const object spread onto the part',
121
+ filename: app,
122
+ code: "import { InlineChip } from '@estiva-app/ui'\nexport function Probe() {\n const chip = { tone: 'neutral', className: 'text-h2' } as const\n return <InlineChip {...chip}>x</InlineChip>\n}\n",
123
+ errors: [{ message: restyled('`text-h2`', '`InlineChip`', noProp('InlineChip')) }],
124
+ },
125
+ {
126
+ name: 'a namespace import',
127
+ filename: app,
128
+ code: "import * as UI from '@estiva-app/ui'\nexport function Probe() {\n return <UI.Chip className=\"text-h2\" label=\"x\" />\n}\n",
129
+ errors: [{ message: restyled('`text-h2`', '`Chip`', ' Use its `type`.') }],
130
+ },
131
+ {
132
+ name: 'a re-export through an app file, by the @/ alias',
133
+ filename: app,
134
+ code: local('Button', '@/components/ui/Button', ' <Button className="text-h2">x</Button>'),
135
+ errors: [{ message: restyled('`text-h2`', '`Button`', USE_VARIANT_OR_SIZE) }],
136
+ },
137
+ {
138
+ name: 'a re-export through a barrel, by a relative path',
139
+ filename: app,
140
+ code: local('Button', './components/ui', ' <Button className="text-h2">x</Button>'),
141
+ errors: [{ message: restyled('`text-h2`', '`Button`', USE_VARIANT_OR_SIZE) }],
142
+ },
143
+ {
144
+ name: 'a part given another name in an app file',
145
+ filename: app,
146
+ code: local('SkeletonSidebarList', '@/components/ui/Skeleton', ' <SkeletonSidebarList className="rounded-lg" />'),
147
+ errors: [{ message: restyled('`rounded-lg`', '`SkeletonList`', noProp('SkeletonList')) }],
148
+ },
149
+ {
150
+ name: 'a wrapper that hands its props on, and EmptyState’s padding',
151
+ filename: app,
152
+ code: local('EmptyState', '@/components/ui/EmptyState', ' <EmptyState className="px-3 py-2 text-text-muted" />'),
153
+ errors: [
154
+ { message: restyled('`text-text-muted`', '`EmptyState`', ' Use its `scope`.') },
155
+ { message: '`px-3` `py-2` pads `EmptyState`. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.' },
156
+ ],
157
+ },
158
+ {
159
+ name: 'an app component that hands its className on to a part',
160
+ filename: app,
161
+ code: local('ConversationCard', '@/components/ConversationCard', ' <ConversationCard title="t" className="mt-2 bg-bg-hover" />'),
162
+ errors: [{ message: restyled('`bg-bg-hover`', '`ConversationCard` (it hands its `className` to `Card`)', ` Use its ${'`fill`, `hover`, `attention`, `selected`, `active`, `hovered`, `quietUntilHover`, `unreadable` or `clip`'}.`) }],
163
+ },
164
+ {
165
+ name: 'a wrapper declared in the same file',
166
+ filename: app,
167
+ code: "import { Link, type LinkProps } from '@estiva-app/ui'\nfunction RouterLink({ href, ...props }: LinkProps) {\n return <Link href={href} {...props} />\n}\nexport function Probe() {\n return <RouterLink href=\"/x\" className=\"text-h5\">x</RouterLink>\n}\n",
168
+ errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant` or `truncate`.') }],
169
+ },
170
+ {
171
+ name: 'EmptyState padded directly',
172
+ filename: app,
173
+ code: ui('EmptyState', ' <EmptyState className="py-6" message="Nothing yet" />'),
174
+ errors: [{ message: '`py-6` pads `EmptyState`. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.' }],
175
+ },
176
+ {
177
+ name: 'an escape with no reason is reported, and so is the class',
178
+ filename: app,
179
+ code: ui('Link', ' // @estiva-escape:\n <Link href="/x" className="text-h2">x</Link>'),
180
+ errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant` or `truncate`.') }],
181
+ },
182
+ {
183
+ name: 'inside the package: a part imported from a sibling',
184
+ filename: pkg,
185
+ code: local('Link', './Link', ' <Link href="/x" className="rounded-lg border">x</Link>'),
186
+ errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant` or `truncate`.') }],
187
+ },
188
+ {
189
+ name: 'inside the package: a part used in the file that declares it',
190
+ filename: pkg,
191
+ code: 'export function Link() {\n return null\n}\nexport function Probe() {\n return <Link className="text-caption" />\n}\n',
192
+ errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant` or `truncate`.') }],
193
+ },
194
+ ],
195
+ })
196
+
197
+ /** Every part the package exports, from its own index.ts. */
198
+ const SRC = new URL('..', import.meta.url)
199
+ const indexSource = readFileSync(new URL('index.ts', SRC), 'utf8')
200
+ const PARTS = [...indexSource.matchAll(/export \{([^}]*)\} from '\.\/[^']+'/g)]
201
+ .flatMap((m) => m[1].split(','))
202
+ .map((name) => name.trim())
203
+ .filter((name) => /^[A-Z][a-z]/.test(name))
204
+
205
+ describe('every part of the package is covered, by where it comes from', () => {
206
+ it('finds the parts', () => {
207
+ expect(PARTS.length).toBeGreaterThan(70)
208
+ expect(PARTS).toContain('EnterHint')
209
+ })
210
+ tester.run('no-restyled-part, one case per exported part', noRestyledPart, {
211
+ valid: [],
212
+ invalid: PARTS.map((part) => ({
213
+ name: `${part}, restyled`,
214
+ filename: app,
215
+ code: ui(part, ` <${part} className="bg-bg-surface" />`),
216
+ errors: [{ message: restyled('`bg-bg-surface`', `\`${part}\``, PART_LOOK_PROPS[part] ? ` Use its ${PART_LOOK_PROPS[part].length === 1 ? `\`${PART_LOOK_PROPS[part][0]}\`` : `${PART_LOOK_PROPS[part].slice(0, -1).map((p) => `\`${p}\``).join(', ')} or \`${PART_LOOK_PROPS[part].at(-1)}\``}.` : noProp(part)) }],
217
+ })),
218
+ })
219
+ })
220
+
221
+ describe('the placement list', () => {
222
+ it('lets placement through and stops a look', () => {
223
+ for (const token of ['m-2', '-mt-px', 'px-3', 'w-[244px]', 'max-h-[70vh]', 'flex', 'flex-col', 'grow', 'basis-1/2', 'gap-x-2', 'space-y-1', 'col-span-2', 'row-start-1', 'order-last', 'self-end', 'justify-between', 'items-center', 'content-start', 'hidden', 'inline-flex', 'relative', '-top-1', 'z-10', 'group', '!mt-2', 'md:hidden'])
224
+ expect(isPlacement(token), token).toBe(true)
225
+ for (const token of ['text-h2', 'text-text-primary', 'bg-bg-surface', 'border', 'border-t', 'rounded-lg', 'shadow-md', 'font-mono', 'truncate', 'whitespace-nowrap', 'break-all', 'overflow-hidden', 'opacity-70', 'transition-colors', 'cursor-pointer', 'pointer-events-none', 'resize-none', 'content-[\'x\']', 'placeholder-text-muted', 'after:absolute', '[&>*]:shrink-0', '*:mt-2', 'ring-1'])
226
+ expect(isPlacement(token), token).toBe(false)
227
+ })
228
+ it('names what each entry lets through', () => {
229
+ expect(PLACEMENT.map((p) => p.what)).toEqual(['space around', 'space inside', 'width and height', 'shown, and how it lays out', 'its place in a row or a grid', 'alignment', 'position', 'a name for hover'])
230
+ })
231
+ })
232
+
233
+ /**
234
+ * The message names the props a part has for how it looks. They are read here
235
+ * from the package's own source with the TypeScript parser: every prop named
236
+ * exists on its part, and every prop of one of these names that a part has is
237
+ * named.
238
+ */
239
+ describe('the look props the message names are the package’s own', () => {
240
+ const LOOK_NAMES = new Set(['variant', 'size', 'tone', 'fill', 'type', 'scope', 'layout', 'surface', 'pressed', 'attention', 'truncate', 'mono'])
241
+ const files = readdirSync(SRC).filter((f) => f.endsWith('.tsx') && !/\.(stories|test)\.tsx$/.test(f))
242
+ const interfaces = new Map<string, ts.InterfaceDeclaration>()
243
+ const functions = new Map<string, ts.FunctionDeclaration>()
244
+ const aliases = new Map<string, ts.TypeNode>()
245
+ for (const file of files) {
246
+ const source = ts.createSourceFile(file, readFileSync(new URL(file, SRC), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
247
+ source.forEachChild(function visit(node) {
248
+ if (ts.isInterfaceDeclaration(node)) interfaces.set(node.name.text, node)
249
+ if (ts.isFunctionDeclaration(node) && node.name) functions.set(node.name.text, node)
250
+ if (ts.isTypeAliasDeclaration(node)) aliases.set(node.name.text, node.type)
251
+ node.forEachChild(visit)
252
+ })
253
+ }
254
+ /** A prop that chooses between fixed looks: a set of words, a yes/no, or a number (a face's size). */
255
+ const choosesALook = (type: ts.TypeNode | undefined): boolean => {
256
+ if (!type) return false
257
+ if (type.kind === ts.SyntaxKind.BooleanKeyword || type.kind === ts.SyntaxKind.NumberKeyword) return true
258
+ if (ts.isTypeReferenceNode(type)) return choosesALook(aliases.get(type.typeName.getText()))
259
+ if (ts.isUnionTypeNode(type)) return type.types.every((t) => ts.isLiteralTypeNode(t) && ts.isStringLiteral(t.literal))
260
+ return ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal)
261
+ }
262
+ /** A part's own props (not the HTML element's it extends), and whether each chooses a look. */
263
+ const propsOf = (part: string): Map<string, boolean> => {
264
+ const props = new Map<string, boolean>()
265
+ const add = (member: ts.TypeElement) => {
266
+ if (member.name) props.set(member.name.getText(), ts.isPropertySignature(member) && choosesALook(member.type))
267
+ }
268
+ const fromInterface = (name: string) => {
269
+ const declaration = interfaces.get(name)
270
+ if (!declaration) return false
271
+ declaration.members.forEach(add)
272
+ for (const clause of declaration.heritageClauses ?? []) for (const type of clause.types) fromInterface(type.expression.getText())
273
+ return true
274
+ }
275
+ if (!fromInterface(`${part}Props`)) {
276
+ const type = functions.get(part)?.parameters[0]?.type
277
+ if (type && ts.isTypeLiteralNode(type)) type.members.forEach(add)
278
+ }
279
+ return props
280
+ }
281
+
282
+ it('every prop the message names exists on its part', () => {
283
+ for (const [part, props] of Object.entries(PART_LOOK_PROPS)) {
284
+ expect(PARTS, part).toContain(part)
285
+ const own = propsOf(part)
286
+ for (const prop of props) expect(own.get(prop), `${part}.${prop}`).toBe(true)
287
+ }
288
+ })
289
+
290
+ /** Named like a look, and not one: AttachmentCard's `size` is the file's, in bytes. */
291
+ const NOT_A_LOOK: Record<string, string[]> = { AttachmentCard: ['size'] }
292
+
293
+ it('every look prop a part has is named', () => {
294
+ for (const part of PARTS) {
295
+ const missing = [...propsOf(part)]
296
+ .filter(([prop, chooses]) => chooses && LOOK_NAMES.has(prop) && !(PART_LOOK_PROPS[part] ?? []).includes(prop) && !(NOT_A_LOOK[part] ?? []).includes(prop))
297
+ .map(([prop]) => prop)
298
+ expect(missing, part).toEqual([])
299
+ }
300
+ })
301
+ })