@estiva-app/ui 0.30.0 → 0.32.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 (37) hide show
  1. package/README.md +12 -1
  2. package/dist/CommandPalette.d.ts.map +1 -1
  3. package/dist/DialogShell.d.ts.map +1 -1
  4. package/dist/eslint/index.d.ts +2 -0
  5. package/dist/eslint/index.d.ts.map +1 -1
  6. package/dist/eslint/index.js +1289 -961
  7. package/dist/eslint/index.js.map +4 -4
  8. package/dist/eslint/no-handmade-empty-state.d.ts +25 -0
  9. package/dist/eslint/no-handmade-empty-state.d.ts.map +1 -0
  10. package/dist/eslint/no-handmade-header.d.ts +17 -0
  11. package/dist/eslint/no-handmade-header.d.ts.map +1 -0
  12. package/dist/eslint/no-restyled-part.d.ts +20 -1
  13. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  14. package/dist/gates/app-checks.d.ts +1 -1
  15. package/dist/gates/app-checks.d.ts.map +1 -1
  16. package/dist/gates/index.js +4 -2
  17. package/dist/gates/index.js.map +2 -2
  18. package/dist/index.js +142 -123
  19. package/dist/index.js.map +4 -4
  20. package/package.json +1 -1
  21. package/registry.json +1 -1
  22. package/src/CommandPalette.tsx +1 -0
  23. package/src/ContainerHeader.mdx +3 -2
  24. package/src/DialogShell.tsx +22 -17
  25. package/src/EmptyState.mdx +5 -2
  26. package/src/FilePicker.stories.tsx +5 -1
  27. package/src/ReactionPicker.stories.tsx +2 -1
  28. package/src/eslint/index.test.ts +17 -9
  29. package/src/eslint/index.ts +12 -2
  30. package/src/eslint/no-handmade-empty-state.test.ts +151 -0
  31. package/src/eslint/no-handmade-empty-state.ts +344 -0
  32. package/src/eslint/no-handmade-header.test.ts +89 -0
  33. package/src/eslint/no-handmade-header.ts +153 -0
  34. package/src/eslint/no-restyled-part.ts +11 -0
  35. package/src/gates/app-checks.ts +5 -3
  36. package/src/gates/create-app.test.ts +1 -1
  37. package/src/gates/gates.test.ts +1 -1
