@estiva-app/ui 0.25.0 → 0.27.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/AttachmentCard.d.ts +48 -6
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Lightbox.d.ts +30 -0
- package/dist/Lightbox.d.ts.map +1 -0
- package/dist/eslint/index.js +4 -4
- package/dist/eslint/index.js.map +2 -2
- 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/cli.js +1 -1
- package/dist/gates/index.js +7 -3
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +594 -464
- package/dist/index.js.map +4 -4
- 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 +94 -4
- package/src/AttachmentCard.mdx +32 -8
- package/src/AttachmentCard.stories.tsx +54 -0
- package/src/AttachmentCard.test.tsx +115 -0
- package/src/AttachmentCard.tsx +212 -37
- package/src/CollapsibleSection.stories.tsx +6 -0
- package/src/Lightbox.mdx +71 -0
- package/src/Lightbox.stories.tsx +72 -0
- package/src/Lightbox.test.tsx +65 -0
- package/src/Lightbox.tsx +87 -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/eslint/no-rebuilt-behaviour.ts +4 -4
- package/src/gates/app-checks.ts +6 -2
- package/src/gates/status.ts +15 -0
- package/src/index.ts +1 -0
- 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/src/registry/registry.test.ts +4 -4
- package/tailwind-preset.js +3 -0
- package/tokens.css +4 -0
- 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: {
|
|
@@ -190,7 +190,7 @@ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
|
|
|
190
190
|
id: 'portal',
|
|
191
191
|
behaviour: 'Floats on top of the page',
|
|
192
192
|
baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Autocomplete', 'Tooltip', 'PreviewCard', 'Toast'],
|
|
193
|
-
owners: ['DialogShell', 'ConfirmDialog', 'CommandPalette', 'Popover', 'Menu', 'Select', 'ChipInput', 'Tooltip', 'PreviewCard', 'Toast'],
|
|
193
|
+
owners: ['DialogShell', 'ConfirmDialog', 'CommandPalette', 'Lightbox', 'Popover', 'Menu', 'Select', 'ChipInput', 'Tooltip', 'PreviewCard', 'Toast'],
|
|
194
194
|
reads: 'createPortal, called, or imported and never called',
|
|
195
195
|
},
|
|
196
196
|
{
|
|
@@ -204,21 +204,21 @@ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
|
|
|
204
204
|
id: 'page-keys',
|
|
205
205
|
behaviour: 'Takes its keys by itself',
|
|
206
206
|
baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Tooltip', 'PreviewCard', 'Toast'],
|
|
207
|
-
owners: ['DialogShell', 'Popover', 'Menu', 'Select', 'Tabs', 'Toolbar'],
|
|
207
|
+
owners: ['DialogShell', 'Lightbox', 'Popover', 'Menu', 'Select', 'Tabs', 'Toolbar'],
|
|
208
208
|
reads: 'a keydown, keyup or keypress listener on window or document',
|
|
209
209
|
},
|
|
210
210
|
{
|
|
211
211
|
id: 'focus',
|
|
212
212
|
behaviour: 'Holds focus inside while open, and gives it back',
|
|
213
213
|
baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox'],
|
|
214
|
-
owners: ['DialogShell', 'CommandPalette'],
|
|
214
|
+
owners: ['DialogShell', 'CommandPalette', 'Lightbox'],
|
|
215
215
|
reads: 'a focusin or focusout listener on window or document; the Tab key compared by hand',
|
|
216
216
|
},
|
|
217
217
|
{
|
|
218
218
|
id: 'scroll-lock',
|
|
219
219
|
behaviour: 'Stops the page behind it scrolling',
|
|
220
220
|
baseUi: ['Dialog', 'AlertDialog'],
|
|
221
|
-
owners: ['DialogShell'],
|
|
221
|
+
owners: ['DialogShell', 'Lightbox'],
|
|
222
222
|
reads: 'overflow written into the style of document.body or document.documentElement',
|
|
223
223
|
},
|
|
224
224
|
{
|
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',
|
|
@@ -190,7 +190,11 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
190
190
|
{ what: 'every part is described and sorted, and every file is accounted for', run: () => h.catalogue(at('.')) },
|
|
191
191
|
]),
|
|
192
192
|
ticket('UIG-19', [
|
|
193
|
-
|
|
193
|
+
// The contract runs inside `estiva-ui check`, which the gate job already runs
|
|
194
|
+
// (UIG-13), so an app changes no script: it takes the package that has it.
|
|
195
|
+
{ 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") },
|
|
196
|
+
{ what: "CI's job gate runs it", run: () => h.ciJob('gate', 'registry:check') },
|
|
197
|
+
{ 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
198
|
]),
|
|
195
199
|
ticket('UIG-20', [
|
|
196
200
|
{ 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')) },
|
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/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export {
|
|
|
44
44
|
type InlineChipProps,
|
|
45
45
|
type InlineChipTone,
|
|
46
46
|
} from './InlineChip'
|
|
47
|
+
export { Lightbox, type LightboxProps } from './Lightbox'
|
|
47
48
|
export { Link, type LinkProps, type LinkVariant } from './Link'
|
|
48
49
|
export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
|
|
49
50
|
export { Kbd, type KbdProps } from './Kbd'
|
package/src/registry/app.test.ts
CHANGED
|
@@ -540,11 +540,24 @@ describe.skipIf(!existsSync(cli))('estiva-ui in an app', () => {
|
|
|
540
540
|
const run = (args: string[], cwd: string) => execFileSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
541
541
|
|
|
542
542
|
it('check prints what it found, and fails on a part with no description', () => {
|
|
543
|
-
expect(run(['check', '--repo', 'fixture'], root)).toMatch(/^fixture: 17 parts in 15 files \(2 more hold none\)/)
|
|
544
543
|
const bare = app({ 'src/One.tsx': 'export function One() {\n return <i />\n}\n' })
|
|
545
544
|
expect(() => run(['check'], bare)).toThrow(/One in src\/One\.tsx has no one-line description/)
|
|
546
545
|
})
|
|
547
546
|
|
|
547
|
+
it('check fails a reusable part with no usage page, naming the page to write (UIG-19)', () => {
|
|
548
|
+
// The fixture's reusable parts have no pages: it was written before the contract ran in `check`.
|
|
549
|
+
let failed: { stdout?: string; stderr?: string } = {}
|
|
550
|
+
try {
|
|
551
|
+
run(['check', '--repo', 'fixture'], root)
|
|
552
|
+
} catch (error) {
|
|
553
|
+
failed = error as { stdout?: string; stderr?: string }
|
|
554
|
+
}
|
|
555
|
+
expect(failed.stdout).toMatch(/^fixture: 17 parts in 15 files \(2 more hold none\)/)
|
|
556
|
+
expect(failed.stderr).toContain('Timeline (reusable, src/components/Timeline.tsx) has no usage page: write src/components/Timeline.mdx')
|
|
557
|
+
const one = app({ 'src/One.tsx': '/** One, drawn once. */\nexport function One() {\n return <i />\n}\n' })
|
|
558
|
+
expect(run(['check'], one)).toContain('usage pages: all 0 reusable parts keep the contract')
|
|
559
|
+
})
|
|
560
|
+
|
|
548
561
|
it("find searches the app and the package, and never takes an app's own registry.json for the package's", () => {
|
|
549
562
|
// What `estiva-ui build` leaves behind in an app, and git ignores.
|
|
550
563
|
run(['build'], root)
|
package/src/registry/app.ts
CHANGED
|
@@ -678,7 +678,11 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
678
678
|
const storyOf = (p: Found) => {
|
|
679
679
|
const stem = (file: string) => basename(file).replace(/\.stories\.[cm]?[jt]sx?$/, '')
|
|
680
680
|
const own = basename(p.file, extname(p.file))
|
|
681
|
-
|
|
681
|
+
// A story file named after the part draws it even when its stories are of a
|
|
682
|
+
// presentational half in the same file — `FilesPanel.stories.tsx` drawing
|
|
683
|
+
// `FilesPanelView` is FilesPanel's picture, and its page attaches there (UIG-19).
|
|
684
|
+
const sameFile = (s: (typeof stories)[number]) => s.imports.some((q) => q.file === p.file)
|
|
685
|
+
const candidates = stories.filter((s) => s.title && s.names.length && (s.component === p || (s.imports.includes(p) && (stem(s.file) === p.name || stem(s.file) === own)) || (stem(s.file) === p.name && sameFile(s))))
|
|
682
686
|
candidates.sort((a, b) => Number(b.component === p) - Number(a.component === p) || a.file.localeCompare(b.file))
|
|
683
687
|
const s = candidates[0]
|
|
684
688
|
if (!s || !s.title) return { docsId: null, storyId: null }
|
package/src/registry/cli.ts
CHANGED
|
@@ -15,16 +15,20 @@
|
|
|
15
15
|
*
|
|
16
16
|
* **In an app**: nothing is committed (Katerina, 18 September 2026 — the
|
|
17
17
|
* package is public, the apps are private). `check` builds the app's catalogue
|
|
18
|
-
* and fails when a part has no one-line description or cannot be sorted
|
|
19
|
-
*
|
|
18
|
+
* and fails when a part has no one-line description or cannot be sorted, or
|
|
19
|
+
* when a reusable part breaks the usage-page contract (UIG-19, `./contract`);
|
|
20
|
+
* CI's `gate` job runs it. `build` writes the catalogue to a file only when asked.
|
|
20
21
|
*
|
|
21
22
|
* `find` searches the package's catalogue — the one shipped inside the installed
|
|
22
23
|
* package, or this repo's own in the package — plus, in an app, the app's own,
|
|
23
24
|
* built fresh, plus each `--also` app that sits beside it.
|
|
24
25
|
*/
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
26
|
+
import { spawnSync } from 'node:child_process'
|
|
27
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { tmpdir } from 'node:os'
|
|
29
|
+
import { basename, join, resolve } from 'node:path'
|
|
27
30
|
import { fileURLToPath } from 'node:url'
|
|
31
|
+
import { CONTRACT_KINDS, contractProblems, linkProblems } from './contract'
|
|
28
32
|
import { findInRegistries, formatFindings } from './find'
|
|
29
33
|
import { CLASSES, validateRegistry, type Registry } from './schema'
|
|
30
34
|
|
|
@@ -39,6 +43,7 @@ import { CLASSES, validateRegistry, type Registry } from './schema'
|
|
|
39
43
|
*/
|
|
40
44
|
const packageBuilder = () => import('./build')
|
|
41
45
|
const appBuilder = () => import('./app')
|
|
46
|
+
const gatedScan = () => import('./gated')
|
|
42
47
|
|
|
43
48
|
const [command, ...rest] = process.argv.slice(2)
|
|
44
49
|
const VALUED = ['--limit', '--out', '--file', '--root', '--repo', '--also']
|
|
@@ -94,6 +99,60 @@ async function buildApp(folder: string, repo: string | undefined): Promise<Regis
|
|
|
94
99
|
return registry
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Every story and docs link in a catalogue, against the Storybook beside it
|
|
104
|
+
* (UIG-19). `storybook index` writes the ids Storybook itself would serve — no
|
|
105
|
+
* server, no browser, seconds — so a renamed story, or a page that no longer
|
|
106
|
+
* compiles, fails here rather than as a dead link somebody finds later.
|
|
107
|
+
* Reports, and says whether it passed. A folder with no Storybook has nothing
|
|
108
|
+
* to check.
|
|
109
|
+
*/
|
|
110
|
+
function checkLinks(registry: Registry, folder: string): boolean {
|
|
111
|
+
const bin = resolve(folder, 'node_modules', '.bin', process.platform === 'win32' ? 'storybook.cmd' : 'storybook')
|
|
112
|
+
if (!existsSync(resolve(folder, '.storybook')) || !existsSync(bin)) {
|
|
113
|
+
process.stdout.write('story links: no Storybook here, nothing to check\n')
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
const out = join(mkdtempSync(join(tmpdir(), 'estiva-ui-index-')), 'index.json')
|
|
117
|
+
// A `.cmd` needs a shell on Windows, and Node wants that as one quoted line
|
|
118
|
+
// rather than an argument list. Both paths are ours: the app's folder and a temp file.
|
|
119
|
+
const run =
|
|
120
|
+
process.platform === 'win32'
|
|
121
|
+
? spawnSync(`"${bin}" index -o "${out}" --quiet`, { cwd: folder, encoding: 'utf8', shell: true })
|
|
122
|
+
: spawnSync(bin, ['index', '-o', out, '--quiet'], { cwd: folder, encoding: 'utf8' })
|
|
123
|
+
if (run.status !== 0 || !existsSync(out)) {
|
|
124
|
+
process.stderr.write(`story links: Storybook could not index its stories, so no link can be trusted:\n${(run.stderr || run.stdout || '').trim()}\n`)
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
const ids = new Set(Object.keys((JSON.parse(readFileSync(out, 'utf8')) as { entries: Record<string, unknown> }).entries))
|
|
128
|
+
const dead = linkProblems(registry, ids)
|
|
129
|
+
const count = registry.entries.reduce((n, entry) => n + (entry.storyId ? 1 : 0) + (entry.docsId ? 1 : 0), 0)
|
|
130
|
+
if (dead.length) {
|
|
131
|
+
process.stderr.write(`story links: ${dead.length} of ${count} lead nowhere:\n ${dead.join('\n ')}\n`)
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
process.stdout.write(`story links: all ${count} open a story or docs page Storybook has\n`)
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What a part draws only when a prop is passed, where no story passes it
|
|
140
|
+
* (UIG-19, `./gated`). Strict, by Katerina's ruling: a part's own stories show
|
|
141
|
+
* everything it can draw. In an app it reads the reusable parts; in the package,
|
|
142
|
+
* every component (`kinds` is the package's one class, `component`).
|
|
143
|
+
*/
|
|
144
|
+
async function checkGated(registry: Registry, folder: string, kinds?: string[]): Promise<boolean> {
|
|
145
|
+
const { gatedFindings } = await gatedScan()
|
|
146
|
+
const found = gatedFindings(registry, folder, { ...(kinds ? { kinds } : {}), pictured: 'own' })
|
|
147
|
+
if (found.length) {
|
|
148
|
+
const lines = found.map((f) => `${f.part} (${f.file}) draws something only when given \`${f.prop}\`, and no story that draws it passes \`${f.prop}\`: pass it in a story`)
|
|
149
|
+
process.stderr.write(`stories: ${found.length === 1 ? 'one thing a part draws is' : `${found.length} things parts draw are`} never shown:\n ${lines.join('\n ')}\n`)
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
process.stdout.write('stories: everything a part draws behind a prop is shown by a story\n')
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
97
156
|
/** One line per kind, in a fixed order, so two runs read the same. */
|
|
98
157
|
function summary(registry: Registry): string {
|
|
99
158
|
const count = (cls: string) => registry.entries.filter((entry) => entry.app?.class === cls).length
|
|
@@ -165,8 +224,19 @@ async function main(): Promise<number> {
|
|
|
165
224
|
|
|
166
225
|
case 'check': {
|
|
167
226
|
if (!isPackage) {
|
|
168
|
-
|
|
169
|
-
|
|
227
|
+
const registry = await buildApp(root, value('repo'))
|
|
228
|
+
process.stdout.write(`${summary(registry)}\n`)
|
|
229
|
+
// The usage-page contract and the story links (UIG-19): the one copy, run by every app's `gate` job.
|
|
230
|
+
const broken = contractProblems(registry, root)
|
|
231
|
+
if (broken.length) {
|
|
232
|
+
process.stderr.write(`${broken.length === 1 ? 'a part breaks' : `${broken.length} parts break`} the usage-page contract:\n ${broken.join('\n ')}\n`)
|
|
233
|
+
} else {
|
|
234
|
+
const owed = registry.entries.filter((entry) => (CONTRACT_KINDS as readonly string[]).includes(entry.app?.class ?? '')).length
|
|
235
|
+
process.stdout.write(`usage pages: all ${owed} reusable parts keep the contract and are drawn somewhere\n`)
|
|
236
|
+
}
|
|
237
|
+
const linked = checkLinks(registry, root)
|
|
238
|
+
const shown = await checkGated(registry, root)
|
|
239
|
+
return broken.length || !linked || !shown ? 1 : 0
|
|
170
240
|
}
|
|
171
241
|
const { buildRegistry, serializeRegistry } = await packageBuilder()
|
|
172
242
|
const path = packageRegistryPath()
|
|
@@ -187,7 +257,9 @@ async function main(): Promise<number> {
|
|
|
187
257
|
return 1
|
|
188
258
|
}
|
|
189
259
|
process.stdout.write(`${path}: current, ${built.entries.length} entries from ${built.builtFrom.exports} exports\n`)
|
|
190
|
-
|
|
260
|
+
const linked = checkLinks(built, root)
|
|
261
|
+
const shown = await checkGated(built, root, ['component'])
|
|
262
|
+
return linked && shown ? 0 : 1
|
|
191
263
|
}
|
|
192
264
|
|
|
193
265
|
default:
|