@estiva-app/ui 0.22.0 → 0.23.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 (82) hide show
  1. package/README.md +21 -0
  2. package/dist/Banner.d.ts +20 -2
  3. package/dist/Banner.d.ts.map +1 -1
  4. package/dist/ContainerHeader.d.ts +25 -0
  5. package/dist/ContainerHeader.d.ts.map +1 -0
  6. package/dist/EmptyState.d.ts +10 -1
  7. package/dist/EmptyState.d.ts.map +1 -1
  8. package/dist/gates/app-checks.d.ts.map +1 -1
  9. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  10. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  11. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  12. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  13. package/dist/gates/cli.js +1 -1
  14. package/dist/gates/create-app.d.ts +2 -0
  15. package/dist/gates/create-app.d.ts.map +1 -1
  16. package/dist/gates/create-app.js +1 -1
  17. package/dist/gates/index.js +25 -3
  18. package/dist/gates/index.js.map +2 -2
  19. package/dist/gates/status.d.ts +2 -0
  20. package/dist/gates/status.d.ts.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +69 -48
  24. package/dist/index.js.map +4 -4
  25. package/dist/registry/app-DQI6WLHR.js +10 -0
  26. package/dist/registry/app.d.ts +21 -0
  27. package/dist/registry/app.d.ts.map +1 -0
  28. package/dist/registry/build-LGFCCOLR.js +27 -0
  29. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  30. package/dist/registry/build.d.ts +68 -2
  31. package/dist/registry/build.d.ts.map +1 -1
  32. package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
  33. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  34. package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
  35. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  36. package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
  37. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  38. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  39. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  40. package/dist/registry/cli.js +81 -26
  41. package/dist/registry/cli.js.map +3 -3
  42. package/dist/registry/find.d.ts +12 -1
  43. package/dist/registry/find.d.ts.map +1 -1
  44. package/dist/registry/index.d.ts +5 -2
  45. package/dist/registry/index.d.ts.map +1 -1
  46. package/dist/registry/index.js +11 -3
  47. package/dist/registry/schema.d.ts +83 -6
  48. package/dist/registry/schema.d.ts.map +1 -1
  49. package/package.json +2 -2
  50. package/registry.json +233 -88
  51. package/src/Banner.mdx +17 -4
  52. package/src/Banner.stories.tsx +22 -0
  53. package/src/Banner.test.tsx +35 -0
  54. package/src/Banner.tsx +34 -9
  55. package/src/ContainerHeader.mdx +56 -0
  56. package/src/ContainerHeader.stories.tsx +62 -0
  57. package/src/ContainerHeader.test.tsx +47 -0
  58. package/src/ContainerHeader.tsx +45 -0
  59. package/src/EmptyState.mdx +12 -0
  60. package/src/EmptyState.stories.tsx +6 -1
  61. package/src/EmptyState.test.tsx +18 -1
  62. package/src/EmptyState.tsx +14 -1
  63. package/src/gates/app-checks.ts +23 -1
  64. package/src/gates/create-app.test.ts +77 -6
  65. package/src/gates/create-app.ts +288 -15
  66. package/src/gates/status.ts +16 -0
  67. package/src/index.ts +1 -0
  68. package/src/registry/app.test.ts +562 -0
  69. package/src/registry/app.ts +854 -0
  70. package/src/registry/build.ts +114 -25
  71. package/src/registry/cli.ts +105 -42
  72. package/src/registry/find.ts +45 -8
  73. package/src/registry/index.ts +8 -1
  74. package/src/registry/registry.test.ts +5 -5
  75. package/src/registry/schema.ts +163 -12
  76. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  77. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
  78. package/dist/registry/build-GOVLABI6.js +0 -13
  79. package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
  80. package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
  81. package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
  82. /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
package/src/Banner.mdx CHANGED
@@ -28,8 +28,8 @@ line, one of four tones, gone when there is nothing to say.
28
28
  under the header at the app's full width; a floating pill is not one, even
29
29
  a permanent one (Katerina, D21).
30
30
  - A standing property of one thing → **Chip** on that thing.
31
- - A notice inside the content flow with its own action — that is a
32
- different component (not in the package yet).
31
+ - More than one action, or a second line of text — that is a different
32
+ component (not in the package yet).
33
33
 
34
34
  ## How
35
35
 
@@ -41,8 +41,21 @@ import { Banner } from '@estiva-app/ui'
41
41
 
42
42
  - Render it only while there is something to say — an empty strip is not
43
43
  a state.
44
- - One line of plain text, `body-2`. If it needs a **second line or an
45
- action of its own**, it has outgrown this component.
44
+ - One line of text, `body-2`. If it needs a **second line**, it has outgrown
45
+ this component.
46
+ - `icon` puts a 16px icon before the line and `action` one small muted Button
47
+ at the end of it, both in the tone (Katerina, 2026-09-18: Peek's strip above
48
+ the composer became a Banner, `info`). With either, the banner is one line
49
+ and the text truncates. One action at most.
50
+
51
+ <Canvas of={BannerStories.WithIconAndAction} />
52
+
53
+ ```tsx
54
+ <Banner tone="info" icon={<IconUserPlus size={16} stroke={1.5} />} action={{ label: 'Join', onClick: join }}>
55
+ You are not in <span className="font-medium">{title}</span> yet
56
+ </Banner>
57
+ ```
58
+
46
59
  - `onDismiss` adds an ✕ at the right-hand end (Katerina, D21). Use it where
47
60
  the reader decides the notice is done with, rather than the app. The row
48
61
  is 40px rather than 36px with it, because the button is taller than the
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconPencilMinus, IconUserPlus } from '@tabler/icons-react'
2
3
  import { Banner } from './Banner'
3
4
 
4
5
  const meta = {
@@ -34,6 +35,27 @@ export const Dismissible: Story = {
34
35
  args: { tone: 'warning', children: 'This workspace is read-only until sign-in.', onDismiss: () => {} },
35
36
  }
36
37
 
38
+ /**
39
+ * With an icon and one action (Katerina, 2026-09-18). One line: the text
40
+ * truncates, and the Button stays at the end.
41
+ */
42
+ export const WithIconAndAction: Story = {
43
+ parameters: { controls: { disable: true } },
44
+ render: () => (
45
+ <div className="flex w-full max-w-xl flex-col gap-2">
46
+ <Banner tone="info" icon={<IconPencilMinus size={16} stroke={1.5} />} action={{ label: 'Invite people', onClick: () => {} }}>
47
+ This is the start of the conversation in <span className="font-medium">Project Alpha</span>
48
+ </Banner>
49
+ <Banner tone="info" icon={<IconPencilMinus size={16} stroke={1.5} />}>
50
+ This is the start of your conversation with <span className="font-medium">Sam Lee</span>
51
+ </Banner>
52
+ <Banner tone="info" icon={<IconUserPlus size={16} stroke={1.5} />} action={{ label: 'Join', onClick: () => {} }}>
53
+ You are not in <span className="font-medium">Project Alpha</span> yet — join to take part in the conversation
54
+ </Banner>
55
+ </div>
56
+ ),
57
+ }
58
+
37
59
  /** All four tones, stacked. */
38
60
  export const AllTones: Story = {
39
61
  parameters: { controls: { disable: true } },
@@ -57,4 +57,39 @@ describe('Banner', () => {
57
57
  )
58
58
  expect(container.firstElementChild!.getAttribute('role')).toBe('alert')
59
59
  })
60
+
61
+ it('puts an icon before the line and one small muted action after it, on one line (Katerina, 2026-09-18)', async () => {
62
+ const onClick = vi.fn()
63
+ const { container } = render(
64
+ <Banner tone="info" icon={<svg data-testid="icon" />} action={{ label: 'Join', onClick }}>
65
+ You are not in <b>Alpha</b> yet
66
+ </Banner>,
67
+ )
68
+ const row = container.firstElementChild!
69
+ expect(row.getAttribute('role')).toBe('status')
70
+ expect(row.firstElementChild!.querySelector('[data-testid="icon"]')).not.toBeNull()
71
+ const line = screen.getByText(/You are not in/)
72
+ expect(line.className).toContain('truncate')
73
+ const button = screen.getByRole('button', { name: 'Join' })
74
+ expect(line.nextElementSibling).toBe(button)
75
+ await userEvent.click(button)
76
+ expect(onClick).toHaveBeenCalledOnce()
77
+ // No dismiss unless asked for.
78
+ expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull()
79
+ })
80
+
81
+ it('an icon alone still keeps the line on one line, and a dismiss alone still wraps', () => {
82
+ const { rerender } = render(
83
+ <Banner tone="info" icon={<svg />}>
84
+ One line
85
+ </Banner>,
86
+ )
87
+ expect(screen.getByText('One line').className).toContain('truncate')
88
+ rerender(
89
+ <Banner tone="info" onDismiss={() => {}}>
90
+ One line
91
+ </Banner>,
92
+ )
93
+ expect(screen.getByText('One line').className).not.toContain('truncate')
94
+ })
60
95
  })