@@ -0,0 +1,344 @@
1
+ import { statSync } from 'node:fs'
2
+ import type { Rule } from 'eslint'
3
+ import { ESCAPE_MESSAGES, isEscaped } from './escape'
4
+ import {
5
+ bindingOfTag,
6
+ classesOf,
7
+ functionComponents,
8
+ parseFile,
9
+ partsOfFile,
10
+ resolveModule,
11
+ walk,
12
+ type Node,
13
+ type Parser,
14
+ type PartBinding,
15
+ } from './no-restyled-part'
16
+
17
+ type Locals = Map<string, PartBinding | 'namespace'>
18
+
19
+ const child = (node: Node, key: string) => node[key] as Node | null | undefined
20
+ const children = (node: Node, key: string) => ((node[key] as (Node | null)[] | undefined) ?? []).filter((n): n is Node => !!n)
21
+ const nameOf = (node: Node | null | undefined): string | undefined =>
22
+ node?.type === 'Identifier' || node?.type === 'JSXIdentifier' ? (node.name as string) : undefined
23
+ const tagName = (element: Node) => nameOf(child(child(element, 'openingElement') as Node, 'name'))
24
+ /**
25
+ * Whether a name is the part itself: imported, re-exported, or an app wrapper
26
+ * that hands everything on (Peek's `EmptyState`). A component that only hands
27
+ * its `className` to one (Ship's `Activity`) is the app's own, and is followed.
28
+ */
29
+ const isPart = (binding: PartBinding | undefined, part: string) => binding?.part === part && binding.props === 'all'
30
+ const attributeOf = (opening: Node, name: string) => children(opening, 'attributes').find((a) => a.type === 'JSXAttribute' && nameOf(child(a, 'name')) === name)
31
+
32
+ // ---------------------------------------------------------------- shape 1: a line drawn by hand
33
+
34
+ /**
35
+ * An empty state is a line, not a paragraph of prose. The words are not read:
36
+ * UIG-1's pass on words alone fired on a hint and a status note, and words
37
+ * missed four of Peek's dialogs ("There is nowhere else to move it…", "Every
38
+ * topic is already in your Open work."). Where the line sits decides.
39
+ */
40
+ const SHORT = 120
41
+
42
+ /**
43
+ * The element's words, when words are all it holds: text, a string, a name
44
+ * dropped into a sentence ("There is nowhere else in {teamName} to put it.").
45
+ * A line with no words of its own — a count, a message's body — is not one.
46
+ */
47
+ function onlyWords(element: Node): string | undefined {
48
+ let line = ''
49
+ let own = ''
50
+ for (const kid of children(element, 'children')) {
51
+ let piece: string
52
+ if (kid.type === 'JSXText') piece = String(kid.value)
53
+ else if (kid.type === 'JSXExpressionContainer') {
54
+ const e = child(kid, 'expression') as Node
55
+ if (e.type === 'JSXEmptyExpression') continue
56
+ if (e.type === 'Literal' && typeof e.value === 'string') piece = e.value
57
+ else if (e.type === 'TemplateLiteral' && children(e, 'expressions').length === 0) piece = children(e, 'quasis').map((q) => (q.value as { cooked: string }).cooked).join('')
58
+ else if (e.type === 'Identifier' || e.type === 'MemberExpression') {
59
+ line += 'x'
60
+ continue
61
+ } else return undefined
62
+ } else return undefined
63
+ own += piece
64
+ line += piece
65
+ }
66
+ if (!/[a-z]{2,}/i.test(own)) return undefined
67
+ return line.replace(/\s+/g, ' ').trim()
68
+ }
69
+
70
+ const isCount = (node: Node | null | undefined): boolean => {
71
+ if (node?.type === 'ChainExpression') return isCount(child(node, 'expression'))
72
+ return node?.type === 'MemberExpression' && !node.computed && /^(?:length|size)$/.test(nameOf(child(node, 'property')) ?? '')
73
+ }
74
+ const isNumber = (node: Node | null | undefined, n: number) => node?.type === 'Literal' && node.value === n
75
+
76
+ /**
77
+ * What a condition says about a list: `empty` when it is true for an empty one
78
+ * (`rows.length === 0`, `!rows.length`, `isEmpty(rows)`), `full` when it is
79
+ * true for a list with rows (`rows.length > 0`, `rows.length`), nothing when it
80
+ * does not ask. `a && b` says what either side says.
81
+ */
82
+ function whenTrue(test: Node | null | undefined): 'empty' | 'full' | undefined {
83
+ if (!test) return undefined
84
+ if (isCount(test)) return 'full'
85
+ switch (test.type) {
86
+ case 'UnaryExpression': {
87
+ if (test.operator !== '!') return undefined
88
+ const inner = whenTrue(child(test, 'argument'))
89
+ return inner === 'empty' ? 'full' : inner === 'full' ? 'empty' : undefined
90
+ }
91
+ case 'LogicalExpression':
92
+ if (test.operator !== '&&') return undefined
93
+ return whenTrue(child(test, 'left')) ?? whenTrue(child(test, 'right'))
94
+ case 'BinaryExpression': {
95
+ let [count, other, op] = [child(test, 'left'), child(test, 'right'), String(test.operator)]
96
+ if (!isCount(count)) [count, other, op] = [other, count, ({ '<': '>', '>': '<', '<=': '>=', '>=': '<=' } as Record<string, string>)[op] ?? op]
97
+ if (!isCount(count)) return undefined
98
+ if (isNumber(other, 0)) return op === '===' || op === '==' || op === '<=' ? 'empty' : op === '>' || op === '!==' || op === '!=' ? 'full' : undefined
99
+ if (isNumber(other, 1)) return op === '<' ? 'empty' : op === '>=' ? 'full' : undefined
100
+ return undefined
101
+ }
102
+ case 'CallExpression': {
103
+ const callee = child(test, 'callee')
104
+ const name = callee?.type === 'MemberExpression' ? nameOf(child(callee, 'property')) : nameOf(callee)
105
+ return name && /^is(?:Empty|Blank)$/.test(name) ? 'empty' : undefined
106
+ }
107
+ default:
108
+ return undefined
109
+ }
110
+ }
111
+
112
+ const isMapCall = (node: Node | null | undefined) => {
113
+ const callee = node?.type === 'CallExpression' ? child(node, 'callee') : undefined
114
+ return callee?.type === 'MemberExpression' && nameOf(child(callee, 'property')) === 'map'
115
+ }
116
+
117
+ /**
118
+ * Whether a branch **is** a list: a `.map(…)`, or a box that holds only that —
119
+ * `<ul>{rows.map(…)}</ul>`. A branch that shows something else and lists a few
120
+ * things inside it (an answer with its sources) is not the list the line stands
121
+ * in for.
122
+ */
123
+ function isAList(branch: Node | null | undefined, depth = 0): boolean {
124
+ if (!branch || depth > 2) return false
125
+ if (isMapCall(branch)) return true
126
+ if (branch.type !== 'JSXElement' && branch.type !== 'JSXFragment') return false
127
+ if (branch.type === 'JSXElement' && !/^[a-z]/.test(tagName(branch) ?? '')) return false
128
+ const kids = children(branch, 'children').filter((k) => !(k.type === 'JSXText' && !String(k.value).trim()) && !(k.type === 'JSXExpressionContainer' && (child(k, 'expression') as Node).type === 'JSXEmptyExpression'))
129
+ return kids.length === 1 && (kids[0].type === 'JSXExpressionContainer' ? isMapCall(child(kids[0], 'expression')) : isAList(kids[0], depth + 1))
130
+ }
131
+
132
+ /** A box around one thing and nothing else: a wrapper that pads or places the line. */
133
+ function soleChildOf(parent: Node | undefined, element: Node): boolean {
134
+ if (parent?.type !== 'JSXElement' || !/^[a-z]/.test(tagName(parent) ?? '')) return false
135
+ const kids = children(parent, 'children').filter((k) => !(k.type === 'JSXText' && !String(k.value).trim()) && !(k.type === 'JSXExpressionContainer' && (child(k, 'expression') as Node).type === 'JSXEmptyExpression'))
136
+ return kids.length === 1 && kids[0] === element
137
+ }
138
+
139
+ /**
140
+ * Whether the element stands where a list's rows would be: one side of a
141
+ * condition whose other side maps a list, or under a condition that asks if
142
+ * something is empty — `cond && …`, `cond ? … : …`, `if (cond) return …`.
143
+ */
144
+ function standsInForAList(element: Node): boolean {
145
+ let at = element
146
+ for (let depth = 0; depth < 3 && soleChildOf(at.parent, at); depth += 1) at = at.parent as Node
147
+ const parent = at.parent
148
+ if (!parent) return false
149
+ if (parent.type === 'ConditionalExpression' && parent.test !== at) {
150
+ const consequent = parent.consequent === at
151
+ const other = child(parent, consequent ? 'alternate' : 'consequent')
152
+ return whenTrue(child(parent, 'test')) === (consequent ? 'empty' : 'full') || isAList(other)
153
+ }
154
+ if (parent.type === 'LogicalExpression' && parent.operator === '&&' && parent.right === at) return whenTrue(child(parent, 'left')) === 'empty'
155
+ if (parent.type === 'ReturnStatement') {
156
+ let holder = parent.parent
157
+ if (holder?.type === 'BlockStatement' && children(holder, 'body').length === 1) holder = holder.parent
158
+ return holder?.type === 'IfStatement' && child(holder, 'consequent') !== undefined && whenTrue(child(holder, 'test')) === 'empty'
159
+ }
160
+ return false
161
+ }
162
+
163
+ // ---------------------------------------------------------------- shape 2: the right part at the wrong scope
164
+
165
+ /** A content box that fills its viewport: `min-h-full` or `h-full` on `contentClassName`. */
166
+ const FILLS = /^(?:min-h-full|h-full)$/
167
+
168
+ function isPageScope(opening: Node): boolean {
169
+ const scope = attributeOf(opening, 'scope')
170
+ if (!scope) return true
171
+ const value = child(scope, 'value')
172
+ return value?.type === 'Literal' && value.value === 'page'
173
+ }
174
+
175
+ interface FileFacts {
176
+ file: string
177
+ locals: Locals
178
+ components: Map<string, Node>
179
+ /** A local name imported from the app's own files: where from, and under what name. */
180
+ imports: Map<string, { file: string; name: string }>
181
+ }
182
+
183
+ const factsCache = new Map<string, { mtime: number; facts: FileFacts | null }>()
184
+
185
+ function factsOf(file: string, program: Node, parser: Parser): FileFacts {
186
+ const imports = new Map<string, { file: string; name: string }>()
187
+ for (const statement of children(program, 'body')) {
188
+ if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type') continue
189
+ const target = resolveModule(file, String((child(statement, 'source') as Node).value))
190
+ if (!target) continue
191
+ for (const specifier of children(statement, 'specifiers')) {
192
+ if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue
193
+ const local = nameOf(child(specifier, 'local'))
194
+ const imported = nameOf(child(specifier, 'imported'))
195
+ if (local && imported) imports.set(local, { file: target, name: imported })
196
+ }
197
+ }
198
+ return { file, locals: partsOfFile(file, program, parser), components: functionComponents(program), imports }
199
+ }
200
+
201
+ function factsOfFile(file: string, parser: Parser): FileFacts | null {
202
+ const mtime = statSync(file).mtimeMs
203
+ const cached = factsCache.get(file)
204
+ if (cached && cached.mtime === mtime) return cached.facts
205
+ factsCache.set(file, { mtime, facts: null })
206
+ const program = parseFile(parser, file)
207
+ const facts = program ? factsOf(file, program, parser) : null
208
+ factsCache.set(file, { mtime, facts })
209
+ return facts
210
+ }
211
+
212
+ interface PageState {
213
+ file: string
214
+ line: number
215
+ }
216
+
217
+ /**
218
+ * Every page-scope `EmptyState` a component can draw, following the app's own
219
+ * components it draws in turn, in its file and in the files it imports from,
220
+ * four deep.
221
+ */
222
+ function pageStatesOf(facts: FileFacts, name: string, parser: Parser, depth: number, seen: Set<string>): PageState[] {
223
+ const key = `${facts.file}#${name}`
224
+ if (depth > 4 || seen.has(key)) return []
225
+ seen.add(key)
226
+ const local = facts.components.get(name)
227
+ if (!local) {
228
+ const from = facts.imports.get(name)
229
+ const there = from ? factsOfFile(from.file, parser) : null
230
+ return there && from ? pageStatesOf(there, from.name, parser, depth + 1, seen) : []
231
+ }
232
+ const found: PageState[] = []
233
+ walk(local, (node) => {
234
+ if (node.type !== 'JSXOpeningElement') return
235
+ const tag = child(node, 'name')
236
+ const binding = bindingOfTag(tag, facts.locals)
237
+ if (isPart(binding, 'EmptyState')) {
238
+ if (isPageScope(node)) found.push({ file: facts.file, line: node.loc?.start.line ?? 0 })
239
+ return
240
+ }
241
+ const component = binding?.props === 'all' ? undefined : nameOf(tag)
242
+ if (component && /^[A-Z]/.test(component)) found.push(...pageStatesOf(facts, component, parser, depth + 1, seen))
243
+ })
244
+ return found
245
+ }
246
+
247
+ const fileName = (file: string) => file.replace(/\\/g, '/').split('/').pop()
248
+
249
+ /**
250
+ * UIG-23: an empty state drawn by hand, or the right part at the wrong scope.
251
+ *
252
+ * **A line drawn by hand.** A plain box whose only content is a short line
253
+ * saying there is nothing ("No tickets yet.") where a list's rows would be: one
254
+ * side of a condition whose other side maps the list, or under a condition that
255
+ * asks if something is empty. Both halves are needed. The words alone fire on a
256
+ * hint and a status note (UIG-1 measured it); the place alone fires on any
257
+ * line a list shows beside itself.
258
+ *
259
+ * **The wrong scope.** A `page`-scope `EmptyState` centres in the room a flex
260
+ * column gives it. Inside a `ScrollArea` the content box is only as tall as
261
+ * what is in it, so the state sits at the top of its pane. The rule looks at
262
+ * the `ScrollArea`'s content and at every component of the app's own placed in
263
+ * it, following them into their files, because that is how four of them
264
+ * arrived on Folders in one merge (peek `7f22e5e`): the state was drawn in one
265
+ * file and scrolled in another. A `contentClassName` with `min-h-full` or
266
+ * `h-full` gives the state its room, and passes.
267
+ *
268
+ * Not judged: the package's `EmptyState` and a part's own lines (a
269
+ * `FieldLine`, a palette's empty row) — a part is already a part.
270
+ */
271
+ export const noHandmadeEmptyState: Rule.RuleModule = {
272
+ meta: {
273
+ type: 'problem',
274
+ docs: { description: 'An empty state drawn by hand, or a page-scope EmptyState that cannot centre' },
275
+ schema: [],
276
+ messages: {
277
+ handmade:
278
+ 'A line saying there is nothing is an EmptyState. Use `EmptyState` from @estiva-app/ui: `scope="section"` inside a section, `scope="page"` for a whole page.',
279
+ wrongScope:
280
+ 'A page-scope EmptyState inside a ScrollArea sits at the top of its pane, not the middle: the ScrollArea\'s content is only as tall as what is in it. Use `scope="section"` when the pane has anything else on it; when the whole pane is empty, draw the page EmptyState in place of the ScrollArea, not inside it.',
281
+ wrongScopeVia:
282
+ '`{{component}}` draws a page-scope EmptyState ({{where}}) inside this ScrollArea, where it sits at the top of its pane, not the middle: the ScrollArea\'s content is only as tall as what is in it. Use `scope="section"` there when the pane has anything else on it; when the whole pane is empty, draw the page EmptyState in place of the ScrollArea, not inside it.',
283
+ ...ESCAPE_MESSAGES,
284
+ },
285
+ },
286
+ create(context) {
287
+ const parser = (context.languageOptions?.parser ?? {}) as Parser
288
+ const file = context.filename
289
+ let facts: FileFacts | undefined
290
+ // A ScrollArea inside another is walked by both; each state is reported once.
291
+ const reported = new Set<Node>()
292
+ const scopeOf = (node: Node) => context.sourceCode.getScope(node as unknown as Parameters<typeof context.sourceCode.getScope>[0])
293
+
294
+ return {
295
+ Program(program) {
296
+ facts = factsOf(file, program as unknown as Node, parser)
297
+ },
298
+ JSXElement(ruleNode: Rule.Node) {
299
+ const element = ruleNode as unknown as Node
300
+ const opening = child(element, 'openingElement') as Node
301
+ const tag = tagName(element)
302
+
303
+ // Shape 1: a plain box holding only a "nothing" line, where a list would be.
304
+ if (tag && /^[a-z]/.test(tag)) {
305
+ const words = onlyWords(element)
306
+ if (!words || words.length > SHORT) return
307
+ if (!standsInForAList(element)) return
308
+ if (isEscaped(context, opening)) return
309
+ context.report({ loc: opening.loc as NonNullable<Rule.Node['loc']>, messageId: 'handmade' })
310
+ return
311
+ }
312
+
313
+ // Shape 2: a page-scope EmptyState in a ScrollArea that does not fill.
314
+ if (!facts || !isPart(bindingOfTag(child(opening, 'name'), facts.locals), 'ScrollArea')) return
315
+ const content = attributeOf(opening, 'contentClassName')
316
+ if (content && classesOf(child(content, 'value'), scopeOf).some((c) => FILLS.test(c.replace(/^(?:[^:\s[\]]+:)+/, '')))) return
317
+ const here = facts
318
+ for (const kid of children(element, 'children')) {
319
+ walk(kid, (node) => {
320
+ if (node.type !== 'JSXOpeningElement') return
321
+ const name = child(node, 'name')
322
+ const binding = bindingOfTag(name, here.locals)
323
+ if (isPart(binding, 'EmptyState')) {
324
+ if (!isPageScope(node) || reported.has(node) || isEscaped(context, node)) return
325
+ reported.add(node)
326
+ context.report({ loc: node.loc as NonNullable<Rule.Node['loc']>, messageId: 'wrongScope' })
327
+ return
328
+ }
329
+ const component = binding?.props === 'all' ? undefined : nameOf(name)
330
+ if (!component || !/^[A-Z]/.test(component)) return
331
+ const states = pageStatesOf(here, component, parser, 0, new Set())
332
+ if (!states.length || reported.has(node) || isEscaped(context, node)) return
333
+ reported.add(node)
334
+ // In reading order: file, then line.
335
+ for (const state of [...states].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)) {
336
+ const where = state.file === file ? `line ${state.line}` : `${fileName(state.file)}:${state.line}`
337
+ context.report({ loc: node.loc as NonNullable<Rule.Node['loc']>, messageId: 'wrongScopeVia', data: { component, where } })
338
+ }
339
+ })
340
+ }
341
+ },
342
+ }
343
+ },
344
+ }
@@ -0,0 +1,89 @@
1
+ import { RuleTester } from 'eslint'
2
+ import { parser } from 'typescript-eslint'
3
+ import { describe, it } from 'vitest'
4
+ import { noHandmadeHeader } from './no-handmade-header'
5
+
6
+ RuleTester.describe = describe
7
+ RuleTester.it = it
8
+ RuleTester.itOnly = it.only
9
+
10
+ const tester = new RuleTester({
11
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
12
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
13
+ })
14
+
15
+ const component = (body: string) => `export function Probe({ title, open }: { title: string; open: boolean }) {\n return (\n${body}\n )\n}\n`
16
+ const handmade = [{ messageId: 'handmade' }]
17
+
18
+ tester.run('no-handmade-header', noHandmadeHeader, {
19
+ valid: [
20
+ { name: 'the part itself', code: component('<div className="flex h-full flex-col">\n<ContainerHeader title="Files" />\n<div />\n</div>') },
21
+ {
22
+ name: "a group heading inside a list: SectionHeader's row, not a column's bar (Ship's issue groups)",
23
+ code: component('<section className="flex flex-col gap-px">\n<h3 className="flex h-8 items-center gap-2 px-3"><StatusLabel value="todo" /><span>{3}</span></h3>\n<div />\n</section>'),
24
+ },
25
+ {
26
+ name: "a label strip on a card (Peek's huddle card)",
27
+ code: component('<Card className="flex flex-col h-[130px]">\n<div className="h-6 bg-bg-inset px-2 flex items-center shrink-0"><span className="text-caption">Huddle</span></div>\n<div />\n</Card>'),
28
+ },
29
+ { name: 'a row a person presses is a row, not a header', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center px-3 py-2" onClick={() => {}}><span>{title}</span></div>\n<div />\n</div>') },
30
+ { name: 'a row with no title: a toolbar, a field', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center gap-1 px-3"><Button>One</Button><Button>Two</Button></div>\n<div />\n</div>') },
31
+ { name: 'a row with nothing after it heads nothing', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n</div>') },
32
+ { name: 'a row with no side padding of its own', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center py-2"><span>{title}</span></div>\n<div />\n</div>') },
33
+ { name: 'a column of rows', code: component('<div className="flex h-full flex-col">\n<div className="flex flex-col px-3"><span>{title}</span></div>\n<div />\n</div>') },
34
+ {
35
+ name: 'an escape with its reason',
36
+ code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: a print header that must not follow the theme */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
37
+ },
38
+ ],
39
+ invalid: [
40
+ {
41
+ // The Folders page's right pane, at the commit before 3dc663b.
42
+ name: 'a pane that heads itself around an editable name',
43
+ code: component('<div className="flex h-full flex-col">\n{/* ContainerHeader draws a static title, so this pane heads itself. */}\n<div className="flex items-center gap-2 px-3 py-2">\n<div className="min-w-0 flex-1"><EditableText value={title} label="Folder name" /></div>\n<IconButton icon={<IconX />} label="Close" />\n</div>\n<div className="min-h-0 flex-1" />\n</div>'),
44
+ errors: handmade,
45
+ },
46
+ {
47
+ // The Folders page's thread pane, at the commit before 3dc663b.
48
+ name: 'a pane that heads itself with a title and a caption',
49
+ code: component('<div className="flex h-full flex-col">\n<div className="flex items-center gap-2 px-3 py-2">\n<div className="min-w-0 flex-1">\n<p className="truncate text-body-2-strong text-text-primary">{title ?? "Conversation"}</p>\n{open && <p className="truncate text-caption text-text-muted">In Ship</p>}\n</div>\n</div>\n<div />\n</div>'),
50
+ errors: handmade,
51
+ },
52
+ {
53
+ name: "ContainerHeader's exact class list, typed by hand (Peek's FilesPanel and ThreadPanel)",
54
+ code: component('<div className="flex flex-col h-full">\n<div className="h-12 shrink-0 flex items-center justify-between pl-5 pr-4 py-2 border-b border-border-subtle">\n<span className="text-body-2-strong text-text-primary">Files</span>\n</div>\n<div />\n</div>'),
55
+ errors: handmade,
56
+ },
57
+ {
58
+ name: 'the shape, not the class names: other padding, other gap, a heading, a column that grows',
59
+ code: component('<aside className="flex grow flex-col">\n<header className="flex items-baseline gap-4 pl-6 pr-2 py-3"><h2>{title}</h2></header>\n<ul />\n</aside>'),
60
+ errors: handmade,
61
+ },
62
+ {
63
+ name: "a header bar's height and hairline, whatever the column is",
64
+ code: component('<section className="flex flex-col">\n<div className="flex h-11 items-center border-b px-4"><span>{title}</span></div>\n<div />\n</section>'),
65
+ errors: handmade,
66
+ },
67
+ {
68
+ name: 'a bar shown only while something is open',
69
+ code: component('<div className="flex h-full flex-col">\n{open && (\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n)}\n<div />\n</div>'),
70
+ errors: handmade,
71
+ },
72
+ {
73
+ name: 'classes put together with cn() and a variant',
74
+ code: component('<div className={cn("flex flex-col", "h-full")}>\n<div className={cn("flex items-center", "md:px-4 py-2")}><span>{title}</span></div>\n<div />\n</div>'),
75
+ errors: handmade,
76
+ },
77
+ {
78
+ // DialogShell's bar, before it drew ContainerHeader: a part's title, named as one.
79
+ name: "a bar whose title is a part's Title",
80
+ code: component('<div className="flex flex-col">\n<div className="h-12 flex items-center justify-between pl-5 pr-4 border-b shrink-0">\n{open ?? <Parts.Title render={<span />}>{title}</Parts.Title>}\n</div>\n<div />\n</div>'),
81
+ errors: handmade,
82
+ },
83
+ {
84
+ name: 'an escape with no reason escapes nothing',
85
+ code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
86
+ errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'handmade' }],
87
+ },
88
+ ],
89
+ })
@@ -0,0 +1,153 @@
1
+ import type { Rule } from 'eslint'
2
+ import { ESCAPE_MESSAGES, isEscaped } from './escape'
3
+
4
+ /** The JSX an element is made of, as far as this rule reads it. */
5
+ interface Node {
6
+ type: string
7
+ [key: string]: unknown
8
+ }
9
+
10
+ interface JSXElement extends Node {
11
+ type: 'JSXElement'
12
+ openingElement: {
13
+ name: { type: string; name?: string; object?: { name?: string }; property?: { name?: string } }
14
+ attributes: { type: string; name?: { name?: string }; value?: Node | null }[]
15
+ loc: NonNullable<Rule.Node['loc']>
16
+ range: [number, number]
17
+ }
18
+ children: Node[]
19
+ }
20
+
21
+ const isElement = (n: unknown): n is JSXElement => typeof n === 'object' && n !== null && (n as Node).type === 'JSXElement'
22
+
23
+ function tagOf(el: JSXElement): string {
24
+ const n = el.openingElement.name
25
+ if (n.type === 'JSXIdentifier') return n.name ?? ''
26
+ if (n.type === 'JSXMemberExpression') return `${n.object?.name ?? ''}.${n.property?.name ?? ''}`
27
+ return ''
28
+ }
29
+
30
+ function attribute(el: JSXElement, name: string) {
31
+ return el.openingElement.attributes.find((a) => a.type === 'JSXAttribute' && a.name?.name === name)
32
+ }
33
+
34
+ /** Every string literal under a node: a `cn()` call, a template, a ternary. */
35
+ function strings(node: Node | null | undefined, out: string[] = []): string[] {
36
+ if (!node || typeof node !== 'object') return out
37
+ if (node.type === 'Literal' && typeof node.value === 'string') out.push(node.value)
38
+ else if (node.type === 'TemplateLiteral') for (const q of node.quasis as { value: { cooked: string | null } }[]) out.push(q.value.cooked ?? '')
39
+ for (const [key, value] of Object.entries(node)) {
40
+ if (key === 'parent' || key === 'loc' || key === 'range') continue
41
+ if (Array.isArray(value)) for (const v of value) strings(v as Node, out)
42
+ else if (value && typeof value === 'object' && (value as Node).type) strings(value as Node, out)
43
+ }
44
+ return out
45
+ }
46
+
47
+ /** The classes an element is given, variants taken off: `md:px-3` is `px-3`. */
48
+ function classesOf(el: JSXElement): string[] {
49
+ const value = attribute(el, 'className')?.value
50
+ if (!value) return []
51
+ const text = value.type === 'Literal' ? String(value.value) : strings(value.expression as Node).join(' ')
52
+ return text
53
+ .split(/\s+/)
54
+ .filter(Boolean)
55
+ .map((c) => c.replace(/^(?:[^:\s[\]]+:)+/, ''))
56
+ }
57
+
58
+ /**
59
+ * An element's children as a reader sees them: whitespace and comments gone,
60
+ * `{open && <X />}` read as `<X />`.
61
+ */
62
+ function meaningful(el: JSXElement): Node[] {
63
+ const out: Node[] = []
64
+ for (const child of el.children) {
65
+ if (child.type === 'JSXText') {
66
+ if (String(child.value).trim()) out.push(child)
67
+ } else if (child.type === 'JSXExpressionContainer') {
68
+ const e = child.expression as Node
69
+ if (e.type === 'JSXEmptyExpression') continue
70
+ if (e.type === 'LogicalExpression' && isElement(e.right)) out.push(e.right as Node)
71
+ else if (isElement(e)) out.push(e)
72
+ else out.push(child)
73
+ } else if (child.type === 'JSXElement' || child.type === 'JSXFragment') out.push(child)
74
+ }
75
+ return out
76
+ }
77
+
78
+ /** Parts that draw a line of text: a title made of one is still a title. */
79
+ const TEXT_PARTS = new Set(['EditableText', 'Person', 'Breadcrumb', 'Link', 'SectionLabel', 'InlineChip'])
80
+ /** What a title written as an expression looks like: a name, a field, a template, a fallback. */
81
+ const VALUE_EXPRESSIONS = new Set(['Identifier', 'MemberExpression', 'TemplateLiteral', 'Literal', 'LogicalExpression', 'ConditionalExpression', 'ChainExpression', 'CallExpression'])
82
+
83
+ /** Whether the row carries a title: text, a value, a heading, or a part that draws text, three boxes deep at most. */
84
+ function carriesTitle(el: JSXElement, depth = 0): boolean {
85
+ if (depth > 3) return false
86
+ for (const child of meaningful(el)) {
87
+ if (child.type === 'JSXText') return true
88
+ if (child.type === 'JSXExpressionContainer') {
89
+ if (VALUE_EXPRESSIONS.has((child.expression as Node).type)) return true
90
+ continue
91
+ }
92
+ if (!isElement(child)) continue
93
+ const tag = tagOf(child)
94
+ // `Dialog.Title`, `Parts.Title`: a part's title, named as one.
95
+ if (/^h[1-6]$/.test(tag) || TEXT_PARTS.has(tag) || /\.(?:Title|Heading)$/.test(tag)) return true
96
+ // A part's own words are its label, a Button's "Save": only plain boxes are looked into.
97
+ if (/^[a-z]/.test(tag) && carriesTitle(child, depth + 1)) return true
98
+ }
99
+ return false
100
+ }
101
+
102
+ const ROW = (c: string[]) => c.includes('flex') && !c.includes('flex-col')
103
+ const OWN_SIDE_PADDING = (c: string[]) => c.some((x) => /^-?(?:p|px|pl|pr|ps|pe)-/.test(x))
104
+ /** A column that takes the height it is given: the pane a header bar tops. */
105
+ const FILLS = (c: string[]) => c.includes('flex-col') && c.some((x) => /^(?:h-full|h-screen|flex-1|grow|min-h-0|h-dvh)$/.test(x))
106
+ /** A header bar's own geometry: a column bar's height, or the hairline under it. */
107
+ const BAR = (c: string[]) => c.some((x) => /^(?:h|min-h)-(?:10|11|12|13|14|\[(?:4[0-9]|5[0-6])px\])$/.test(x)) || c.some((x) => /^border-b(?:-|$)/.test(x))
108
+ /** A row a person presses is a row of its own kind, not a header. */
109
+ const INTERACTIVE = ['onClick', 'href', 'role', 'onKeyDown', 'onPointerDown']
110
+
111
+ /**
112
+ * UIG-22: a column's header bar built by hand.
113
+ *
114
+ * The general rules cannot see this one: a hand-made header is made entirely of
115
+ * legal parts, a box, some text, some padding. So this rule reads the shape.
116
+ * A row, first in a column, with side padding of its own and a title in it,
117
+ * where the column fills the height it is given, or the row has a header bar's
118
+ * height or the hairline under one. `ContainerHeader` is that bar: 48px, the
119
+ * title, the column's buttons at the right edge, a hairline under it.
120
+ *
121
+ * What it leaves alone, on purpose: a group heading inside a list (the row one
122
+ * section starts with), a label strip on a card, and a row a person presses.
123
+ * Only a lowercase element is judged: a part is already a part.
124
+ */
125
+ export const noHandmadeHeader: Rule.RuleModule = {
126
+ meta: {
127
+ type: 'problem',
128
+ docs: { description: "A column's header bar drawn by hand where ContainerHeader is the part" },
129
+ schema: [],
130
+ messages: {
131
+ handmade:
132
+ "A column's header bar drawn by hand. Use `ContainerHeader` from @estiva-app/ui: 48px, the title, the column's buttons at the right edge (`actions`), a hairline under it.",
133
+ ...ESCAPE_MESSAGES,
134
+ },
135
+ },
136
+ create(context) {
137
+ return {
138
+ JSXElement(node: Rule.Node) {
139
+ const column = node as unknown as JSXElement
140
+ const kids = meaningful(column)
141
+ const first = kids[0]
142
+ if (kids.length < 2 || !isElement(first) || !/^[a-z]/.test(tagOf(first))) return
143
+ const own = classesOf(first)
144
+ if (!ROW(own) || !OWN_SIDE_PADDING(own)) return
145
+ if (INTERACTIVE.some((name) => attribute(first, name))) return
146
+ if (!FILLS(classesOf(column)) && !BAR(own)) return
147
+ if (!carriesTitle(first)) return
148
+ if (isEscaped(context, first.openingElement)) return
149
+ context.report({ loc: first.openingElement.loc, messageId: 'handmade' })
150
+ },
151
+ }
152
+ },
153
+ }
@@ -399,6 +399,17 @@ function moduleParts(file: string, program: Node, parser: Parser, stack: Set<str
399
399
  return { locals, exports }
400
400
  }
401
401
 
402
+ /**
403
+ * What a file's names stand for, found the way this rule finds them: for a rule
404
+ * that asks whether a tag is one particular part, through an app's wrapper too
405
+ * (UIG-23 asks for `EmptyState` and `ScrollArea`).
406
+ */
407
+ export function partsOfFile(file: string, program: Node, parser: Parser): Map<string, PartBinding | 'namespace'> {
408
+ return moduleParts(file, program, parser, new Set([file])).locals
409
+ }
410
+
411
+ export { bindingOfTag, functionComponents, parseFile, resolveModule, walk, type Node, type Parser }
412
+
402
413
  function moduleExports(file: string, parser: Parser, stack: Set<string>): Map<string, PartBinding> {
403
414
  const mtime = statSync(file).mtimeMs
404
415
  const cached = moduleCache.get(file)
@@ -31,7 +31,7 @@ export interface AppCheckOptions {
31
31
  chain?: { ref: string; owner: boolean }
32
32
  /** The token lint configs the app has, relative to the app. */
33
33
  tokenConfigs?: string[]
34
- /** The name of the app's header component a hand-made header row should name (UIG-22's first guess). */
34
+ /** The part a hand-made column header should name (UIG-22): `ContainerHeader`. */
35
35
  headers?: string[]
36
36
  }
37
37
 
@@ -70,7 +70,7 @@ function all(h: GateHelpers, steps: (() => CheckResult | Promise<CheckResult>)[]
70
70
  }
71
71
  }
72
72
 
73
- export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['ContainerHeader', 'SectionHeader'] }: AppCheckOptions): GateTicket[] {
73
+ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['ContainerHeader'] }: AppCheckOptions): GateTicket[] {
74
74
  const WEB = app === '.' ? '' : `${app.replace(/\/$/, '')}/`
75
75
  const cwd = app === '.' ? undefined : app
76
76
  const PROBE = 'src/components/__gates_probe__.tsx'
@@ -219,7 +219,9 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
219
219
  { what: "a plain tailwind-merge is an error naming the package's cn()", run: tokenProbe("import { twMerge } from 'tailwind-merge'\nexport const merged = twMerge('p-2', 'p-3')\n", 'error', 'cn()', 'src/lib/__gates_probe__.ts') },
220
220
  ]),
