@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.
- package/dist/CommandPalette.d.ts +16 -0
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/eslint/index.js +21 -0
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-handmade-empty-state.d.ts +7 -0
- package/dist/eslint/no-handmade-empty-state.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-SYVKGOWM.js → chunk-P5W6O5TF.js} +114 -63
- package/dist/gates/chunk-P5W6O5TF.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/index.js +13 -3
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/token-joined.d.ts +28 -0
- package/dist/gates/token-joined.d.ts.map +1 -0
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/registry.json +41 -3
- package/src/CommandPalette.mdx +6 -0
- package/src/CommandPalette.stories.tsx +26 -1
- package/src/CommandPalette.test.tsx +14 -0
- package/src/CommandPalette.tsx +29 -0
- package/src/EmptyState.mdx +1 -1
- package/src/eslint/no-handmade-empty-state.test.ts +23 -1
- package/src/eslint/no-handmade-empty-state.ts +37 -1
- package/src/gates/app-checks.ts +7 -0
- package/src/gates/gate-config.ts +2 -0
- package/src/gates/gates.test.ts +26 -0
- package/src/gates/token-joined.ts +94 -0
- package/src/gates/token-lint.ts +68 -66
- package/src/index.ts +2 -0
- package/src/registry/registry.test.ts +3 -3
- package/dist/gates/chunk-SYVKGOWM.js.map +0 -7
package/package.json
CHANGED
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.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
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",
|
package/src/CommandPalette.mdx
CHANGED
|
@@ -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…')
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -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
|
+
}
|
package/src/EmptyState.mdx
CHANGED
|
@@ -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
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -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', [
|
package/src/gates/gate-config.ts
CHANGED
|
@@ -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
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -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 } }
|