@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.
Files changed (69) hide show
  1. package/dist/AttachmentCard.d.ts +48 -6
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Lightbox.d.ts +30 -0
  4. package/dist/Lightbox.d.ts.map +1 -0
  5. package/dist/eslint/index.js +4 -4
  6. package/dist/eslint/index.js.map +2 -2
  7. package/dist/gates/app-checks.d.ts.map +1 -1
  8. package/dist/gates/{chunk-GTQZEHPC.js → chunk-I7CJLUPM.js} +13 -1
  9. package/dist/gates/chunk-I7CJLUPM.js.map +7 -0
  10. package/dist/gates/cli.js +1 -1
  11. package/dist/gates/index.js +7 -3
  12. package/dist/gates/index.js.map +2 -2
  13. package/dist/gates/status.d.ts +2 -0
  14. package/dist/gates/status.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +594 -464
  18. package/dist/index.js.map +4 -4
  19. package/dist/registry/{app-DQI6WLHR.js → app-FXN4EI34.js} +2 -2
  20. package/dist/registry/app.d.ts.map +1 -1
  21. package/dist/registry/{chunk-W2B2G7OE.js → chunk-6OH3TWGF.js} +59 -1
  22. package/dist/registry/chunk-6OH3TWGF.js.map +7 -0
  23. package/dist/registry/{chunk-WMFF3MPP.js → chunk-XCBN2EKW.js} +3 -2
  24. package/dist/registry/{chunk-WMFF3MPP.js.map → chunk-XCBN2EKW.js.map} +2 -2
  25. package/dist/registry/cli.js +69 -8
  26. package/dist/registry/cli.js.map +3 -3
  27. package/dist/registry/contract.d.ts +29 -0
  28. package/dist/registry/contract.d.ts.map +1 -0
  29. package/dist/registry/gated-5CPIUQAW.js +230 -0
  30. package/dist/registry/gated-5CPIUQAW.js.map +7 -0
  31. package/dist/registry/gated.d.ts +31 -0
  32. package/dist/registry/gated.d.ts.map +1 -0
  33. package/dist/registry/index.d.ts +1 -0
  34. package/dist/registry/index.d.ts.map +1 -1
  35. package/dist/registry/index.js +15 -3
  36. package/package.json +1 -1
  37. package/registry.json +94 -4
  38. package/src/AttachmentCard.mdx +32 -8
  39. package/src/AttachmentCard.stories.tsx +54 -0
  40. package/src/AttachmentCard.test.tsx +115 -0
  41. package/src/AttachmentCard.tsx +212 -37
  42. package/src/CollapsibleSection.stories.tsx +6 -0
  43. package/src/Lightbox.mdx +71 -0
  44. package/src/Lightbox.stories.tsx +72 -0
  45. package/src/Lightbox.test.tsx +65 -0
  46. package/src/Lightbox.tsx +87 -0
  47. package/src/ListColumn.stories.tsx +6 -0
  48. package/src/MenuItem.stories.tsx +151 -135
  49. package/src/Person.stories.tsx +7 -0
  50. package/src/PersonTrigger.stories.tsx +7 -0
  51. package/src/SectionHeader.stories.tsx +5 -0
  52. package/src/eslint/no-rebuilt-behaviour.ts +4 -4
  53. package/src/gates/app-checks.ts +6 -2
  54. package/src/gates/status.ts +15 -0
  55. package/src/index.ts +1 -0
  56. package/src/registry/app.test.ts +14 -1
  57. package/src/registry/app.ts +5 -1
  58. package/src/registry/cli.ts +79 -7
  59. package/src/registry/contract.test.ts +158 -0
  60. package/src/registry/contract.ts +108 -0
  61. package/src/registry/gated.test.ts +98 -0
  62. package/src/registry/gated.ts +308 -0
  63. package/src/registry/index.ts +1 -0
  64. package/src/registry/registry.test.ts +4 -4
  65. package/tailwind-preset.js +3 -0
  66. package/tokens.css +4 -0
  67. package/dist/gates/chunk-GTQZEHPC.js.map +0 -7
  68. package/dist/registry/chunk-W2B2G7OE.js.map +0 -7
  69. /package/dist/registry/{app-DQI6WLHR.js.map → app-FXN4EI34.js.map} +0 -0
@@ -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: <EnterHint />,
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
+ }
@@ -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
  {
@@ -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, and make the three Storybooks one search',
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
- { what: 'the usage-page contract runs in CI', run: () => h.ci(/[\w:-]*contract[\w:-]*/) },
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')) },
@@ -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'
@@ -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)
@@ -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
- 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))))
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 }
@@ -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; CI's
19
- * `gate` job runs it. `build` writes the catalogue to a file only when asked.
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 { existsSync, readFileSync, writeFileSync } from 'node:fs'
26
- import { basename, resolve } from 'node:path'
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
- process.stdout.write(`${summary(await buildApp(root, value('repo')))}\n`)
169
- return 0
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
- return 0
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: