@estiva-app/ui 0.22.0 → 0.24.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 (173) hide show
  1. package/README.md +21 -0
  2. package/dist/AppShell.d.ts.map +1 -1
  3. package/dist/AttachmentCard.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +20 -2
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/ContainerHeader.d.ts +25 -0
  7. package/dist/ContainerHeader.d.ts.map +1 -0
  8. package/dist/EmptyState.d.ts +10 -1
  9. package/dist/EmptyState.d.ts.map +1 -1
  10. package/dist/Form.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts +9 -1
  12. package/dist/IconButton.d.ts.map +1 -1
  13. package/dist/PreviewCard.d.ts +9 -2
  14. package/dist/PreviewCard.d.ts.map +1 -1
  15. package/dist/SectionHeader.d.ts +7 -1
  16. package/dist/SectionHeader.d.ts.map +1 -1
  17. package/dist/Select.d.ts +11 -3
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/Tooltip.d.ts +13 -1
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/eslint/index.js +2 -1
  22. package/dist/eslint/index.js.map +2 -2
  23. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  24. package/dist/gates/app-checks.d.ts.map +1 -1
  25. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  26. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  27. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  28. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  29. package/dist/gates/cli.js +1 -1
  30. package/dist/gates/create-app.d.ts +2 -0
  31. package/dist/gates/create-app.d.ts.map +1 -1
  32. package/dist/gates/create-app.js +1 -1
  33. package/dist/gates/index.js +25 -3
  34. package/dist/gates/index.js.map +2 -2
  35. package/dist/gates/status.d.ts +2 -0
  36. package/dist/gates/status.d.ts.map +1 -1
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +196 -156
  40. package/dist/index.js.map +4 -4
  41. package/dist/registry/app-DQI6WLHR.js +10 -0
  42. package/dist/registry/app.d.ts +21 -0
  43. package/dist/registry/app.d.ts.map +1 -0
  44. package/dist/registry/build-LGFCCOLR.js +27 -0
  45. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  46. package/dist/registry/build.d.ts +68 -2
  47. package/dist/registry/build.d.ts.map +1 -1
  48. package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
  49. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  50. package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
  51. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  52. package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
  53. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  54. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  55. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  56. package/dist/registry/cli.js +81 -26
  57. package/dist/registry/cli.js.map +3 -3
  58. package/dist/registry/find.d.ts +12 -1
  59. package/dist/registry/find.d.ts.map +1 -1
  60. package/dist/registry/index.d.ts +5 -2
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js +11 -3
  63. package/dist/registry/schema.d.ts +83 -6
  64. package/dist/registry/schema.d.ts.map +1 -1
  65. package/package.json +2 -2
  66. package/registry.json +283 -95
  67. package/src/AppShell.mdx +10 -0
  68. package/src/AppShell.test.tsx +16 -0
  69. package/src/AppShell.tsx +4 -1
  70. package/src/AttachmentCard.mdx +9 -0
  71. package/src/AttachmentCard.test.tsx +10 -0
  72. package/src/AttachmentCard.tsx +8 -5
  73. package/src/Avatar.mdx +13 -3
  74. package/src/AvatarGroup.mdx +4 -0
  75. package/src/Banner.mdx +30 -7
  76. package/src/Banner.stories.tsx +22 -0
  77. package/src/Banner.test.tsx +35 -0
  78. package/src/Banner.tsx +34 -9
  79. package/src/Breadcrumb.mdx +10 -0
  80. package/src/Button.mdx +14 -3
  81. package/src/Card.mdx +9 -0
  82. package/src/Checkbox.mdx +10 -0
  83. package/src/Chip.mdx +4 -0
  84. package/src/ChipInput.mdx +12 -0
  85. package/src/CollapsibleSection.mdx +9 -0
  86. package/src/CommandPalette.mdx +14 -2
  87. package/src/ConfirmDialog.mdx +11 -0
  88. package/src/ContainerHeader.mdx +60 -0
  89. package/src/ContainerHeader.stories.tsx +62 -0
  90. package/src/ContainerHeader.test.tsx +47 -0
  91. package/src/ContainerHeader.tsx +45 -0
  92. package/src/DialogShell.mdx +14 -0
  93. package/src/Divider.mdx +9 -1
  94. package/src/EditableText.mdx +11 -0
  95. package/src/EmptyState.mdx +27 -4
  96. package/src/EmptyState.stories.tsx +6 -1
  97. package/src/EmptyState.test.tsx +18 -1
  98. package/src/EmptyState.tsx +14 -1
  99. package/src/Field.mdx +10 -1
  100. package/src/FieldLine.mdx +9 -1
  101. package/src/FilePicker.mdx +8 -0
  102. package/src/Form.mdx +10 -0
  103. package/src/Form.test.tsx +26 -0
  104. package/src/Form.tsx +7 -0
  105. package/src/IconButton.mdx +16 -2
  106. package/src/IconButton.stories.tsx +3 -0
  107. package/src/IconButton.test.tsx +25 -0
  108. package/src/IconButton.tsx +61 -46
  109. package/src/IdentityMenu.mdx +9 -0
  110. package/src/InlineChip.mdx +8 -0
  111. package/src/Kbd.mdx +4 -0
  112. package/src/Link.mdx +10 -0
  113. package/src/Menu.mdx +16 -1
  114. package/src/MenuItem.mdx +12 -2
  115. package/src/NavItem.mdx +8 -0
  116. package/src/Person.mdx +4 -0
  117. package/src/PersonTrigger.mdx +9 -0
  118. package/src/Popover.mdx +21 -0
  119. package/src/PreviewCard.mdx +19 -4
  120. package/src/PreviewCard.tsx +11 -4
  121. package/src/ProgressBar.mdx +8 -0
  122. package/src/Property.mdx +4 -0
  123. package/src/Rail.mdx +9 -1
  124. package/src/RailItem.mdx +8 -0
  125. package/src/Reaction.mdx +9 -0
  126. package/src/ReactionPicker.mdx +8 -0
  127. package/src/ScrollArea.mdx +13 -2
  128. package/src/SearchInput.mdx +9 -0
  129. package/src/SectionHeader.mdx +13 -0
  130. package/src/SectionHeader.stories.tsx +9 -0
  131. package/src/SectionHeader.test.tsx +9 -0
  132. package/src/SectionHeader.tsx +8 -2
  133. package/src/SectionLabel.mdx +4 -0
  134. package/src/Select.mdx +21 -2
  135. package/src/Select.stories.tsx +4 -1
  136. package/src/Select.test.tsx +17 -0
  137. package/src/Select.tsx +34 -13
  138. package/src/Sidebar.mdx +8 -0
  139. package/src/Skeleton.mdx +4 -0
  140. package/src/Tabs.mdx +12 -1
  141. package/src/TextInput.mdx +8 -0
  142. package/src/Textarea.mdx +8 -0
  143. package/src/Toast.mdx +12 -1
  144. package/src/Toolbar.mdx +11 -0
  145. package/src/Tooltip.mdx +26 -2
  146. package/src/Tooltip.stories.tsx +26 -0
  147. package/src/Tooltip.test.tsx +29 -0
  148. package/src/Tooltip.tsx +17 -4
  149. package/src/TopBar.mdx +4 -0
  150. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  151. package/src/eslint/no-restyled-part.ts +1 -0
  152. package/src/gates/app-checks.ts +23 -1
  153. package/src/gates/create-app.test.ts +77 -6
  154. package/src/gates/create-app.ts +288 -15
  155. package/src/gates/status.ts +16 -0
  156. package/src/index.ts +1 -0
  157. package/src/pages.test.ts +142 -0
  158. package/src/registry/app.test.ts +562 -0
  159. package/src/registry/app.ts +854 -0
  160. package/src/registry/build.ts +114 -25
  161. package/src/registry/cli.ts +105 -42
  162. package/src/registry/find.ts +45 -8
  163. package/src/registry/index.ts +8 -1
  164. package/src/registry/registry.test.ts +5 -5
  165. package/src/registry/schema.ts +163 -12
  166. package/tailwind-preset.js +22 -1
  167. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  168. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
  169. package/dist/registry/build-GOVLABI6.js +0 -13
  170. package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
  171. package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
  172. package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
  173. /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
