@estiva-app/ui 0.42.0 → 0.44.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/dist/CommandPalette.d.ts +16 -0
  2. package/dist/CommandPalette.d.ts.map +1 -1
  3. package/dist/eslint/index.js +21 -0
  4. package/dist/eslint/index.js.map +2 -2
  5. package/dist/eslint/no-handmade-empty-state.d.ts +7 -0
  6. package/dist/eslint/no-handmade-empty-state.d.ts.map +1 -1
  7. package/dist/gates/app-checks.d.ts.map +1 -1
  8. package/dist/gates/{chunk-SYVKGOWM.js → chunk-P5W6O5TF.js} +114 -63
  9. package/dist/gates/chunk-P5W6O5TF.js.map +7 -0
  10. package/dist/gates/cli.js +1 -1
  11. package/dist/gates/gate-config.d.ts.map +1 -1
  12. package/dist/gates/index.js +13 -3
  13. package/dist/gates/index.js.map +2 -2
  14. package/dist/gates/token-joined.d.ts +28 -0
  15. package/dist/gates/token-joined.d.ts.map +1 -0
  16. package/dist/gates/token-lint.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +10 -0
  20. package/dist/index.js.map +2 -2
  21. package/package.json +1 -1
  22. package/registry.json +41 -3
  23. package/src/CommandPalette.mdx +6 -0
  24. package/src/CommandPalette.stories.tsx +26 -1
  25. package/src/CommandPalette.test.tsx +14 -0
  26. package/src/CommandPalette.tsx +29 -0
  27. package/src/EmptyState.mdx +1 -1
  28. package/src/eslint/no-handmade-empty-state.test.ts +23 -1
  29. package/src/eslint/no-handmade-empty-state.ts +37 -1
  30. package/src/gates/app-checks.ts +7 -0
  31. package/src/gates/gate-config.ts +2 -0
  32. package/src/gates/gates.test.ts +26 -0
  33. package/src/gates/token-joined.ts +94 -0
  34. package/src/gates/token-lint.ts +68 -66
  35. package/src/index.ts +2 -0
  36. package/src/registry/registry.test.ts +3 -3
  37. package/dist/gates/chunk-SYVKGOWM.js.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.42.0",
8
- "exports": 90,
9
- "typeExports": 101,
7
+ "packageVersion": "0.44.0",
8
+ "exports": 91,
9
+ "typeExports": 102,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -1280,6 +1280,44 @@
1280
1280
  "app": null,
1281
1281
  "looks": []
1282
1282
  },