221
221
  ticket('UIG-22', [
222
- { what: 'a hand-made header row is an error naming the header part', run: all(h, headers.map((name) => probe(component(`<section className="flex flex-col"><div className="flex items-center px-3 py-2"><span>${name === 'SectionHeader' ? 'Issues' : 'Folders'}</span></div><div /></section>`), 'error', name)), 'a hand-made header row is an error naming the header part') },
222
+ // The Folders pane's own row, at the commit before peek 3dc663b.
223
+ { what: 'a hand-made column header is an error naming ContainerHeader', run: all(h, headers.map((name) => probe(component('<div className="flex h-full flex-col"><div className="flex items-center gap-2 px-3 py-2"><span>Folders</span></div><div /></div>'), 'error', name)), 'a hand-made column header is an error naming ContainerHeader') },
224
+ { what: 'a group heading inside a list is left alone', run: probe(component('<section className="flex flex-col gap-px"><h3 className="flex h-8 items-center gap-2 px-3"><span>Todo</span></h3><div /></section>'), 'none') },
223
225
  ]),
224
226
  ticket('UIG-23', [
225
227
  { what: 'a hand-made empty line is an error naming EmptyState', run: probe("export function Probe({ items }: { items: string[] }) {\n return <div>{items.length === 0 ? <p className=\"text-text-secondary\">Nothing here</p> : items.map((i) => <span key={i}>{i}</span>)}</div>\n}\n", 'error', 'EmptyState') },
@@ -59,7 +59,7 @@ describe('create-estiva-app', () => {
59
59
 
60
60
  it('starts the count at zero and keeps the CI job named gate', () => {
61
61
  const count = JSON.parse(files['.gates-count.json'])
62
- expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
62
+ expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
63
63
  expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run lint:rules/)
64
64
  expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
65
65
  })