package/src/AppShell.mdx CHANGED
@@ -75,6 +75,16 @@ import { AppShell, Banner, IdentityMenu, Rail, RailItem } from '@estiva-app/ui'
75
75
  - In the floating manner the card owns `overflow-hidden` — your content
76
76
  manages its own scrolling inside it.
77
77
  - Desktop only for now — no narrow-screen behaviour is provided.
78
+ - In the Signal theme the floating frame draws the control-room dot grid on
79
+ its ground, behind the card. Nothing to add: the preset makes it.
80
+
81
+ ## What it owns
82
+
83
+ It does these by itself. Write none of them around it.
84
+
85
+ | It does | So you never write | Refused when written by hand |
86
+ |---|---|---|
87
+ | Scrolls the page's content column, in a ScrollArea | `overflow-auto` on the page or on `main` | |
78
88
 
79
89
  ## Props
80
90
 
@@ -36,4 +36,20 @@ describe('AppShell', () => {
36
36
  expect(main.className).toContain('overflow-hidden')
37
37
  expect(main.closest('[style*="overflow: scroll"]')).toBeNull()
38
38
  })
39
+
40
+ it('carries the Signal canvas on the floating frame, and only there', () => {
41
+ const { container, unmount } = render(
42
+ <AppShell variant="floating" nav={<nav>Nav</nav>}>
43
+ <p>Page</p>
44
+ </AppShell>,
45
+ )
46
+ expect((container.firstElementChild as HTMLElement).className).toContain('signal-canvas')
47
+ unmount()
48
+ const solid = render(
49
+ <AppShell nav={<nav>Nav</nav>}>
50
+ <p>Page</p>
51
+ </AppShell>,
52
+ )
53
+ expect(solid.container.querySelector('.signal-canvas')).toBeNull()
54
+ })
39
55
  })