package/src/Banner.tsx CHANGED
@@ -1,4 +1,6 @@
1
+ import type { ReactNode } from 'react'
1
2
  import { IconX } from '@tabler/icons-react'
3
+ import { Button } from './Button'
2
4
  import { cn } from './cn'
3
5
  import { IconButton } from './IconButton'
4
6
 
@@ -13,6 +15,11 @@ import { IconButton } from './IconButton'
13
15
  * An `error` announces itself (`role="alert"`); the other tones are
14
16
  * polite (`role="status"`).
15
17
  *
18
+ * `icon` and `action` put a 16px icon before the line and one small muted
19
+ * Button after it (Katerina, 2026-09-18, UIG-13: Peek's composer strip became
20
+ * a Banner in the `info` tone). With either, the banner is one line and the
21
+ * text truncates.
22
+ *
16
23
  * `onDismiss` adds an `✕` at the right-hand end (Katerina, D21,
17
24
  * 2026-09-07). Without it the strip is exactly what it was — a banner an
18
25
  * app removes by not rendering it. With it the row is 40px rather than
@@ -28,7 +35,16 @@ export type BannerTone = 'ok' | 'error' | 'info' | 'warning'
28
35
 
29
36
  export interface BannerProps {
30
37
  tone: BannerTone
31
- children: string
38
+ /** The one line. Text, or text with a name in `font-medium`. */
39
+ children: ReactNode
40
+ /**
41
+ * A 16px icon before the line, in the tone's colour (Katerina, 2026-09-18:
42
+ * Peek's composer strip became a Banner). With an icon or an action the
43
+ * banner is one line, and the text truncates.
44
+ */
45
+ icon?: ReactNode
46
+ /** The one thing to do: the package's Button, muted and small, at the end of the line. */
47
+ action?: { label: string; onClick?: () => void }
32
48
  /** Adds an `✕` at the right-hand end. Absent: the app removes the banner. */
33
49
  onDismiss?: () => void
34
50
  /** The dismiss button's accessible name. Defaults to "Dismiss". */
@@ -43,25 +59,34 @@ const TONE_STYLES: Record<BannerTone, string> = {
43
59
  warning: 'bg-warning-muted text-warning-default',
44
60
  }
45
61
 
46
- export function Banner({ tone, children, onDismiss, dismissLabel = 'Dismiss', className }: BannerProps) {
62
+ export function Banner({ tone, children, icon, action, onDismiss, dismissLabel = 'Dismiss', className }: BannerProps) {
47
63
  const role = tone === 'error' ? 'alert' : 'status'
48
- // No dismiss, no row: every banner that predates the prop keeps the DOM and
49
- // the height it had.
50
- if (!onDismiss) {
64
+ // Nothing beside the line, no row: every banner that predates the props keeps
65
+ // the DOM and the height it had.
66
+ if (!onDismiss && !icon && !action) {
51
67
  return (
52
68
  <div role={role} className={cn('px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
53
69
  {children}
54
70
  </div>
55
71
  )
56
72
  }
73
+ const oneLine = Boolean(icon || action)
57
74
  return (
58
75
  <div role={role} className={cn('flex items-center gap-3 px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
59
- <span className="min-w-0 flex-1">{children}</span>
76
+ {icon && <span className="flex shrink-0">{icon}</span>}
77
+ <span className={cn('min-w-0 flex-1', oneLine && 'truncate')}>{children}</span>
78
+ {action && (
79
+ <Button variant="muted" size="small" className="shrink-0" onClick={action.onClick}>
80
+ {action.label}
81
+ </Button>
82
+ )}
60
83
  {/* `current` so the ✕ takes the tone's colour rather than the
61
84
  muted grey an IconButton wears on a neutral surface. */}
62
- <IconButton variant="current" aria-label={dismissLabel} onClick={onDismiss} className="-mr-1 shrink-0">
63
- <IconX size={16} stroke={1.5} />
64
- </IconButton>
85
+ {onDismiss && (
86
+ <IconButton variant="current" aria-label={dismissLabel} onClick={onDismiss} className="-mr-1 shrink-0">
87
+ <IconX size={16} stroke={1.5} />
88
+ </IconButton>
89
+ )}
65
90
  </div>
66
91
  )
67
92
  }
@@ -0,0 +1,56 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ContainerHeaderStories from './ContainerHeader.stories'
3
+
4
+ <Meta of={ContainerHeaderStories} />
5
+
6
+ # ContainerHeader
7
+
8
+ The bar across the top of a column — a list, a thread, a side panel: its
9
+ title, and the buttons that act on the whole column at the right edge. 48px,
10
+ with a hairline under it. Peek's ContainerHeader, moved into the package as it
11
+ looks (Katerina, 2026-09-18).
12
+
13
+ <Canvas of={ContainerHeaderStories.WithActions} />
14
+
15
+ ## When
16
+
17
+ - The top of a column in a frame: the list beside the conversation, the
18
+ thread, a side panel. One per column.
19
+ - The column's own actions — new, sort, close — as IconButtons with tooltips
20
+ in `actions`.
21
+
22
+ ## When not
23
+
24
+ - One section inside a column → **SectionHeader**, the 32px row with a
25
+ SectionLabel.
26
+ - The top of the whole app → **TopBar**.
27
+ - A dialog's title → **DialogShell** draws its own.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { ContainerHeader, IconButton } from '@estiva-app/ui'
33
+
34
+ <ContainerHeader
35
+ title="All items"
36
+ chevron
37
+ actions={
38
+ <IconButton tooltip="New item" aria-label="New item" onClick={openNew}>
39
+ <IconEdit size={16} stroke={1.5} />
40
+ </IconButton>
41
+ }
42
+ />
43
+ ```
44
+
45
+ - A string title is one line in `body-2-strong` and keeps its width. Anything
46
+ else — an EditableText, a title over a caption — takes the room left and is
47
+ drawn as given.
48
+
49
+ <Canvas of={ContainerHeaderStories.WithRichTitle} />
50
+
51
+ - Only offer a button that does something: a button with nothing behind it is
52
+ a promise the column cannot keep.
53
+
54
+ ## Props
55
+
56
+ <Controls of={ContainerHeaderStories.Default} />
@@ -0,0 +1,62 @@
1
+ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconEdit, IconSortDescending, IconX } from '@tabler/icons-react'
3
+ import { ContainerHeader } from './ContainerHeader'
4
+ import { IconButton } from './IconButton'
5
+
6
+ // The top of a column: a surface with a hairline, so the bar's own hairline and height can be seen.
7
+ const inColumn: Decorator = (Story) => <div className="w-96 overflow-hidden rounded-lg border border-border-default bg-bg-surface">{Story()}</div>
8
+
9
+ const meta = {
10
+ title: 'Navigation/ContainerHeader',
11
+ component: ContainerHeader,
12
+ args: { title: 'Items' },
13
+ argTypes: { actions: { control: false } },
14
+ decorators: [inColumn],
15
+ } satisfies Meta<typeof ContainerHeader>
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof meta>
19
+
20
+ /** A string title, one line, alone. */
21
+ export const Default: Story = {}
22
+
23
+ /** A chevron after the title, for a title that opens something. */
24
+ export const WithChevron: Story = { args: { title: 'All items', chevron: true } }
25
+
26
+ /** The column's own buttons at the right edge: IconButtons with tooltips. */
27
+ export const WithActions: Story = {
28
+ args: {
29
+ title: 'All items',
30
+ chevron: true,
31
+ actions: (
32
+ <>
33
+ <IconButton tooltip="Sort by" aria-label="Sort by">
34
+ <IconSortDescending size={16} stroke={1.5} />
35
+ </IconButton>
36
+ <IconButton tooltip="New item" aria-label="New item">
37
+ <IconEdit size={16} stroke={1.5} />
38
+ </IconButton>
39
+ </>
40
+ ),
41
+ },
42
+ }
43
+
44
+ /** A title that is not a string — a name over its caption — takes the room left and is drawn as given. */
45
+ export const WithRichTitle: Story = {
46
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled: the caption
47
+ // is muted text, 3.94:1 in signal (AA 4.5:1) — as Peek's file page draws it.
48
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
49
+ args: {
50
+ title: (
51
+ <div className="min-w-0">
52
+ <p className="truncate text-body-2-strong text-text-primary">Quarterly plan</p>
53
+ <p className="truncate text-caption text-text-muted">In Project Alpha</p>
54
+ </div>
55
+ ),
56
+ actions: (
57
+ <IconButton tooltip="Close" aria-label="Close">
58
+ <IconX size={16} stroke={1.5} />
59
+ </IconButton>
60
+ ),
61
+ },
62
+ }
@@ -0,0 +1,47 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What `ContainerHeader.mdx` claims: a string title is one line that keeps its
4
+ * width, a node is drawn as given and takes the room left, and the column's
5
+ * buttons sit at the right edge. The pixels are proved against Peek's own
6
+ * (docs/GATES.md, UIG-13: A).
7
+ */
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import { ContainerHeader } from './ContainerHeader'
11
+
12
+ afterEach(cleanup)
13
+
14
+ describe('ContainerHeader', () => {
15
+ it('is the 48px bar with a hairline under it', () => {
16
+ const { container } = render(<ContainerHeader title="Items" />)
17
+ const bar = container.firstElementChild!.className.split(' ')
18
+ expect(bar).toContain('h-12')
19
+ expect(bar).toContain('border-b')
20
+ expect(bar).toContain('border-border-subtle')
21
+ })
22
+
23
+ it('a string title is one line in body-2-strong, and keeps its width', () => {
24
+ render(<ContainerHeader title="Items" />)
25
+ const title = screen.getByText('Items')
26
+ expect(title.className).toContain('text-body-2-strong')
27
+ expect(title.className).toContain('whitespace-nowrap')
28
+ expect(title.parentElement!.className).toContain('shrink-0')
29
+ })
30
+
31
+ it('a node title is drawn as given and takes the room left', () => {
32
+ render(<ContainerHeader title={<em>Plan</em>} />)
33
+ const holder = screen.getByText('Plan').parentElement!
34
+ expect(holder.className).toContain('flex-1')
35
+ expect(holder.className).toContain('min-w-0')
36
+ })
37
+
38
+ it('draws a chevron only when asked, and the buttons at the right edge', () => {
39
+ const { container, rerender } = render(<ContainerHeader title="Items" />)
40
+ expect(container.querySelector('svg')).toBeNull()
41
+ expect(container.firstElementChild!.children).toHaveLength(1)
42
+ rerender(<ContainerHeader title="Items" chevron actions={<button type="button">New</button>} />)
43
+ expect(container.querySelector('svg')).not.toBeNull()
44
+ expect(container.firstElementChild!.children).toHaveLength(2)
45
+ expect(screen.getByRole('button', { name: 'New' }).closest('div')!.className).toContain('gap-1')
46
+ })
47
+ })
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from 'react'
2
+ import { IconChevronDown } from '@tabler/icons-react'
3
+ import { cn } from './cn'
4
+
5
+ /**
6
+ * The bar across the top of a column — a list, a thread, a side panel: its
7
+ * title, and the buttons that act on the whole column at the right edge.
8
+ * Peek's ContainerHeader, moved into the package exactly as it looks
9
+ * (Katerina, 2026-09-18, UIG-13).
10
+ *
11
+ * 48px tall, a hairline under it. A string title is one line in
12
+ * `body-2-strong` and keeps its width; anything else — an EditableText, a
13
+ * title over a caption — takes the room that is left and is drawn as given.
14
+ *
15
+ * Not a SectionHeader: that is the 32px row one section of a column starts
16
+ * with. This is the column's own bar, one per column.
17
+ */
18
+ export interface ContainerHeaderProps {
19
+ /** A string keeps the one-line treatment; a node is drawn as given. */
20
+ title: ReactNode
21
+ /** A chevron after the title, for a title that opens something. */
22
+ chevron?: boolean
23
+ /** The column's own buttons, at the right edge: IconButtons with tooltips, 4px apart. */
24
+ actions?: ReactNode
25
+ className?: string
26
+ }
27
+
28
+ export function ContainerHeader({ title, chevron = false, actions, className }: ContainerHeaderProps) {
29
+ const text = typeof title === 'string'
30
+ return (
31
+ <div className={cn('flex h-12 shrink-0 items-center justify-between overflow-hidden border-b border-border-subtle py-2 pr-4 pl-5', className)}>
32
+ <div className={cn('flex items-center gap-2 overflow-hidden', text ? 'shrink-0' : 'min-w-0 flex-1')}>
33
+ <div className={cn('flex items-center gap-1', text ? 'shrink-0' : 'min-w-0 flex-1')}>
34
+ {text ? <span className="whitespace-nowrap text-body-2-strong text-text-primary">{title}</span> : <div className="min-w-0 flex-1">{title}</div>}
35
+ {chevron && <IconChevronDown size={12} stroke={1.5} className="shrink-0 text-text-secondary" />}
36
+ </div>
37
+ </div>
38
+ {actions && (
39
+ <div className="flex shrink-0 items-center justify-end gap-3">
40
+ <div className="flex shrink-0 items-center gap-1">{actions}</div>
41
+ </div>
42
+ )}
43
+ </div>
44
+ )
45
+ }
@@ -53,6 +53,18 @@ import { EmptyState } from '@estiva-app/ui'
53
53
  <EmptyState scope="section" message="No comments yet." />
54
54
  ```
55
55
 
56
+ **When there is one thing to do about it, give it as `action`.** It is drawn
57
+ 16px under the line as the package's Button, outlined — the same look wherever
58
+ it appears (Katerina, 2026-09-18). Never put a Button beside an EmptyState in a
59
+ box of your own: the box places it, and no two boxes place it alike. A `page`
60
+ only; a section's line takes no action.
61
+
62
+ <Canvas of={EmptyStateStories.WithAction} />
63
+
64
+ ```tsx
65
+ <EmptyState icon={<IconMessagePlus size={16} stroke={1.5} />} message="No topics yet." action={{ label: 'New topic', onClick: openNewTopic }} />
66
+ ```
67
+
56
68
  **Put a section's empty state inside the box its rows live in, and give it no
57
69
  padding.** Write the box once; inside it goes either the rows or the empty
58
70
  state. The box's padding then places both, and the line starts where the first
@@ -1,5 +1,5 @@
1
1
  import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
2
- import { IconLock } from '@tabler/icons-react'
2
+ import { IconLock, IconMessagePlus } from '@tabler/icons-react'
3
3
  import { EmptyState } from './EmptyState'
4
4
 
5
5
  // A box with a hairline, so where the state sits inside its room can be seen: a page's in the middle both ways, a section's at the top left.
@@ -25,6 +25,11 @@ const sectionContrastDeferred = { a11y: { config: { rules: [{ id: 'color-contras
25
25
  export const Section: Story = { args: { scope: 'section' }, decorators: [inBox], parameters: sectionContrastDeferred }
26
26
  export const LongerMessage: Story = { args: { message: 'No items yet. Add one from any list.' }, decorators: [inBox] }
27
27
  export const CustomIcon: Story = { args: { icon: <IconLock size={16} stroke={1.5} />, message: 'Nothing you can read here yet.' }, decorators: [inBox] }
28
+ /** The one thing to do about it, under the line: the package's Button, outlined. */
29
+ export const WithAction: Story = {
30
+ args: { icon: <IconMessagePlus size={16} stroke={1.5} />, message: 'No topics yet.', action: { label: 'New topic', onClick: () => {} } },
31
+ decorators: [inBox],
32
+ }
28
33
 
29
34
  /** One box, written once, holding the rows or the empty state: its padding places both, so the line starts where the first row does. */
30
35
  export const InsideTheRowsBox: Story = {
@@ -6,7 +6,7 @@
6
6
  * structure.
7
7
  */
8
8
  import { afterEach, describe, expect, it } from 'vitest'
9
- import { cleanup, render, screen } from '@testing-library/react'
9
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
10
10
  import { EmptyState } from './EmptyState'
11
11
 
12
12
  afterEach(cleanup)
@@ -59,4 +59,21 @@ describe('EmptyState', () => {
59
59
  expect(container.querySelector('[data-testid="own"]')).not.toBeNull()
60
60
  expect(container.querySelector('svg')).toBeNull()
61
61
  })
62
+
63
+ it('draws its action under the line as the package Button, outlined (Katerina, 2026-09-18)', () => {
64
+ let clicked = 0
65
+ render(<EmptyState message="No topics yet." action={{ label: 'New topic', onClick: () => (clicked += 1) }} />)
66
+ const button = screen.getByRole('button', { name: 'New topic' })
67
+ expect(button.className).toContain('border-border-default')
68
+ expect(button.className).toContain('mt-2')
69
+ // Under the line, in the same column.
70
+ expect(screen.getByText('No topics yet.').nextElementSibling).toBe(button)
71
+ fireEvent.click(button)
72
+ expect(clicked).toBe(1)
73
+ })
74
+
75
+ it('a section takes no action', () => {
76
+ render(<EmptyState scope="section" message="Nothing here yet." action={{ label: 'Add', onClick: () => {} }} />)
77
+ expect(screen.queryByRole('button')).toBeNull()
78
+ })
62
79
  })
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { IconMessage2 } from '@tabler/icons-react'
3
+ import { Button } from './Button'
3
4
  import { cn } from './cn'
4
5
 
5
6
  /**
@@ -28,10 +29,16 @@ export interface EmptyStateProps {
28
29
  message: string
29
30
  /** `page` when the whole page is empty; `section` when one part of it is. */
30
31
  scope?: 'page' | 'section'
32
+ /**
33
+ * The one thing to do about it — "New topic", "Try again" — drawn 16px under
34
+ * the line as the package's Button, outlined (Katerina, 2026-09-18). A `page`
35
+ * only: a section's empty line is one line among others, not a stage.
36
+ */
37
+ action?: { label: string; onClick: () => void }
31
38
  className?: string
32
39
  }
33
40
 
34
- export function EmptyState({ icon, message, scope = 'page', className }: EmptyStateProps) {
41
+ export function EmptyState({ icon, message, scope = 'page', action, className }: EmptyStateProps) {
35
42
  if (scope === 'section') {
36
43
  return <p className={cn('text-caption text-text-muted', className)}>{message}</p>
37
44
  }
@@ -39,6 +46,12 @@ export function EmptyState({ icon, message, scope = 'page', className }: EmptySt
39
46
  <div className={cn('flex flex-1 flex-col items-center justify-center gap-2', className)}>
40
47
  <span className="text-text-secondary">{icon ?? <IconMessage2 size={16} stroke={1.5} />}</span>
41
48
  <p className="text-body-2 text-text-secondary text-center">{message}</p>
49
+ {/* 8px of the column's gap and 8px of its own: 16px under the line, as Peek had it. */}
50
+ {action && (
51
+ <Button variant="outlined" className="mt-2" onClick={action.onClick}>
52
+ {action.label}
53
+ </Button>
54
+ )}
42
55
  </div>
43
56
  )
44
57
  }
@@ -43,6 +43,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
43
43
  'UIG-7': 'Lint rule — every remaining raw element',
44
44
  'UIG-8': 'Lint rule — forbid the reach',
45
45
  'UIG-9': 'Lint rule — the className allow-list',
46
+ 'UIG-10': 'create-app — a command that makes a new Estiva app that runs',
46
47
  'UIG-13': "The registry widens to Peek's 115 and Ship's 74, with classification",
47
48
  'UIG-19': 'Lock the contract in CI, and make the three Storybooks one search',
48
49
  'UIG-20': 'The Claude skill, reading the registry',
@@ -164,8 +165,29 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
164
165
  } },
165
166
  { what: 'a real page gets no error', run: realPage },
166
167
  ]),
168
+ // UIG-10, reopened 18 September: a tab holds one relay client, the way Peek and Ship hold
169
+ // theirs and a made app is written to. Measured on both mains that day: one each, and no
170
+ // socket opened any other way. A second holder, or a socket of the app's own, is a second
171
+ // connection the relay signs in and budgets separately.
172
+ ticket('UIG-10', [
173
+ { what: 'the app holds one relay client, at module level, and opens no socket of its own', run: () => {
174
+ const sources = h.listFiles(at('src'), (n) => /\.tsx?$/.test(n) && !/\.(test|stories)\.tsx?$/.test(n))
175
+ const own = sources.filter((f) => /\bcreateLiveClient\(|\bcreateLiveRelay\(|\bnew WebSocket\(/.test(h.read(f)))
176
+ if (own.length) return h.FAIL(`opens a socket of its own (${own.join(', ')}): hold one client from createLiveClientHolder()`)
177
+ const holders = sources.filter((f) => /^\s*(?:export\s+)?const\s+\w+\s*=\s*createLiveClientHolder\(\)/m.test(h.read(f)))
178
+ if (holders.length !== 1) return h.FAIL(holders.length ? `${holders.length} client holders (${holders.join(', ')}): a tab holds one` : 'no module-level createLiveClientHolder(): the app has no relay client')
179
+ return h.PASS(`one client for the tab, held in ${holders[0]}`)
180
+ } },
181
+ ]),
182
+ // Built by UIG-13, 18 September. An app's catalogue is built fresh from its code
183
+ // and never committed: the package is public and the apps are private (Katerina).
167
184
  ticket('UIG-13', [
168
- { what: 'registry.json is committed and has entries', run: () => h.json(h.exists('registry.json') ? 'registry.json' : at('registry.json'), (d) => ((d as { entries?: unknown[]; components?: unknown[] }).entries ?? (d as { components?: unknown[] }).components ?? []).length > 0, 'registry.json has entries') },
185
+ { what: "npm run ui:find is the package's search, over the package and this app", run: () => h.contains(at('package.json'), /"ui:find":\s*"estiva-ui find\b/, "ui:find runs the package's estiva-ui find") },
186
+ { what: "npm run registry:check is the package's check", run: () => h.contains(at('package.json'), /"registry:check":\s*"estiva-ui check\b/, 'registry:check runs estiva-ui check') },
187
+ { what: "CI's job gate refuses a part with no description", run: () => h.ciJob('gate', 'registry:check') },
188
+ // In the .gitignore of the app's own folder, where `estiva-ui build` would write it.
189
+ { what: 'the catalogue is never committed: git ignores registry.json', run: () => h.contains(at('.gitignore'), /^\/?registry\.json\r?$/m, `${at('.gitignore')} ignores registry.json`) },
190
+ { what: 'every part is described and sorted, and every file is accounted for', run: () => h.catalogue(at('.')) },
169
191
  ]),
170
192
  ticket('UIG-19', [
171
193
  { what: 'the usage-page contract runs in CI', run: () => h.ci(/[\w:-]*contract[\w:-]*/) },