1283
+ {
1284
+ "name": "CommandPaletteProposal",
1285
+ "repo": "estiva-ui",
1286
+ "kind": "component",
1287
+ "importPath": "@estiva-app/ui",
1288
+ "sourceFile": "src/CommandPalette.tsx",
1289
+ "purpose": "Something the palette offers to make, before anything is made: a card with where it would be made on top, then what it would be.",
1290
+ "purposeFrom": "comment",
1291
+ "props": [
1292
+ {
1293
+ "name": "source",
1294
+ "takes": "text",
1295
+ "required": true,
1296
+ "note": "Where it would be made: the app and the action — \"App › New item\"."
1297
+ },
1298
+ {
1299
+ "name": "mark",
1300
+ "takes": "anything",
1301
+ "required": false,
1302
+ "note": "The app's mark before the source, as small as the line (about 10px)."
1303
+ },
1304
+ {
1305
+ "name": "children",
1306
+ "takes": "text",
1307
+ "required": true,
1308
+ "note": "What would be made, in a sentence."
1309
+ }
1310
+ ],
1311
+ "variants": [],
1312
+ "ownsBehaviours": [],
1313
+ "status": "stable",
1314
+ "migrationStage": null,
1315
+ "docsId": "overlays-commandpalette--docs",
1316
+ "storyId": "overlays-commandpalette--first-level",
1317
+ "docPage": null,
1318
+ "app": null,
1319
+ "looks": []
1320
+ },
1283
1321
  {
1284
1322
  "name": "CommandPaletteQuote",
1285
1323
  "repo": "estiva-ui",
@@ -23,6 +23,12 @@ form — and the app keeps the levels.
23
23
  above the rows, for a result that is written rather than listed: a line while
24
24
  it is on its way, an answer with marks pointing at the rows it came from, and
25
25
  text written to be used somewhere else.
26
+ - **`CommandPaletteProposal`** — above the rows, for something the palette
27
+ offers to make before anything is made: a card with where it would be made
28
+ (`source`, with the app's `mark`), then what it would be. The rows under it
29
+ say what happens next.
30
+
31
+ <Canvas of={CommandPaletteStories.Proposal} />
26
32
 
27
33
  <Canvas of={CommandPaletteStories.Answer} />
28
34
 
@@ -7,6 +7,7 @@ import {
7
7
  CommandPalette,
8
8
  CommandPaletteAnswer,
9
9
  CommandPaletteForm,
10
+ CommandPaletteProposal,
10
11
  CommandPaletteQuote,
11
12
  CommandPaletteSearch,
12
13
  CommandPaletteWorking,
@@ -54,6 +55,7 @@ type Level =
54
55
  | { kind: 'place'; name: string }
55
56
  | { kind: 'ask' }
56
57
  | { kind: 'draft' }
58
+ | { kind: 'make' }
57
59
  | { kind: 'form' }
58
60
  | { kind: 'one-list' }
59
61
 
@@ -62,7 +64,7 @@ interface Frame {
62
64
  query: string
63
65
  }
64
66
 
65
- type Start = 'first' | 'arriving' | 'nothing' | 'place' | 'working' | 'answer' | 'draft' | 'form' | 'refused' | 'one-list'
67
+ type Start = 'first' | 'arriving' | 'nothing' | 'place' | 'working' | 'answer' | 'draft' | 'make' | 'form' | 'refused' | 'one-list'
66
68
 
67
69
  const PLACES = ['Place one', 'Place two']
68
70
  const ACTIONS = [
@@ -81,6 +83,7 @@ const GROUPS = [
81
83
  ]
82
84
  const ANSWER = 'Item one moved on Monday [1], and item two is waiting on a reply [2].\n\nNothing else changed this week.'
83
85
  const DRAFT = 'Thanks, both. I will look at item one today\nand reply on item two tomorrow.'
86
+ const PROPOSAL = 'An item for the reply on item two, due tomorrow.'
84
87
 
85
88
  function startStack(start: Start): Frame[] {
86
89
  const first: Frame = { level: { kind: 'first' }, query: '' }
@@ -96,6 +99,8 @@ function startStack(start: Start): Frame[] {
96
99
  return [first, { level: { kind: 'ask' }, query: 'What changed this week?' }]
97
100
  case 'draft':
98
101
  return [first, { level: { kind: 'draft' }, query: 'Say I will look today' }]
102
+ case 'make':
103
+ return [first, { level: { kind: 'make' }, query: '' }]
99
104
  case 'form':
100
105
  case 'refused':
101
106
  return [first, { level: { kind: 'place', name: 'Place one' }, query: '' }, { level: { kind: 'form' }, query: '' }]
@@ -180,6 +185,17 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
180
185
  },
181
186
  ]
182
187
  }
188
+ if (level.kind === 'make') {
189
+ return [
190
+ {
191
+ label: 'Next',
192
+ rows: [
193
+ { id: 'make-open', label: 'Open the form', description: 'Place one › Action one · nothing is made until you submit', icon, onSelect: () => push({ kind: 'form' }) },
194
+ { id: 'make-again', label: 'Suggest something else', icon, onSelect: () => close('Suggest something else') },
195
+ ],
196
+ },
197
+ ]
198
+ }
183
199
  if (level.kind === 'ask' || level.kind === 'draft') {
184
200
  if (!asked?.done) return []
185
201
  const changed = frame.query.trim() !== asked.question.trim()
@@ -236,6 +252,7 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
236
252
  rows: [
237
253
  { id: 'ask', label: 'Ask about this page', icon, onSelect: () => push({ kind: 'ask' }) },
238
254
  { id: 'draft', label: 'Write a draft', icon, onSelect: () => push({ kind: 'draft' }) },
255
+ { id: 'make', label: 'Make something from this page', icon, onSelect: () => push({ kind: 'make' }) },
239
256
  { id: 'one-list', label: 'Change kind', description: 'Item one', icon, onSelect: () => push({ kind: 'one-list' }) },
240
257
  ],
241
258
  },
@@ -289,6 +306,7 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
289
306
  }
290
307
 
291
308
  const body = (() => {
309
+ if (level.kind === 'make') return <CommandPaletteProposal source="Place one › Action one" mark={<IconSquareRounded size={10} stroke={1.5} />}>{PROPOSAL}</CommandPaletteProposal>
292
310
  if (level.kind !== 'ask' && level.kind !== 'draft') return undefined
293
311
  if (!asked || !asked.done) return <CommandPaletteWorking>Reading 12 notes…</CommandPaletteWorking>
294
312
  const changed = frame.query.trim() !== asked.question.trim()
@@ -355,6 +373,8 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
355
373
  ? 'Ask anything about this page'
356
374
  : level.kind === 'draft'
357
375
  ? 'What should it say?'
376
+ : level.kind === 'make'
377
+ ? 'Looking at this page'
358
378
  : 'Search, or choose a place'
359
379
  }
360
380
  groups={groups}
@@ -365,6 +385,8 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
365
385
  ? { label: 'Ask', onBack: back }
366
386
  : level.kind === 'draft'
367
387
  ? { label: 'Draft', onBack: back }
388
+ : level.kind === 'make'
389
+ ? { label: 'Make', onBack: back }
368
390
  : undefined
369
391
  }
370
392
  pending={level.kind === 'first' && searching ? 'Searching…' : undefined}
@@ -412,6 +434,9 @@ export const Answer: Story = story('answer')
412
434
  export const Quote: Story = story('draft')
413
435
 
414
436
  /** A form. Ctrl+Enter creates; a missing field says so and takes focus; Backspace in an empty field goes back and keeps what was typed. */
437
+ /** Something it offers to make: where, then what. Nothing is made until the form is sent. */
438
+ export const Proposal: Story = story('make')
439
+
415
440
  export const Form: Story = story('form')
416
441
 
417
442
  /** Creating is refused: the fields unlock, the reason sits beside the button, and focus is back where it was. */
@@ -17,6 +17,7 @@ import {
17
17
  CommandPalette,
18
18
  CommandPaletteAnswer,
19
19
  CommandPaletteForm,
20
+ CommandPaletteProposal,
20
21
  CommandPaletteQuote,
21
22
  CommandPaletteSearch,
22
23
  CommandPaletteWorking,
@@ -421,6 +422,19 @@ describe('what sits above the rows', () => {
421
422
  expect(screen.getByText(/Line one/).textContent).toBe('Line one\nLine two')
422
423
  })
423
424
 
425
+ it('draws a proposal as a card: where it would be made, with its mark, then what', () => {
426
+ const { container } = render(
427
+ <CommandPaletteProposal source="Place one › Action one" mark={<svg data-testid="mark" />}>
428
+ An item for tomorrow.
429
+ </CommandPaletteProposal>,
430
+ )
431
+ const lines = container.firstElementChild?.children
432
+ expect(lines).toHaveLength(2)
433
+ expect(lines?.[0].textContent).toBe('Place one › Action one')
434
+ expect(lines?.[0].contains(screen.getByTestId('mark'))).toBe(true)
435
+ expect(lines?.[1].textContent).toBe('An item for tomorrow.')
436
+ })
437
+
424
438
  it('announces what is being worked on, with its grey bars', () => {
425
439
  const { container } = render(<CommandPaletteWorking bars={3}>Reading…</CommandPaletteWorking>)
426
440
  expect(screen.getByRole('status').textContent).toContain('Reading…')
@@ -16,6 +16,7 @@ import type { BaseUIEvent } from '@base-ui/react/types'
16
16
  import { IconLoader2, IconSearch } from '@tabler/icons-react'
17
17
  import { cn } from './cn'
18
18
  import { Button } from './Button'
19
+ import { Card } from './Card'
19
20
  import { InputChip } from './ChipInput'
20
21
  import { EmptyState } from './EmptyState'
21
22
  import { FieldLine } from './Field'
@@ -661,3 +662,31 @@ export interface CommandPaletteQuoteProps {
661
662
  export function CommandPaletteQuote({ children }: CommandPaletteQuoteProps) {
662
663
  return <p className="whitespace-pre-wrap border-l-2 border-border-default pl-3 text-body-2 text-text-primary">{children}</p>
663
664
  }
665
+
666
+ export interface CommandPaletteProposalProps {
667
+ /** Where it would be made: the app and the action — "App › New item". */
668
+ source: string
669
+ /** The app's mark before the source, as small as the line (about 10px). */
670
+ mark?: ReactNode
671
+ /** What would be made, in a sentence. */
672
+ children: string
673
+ }
674
+
675
+ /**
676
+ * Something the palette offers to make, before anything is made: a card with
677
+ * where it would be made on top, then what it would be. A card because it is a
678
+ * thing about to exist, not text to read or use; the source first because two
679
+ * apps may offer something alike (UIG-40, Katerina 28 September: the look Peek's
680
+ * launcher already had, now drawn by the palette, ruling P4).
681
+ */
682
+ export function CommandPaletteProposal({ source, mark, children }: CommandPaletteProposalProps) {
683
+ return (
684
+ <Card fill="surface" className="flex flex-col gap-1 p-3">
685
+ <span className="flex items-center gap-2 text-caption text-text-secondary">
686
+ {mark}
687
+ {source}
688
+ </span>
689
+ <span className="text-body-2 text-text-primary">{children}</span>
690
+ </Card>
691
+ )
692
+ }
@@ -94,7 +94,7 @@ It does these by itself. Write none of them around it.
94
94
 
95
95
  | It does | So you never write | Refused when written by hand |
96
96
  |---|---|---|
97
- | As a `page`, fills the room it is given and centres itself in it | centring, or padding around it | a `page` inside a `ScrollArea`, where it cannot centre (`estiva/no-handmade-empty-state`) |
97
+ | As a `page`, fills the room it is given and centres itself in it | centring, or padding around it | a box of its own that centres or pads a `page`, and a `page` inside a `ScrollArea`, where it cannot centre (`estiva/no-handmade-empty-state`) |
98
98
  | Says a list is empty, in place of its rows | a paragraph or span of your own saying so | `estiva/no-handmade-empty-state` |
99
99
  | Draws its one action, an outlined Button, under the line when you pass `action` | a button of your own under the line | |
100
100
 
@@ -52,6 +52,8 @@ const jsx = (body: string) => component(` return (\n${body}\n )`)
52
52
  const scrolled = (imports: string, body: string) =>
53
53
  `import { ScrollArea, EmptyState } from '@estiva-app/ui'\n${imports}\nexport function Probe({ rows }: { rows: string[] }) {\n return (\n${body}\n )\n}\n`
54
54
  const handmade = [{ messageId: 'handmade' }]
55
+ /** A component that draws `body`, with the app's own EmptyState imported. */
56
+ const pageIn = (body: string) => `import { EmptyState } from '@/components/ui/EmptyState'\nexport function Probe() {\n return (\n${body}\n )\n}\n`
55
57
 
56
58
  tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
57
59
  valid: [
@@ -100,6 +102,17 @@ tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
100
102
  { name: 'a page state inside a ScrollArea whose content fills it', filename: app, code: scrolled('', '<ScrollArea className="flex-1" contentClassName="flex min-h-full flex-col">{rows.length === 0 && <EmptyState message="Nothing here yet." />}</ScrollArea>') },
101
103
  { name: 'a scope that is decided at run time is not judged', filename: app, code: scrolled('', '<ScrollArea className="flex-1"><EmptyState scope={rows.length ? "section" : "page"} message="Nothing here yet." /></ScrollArea>') },
102
104
  { name: "a component that hands its className to a section state (Ship's Activity), followed and fine", filename: app, code: scrolled("import { Activity } from '@/components/Activity'", '<ScrollArea className="flex-1" contentClassName="px-6"><section><Activity items={rows} /></section></ScrollArea>') },
105
+
106
+ // UIG-42: a box around a page state. The column it asks for, and a section's inset, pass.
107
+ { name: 'a page state in the flex column it asks for (Peek FilePage.tsx:315)', filename: app, code: pageIn('<div className="flex min-h-screen flex-col"><EmptyState message="Nothing here." /></div>') },
108
+ { name: "a section state in a box that gives it the rows' inset (Katerina, 14 September)", filename: app, code: pageIn('<div className="px-2 py-1"><EmptyState scope="section" message="Nothing starred." /></div>') },
109
+ { name: 'a page state that is not alone in its padded box (Ship ProjectsView.tsx:30)', filename: app, code: pageIn('<div className="flex flex-1 flex-col gap-4 px-6 py-5"><h2>Projects</h2><EmptyState message="No projects yet." /></div>') },
110
+ { name: "props spread in may carry the scope, so it is not judged (the package's EmptyState story)", filename: app, code: pageIn('<div className="p-4"><EmptyState {...({ scope: "section" } as const)} /></div>') },
111
+ {
112
+ name: 'a box kept with a reason',
113
+ filename: app,
114
+ code: pageIn('<>\n{/* @estiva-escape(no-handmade-empty-state): a probe that keeps its box */}\n<div className="p-4"><EmptyState /></div>\n</>'),
115
+ },
103
116
  ],
104
117
  invalid: [
105
118
  // the hand-made line, each way it stands in for a list
@@ -122,10 +135,19 @@ tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
122
135
  errors: [{ messageId: 'escapeWithoutReason' }, ...handmade],
123
136
  },
124
137
 
138
+ // UIG-42: a box that places a page state. Peek's direct-message view, and the starter's home page before UIG-10's fix.
139
+ { name: 'a box that centres a page state', filename: app, code: pageIn('<div className="flex-1 flex items-center justify-center h-full"><EmptyState /></div>'), errors: [{ messageId: 'boxedPage', data: { what: 'centres' } }] },
140
+ {
141
+ name: 'a box that centres and pads it, from the package',
142
+ filename: app,
143
+ code: "import { EmptyState } from '@estiva-app/ui'\nexport function Probe() {\n return (\n <main className=\"flex justify-center px-6 py-16\">\n <EmptyState message=\"Nothing here yet.\" />\n </main>\n )\n}\n",
144
+ errors: [{ messageId: 'boxedPage', data: { what: 'centres and pads' } }],
145
+ },
146
+
125
147
  // the wrong scope
126
148
  { name: 'a page state straight inside a ScrollArea', filename: app, code: scrolled('', '<ScrollArea className="min-h-0 flex-1">{rows.length === 0 && <EmptyState message="Nothing here yet." />}</ScrollArea>'), errors: [{ messageId: 'wrongScope' }] },
127
149
  { name: 'scope="page" said out loud', filename: app, code: scrolled('', '<ScrollArea className="flex-1" contentClassName="flex flex-col gap-3 p-4"><EmptyState scope="page" message="Nothing here yet." /></ScrollArea>'), errors: [{ messageId: 'wrongScope' }] },
128
- { name: "through the app's wrapper, one box down", filename: app, code: "import { ScrollArea } from '@estiva-app/ui'\nimport { EmptyState } from '@/components/ui/EmptyState'\nexport function Probe() {\n return <ScrollArea className=\"flex-1\"><div className=\"p-4\"><EmptyState message=\"Nothing said about this yet.\" /></div></ScrollArea>\n}\n", errors: [{ messageId: 'wrongScope' }] },
150
+ { name: "through the app's wrapper, one box down", filename: app, code: "import { ScrollArea } from '@estiva-app/ui'\nimport { EmptyState } from '@/components/ui/EmptyState'\nexport function Probe() {\n return <ScrollArea className=\"flex-1\"><div className=\"p-4\"><EmptyState message=\"Nothing said about this yet.\" /></div></ScrollArea>\n}\n", errors: [{ messageId: 'boxedPage', data: { what: 'pads' } }, { messageId: 'wrongScope' }] },
129
151
  {
130
152
  name: 'the file conversation at 7f22e5e~1: a view drawn in another file, scrolled here — both its states',
131
153
  filename: app,
@@ -165,6 +165,16 @@ function standsInForAList(element: Node): boolean {
165
165
  /** A content box that fills its viewport: `min-h-full` or `h-full` on `contentClassName`. */
166
166
  const FILLS = /^(?:min-h-full|h-full)$/
167
167
 
168
+ /** What a box does that a page-scope EmptyState already does itself: centre, or leave room round it. */
169
+ const CENTRES = /^(?:justify-center|items-center|content-center|place-(?:items|content)-center)$/
170
+ const PADS = /^p[xytrblse]?-/
171
+
172
+ /** The only element a box holds, when it holds one and nothing else but blank space. */
173
+ function onlyElement(box: Node): Node | undefined {
174
+ const kids = children(box, 'children').filter((kid) => !(kid.type === 'JSXText' && !String(kid.value).trim()))
175
+ return kids.length === 1 && kids[0].type === 'JSXElement' ? kids[0] : undefined
176
+ }
177
+
168
178
  function isPageScope(opening: Node): boolean {
169
179
  const scope = attributeOf(opening, 'scope')
170
180
  if (!scope) return true
@@ -265,6 +275,13 @@ const fileName = (file: string) => file.replace(/\\/g, '/').split('/').pop()
265
275
  * file and scrolled in another. A `contentClassName` with `min-h-full` or
266
276
  * `h-full` gives the state its room, and passes.
267
277
  *
278
+ * **A box that places it** (UIG-42). A plain box holding only a page-scope
279
+ * `EmptyState` and centring or padding it does the part's own job, and a box
280
+ * like that pinned the starter's home page 64px from the top (UIG-10). A box
281
+ * that is only the flex column the state asks for passes, and so does any box
282
+ * round a `section` state, which takes its inset from the box (Katerina, 14
283
+ * September).
284
+ *
268
285
  * Not judged: the package's `EmptyState` and a part's own lines (a
269
286
  * `FieldLine`, a palette's empty row) — a part is already a part.
270
287
  */
@@ -278,6 +295,8 @@ export const noHandmadeEmptyState: Rule.RuleModule = {
278
295
  '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
296
  wrongScope:
280
297
  '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.',
298
+ boxedPage:
299
+ 'This box {{what}} a page-scope EmptyState, which centres itself in the room a flex column gives it and takes no padding. A box that places it can push it out of the middle, as the starter\'s home page did (UIG-10). Draw the EmptyState straight into the column, with `className="h-full"` outside a flex column.',
281
300
  wrongScopeVia:
282
301
  '`{{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
302
  ...ESCAPE_MESSAGES,
@@ -300,8 +319,25 @@ export const noHandmadeEmptyState: Rule.RuleModule = {
300
319
  const opening = child(element, 'openingElement') as Node
301
320
  const tag = tagName(element)
302
321
 
303
- // Shape 1: a plain box holding only a "nothing" line, where a list would be.
304
322
  if (tag && /^[a-z]/.test(tag)) {
323
+ // Shape 3 (UIG-42): a plain box around a page-scope EmptyState that centres or pads it.
324
+ const only = facts && onlyElement(element)
325
+ const inner = only && (child(only, 'openingElement') as Node)
326
+ // Props spread in (`{...args}`) may carry the scope, so it is not judged.
327
+ const spread = inner && children(inner, 'attributes').some((a) => a.type === 'JSXSpreadAttribute')
328
+ if (inner && !spread && isPart(bindingOfTag(child(inner, 'name'), facts!.locals), 'EmptyState') && isPageScope(inner)) {
329
+ const className = attributeOf(opening, 'className')
330
+ const own = className ? classesOf(child(className, 'value'), scopeOf).map((c) => c.replace(/^(?:[^:\s[\]]+:)+/, '')) : []
331
+ const centres = own.some((c) => CENTRES.test(c))
332
+ const pads = own.some((c) => PADS.test(c))
333
+ if ((centres || pads) && !isEscaped(context, opening)) {
334
+ const what = centres && pads ? 'centres and pads' : centres ? 'centres' : 'pads'
335
+ context.report({ loc: opening.loc as NonNullable<Rule.Node['loc']>, messageId: 'boxedPage', data: { what } })
336
+ }
337
+ return
338
+ }
339
+
340
+ // Shape 1: a plain box holding only a "nothing" line, where a list would be.
305
341
  const words = onlyWords(element)
306
342
  if (!words || words.length > SHORT) return
307
343
  if (!standsInForAList(element)) return
@@ -57,6 +57,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
57
57
  'UIG-30': 'RichText — one component that draws a message\'s text, for both apps',
58
58
  'UIG-32': 'Peek and Ship take their gate pieces from the package',
59
59
  'UIG-37': 'The gates refuse a TypeScript eslint-disable comment — know the rule names, rules off',
60
+ 'UIG-42': "What the gate can't see yet — a box around a part, a screen drawn by hand, two blind spots in the token check",
60
61
  }
61
62
 
62
63
  /** Run several checks as one: the first that does not pass is the answer. */
@@ -254,6 +255,12 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
254
255
  return probe(code, 'none', undefined, 'src/components/__gates_probe__.test.tsx', config)()
255
256
  }), 'a test file is not checked for hand-written values, while source is') },
256
257
  ]),
258
+ // Built 28 September: the token check's two blind spots, and a box that places a page EmptyState.
259
+ ticket('UIG-42', [
260
+ { what: 'a class behind an arbitrary variant is an error', run: tokenProbe(component('<div className="group-hover/row:bg-gray-100">x</div>'), 'error') },
261
+ { what: 'a class list joined by hand is read', run: tokenProbe(component("<div className={['text-sm', 'p-2'].join(' ')}>x</div>"), 'error') },
262
+ { what: 'a box that centres a page EmptyState is an error naming it', run: probe(`import { EmptyState } from '@estiva-app/ui'\n${component('<div className="flex items-center justify-center"><EmptyState message="Nothing here" /></div>')}`, 'error', 'page-scope EmptyState') },
263
+ ]),
257
264
  // Built 28 September: both apps drew the protocol's text tree by hand, and drifted apart.
258
265
  // A file that reads the protocol and draws a quote or a numbered list itself is a third copy.
259
266
  ticket('UIG-30', [
@@ -21,6 +21,7 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
21
21
  import { parser as typescriptParser } from 'typescript-eslint'
22
22
  import estiva from '../eslint/index'
23
23
  import { KNOWN_PLUGINS, TOKEN_LINT_IGNORES } from './token-lint'
24
+ import { tokenJoinedPlugin } from './token-joined'
24
25
  import { tokenStylePlugin } from './token-style'
25
26
 
26
27
  export interface GateConfigOptions {
@@ -72,6 +73,7 @@ export function gateConfig({ audience = 'app', quiet = {}, ignores = [] }: GateC
72
73
  'token-values': betterTailwindcss,
73
74
  'token-spacing': betterTailwindcss,
74
75
  'token-style': tokenStylePlugin,
76
+ 'token-joined': tokenJoinedPlugin,
75
77
  },
76
78
  // Every other config's directives are unused here, because their rules are
77
79
  // off here. Reporting them would be this config complaining about the
@@ -91,6 +91,32 @@ describe('tokenLint and tokenValues', () => {
91
91
  expect(await lintWith(gateConfig(), `// eslint-disable-next-line token-style/no-token-style -- @estiva-escape: a colour the probe keeps\n${kept}`, 'src/Probe.tsx')).toEqual([])
92
92
  })
93
93
 
94
+ it('read a class behind an arbitrary variant or a `!` (UIG-42)', async () => {
95
+ for (const name of ['[&_pre]:text-sm', 'group-hover/row:bg-gray-100', 'data-[state=open]:bg-[#fff]', 'group-hover/row:bg-bg-surface/50', '!text-sm']) {
96
+ const messages = await lintWith([tokenLint(), tokenValues()], component(`<div className="${name}">x</div>`), 'src/Probe.tsx')
97
+ expect(messages.filter((m) => m.ruleId === 'better-tailwindcss/no-restricted-classes'), name).toEqual([expect.objectContaining({ severity: 2 })])
98
+ }
99
+ })
100
+
101
+ it('read a class list joined by hand, with the same messages and levels (UIG-42)', async () => {
102
+ const code = `import { cn } from '@estiva-app/ui'\nconst look = ['text-sm', open ? 'rounded-[3px]' : '', \`h-[240px] \${size}\`].filter(Boolean).join(' ')\n${component("<div className={cn(look, ['bg-gray-100'].join(' '))}>x</div>")}`
103
+ const messages = (await lintWith([tokenLint(), tokenValues()], code, 'src/Probe.tsx')).filter((m) => m.ruleId?.startsWith('token-joined/'))
104
+ expect(messages.map((m) => [m.ruleId, m.severity, m.line])).toEqual([
105
+ ['token-joined/classes', 2, 2],
106
+ ['token-joined/values', 2, 2],
107
+ ['token-joined/spacing', 1, 2],
108
+ ['token-joined/classes', 2, 4],
109
+ ])
110
+ expect(messages[0].message).toContain("Tailwind's type ramp")
111
+ expect(messages[1].message).toContain('rounded-[3px] is a corner written by hand')
112
+ })
113
+
114
+ it('leave a sentence joined with spaces, and a list joined with anything else', async () => {
115
+ // Peek's prompt hint (src/lib/promptApi.ts) is built this way.
116
+ const code = "export const hint = ['Reply with only a JSON object, no prose and no code fence.', `It must have exactly these keys: ${keys}.`].join(' ')\nexport const keys = ['text-sm', 'bg-gray-100'].join(', ')\n"
117
+ expect(await lintWith([tokenLint(), tokenValues()], code, 'src/lib/probe.ts')).toEqual([])
118
+ })
119
+
94
120
  it('leave a test file to its test', async () => {
95
121
  const messages = await lintWith([tokenLint(), tokenValues()], component('<div style={{ color: "red" }}>x</div>'), 'src/Probe.test.tsx')
96
122
  expect(messages.filter((m) => m.severity === 2)).toEqual([])
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The token contract, read inside a class list joined by hand (UIG-42):
3
+ *
4
+ * <div className={['text-sm', open && 'bg-gray-100'].join(' ')} />
5
+ *
6
+ * The Tailwind plugin stops at a function call, so it never reads the list in
7
+ * front of `.join(' ')`, and no setting can make it: a call it is told about is
8
+ * read at its arguments, which here is the `' '`. This rule reads the list and
9
+ * tests each class against the same patterns, with the same messages, as the
10
+ * rule it stands in for. It registers once per rule it mirrors, so an error
11
+ * stays an error and a warning a warning (`token-joined/classes`, `/values`,
12
+ * `/spacing`).
13
+ *
14
+ * A list joined with anything but one space is not a class list, and a
15
+ * sentence built this way passes unless one of its words is itself such a
16
+ * class, as in `'set text-sm here'`. Only strings written in the list are
17
+ * read, directly or behind `cond && …`, `a ? … : …` and a template's fixed
18
+ * parts; a list worked out while it runs cannot be read.
19
+ */
20
+ import type { ESLint, Rule } from 'eslint'
21
+
22
+ /** One pattern and its message, the shape `no-restricted-classes` takes. `$0` is the class. */
23
+ export interface Restriction {
24
+ pattern: string
25
+ message: string
26
+ }
27
+
28
+ interface Node {
29
+ type: string
30
+ [key: string]: unknown
31
+ }
32
+
33
+ const isOneSpace = (node: Node | undefined) => node?.type === 'Literal' && node.value === ' '
34
+
35
+ /** `.join` on a list, through any `.filter(…)` between them. */
36
+ function joinedList(call: Node): Node | undefined {
37
+ const callee = call.callee as Node
38
+ if (callee.type !== 'MemberExpression' || callee.computed || (callee.property as Node).name !== 'join') return undefined
39
+ if ((call.arguments as Node[]).length !== 1 || !isOneSpace((call.arguments as Node[])[0])) return undefined
40
+ let list = callee.object as Node
41
+ while (list.type === 'CallExpression' && (list.callee as Node).type === 'MemberExpression' && ((list.callee as Node).property as Node).name === 'filter') {
42
+ list = (list.callee as Node).object as Node
43
+ }
44
+ return list.type === 'ArrayExpression' ? list : undefined
45
+ }
46
+
47
+ /** The strings written in one item of the list, each with the node an escape sits above. */
48
+ function stringsIn(node: Node | null, out: { node: Node; text: string }[] = []) {
49
+ if (!node) return out
50
+ if (node.type === 'Literal' && typeof node.value === 'string') out.push({ node, text: node.value })
51
+ else if (node.type === 'TemplateLiteral') for (const quasi of node.quasis as Node[]) out.push({ node: quasi, text: String((quasi.value as { cooked: string }).cooked ?? '') })
52
+ else if (node.type === 'ConditionalExpression') stringsIn(node.consequent as Node, out), stringsIn(node.alternate as Node, out)
53
+ else if (node.type === 'LogicalExpression') {
54
+ if (node.operator !== '&&') stringsIn(node.left as Node, out)
55
+ stringsIn(node.right as Node, out)
56
+ } else if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') stringsIn(node.expression as Node, out)
57
+ return out
58
+ }
59
+
60
+ const joinedListRule: Rule.RuleModule = {
61
+ meta: {
62
+ type: 'problem',
63
+ docs: { description: 'The token contract, inside a class list joined with .join(" ")' },
64
+ schema: [
65
+ {
66
+ type: 'object',
67
+ properties: { restrict: { type: 'array', items: { type: 'object', properties: { pattern: { type: 'string' }, message: { type: 'string' } }, required: ['pattern', 'message'] } } },
68
+ required: ['restrict'],
69
+ additionalProperties: false,
70
+ },
71
+ ],
72
+ },
73
+ create(context) {
74
+ const { restrict } = context.options[0] as { restrict: Restriction[] }
75
+ const rules = restrict.map(({ pattern, message }) => ({ pattern: new RegExp(pattern), message }))
76
+ return {
77
+ CallExpression(ruleNode: Rule.Node) {
78
+ const list = joinedList(ruleNode as unknown as Node)
79
+ if (!list) return
80
+ for (const item of list.elements as (Node | null)[]) {
81
+ for (const { node, text } of stringsIn(item)) {
82
+ for (const name of text.split(/\s+/)) {
83
+ const rule = name && rules.find(({ pattern }) => pattern.test(name))
84
+ if (rule) context.report({ node: node as unknown as Rule.Node, message: rule.message.replaceAll('$0', name) })
85
+ }
86
+ }
87
+ }
88
+ },
89
+ }
90
+ },
91
+ }
92
+
93
+ /** The plugin, as the token lint registers it: one rule, under three names. */
94
+ export const tokenJoinedPlugin: ESLint.Plugin = { rules: { classes: joinedListRule, values: joinedListRule, spacing: joinedListRule } }