package/src/AppShell.tsx CHANGED
@@ -45,7 +45,10 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
45
45
 
46
46
  if (variant === 'floating') {
47
47
  return (
48
- <div className="relative h-full min-h-0 overflow-hidden bg-bg-base">
48
+ // `signal-canvas`: in the Signal theme, the preset draws the control-room dot
49
+ // grid behind everything in the frame (Peek's, moved here with the frame:
50
+ // UIG-14, Katerina, 19 September — the canvas is the theme's, not an app's).
51
+ <div className="signal-canvas relative h-full min-h-0 overflow-hidden bg-bg-base">
49
52
  {bar}
50
53
  <div className="flex h-full pb-4 pr-4 pt-[52px]">
51
54
  {nav}
@@ -57,6 +57,15 @@ import { AttachmentCard } from '@estiva-app/ui'
57
57
  on Base UI's `Button` like InputChip's ✕ — not an `IconButton`, the 24px
58
58
  square that fills on hover.
59
59
 
60
+ ## What it owns
61
+
62
+ It does these by itself. Write none of them around it.
63
+
64
+ | It does | So you never write | Refused when written by hand |
65
+ |---|---|---|
66
+ | Stops the clicks on its picture, its download and its remove control, so they never also click the card or row around it | `stopPropagation` | |
67
+ | Shows the full name on hover when the name is cut off | a tooltip of your own | |
68
+
60
69
  ## Props
61
70
 
62
71
  <Controls of={AttachmentCardStories.Document} />
@@ -169,4 +169,14 @@ describe('AttachmentCard', () => {
169
169
  expect(onRemove).toHaveBeenCalledTimes(1)
170
170
  expect(outside).not.toHaveBeenCalled()
171
171
  })
172
+
173
+ it('the ✕ shows when the keyboard reaches it, not only on hover (C4)', async () => {
174
+ render(<AttachmentCard pending name="a.png" size={1} onRemove={() => {}} />)
175
+ const remove = screen.getByRole('button', { name: 'Remove a.png' })
176
+ // Hidden at rest; each of these reveals it. jsdom draws nothing, so the classes are the contract.
177
+ expect(remove.className).toContain('opacity-0')
178
+ for (const reveal of ['group-hover:opacity-100', 'group-focus-within:opacity-100', 'focus-visible:opacity-100']) expect(remove.className).toContain(reveal)
179
+ await userEvent.tab()
180
+ expect(document.activeElement).toBe(remove)
181
+ })
172
182
  })
