@estiva-app/ui 0.26.0 → 0.28.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/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-GTQZEHPC.js → chunk-I7CJLUPM.js} +13 -1
- package/dist/gates/chunk-I7CJLUPM.js.map +7 -0
- package/dist/gates/{chunk-EA33NP5B.js → chunk-XE6HC5MJ.js} +8 -12
- package/dist/gates/chunk-XE6HC5MJ.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/gate-config.d.ts +1 -1
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/index.d.ts +2 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +36 -6
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +2 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts +27 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/registry/{app-DQI6WLHR.js → app-FXN4EI34.js} +2 -2
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/{chunk-W2B2G7OE.js → chunk-6OH3TWGF.js} +59 -1
- package/dist/registry/chunk-6OH3TWGF.js.map +7 -0
- package/dist/registry/{chunk-WMFF3MPP.js → chunk-XCBN2EKW.js} +3 -2
- package/dist/registry/{chunk-WMFF3MPP.js.map → chunk-XCBN2EKW.js.map} +2 -2
- package/dist/registry/cli.js +69 -8
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/contract.d.ts +29 -0
- package/dist/registry/contract.d.ts.map +1 -0
- package/dist/registry/gated-5CPIUQAW.js +230 -0
- package/dist/registry/gated-5CPIUQAW.js.map +7 -0
- package/dist/registry/gated.d.ts +31 -0
- package/dist/registry/gated.d.ts.map +1 -0
- package/dist/registry/index.d.ts +1 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +15 -3
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/CollapsibleSection.stories.tsx +6 -0
- package/src/ListColumn.stories.tsx +6 -0
- package/src/MenuItem.stories.tsx +151 -135
- package/src/Person.stories.tsx +7 -0
- package/src/PersonTrigger.stories.tsx +7 -0
- package/src/SectionHeader.stories.tsx +5 -0
- package/src/gates/app-checks.ts +19 -2
- package/src/gates/create-app.ts +7 -11
- package/src/gates/gate-config.ts +3 -2
- package/src/gates/gates.test.ts +35 -1
- package/src/gates/index.ts +2 -1
- package/src/gates/status.ts +15 -0
- package/src/gates/token-lint.ts +47 -2
- package/src/registry/app.test.ts +14 -1
- package/src/registry/app.ts +5 -1
- package/src/registry/cli.ts +79 -7
- package/src/registry/contract.test.ts +158 -0
- package/src/registry/contract.ts +108 -0
- package/src/registry/gated.test.ts +98 -0
- package/src/registry/gated.ts +308 -0
- package/src/registry/index.ts +1 -0
- package/dist/gates/chunk-EA33NP5B.js.map +0 -7
- package/dist/gates/chunk-GTQZEHPC.js.map +0 -7
- package/dist/registry/chunk-W2B2G7OE.js.map +0 -7
- /package/dist/registry/{app-DQI6WLHR.js.map → app-FXN4EI34.js.map} +0 -0
package/src/MenuItem.stories.tsx
CHANGED
|
@@ -1,135 +1,151 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
-
import { IconCopy, IconPin, IconTrash } from '@tabler/icons-react'
|
|
3
|
-
import { Avatar } from './Avatar'
|
|
4
|
-
import { useRef, useState } from 'react'
|
|
5
|
-
import { Button } from './Button'
|
|
6
|
-
import { Divider } from './Divider'
|
|
7
|
-
import { EnterHint, Menu, MenuItem, MenuPanel, MenuSub } from './Menu'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* One row of a menu. Anatomy: `leading` (a 16px icon or an Avatar) · label
|
|
11
|
-
* with an optional `description` line · one thing at the right edge —
|
|
12
|
-
* `trailing` (any hint), else `shortcut` (the kbd chip), else the `submenu`
|
|
13
|
-
* chevron. Stories sit on a `MenuPanel` — the menu's surface, drawn without
|
|
14
|
-
* its behaviour — so the row is seen where it lives. A live menu portals and
|
|
15
|
-
* places itself, so it cannot stand in the page (Katerina, D25); **Menu →
|
|
16
|
-
* FromATrigger** is where the keyboard and the placement are.
|
|
17
|
-
*/
|
|
18
|
-
const meta = {
|
|
19
|
-
title: 'Overlays/MenuItem',
|
|
20
|
-
component: MenuItem,
|
|
21
|
-
decorators: [
|
|
22
|
-
(Story) => (
|
|
23
|
-
<div className="flex min-h-[120px] w-full items-center justify-center">
|
|
24
|
-
<MenuPanel className="w-72">
|
|
25
|
-
<Story />
|
|
26
|
-
</MenuPanel>
|
|
27
|
-
</div>
|
|
28
|
-
),
|
|
29
|
-
],
|
|
30
|
-
args: { label: 'Rename', onClick: () => {} },
|
|
31
|
-
argTypes: {
|
|
32
|
-
onClick: { control: false },
|
|
33
|
-
leading: { control: false },
|
|
34
|
-
trailing: { control: false },
|
|
35
|
-
},
|
|
36
|
-
} satisfies Meta<typeof MenuItem>
|
|
37
|
-
|
|
38
|
-
export default meta
|
|
39
|
-
type Story = StoryObj<typeof meta>
|
|
40
|
-
|
|
41
|
-
export const Default: Story = {}
|
|
42
|
-
|
|
43
|
-
export const WithAnIcon: Story = {
|
|
44
|
-
args: { label: 'Pin', leading: <IconPin size={16} stroke={1.5} className="text-text-secondary" /> },
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export const WithAShortcut: Story = {
|
|
48
|
-
args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'Ctrl+C' },
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** The row opens another menu. */
|
|
52
|
-
export const Submenu: Story = {
|
|
53
|
-
args: { label: 'Move to…', submenu: true },
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* The submenu, working. The story above draws the row at rest — the chevron is
|
|
58
|
-
* the whole affordance — but a row that opens another menu can only be tried
|
|
59
|
-
* inside a real one, so here is one.
|
|
60
|
-
*
|
|
61
|
-
* Hover **Move to…**, or arrow onto it and press →. The panel opens beside the
|
|
62
|
-
* row, flips to the other side at a screen edge, and stays open while you
|
|
63
|
-
* cross the diagonal into it.
|
|
64
|
-
*
|
|
65
|
-
* From the keyboard: → opens it and leaves the highlight on the row, ↓ steps
|
|
66
|
-
* into it, and ← closes it and puts the highlight back on the row. Measured in
|
|
67
|
-
* that order — → then ← alone does nothing, because focus has not entered yet.
|
|
68
|
-
*/
|
|
69
|
-
export const SubmenuLive: Story = {
|
|
70
|
-
parameters: { controls: { disable: true } },
|
|
71
|
-
decorators: [(Story) => <Story />],
|
|
72
|
-
render: () => (
|
|
73
|
-
<div className="flex min-h-[220px] w-full items-start justify-center pt-4">
|
|
74
|
-
<Menu trigger={<Button variant="outlined">Open the menu</Button>}>
|
|
75
|
-
<MenuItem label="Rename" onClick={() => {}} />
|
|
76
|
-
<MenuSub label="Move to…">
|
|
77
|
-
<MenuItem label="Item one" onClick={() => {}} />
|
|
78
|
-
<MenuItem label="Item two" onClick={() => {}} />
|
|
79
|
-
<MenuItem label="Item three" onClick={() => {}} />
|
|
80
|
-
</MenuSub>
|
|
81
|
-
<Divider className="my-1" />
|
|
82
|
-
<MenuItem label="Delete" destructive onClick={() => {}} />
|
|
83
|
-
</Menu>
|
|
84
|
-
</div>
|
|
85
|
-
),
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** A person as a row — the face, the name, a second line, a trailing hint. Peek's mention rows. */
|
|
89
|
-
export const APerson: Story = {
|
|
90
|
-
args: {
|
|
91
|
-
label: 'Ana Duarte',
|
|
92
|
-
description: 'Product designer',
|
|
93
|
-
leading: <Avatar name="Ana Duarte" size={32} />,
|
|
94
|
-
trailing: <EnterHint />,
|
|
95
|
-
},
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** The submenu's chosen value. */
|
|
99
|
-
export const Selected: Story = {
|
|
100
|
-
args: { label: 'Item one', selected: true },
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export const Destructive: Story = {
|
|
104
|
-
args: { label: 'Delete', destructive: true, leading: <IconTrash size={16} stroke={1.5} className="text-error-default" /> },
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Every shape on one canvas. */
|
|
108
|
-
export const AllVariants: Story = {
|
|
109
|
-
parameters: { controls: { disable: true } },
|
|
110
|
-
render: () => (
|
|
111
|
-
<>
|
|
112
|
-
<MenuItem label="Bare" onClick={() => {}} />
|
|
113
|
-
<MenuItem label="With an icon" leading={<IconPin size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
|
|
114
|
-
<MenuItem label="With a shortcut" shortcut="Ctrl+K" onClick={() => {}} />
|
|
115
|
-
<MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
|
|
116
|
-
<MenuItem label="Opens another menu" submenu onClick={() => {}} />
|
|
117
|
-
<MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<EnterHint />} onClick={() => {}} />
|
|
118
|
-
<MenuItem label="The chosen value" selected onClick={() => {}} />
|
|
119
|
-
<MenuItem label="Destructive" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
|
|
120
|
-
<MenuItem label="A very long label that runs out of room and truncates" onClick={() => {}} />
|
|
121
|
-
</>
|
|
122
|
-
),
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** The tall picker row — 48px, px-3, a 32px face or tile, the description line, and the hint while highlighted. TopicMenu, MentionMenu, the files menu and the launcher all draw this row. */
|
|
126
|
-
export const Tall: Story = {
|
|
127
|
-
args: {
|
|
128
|
-
size: 'tall',
|
|
129
|
-
label: 'Ana Duarte',
|
|
130
|
-
description: 'Product designer',
|
|
131
|
-
leading: <Avatar name="Ana Duarte" size={32} />,
|
|
132
|
-
selected: true,
|
|
133
|
-
trailing
|
|
134
|
-
|
|
135
|
-
}
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconCopy, IconHash, IconPin, IconTrash } from '@tabler/icons-react'
|
|
3
|
+
import { Avatar } from './Avatar'
|
|
4
|
+
import { useRef, useState } from 'react'
|
|
5
|
+
import { Button } from './Button'
|
|
6
|
+
import { Divider } from './Divider'
|
|
7
|
+
import { EnterHint, Menu, MenuItem, MenuPanel, MenuSub } from './Menu'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One row of a menu. Anatomy: `leading` (a 16px icon or an Avatar) · label
|
|
11
|
+
* with an optional `description` line · one thing at the right edge —
|
|
12
|
+
* `trailing` (any hint), else `shortcut` (the kbd chip), else the `submenu`
|
|
13
|
+
* chevron. Stories sit on a `MenuPanel` — the menu's surface, drawn without
|
|
14
|
+
* its behaviour — so the row is seen where it lives. A live menu portals and
|
|
15
|
+
* places itself, so it cannot stand in the page (Katerina, D25); **Menu →
|
|
16
|
+
* FromATrigger** is where the keyboard and the placement are.
|
|
17
|
+
*/
|
|
18
|
+
const meta = {
|
|
19
|
+
title: 'Overlays/MenuItem',
|
|
20
|
+
component: MenuItem,
|
|
21
|
+
decorators: [
|
|
22
|
+
(Story) => (
|
|
23
|
+
<div className="flex min-h-[120px] w-full items-center justify-center">
|
|
24
|
+
<MenuPanel className="w-72">
|
|
25
|
+
<Story />
|
|
26
|
+
</MenuPanel>
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
args: { label: 'Rename', onClick: () => {} },
|
|
31
|
+
argTypes: {
|
|
32
|
+
onClick: { control: false },
|
|
33
|
+
leading: { control: false },
|
|
34
|
+
trailing: { control: false },
|
|
35
|
+
},
|
|
36
|
+
} satisfies Meta<typeof MenuItem>
|
|
37
|
+
|
|
38
|
+
export default meta
|
|
39
|
+
type Story = StoryObj<typeof meta>
|
|
40
|
+
|
|
41
|
+
export const Default: Story = {}
|
|
42
|
+
|
|
43
|
+
export const WithAnIcon: Story = {
|
|
44
|
+
args: { label: 'Pin', leading: <IconPin size={16} stroke={1.5} className="text-text-secondary" /> },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const WithAShortcut: Story = {
|
|
48
|
+
args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'Ctrl+C' },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The row opens another menu. */
|
|
52
|
+
export const Submenu: Story = {
|
|
53
|
+
args: { label: 'Move to…', submenu: true },
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The submenu, working. The story above draws the row at rest — the chevron is
|
|
58
|
+
* the whole affordance — but a row that opens another menu can only be tried
|
|
59
|
+
* inside a real one, so here is one.
|
|
60
|
+
*
|
|
61
|
+
* Hover **Move to…**, or arrow onto it and press →. The panel opens beside the
|
|
62
|
+
* row, flips to the other side at a screen edge, and stays open while you
|
|
63
|
+
* cross the diagonal into it.
|
|
64
|
+
*
|
|
65
|
+
* From the keyboard: → opens it and leaves the highlight on the row, ↓ steps
|
|
66
|
+
* into it, and ← closes it and puts the highlight back on the row. Measured in
|
|
67
|
+
* that order — → then ← alone does nothing, because focus has not entered yet.
|
|
68
|
+
*/
|
|
69
|
+
export const SubmenuLive: Story = {
|
|
70
|
+
parameters: { controls: { disable: true } },
|
|
71
|
+
decorators: [(Story) => <Story />],
|
|
72
|
+
render: () => (
|
|
73
|
+
<div className="flex min-h-[220px] w-full items-start justify-center pt-4">
|
|
74
|
+
<Menu trigger={<Button variant="outlined">Open the menu</Button>}>
|
|
75
|
+
<MenuItem label="Rename" onClick={() => {}} />
|
|
76
|
+
<MenuSub label="Move to…">
|
|
77
|
+
<MenuItem label="Item one" onClick={() => {}} />
|
|
78
|
+
<MenuItem label="Item two" onClick={() => {}} />
|
|
79
|
+
<MenuItem label="Item three" onClick={() => {}} />
|
|
80
|
+
</MenuSub>
|
|
81
|
+
<Divider className="my-1" />
|
|
82
|
+
<MenuItem label="Delete" destructive onClick={() => {}} />
|
|
83
|
+
</Menu>
|
|
84
|
+
</div>
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A person as a row — the face, the name, a second line, a trailing hint. Peek's mention rows. */
|
|
89
|
+
export const APerson: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
label: 'Ana Duarte',
|
|
92
|
+
description: 'Product designer',
|
|
93
|
+
leading: <Avatar name="Ana Duarte" size={32} />,
|
|
94
|
+
trailing: <EnterHint />,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The submenu's chosen value. */
|
|
99
|
+
export const Selected: Story = {
|
|
100
|
+
args: { label: 'Item one', selected: true },
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const Destructive: Story = {
|
|
104
|
+
args: { label: 'Delete', destructive: true, leading: <IconTrash size={16} stroke={1.5} className="text-error-default" /> },
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Every shape on one canvas. */
|
|
108
|
+
export const AllVariants: Story = {
|
|
109
|
+
parameters: { controls: { disable: true } },
|
|
110
|
+
render: () => (
|
|
111
|
+
<>
|
|
112
|
+
<MenuItem label="Bare" onClick={() => {}} />
|
|
113
|
+
<MenuItem label="With an icon" leading={<IconPin size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
|
|
114
|
+
<MenuItem label="With a shortcut" shortcut="Ctrl+K" onClick={() => {}} />
|
|
115
|
+
<MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
|
|
116
|
+
<MenuItem label="Opens another menu" submenu onClick={() => {}} />
|
|
117
|
+
<MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<EnterHint />} onClick={() => {}} />
|
|
118
|
+
<MenuItem label="The chosen value" selected onClick={() => {}} />
|
|
119
|
+
<MenuItem label="Destructive" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
|
|
120
|
+
<MenuItem label="A very long label that runs out of room and truncates" onClick={() => {}} />
|
|
121
|
+
</>
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The tall picker row — 48px, px-3, a 32px face or tile, the description line, and the hint while highlighted. TopicMenu, MentionMenu, the files menu and the launcher all draw this row. */
|
|
126
|
+
export const Tall: Story = {
|
|
127
|
+
args: {
|
|
128
|
+
size: 'tall',
|
|
129
|
+
label: 'Ana Duarte',
|
|
130
|
+
description: 'Product designer',
|
|
131
|
+
leading: <Avatar name="Ana Duarte" size={32} />,
|
|
132
|
+
selected: true,
|
|
133
|
+
// `hint`, not `trailing`: shown only while the row is highlighted, on the fill's own timing.
|
|
134
|
+
hint: <EnterHint />,
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A topic in the picker: `EnterHint`'s `target` says what Enter gives you, after the key. Hover another row and back to see the hint follow the highlight. */
|
|
139
|
+
export const ATopic: Story = {
|
|
140
|
+
args: {
|
|
141
|
+
size: 'tall',
|
|
142
|
+
label: 'design',
|
|
143
|
+
description: '12 members',
|
|
144
|
+
leading: <IconHash size={16} stroke={1.5} className="text-text-secondary" />,
|
|
145
|
+
selected: true,
|
|
146
|
+
hint: <EnterHint target="#design" />,
|
|
147
|
+
},
|
|
148
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled: the
|
|
149
|
+
// target is muted text, 3.05:1 on a highlighted row in signal (AA 4.5:1).
|
|
150
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
151
|
+
}
|
package/src/Person.stories.tsx
CHANGED
|
@@ -13,6 +13,13 @@ type Story = StoryObj<typeof meta>
|
|
|
13
13
|
|
|
14
14
|
export const Named: Story = {}
|
|
15
15
|
|
|
16
|
+
/** With their picture — here an inline SVG, as Avatar's own story uses, so the story needs nothing from anywhere. */
|
|
17
|
+
export const WithPicture: Story = {
|
|
18
|
+
args: {
|
|
19
|
+
picture: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="%2346c08a"/><circle cx="5" cy="4" r="2" fill="%23fff"/></svg>',
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
/** Nobody has published a name: the silhouette and the em dash, muted — the same mark a property with no value uses. */
|
|
17
24
|
export const Unnamed: Story = {
|
|
18
25
|
args: { name: undefined },
|
|
@@ -14,6 +14,13 @@ type Story = StoryObj<typeof meta>
|
|
|
14
14
|
/** The row shape — face · name · chevron on a 32px button, Ship's top bar. */
|
|
15
15
|
export const Row: Story = {}
|
|
16
16
|
|
|
17
|
+
/** The row with the person's picture — an inline SVG, as Avatar's own story uses. */
|
|
18
|
+
export const RowWithPicture: Story = {
|
|
19
|
+
args: {
|
|
20
|
+
picture: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="%2346c08a"/><circle cx="5" cy="4" r="2" fill="%23fff"/></svg>',
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
/** While what it opens is open, the row holds its hover fill. */
|
|
18
25
|
export const RowOpen: Story = {
|
|
19
26
|
args: { open: true },
|
|
@@ -17,6 +17,11 @@ type Story = StoryObj<typeof meta>
|
|
|
17
17
|
|
|
18
18
|
export const Plain: Story = {}
|
|
19
19
|
|
|
20
|
+
/** `chevron`: the header of a section that folds. The title becomes the toggle, and the chevron turns with it. CollapsibleSection draws this for you. */
|
|
21
|
+
export const Folding: Story = {
|
|
22
|
+
args: { chevron: true, isExpanded: true, onToggle: () => {} },
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
/** Actions beside the title, in the order given — revealed on hover or focus. */
|
|
21
26
|
export const WithActions: Story = {
|
|
22
27
|
args: {
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -45,7 +45,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
|
|
|
45
45
|
'UIG-9': 'Lint rule — the className allow-list',
|
|
46
46
|
'UIG-10': 'create-app — a command that makes a new Estiva app that runs',
|
|
47
47
|
'UIG-13': "The registry widens to Peek's 115 and Ship's 74, with classification",
|
|
48
|
-
'UIG-19': 'Lock the contract in CI
|
|
48
|
+
'UIG-19': 'Lock the contract in CI',
|
|
49
49
|
'UIG-20': 'The Claude skill, reading the registry',
|
|
50
50
|
'UIG-21': 'CLAUDE.md becomes an index, not a lecture',
|
|
51
51
|
'UIG-22': 'Fingerprint — a hand-made header row',
|
|
@@ -56,6 +56,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
|
|
|
56
56
|
'UIG-28': 'Close the two holes in the token contract — arbitrary values, and inline style',
|
|
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
|
+
'UIG-37': 'The gates refuse a TypeScript eslint-disable comment — know the rule names, rules off',
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/** Run several checks as one: the first that does not pass is the answer. */
|
|
@@ -81,6 +82,8 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
81
82
|
const installed = at('node_modules/@estiva-app/ui/dist/index.d.ts')
|
|
82
83
|
const realPage = (): Promise<CheckResult> => h.lint({ ...(cwd ? { cwd } : {}), config: GATES, file: page, code: h.read(at(page)), expect: 'none' })
|
|
83
84
|
const button = component('<button type="button">x</button>')
|
|
85
|
+
/** A line TypeScript's own lint writes; ESLint refuses it where TypeScript's plugin is unknown (UIG-37). */
|
|
86
|
+
const typescriptDirective = '// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const probe: any = 1\n'
|
|
84
87
|
const ticket = (ref: string, checks: GateCheck[], owner = false): GateTicket => ({ ref, title: APP_TICKET_TITLES[ref], owner, checks })
|
|
85
88
|
|
|
86
89
|
const tokenProbe = (code: string, expect: 'error' | 'warning' | 'none', mentions?: string, file = PROBE) =>
|
|
@@ -190,7 +193,11 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
190
193
|
{ what: 'every part is described and sorted, and every file is accounted for', run: () => h.catalogue(at('.')) },
|
|
191
194
|
]),
|
|
192
195
|
ticket('UIG-19', [
|
|
193
|
-
|
|
196
|
+
// The contract runs inside `estiva-ui check`, which the gate job already runs
|
|
197
|
+
// (UIG-13), so an app changes no script: it takes the package that has it.
|
|
198
|
+
{ what: 'the installed package checks the usage-page contract and the story links', run: () => h.contains(at('node_modules/@estiva-app/ui/dist/registry/cli.js'), 'the usage-page contract', "the installed estiva-ui check runs the contract") },
|
|
199
|
+
{ what: "CI's job gate runs it", run: () => h.ciJob('gate', 'registry:check') },
|
|
200
|
+
{ what: 'the app keeps no copy of it in its own gates-checks.mjs', run: () => h.exists(at('scripts/gates-checks.mjs')) ? h.lacks(at('scripts/gates-checks.mjs'), '"When not"', 'the app checks no page sections itself') : h.PASS('the app has no gates-checks.mjs') },
|
|
194
201
|
]),
|
|
195
202
|
ticket('UIG-20', [
|
|
196
203
|
{ what: 'a committed skill runs ui:find', run: () => (h.listFiles('.claude/skills', (n) => n === 'SKILL.md').some((f) => h.read(f).includes('ui:find')) ? h.PASS('a skill runs ui:find') : h.FAIL('no skill in .claude/skills runs ui:find')) },
|
|
@@ -244,5 +251,15 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
244
251
|
() => (h.exists('scripts/gates-status.mjs') ? h.FAIL('scripts/gates-status.mjs is a copy of the status engine: run estiva-gates status instead') : h.PASS('no copy of the status engine')),
|
|
245
252
|
], 'the gate config, the hook and gates:status are the package\'s; no copy of the engine') },
|
|
246
253
|
]),
|
|
254
|
+
ticket('UIG-37', [
|
|
255
|
+
{ what: 'a TypeScript eslint-disable comment breaks neither the gate nor the token lint', run: all(h, [
|
|
256
|
+
probe(typescriptDirective, 'none'),
|
|
257
|
+
tokenProbe(typescriptDirective, 'none'),
|
|
258
|
+
], `${[GATES, ...tokenConfigs].join(', ')}: a TypeScript directive is no error`) },
|
|
259
|
+
{ what: 'a token lint on its own is the package\'s tokenConfig, not built by hand', run: () =>
|
|
260
|
+
(h.exists(at('eslint.tokens.config.js'))
|
|
261
|
+
? h.contains(at('eslint.tokens.config.js'), /\btokenConfig\(/, 'eslint.tokens.config.js runs tokenConfig from @estiva-app/ui/gates')
|
|
262
|
+
: h.PASS('no token lint on its own')) },
|
|
263
|
+
]),
|
|
247
264
|
]
|
|
248
265
|
}
|
package/src/gates/create-app.ts
CHANGED
|
@@ -264,18 +264,13 @@ export default defineConfig([
|
|
|
264
264
|
gateLint(),
|
|
265
265
|
])
|
|
266
266
|
`,
|
|
267
|
-
'eslint.tokens.config.js': `import {
|
|
267
|
+
'eslint.tokens.config.js': `import { tokenConfig } from '@estiva-app/ui/gates'
|
|
268
268
|
import reactHooks from 'eslint-plugin-react-hooks'
|
|
269
|
-
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
270
269
|
|
|
271
|
-
// The token contract on its own (\`npm run lint:tokens\`)
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
{ plugins: { 'react-hooks': reactHooks }, linterOptions: { reportUnusedDisableDirectives: 'off' } },
|
|
276
|
-
tokenLint(),
|
|
277
|
-
tokenValues(),
|
|
278
|
-
])
|
|
270
|
+
// The token contract on its own (\`npm run lint:tokens\`), the package's piece.
|
|
271
|
+
// React's hooks plugin is named so its directives do not break it; TypeScript's
|
|
272
|
+
// the package names itself.
|
|
273
|
+
export default tokenConfig({ quiet: { 'react-hooks': reactHooks } })
|
|
279
274
|
`,
|
|
280
275
|
'eslint.gates.config.js': `import { gateConfig } from '@estiva-app/ui/gates'
|
|
281
276
|
import reactHooks from 'eslint-plugin-react-hooks'
|
|
@@ -284,7 +279,8 @@ import reactHooks from 'eslint-plugin-react-hooks'
|
|
|
284
279
|
* The UI Guardrails' rules on their own — \`npm run lint:rules\`, CI's job \`gate\`
|
|
285
280
|
* (GitHub requires it on main: never rename it), and what the editor hook in
|
|
286
281
|
* \`.claude/settings.json\` lints a proposed write with. The package's gate,
|
|
287
|
-
* imported. React's hooks plugin is named so its directives do not break it
|
|
282
|
+
* imported. React's hooks plugin is named so its directives do not break it;
|
|
283
|
+
* TypeScript's the package names itself.
|
|
288
284
|
*
|
|
289
285
|
* A place that keeps something the gate refuses says why, on the line above:
|
|
290
286
|
* \`// @estiva-escape: <reason>\`. Never \`eslint-disable\`: the count refuses it.
|
package/src/gates/gate-config.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { ESLint, Linter } from 'eslint'
|
|
|
20
20
|
import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
|
|
21
21
|
import { parser as typescriptParser } from 'typescript-eslint'
|
|
22
22
|
import estiva from '../eslint/index'
|
|
23
|
-
import { TOKEN_LINT_IGNORES } from './token-lint'
|
|
23
|
+
import { KNOWN_PLUGINS, TOKEN_LINT_IGNORES } from './token-lint'
|
|
24
24
|
|
|
25
25
|
export interface GateConfigOptions {
|
|
26
26
|
/** `app` (the default) runs `configs.recommended`; `package` runs this package's inward set. */
|
|
@@ -29,7 +29,7 @@ export interface GateConfigOptions {
|
|
|
29
29
|
* Plugins whose `eslint-disable` directives appear in the source, registered
|
|
30
30
|
* here with every rule off. ESLint refuses a directive for a rule it does not
|
|
31
31
|
* know, so without them the gate would fail on another lint's comments.
|
|
32
|
-
* The token lint's names are registered already.
|
|
32
|
+
* The token lint's names and TypeScript's are registered already (UIG-37).
|
|
33
33
|
*/
|
|
34
34
|
quiet?: Record<string, ESLint.Plugin>
|
|
35
35
|
/** Folders the gate never reads, beside build output. */
|
|
@@ -63,6 +63,7 @@ export function gateConfig({ audience = 'app', quiet = {}, ignores = [] }: GateC
|
|
|
63
63
|
{ ignores: [...TOKEN_LINT_IGNORES, ...ignores] },
|
|
64
64
|
{
|
|
65
65
|
plugins: {
|
|
66
|
+
...KNOWN_PLUGINS,
|
|
66
67
|
...quiet,
|
|
67
68
|
'better-tailwindcss': betterTailwindcss,
|
|
68
69
|
'token-values': betterTailwindcss,
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { writeGateCount } from './count'
|
|
|
7
7
|
import { gateConfig, gateLint } from './gate-config'
|
|
8
8
|
import { runHook } from './hook'
|
|
9
9
|
import { helpers, runStatus } from './status'
|
|
10
|
-
import { tokenLint, tokenValues } from './token-lint'
|
|
10
|
+
import { tokenConfig, tokenLint, tokenValues } from './token-lint'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The gate pieces, as an app and this package use them (UIG-10, docs/GATES.md
|
|
@@ -44,6 +44,8 @@ async function lintWith(config: Linter.Config[], code: string, filePath: string)
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const component = (body: string) => `export function Probe() {\n return ${body}\n}\n`
|
|
47
|
+
/** A line TypeScript's own lint writes: Peek PR #324 failed the token lint on it (UIG-37). */
|
|
48
|
+
const typescriptDirective = '// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const probe: any = 1\n'
|
|
47
49
|
|
|
48
50
|
describe('tokenLint and tokenValues', () => {
|
|
49
51
|
it('refuse a hand-written text size and name the token, in the words for an app', async () => {
|
|
@@ -98,6 +100,38 @@ describe('gateConfig', () => {
|
|
|
98
100
|
const directive = `// eslint-disable-next-line house/some-rule\n${component('<div>x</div>')}`
|
|
99
101
|
expect(await lintWith(gateConfig({ quiet: { house: quiet } }), directive, 'src/Probe.tsx')).toEqual([])
|
|
100
102
|
})
|
|
103
|
+
|
|
104
|
+
it("knows TypeScript's rule names, so a TypeScript directive does not break the gate (UIG-37)", async () => {
|
|
105
|
+
expect(await lintWith(gateConfig(), typescriptDirective, 'src/lib/probe.ts')).toEqual([])
|
|
106
|
+
// An app that names the plugin itself, from its own copy, still loads: its copy wins.
|
|
107
|
+
const own = { rules: { 'no-explicit-any': { create: () => ({}) } } }
|
|
108
|
+
expect(await lintWith(gateConfig({ quiet: { '@typescript-eslint': own } }), typescriptDirective, 'src/lib/probe.ts')).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('tokenConfig', () => {
|
|
113
|
+
it('runs the token contract on its own: the same messages as the two pieces, for an app and for the package', async () => {
|
|
114
|
+
const code = component('<div className="bg-gray-100 rounded-[3px]">x</div>')
|
|
115
|
+
const app = await lintWith(tokenConfig(), code, 'src/Probe.tsx')
|
|
116
|
+
expect(app.length).toBeGreaterThan(0)
|
|
117
|
+
expect(app).toEqual(await lintWith([tokenLint(), tokenValues()], code, 'src/Probe.tsx'))
|
|
118
|
+
const pkg = await lintWith(tokenConfig({ audience: 'package' }), code, 'src/Probe.tsx')
|
|
119
|
+
expect(pkg).toEqual(await lintWith([tokenLint({ audience: 'package' }), tokenValues({ audience: 'package' })], code, 'src/Probe.tsx'))
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("knows TypeScript's rule names and the quiet plugins, so their directives do not break it (UIG-37)", async () => {
|
|
123
|
+
expect(await lintWith(tokenConfig(), typescriptDirective, 'src/lib/probe.ts')).toEqual([])
|
|
124
|
+
const quiet = { rules: { 'some-rule': { create: () => ({}) } } }
|
|
125
|
+
const directive = `// eslint-disable-next-line house/some-rule\n${component('<div>x</div>')}`
|
|
126
|
+
expect(await lintWith(tokenConfig({ quiet: { house: quiet } }), directive, 'src/Probe.tsx')).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('never reads build output, nor a folder the app names', async () => {
|
|
130
|
+
const hand = component('<div className="text-[14px]">x</div>')
|
|
131
|
+
const ignored = (messages: Linter.LintMessage[]) => messages.every((m) => !m.ruleId)
|
|
132
|
+
expect(ignored(await lintWith(tokenConfig(), hand, 'dist/Probe.tsx'))).toBe(true)
|
|
133
|
+
expect(ignored(await lintWith(tokenConfig({ ignores: ['demo'] }), hand, 'demo/Probe.tsx'))).toBe(true)
|
|
134
|
+
})
|
|
101
135
|
})
|
|
102
136
|
|
|
103
137
|
describe('writeGateCount', () => {
|
package/src/gates/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* here, and an app imports them:
|
|
9
9
|
*
|
|
10
10
|
* - `tokenLint`, `tokenValues` — the token contract's lint settings (UIG-28).
|
|
11
|
+
* - `tokenConfig` — the token lint on its own, as `lint:tokens` runs it (UIG-37).
|
|
11
12
|
* - `gateLint`, `gateConfig` — where the rules apply, and the gate that runs them alone.
|
|
12
13
|
* - `writeGateCount` — `.gates-count.json` (seam S3).
|
|
13
14
|
* - `runHook` — the editor gate a `PreToolUse` hook runs.
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
* Built by build.mjs into `dist/gates/`, for Node. Nothing here reaches the
|
|
22
23
|
* components' browser bundle.
|
|
23
24
|
*/
|
|
24
|
-
export { TOKEN_LINT_IGNORES, tokenLint, tokenValues, type TokenAudience, type TokenLintOptions } from './token-lint'
|
|
25
|
+
export { TOKEN_LINT_IGNORES, tokenConfig, tokenLint, tokenValues, type TokenAudience, type TokenConfigOptions, type TokenLintOptions } from './token-lint'
|
|
25
26
|
export { gateConfig, gateLint, type GateConfigOptions } from './gate-config'
|
|
26
27
|
export { writeGateCount, type CountOptions, type CountResult } from './count'
|
|
27
28
|
export { runHook, type HookOptions, type HookResult } from './hook'
|
package/src/gates/status.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface GateHelpers {
|
|
|
73
73
|
share(files: string[], test: (file: string) => boolean, label: string): CheckResult
|
|
74
74
|
/** Build the catalogue of the app in `appDir` (UIG-13): passes when every part is described and sorted. */
|
|
75
75
|
catalogue(appDir: string): Promise<CheckResult>
|
|
76
|
+
/** Every reusable part keeps the usage-page contract (UIG-19): the package's one copy of it. */
|
|
77
|
+
contract(appDir: string): Promise<CheckResult>
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
export interface GateCheck {
|
|
@@ -178,6 +180,19 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
178
180
|
}
|
|
179
181
|
},
|
|
180
182
|
|
|
183
|
+
async contract(appDir) {
|
|
184
|
+
try {
|
|
185
|
+
const { buildAppRegistry, contractProblems, CONTRACT_KINDS } = await import('../registry/index')
|
|
186
|
+
const registry = buildAppRegistry({ root: abs(appDir) })
|
|
187
|
+
const problems = contractProblems(registry, abs(appDir))
|
|
188
|
+
const owed = registry.entries.filter((entry) => (CONTRACT_KINDS as readonly string[]).includes(entry.app?.class ?? '')).length
|
|
189
|
+
if (problems.length) return FAIL(`${problems.length} of ${owed}; the first: ${problems[0]}`)
|
|
190
|
+
return PASS(`all ${owed} reusable parts keep the usage-page contract and are drawn somewhere`)
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return FAIL(String(error instanceof Error ? error.message : error).split('\n')[0])
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
|
|
181
196
|
file(rel) {
|
|
182
197
|
return exists(rel) ? PASS(`${rel} exists`) : FAIL(`${rel} does not exist`)
|
|
183
198
|
},
|
package/src/gates/token-lint.ts
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* inline style that sets a colour (errors); heights, spacing and border
|
|
17
17
|
* widths written by hand (warnings, never blocking).
|
|
18
18
|
*
|
|
19
|
+
* And `tokenConfig`, the two on their own as an app's `lint:tokens` runs them
|
|
20
|
+
* (UIG-37), with TypeScript's rule names known so its directives pass.
|
|
21
|
+
*
|
|
19
22
|
* The patterns are the same for everyone. Only the words differ: an app is told
|
|
20
23
|
* to use "the package", the package is told to add the token to `tokens.css`.
|
|
21
24
|
* Measured on 17 September: the UIG-28 block was byte for byte the same in Peek
|
|
@@ -32,10 +35,10 @@
|
|
|
32
35
|
*/
|
|
33
36
|
import { createRequire } from 'node:module'
|
|
34
37
|
import { fileURLToPath } from 'node:url'
|
|
35
|
-
import type { Linter } from 'eslint'
|
|
38
|
+
import type { ESLint, Linter } from 'eslint'
|
|
36
39
|
import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
|
|
37
40
|
import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults'
|
|
38
|
-
import { parser as typescriptParser } from 'typescript-eslint'
|
|
41
|
+
import { parser as typescriptParser, plugin as typescriptPlugin } from 'typescript-eslint'
|
|
39
42
|
|
|
40
43
|
/** Who reads the messages. */
|
|
41
44
|
export type TokenAudience = 'app' | 'package'
|
|
@@ -47,9 +50,30 @@ export interface TokenLintOptions {
|
|
|
47
50
|
audience?: TokenAudience
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
export interface TokenConfigOptions extends TokenLintOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Plugins whose `eslint-disable` directives appear in the source, registered
|
|
56
|
+
* with every rule off. ESLint refuses a directive for a rule it does not
|
|
57
|
+
* know. TypeScript's are registered already.
|
|
58
|
+
*/
|
|
59
|
+
quiet?: Record<string, ESLint.Plugin>
|
|
60
|
+
/** Folders the token lint never reads, beside build output. */
|
|
61
|
+
ignores?: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
/** What the token lint never reads: build output. */
|
|
51
65
|
export const TOKEN_LINT_IGNORES = ['dist/', 'storybook-static/', 'node_modules/']
|
|
52
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Plugins a check that runs on its own registers, every rule off, so the
|
|
69
|
+
* directives another lint writes do not fail it (UIG-37). TypeScript's lint is
|
|
70
|
+
* in every app and writes `eslint-disable-next-line @typescript-eslint/...`;
|
|
71
|
+
* Peek PR #324 failed the token lint on one. Only the checks that run alone
|
|
72
|
+
* take this — a full lint that loads TypeScript's rules from its own copy would
|
|
73
|
+
* refuse a second plugin under the same name.
|
|
74
|
+
*/
|
|
75
|
+
export const KNOWN_PLUGINS: Record<string, ESLint.Plugin> = { '@typescript-eslint': typescriptPlugin as ESLint.Plugin }
|
|
76
|
+
|
|
53
77
|
const CLASS_MAP_SELECTOR = {
|
|
54
78
|
kind: 'variable',
|
|
55
79
|
name: '^(?:[A-Z][A-Z0-9_]*_(?:STYLES|CLASSES)|[a-z][a-zA-Z0-9]*(?:Styles|Classes))$',
|
|
@@ -229,3 +253,24 @@ export function tokenValues({ audience = 'app' }: Pick<TokenLintOptions, 'audien
|
|
|
229
253
|
},
|
|
230
254
|
}
|
|
231
255
|
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The token lint on its own — an app's `eslint.tokens.config.js`, `npm run
|
|
259
|
+
* lint:tokens`, the CI step. It fails on the token contract and on nothing
|
|
260
|
+
* else, so a backlog in the full lint never blocks it. Peek built this file by
|
|
261
|
+
* hand until UIG-37.
|
|
262
|
+
*/
|
|
263
|
+
export function tokenConfig({ quiet = {}, ignores = [], ...options }: TokenConfigOptions = {}): Linter.Config[] {
|
|
264
|
+
return [
|
|
265
|
+
{ ignores: [...TOKEN_LINT_IGNORES, ...ignores] },
|
|
266
|
+
{
|
|
267
|
+
plugins: { ...KNOWN_PLUGINS, ...quiet },
|
|
268
|
+
// Every other lint's directives are unused here, because their rules are
|
|
269
|
+
// off here. Reporting them would be this config complaining about the
|
|
270
|
+
// others' business.
|
|
271
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
272
|
+
},
|
|
273
|
+
tokenLint(options),
|
|
274
|
+
tokenValues({ audience: options.audience }),
|
|
275
|
+
]
|
|
276
|
+
}
|