@@ -20,7 +20,7 @@ import {
20
20
  import { Card } from './Card'
21
21
  import { IconButton } from './IconButton'
22
22
  import { Link } from './Link'
23
- import { WithTooltip } from './Tooltip'
23
+ import { WithTooltip, type WithTooltipProps } from './Tooltip'
24
24
  import { cn } from './cn'
25
25
 
26
26
  /**
@@ -98,7 +98,7 @@ function TypeIcon({ name }: { name: string }) {
98
98
  * the fallback face can fit and then not, in a line whose box never changes. The wrapper is `flex-col` so the
99
99
  * text stretches across it, as wide as it is without one.
100
100
  */
101
- function Truncating({ text, hint, className, wrapperClassName }: { text: string; hint?: string; className: string; wrapperClassName?: string }) {
101
+ function Truncating({ text, hint, className, wrapperClassName, placement }: { text: string; hint?: string; className: string; wrapperClassName?: string; placement?: WithTooltipProps['placement'] }) {
102
102
  const ref = useRef<HTMLSpanElement>(null)
103
103
  const [cut, setCut] = useState(false)
104
104
  useLayoutEffect(() => {
@@ -127,7 +127,7 @@ function Truncating({ text, hint, className, wrapperClassName }: { text: string;
127
127
  )
128
128
  if (!hint && !cut) return line
129
129
  return (
130
- <WithTooltip label={hint ?? text} wrapperClassName={cn('min-w-0 flex-col', wrapperClassName)}>
130
+ <WithTooltip label={hint ?? text} placement={placement} wrapperClassName={cn('min-w-0 flex-col', wrapperClassName)}>
131
131
  {line}
132
132
  </WithTooltip>
133
133
  )
@@ -206,7 +206,8 @@ export function AttachmentCard({
206
206
  )}
207
207
  </div>
208
208
  <div className="flex flex-col gap-[1px] min-w-0">
209
- <Truncating text={name} className={NAME_CLASSES} />
209
+ {/* Below, not above: above, a cut name's tooltip covered the ✕ on the corner (UIG-14, C4). */}
210
+ <Truncating text={name} className={NAME_CLASSES} placement={onRemove ? 'bottom' : undefined} />
210
211
  <Truncating
211
212
  text={(failed || warning ? note : state === 'uploading' ? (note ?? 'Uploading…') : (note ?? sizeText)) ?? ''}
212
213
  hint={noteHint}
@@ -219,7 +220,9 @@ export function AttachmentCard({
219
220
  <BaseButton
220
221
  type="button"
221
222
  aria-label={`Remove ${name}`}
222
- className="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-bg-elevated border border-border-strong flex items-center justify-center text-text-secondary hover:text-text-primary opacity-0 group-hover:opacity-100 transition-opacity"
223
+ // Shows with the card's hover and with keyboard focus, as Download does: hover-only left it invisible to
224
+ // the keyboard that had reached it (UIG-14, C4, Katerina 19 September).
225
+ className="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-bg-elevated border border-border-strong flex items-center justify-center text-text-secondary hover:text-text-primary opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity"
223
226
  onClick={(event) => {
224
227
  event.stopPropagation()
225
228
  onRemove()
package/src/Avatar.mdx CHANGED
@@ -13,13 +13,15 @@ theirs, or — when there is no name to take initials from — a silhouette.
13
13
  ## When
14
14
 
15
15
  - Anywhere a person appears at a glance: a row, a card, a picker, a byline.
16
- - The scale is 16 · 24 · 32 · 36 (default) pick from it rather than
17
- inventing sizes.
16
+ - The scale is 16 · 18 · 20 · 24 · 32 · 36 (default): 18 in a group's reply
17
+ row, 20 in a **Person**. Pick from it rather than inventing sizes.
18
18
 
19
19
  ## When not
20
20
 
21
21
  - Several people at once → **AvatarGroup**.
22
- - The face beside the name → **Person**.
22
+ - The face beside a name, with nothing else on the line → **Person**. A byline
23
+ that sets the name its own way — bold, with a time — is an Avatar beside its
24
+ own text.
23
25
  - The face as a button that opens a menu → **PersonTrigger**.
24
26
 
25
27
  ## How
@@ -66,6 +68,14 @@ a stack of members — and the tile becomes one image with one name.
66
68
 
67
69
  <Canvas of={AvatarStories.Palette} />
68
70
 
71
+ ## What it owns
72
+
73
+ It does these by itself. Write none of them around it.
74
+
75
+ | It does | So you never write | Refused when written by hand |
76
+ |---|---|---|
77
+ | Shows initials from the name when there is no picture, or when it fails to load | an `onError`, or initials of your own | |
78
+
69
79
  ## Props
70
80
 
71
81
  <Controls of={AvatarStories.Initials} />
@@ -34,6 +34,10 @@ import { AvatarGroup } from '@estiva-app/ui'
34
34
  - The ring rides on the Avatar itself (one box); don't wrap each face in a
35
35
  clipping window — a window minus a border crops the initials.
36
36
 
37
+ ## What it owns
38
+
39
+ Nothing. It only draws. Clicks and keys are yours.
40
+
37
41
  ## Props
38
42
 
39
43
  <Controls of={AvatarGroupStories.ThreeMembers} />
package/src/Banner.mdx CHANGED
@@ -12,8 +12,8 @@ line, one of four tones, gone when there is nothing to say.
12
12
 
13
13
  ## When
14
14
 
15
- - The outcome or state of the whole surface: "Public key copied.", the
16
- reason the last read failed, a read-only notice.
15
+ - The state of the whole surface: the reason the last read failed, a read-only
16
+ notice.
17
17
  - **`ok`** confirms, **`info`** informs, **`warning`** cautions,
18
18
  **`error`** reports a failure — and only `error` announces itself
19
19
  (`role="alert"`); the others are polite (`role="status"`).
@@ -22,14 +22,15 @@ line, one of four tones, gone when there is nothing to say.
22
22
 
23
23
  ## When not
24
24
 
25
- - Feedback about one action, briefly → **Toast**.
25
+ - Feedback about one action, briefly → **Toast**. A failure is the exception:
26
+ it stays, in the Banner, until the person acts on it.
26
27
  - Something that floats over the page and must **not** fade → still
27
28
  **Toast**, with `durationMs: 0` and an action. A banner is the strip
28
29
  under the header at the app's full width; a floating pill is not one, even
29
30
  a permanent one (Katerina, D21).
30
31
  - 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).
32
+ - More than one action, or a second line of text — that is a different
33
+ component (not in the package yet).
33
34
 
34
35
  ## How
35
36
 
@@ -41,13 +42,35 @@ import { Banner } from '@estiva-app/ui'
41
42
 
42
43
  - Render it only while there is something to say — an empty strip is not
43
44
  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.
45
+ - One line of text, `body-2`. If it needs a **second line**, it has outgrown
46
+ this component.
47
+ - `icon` puts a 16px icon before the line and `action` one small muted Button
48
+ at the end of it, both in the tone (Katerina, 2026-09-18: Peek's strip above
49
+ the composer became a Banner, `info`). With either, the banner is one line
50
+ and the text truncates. One action at most.
51
+
52
+ <Canvas of={BannerStories.WithIconAndAction} />
53
+
54
+ ```tsx
55
+ <Banner tone="info" icon={<IconUserPlus size={16} stroke={1.5} />} action={{ label: 'Join', onClick: join }}>
56
+ You are not in <span className="font-medium">{title}</span> yet
57
+ </Banner>
58
+ ```
59
+
46
60
  - `onDismiss` adds an ✕ at the right-hand end (Katerina, D21). Use it where
47
61
  the reader decides the notice is done with, rather than the app. The row
48
62
  is 40px rather than 36px with it, because the button is taller than the
49
63
  line of text; without it, nothing about the strip changes.
50
64
 
65
+ ## What it owns
66
+
67
+ It does these by itself. Write none of them around it.
68
+
69
+ | It does | So you never write | Refused when written by hand |
70
+ |---|---|---|
71
+ | Says what it is to assistive technology: an alert for an error, a status for the rest | `role="alert"` or `role="status"` | ✓ |
72
+ | Draws its own ✕ when you pass `onDismiss`, and its one small action when you pass `action` | a close button or an action button of your own | |
73
+
51
74
  ## Props
52
75
 
53
76
  <Controls of={BannerStories.Ok} />
@@ -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
  }
@@ -56,6 +56,16 @@ import { Breadcrumb } from '@estiva-app/ui'
56
56
  `min-w-0` container so it can truncate at all.
57
57
  - The separators are `aria-hidden` — the trail reads as its places.
58
58
 
59
+ ## What it owns
60
+
61
+ It does these by itself. Write none of them around it.
62
+
63
+ | It does | So you never write | Refused when written by hand |
64
+ |---|---|---|
65
+ | Shortens a trail that does not fit, and shows a cut crumb in full on hover | truncation code, or a tooltip | |
66
+ | Names itself as a navigation, and marks the last crumb as the current page | `<nav>`, `aria-label` or `aria-current` | |
67
+ | Hides the separators from screen readers, so the trail reads as its places | `aria-hidden` on each separator | |
68
+
59
69
  ## Props
60
70
 
61
71
  <Controls of={BreadcrumbStories.OnAnItem} />
package/src/Button.mdx CHANGED
@@ -27,12 +27,13 @@ The push-button for an action: a verb, clicked once — "Create", "Save",
27
27
  ## When not
28
28
 
29
29
  - An icon with no label → **IconButton**.
30
- - Going somewhere rather than doing something → **Tabs**, **Breadcrumb**,
31
- or a plain link.
30
+ - Going somewhere rather than doing something → **Link**; **Tabs** or
31
+ **Breadcrumb** when it is a view or a trail.
32
32
  - Choosing a value → **Select**. Acting from a popover list → **MenuItem**.
33
33
  - A control the person may never be allowed to use is **absent, not
34
34
  disabled**. `disabled` is for *momentarily* unavailable — and it says why,
35
- with `disabledReason` ("Available after sign-in").
35
+ with `disabledReason` ("Available after sign-in"). A send button waiting for
36
+ its fields needs no reason.
36
37
 
37
38
  ## How
38
39
 
@@ -68,6 +69,16 @@ import { IconPlus } from '@tabler/icons-react'
68
69
  flex and grid, and position; a colour, a text size, a border, a corner or a
69
70
  shadow is refused by the lint (`estiva/no-restyled-part`, UIG-9).
70
71
 
72
+ ## What it owns
73
+
74
+ It does these by itself. Write none of them around it.
75
+
76
+ | It does | So you never write | Refused when written by hand |
77
+ |---|---|---|
78
+ | Says what it is to assistive technology: a button | `role="button"` | ✓ |
79
+ | Is reachable with Tab, also when it is disabled with a reason | a `tabIndex` | ✓ |
80
+ | Presses on Enter and Space, and does nothing while disabled | a key handler | |
81
+
71
82
  ## Keys
72
83
 
73
84
  | Key | Does |
package/src/Card.mdx CHANGED
@@ -76,6 +76,15 @@ import { Card } from '@estiva-app/ui'
76
76
  shadow is refused by the lint (`estiva/no-restyled-part`, UIG-9). What is inside the card is yours: its padding and its words go on
77
77
  your own element inside.
78
78
 
79
+ ## What it owns
80
+
81
+ It does these by itself. Write none of them around it.
82
+
83
+ | It does | So you never write | Refused when written by hand |
84
+ |---|---|---|
85
+ | With `href`, becomes a link: Link's anchor, with a real address | an anchor around the card | |
86
+ | Without `href`, shows it can be clicked when you pass `onClick` | `cursor-pointer` | |
87
+
79
88
  ## Props
80
89
 
81
90
  <Controls of={CardStories.Surface} />
package/src/Checkbox.mdx CHANGED
@@ -79,6 +79,16 @@ import { Checkbox } from '@estiva-app/ui'
79
79
  - It does not move when it toggles: the tick is always in the box, hidden
80
80
  when unchecked, so both states hang on a line of text the same way.
81
81
 
82
+ ## What it owns
83
+
84
+ It does these by itself. Write none of them around it.
85
+
86
+ | It does | So you never write | Refused when written by hand |
87
+ |---|---|---|
88
+ | Says what it is to assistive technology: a checkbox, ticked or not | `role="checkbox"` or `aria-checked` | ✓ |
89
+ | Toggles on Space and on a click; Enter is left to the form | a click or key handler | |
90
+ | Is one Tab stop, in a `row` too | a `tabIndex` on the row | |
91
+
82
92
  ## Keys
83
93
 
84
94
  | Key | Does |
package/src/Chip.mdx CHANGED
@@ -38,6 +38,10 @@ import { Chip } from '@estiva-app/ui'
38
38
  it never rides the text baseline.
39
39
  - A count alone works too — `label="3"`, no icon.
40
40
 
41
+ ## What it owns
42
+
43
+ Nothing. It only draws. Clicks and keys are yours.
44
+
41
45
  ## Props
42
46
 
43
47
  <Controls of={ChipStories.Neutral} />
package/src/ChipInput.mdx CHANGED
@@ -74,6 +74,18 @@ import { ChipInput } from '@estiva-app/ui'
74
74
 
75
75
  <Canvas of={ChipInputStories.ALongLabelCut} />
76
76
 
77
+ ## What it owns
78
+
79
+ It does these by itself. Write none of them around it.
80
+
81
+ | It does | So you never write | Refused when written by hand |
82
+ |---|---|---|
83
+ | Floats on top of the page: its suggestions | a portal, or a `z-index` to win | ✓ |
84
+ | Moves through its items with the arrow keys, through the suggestions | an arrow-key handler | ✓ |
85
+ | Says what it is to assistive technology: a combobox and its options | `role="combobox"`, `listbox` or `option` | ✓ |
86
+ | Filters as you type; Enter adds the highlighted entry, Backspace on an empty query removes the last chip | a filter, or key handlers | |
87
+ | Escape clears the query first, and only then lets the surface around it act | an Escape handler | |
88
+
77
89
  ## Keys
78
90
 
79
91
  | Input | What happens |
@@ -60,6 +60,15 @@ import { CollapsibleSection, NavItem } from '@estiva-app/ui'
60
60
  with `open`.
61
61
  - A browser that blocks storage still gets a working section; it forgets.
62
62
 
63
+ ## What it owns
64
+
65
+ It does these by itself. Write none of them around it.
66
+
67
+ | It does | So you never write | Refused when written by hand |
68
+ |---|---|---|
69
+ | Opens and closes from its title, on a click, Enter or Space | open state, or a key handler | |
70
+ | Tells assistive technology whether it is open | `aria-expanded` | |
71
+
63
72
  ## Keys
64
73
 
65
74
  | Key | Does |
@@ -49,8 +49,8 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
49
49
  onQueryChange={setQuery}
50
50
  placeholder="Search, or choose a place"
51
51
  groups={[
52
- { label: 'Recent', rows: [{ id: 'item-one', label: 'Item one', icon: <IconSquareRounded size={16} stroke={1.5} />, onSelect: open, onForget: forget }] },
53
- { label: 'Places', rows: [{ id: 'place-one', label: 'Place one', icon, onSelect: goIn, onGoIn: goIn }] },
52
+ { label: 'Recent', rows: [{ id: 'item-one', label: 'Item one', icon: <IconSquareRounded size={16} stroke={1.5} />, onSelect: openItem, onForget: forget }] },
53
+ { label: 'Places', rows: [{ id: 'place-one', label: 'Place one', icon: <IconSquareRounded size={16} stroke={1.5} />, onSelect: goIn, onGoIn: goIn }] },
54
54
  ]}
55
55
  />
56
56
  </CommandPalette>
@@ -102,6 +102,18 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
102
102
  - **Focus never falls out of a level.** When the element that had it goes — a
103
103
  row picked, a level swapped — focus goes to the level's first control.
104
104
 
105
+ ## What it owns
106
+
107
+ It does these by itself. Write none of them around it.
108
+
109
+ | It does | So you never write | Refused when written by hand |
110
+ |---|---|---|
111
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
112
+ | Holds focus inside while open, and gives it back to whatever opened it | focus code, or a Tab handler | ✓ |
113
+ | Moves through its items with the arrow keys, through the rows, stopping at the ends | an arrow-key handler | ✓ |
114
+ | Says what it is to assistive technology: a list of options you search | `role="combobox"`, `listbox` or `option` | ✓ |
115
+ | Closes on Esc or a click outside; Esc closes an open list first | close handlers | |
116
+
105
117
  ## Keys
106
118
 
107
119
  | Where | Input | What happens |
@@ -53,6 +53,17 @@ import { ConfirmDialog } from '@estiva-app/ui'
53
53
  - Focus is trapped in the card and returns to whatever opened it, as for
54
54
  every dialog since stage 3.
55
55
 
56
+ ## What it owns
57
+
58
+ It does these by itself. Write none of them around it.
59
+
60
+ | It does | So you never write | Refused when written by hand |
61
+ |---|---|---|
62
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
63
+ | Says what it is to assistive technology: a dialog that must be answered | `role="alertdialog"` | ✓ |
64
+ | Keeps focus inside while open, as DialogShell does, and gives it back to whatever opened it | focus code | |
65
+ | Ignores a press outside, because the question has to be answered; Escape cancels | a backdrop handler | |
66
+
56
67
  ## Keys
57
68
 
58
69
  Different from `DialogShell`'s in one row, and it is the row that matters.
@@ -0,0 +1,60 @@
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
+ ## What it owns
55
+
56
+ Nothing. It only draws. Clicks and keys are yours.
57
+
58
+ ## Props
59
+
60
+ <Controls of={ContainerHeaderStories.Default} />