@estiva-app/ui 0.1.0 → 0.3.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 (157) hide show
  1. package/README.md +12 -9
  2. package/base.css +43 -0
  3. package/dist/AppShell.d.ts +39 -0
  4. package/dist/AppShell.d.ts.map +1 -0
  5. package/dist/Avatar.d.ts +7 -2
  6. package/dist/Avatar.d.ts.map +1 -1
  7. package/dist/AvatarGroup.d.ts +23 -0
  8. package/dist/AvatarGroup.d.ts.map +1 -0
  9. package/dist/Banner.d.ts +19 -0
  10. package/dist/Banner.d.ts.map +1 -0
  11. package/dist/Breadcrumb.d.ts +28 -0
  12. package/dist/Breadcrumb.d.ts.map +1 -0
  13. package/dist/Checkbox.d.ts +18 -0
  14. package/dist/Checkbox.d.ts.map +1 -0
  15. package/dist/ChipInput.d.ts +57 -0
  16. package/dist/ChipInput.d.ts.map +1 -0
  17. package/dist/ConfirmDialog.d.ts +22 -0
  18. package/dist/ConfirmDialog.d.ts.map +1 -0
  19. package/dist/DialogShell.d.ts +9 -2
  20. package/dist/DialogShell.d.ts.map +1 -1
  21. package/dist/EditableText.d.ts +56 -0
  22. package/dist/EditableText.d.ts.map +1 -0
  23. package/dist/IdentityMenu.d.ts +58 -0
  24. package/dist/IdentityMenu.d.ts.map +1 -0
  25. package/dist/Menu.d.ts +82 -0
  26. package/dist/Menu.d.ts.map +1 -0
  27. package/dist/NavItem.d.ts +26 -0
  28. package/dist/NavItem.d.ts.map +1 -0
  29. package/dist/Person.d.ts +26 -0
  30. package/dist/Person.d.ts.map +1 -0
  31. package/dist/PersonTrigger.d.ts +23 -0
  32. package/dist/PersonTrigger.d.ts.map +1 -0
  33. package/dist/Property.d.ts +24 -0
  34. package/dist/Property.d.ts.map +1 -0
  35. package/dist/Rail.d.ts +18 -0
  36. package/dist/Rail.d.ts.map +1 -0
  37. package/dist/RailItem.d.ts +23 -0
  38. package/dist/RailItem.d.ts.map +1 -0
  39. package/dist/SearchInput.d.ts +21 -0
  40. package/dist/SearchInput.d.ts.map +1 -0
  41. package/dist/SectionHeader.d.ts +34 -0
  42. package/dist/SectionHeader.d.ts.map +1 -0
  43. package/dist/SectionLabel.d.ts +19 -0
  44. package/dist/SectionLabel.d.ts.map +1 -0
  45. package/dist/Select.d.ts +42 -5
  46. package/dist/Select.d.ts.map +1 -1
  47. package/dist/Sidebar.d.ts +18 -0
  48. package/dist/Sidebar.d.ts.map +1 -0
  49. package/dist/Tabs.d.ts +34 -0
  50. package/dist/Tabs.d.ts.map +1 -0
  51. package/dist/Toast.d.ts +48 -0
  52. package/dist/Toast.d.ts.map +1 -0
  53. package/dist/Tooltip.d.ts +3 -1
  54. package/dist/Tooltip.d.ts.map +1 -1
  55. package/dist/TopBar.d.ts +33 -0
  56. package/dist/TopBar.d.ts.map +1 -0
  57. package/dist/cn.d.ts +14 -4
  58. package/dist/cn.d.ts.map +1 -1
  59. package/dist/index.d.ts +23 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1254 -106
  62. package/dist/index.js.map +4 -4
  63. package/package.json +5 -2
  64. package/src/AppShell.mdx +66 -0
  65. package/src/AppShell.stories.tsx +117 -0
  66. package/src/AppShell.tsx +77 -0
  67. package/src/Avatar.initials.test.ts +21 -0
  68. package/src/Avatar.mdx +49 -0
  69. package/src/Avatar.tsx +12 -9
  70. package/src/AvatarGroup.mdx +39 -0
  71. package/src/AvatarGroup.stories.tsx +56 -0
  72. package/src/AvatarGroup.tsx +42 -0
  73. package/src/Banner.mdx +45 -0
  74. package/src/Banner.stories.tsx +35 -0
  75. package/src/Banner.tsx +35 -0
  76. package/src/Breadcrumb.mdx +52 -0
  77. package/src/Breadcrumb.stories.tsx +42 -0
  78. package/src/Breadcrumb.tsx +106 -0
  79. package/src/Button.mdx +57 -0
  80. package/src/Checkbox.mdx +44 -0
  81. package/src/Checkbox.stories.tsx +49 -0
  82. package/src/Checkbox.tsx +46 -0
  83. package/src/Chip.mdx +43 -0
  84. package/src/ChipInput.mdx +68 -0
  85. package/src/ChipInput.stories.tsx +99 -0
  86. package/src/ChipInput.tsx +247 -0
  87. package/src/ConfirmDialog.mdx +51 -0
  88. package/src/ConfirmDialog.stories.tsx +63 -0
  89. package/src/ConfirmDialog.tsx +55 -0
  90. package/src/DialogShell.mdx +68 -0
  91. package/src/DialogShell.tsx +12 -5
  92. package/src/Divider.mdx +41 -0
  93. package/src/EditableText.mdx +59 -0
  94. package/src/EditableText.stories.tsx +63 -0
  95. package/src/EditableText.tsx +169 -0
  96. package/src/EmptyState.mdx +41 -0
  97. package/src/Field.mdx +39 -0
  98. package/src/IconButton.mdx +47 -0
  99. package/src/IdentityMenu.mdx +62 -0
  100. package/src/IdentityMenu.stories.tsx +52 -0
  101. package/src/IdentityMenu.tsx +159 -0
  102. package/src/Menu.mdx +111 -0
  103. package/src/Menu.stories.tsx +66 -0
  104. package/src/Menu.tsx +172 -0
  105. package/src/MenuItem.mdx +55 -0
  106. package/src/MenuItem.stories.tsx +98 -0
  107. package/src/NavItem.mdx +53 -0
  108. package/src/NavItem.stories.tsx +36 -0
  109. package/src/NavItem.tsx +53 -0
  110. package/src/Person.mdx +44 -0
  111. package/src/Person.stories.tsx +43 -0
  112. package/src/Person.tsx +39 -0
  113. package/src/PersonTrigger.mdx +51 -0
  114. package/src/PersonTrigger.stories.tsx +30 -0
  115. package/src/PersonTrigger.tsx +62 -0
  116. package/src/Property.mdx +51 -0
  117. package/src/Property.stories.tsx +91 -0
  118. package/src/Property.tsx +44 -0
  119. package/src/Rail.tsx +26 -0
  120. package/src/RailItem.mdx +50 -0
  121. package/src/RailItem.stories.tsx +37 -0
  122. package/src/RailItem.tsx +53 -0
  123. package/src/SearchInput.mdx +39 -0
  124. package/src/SearchInput.stories.tsx +24 -0
  125. package/src/SearchInput.tsx +45 -0
  126. package/src/SectionHeader.mdx +52 -0
  127. package/src/SectionHeader.stories.tsx +66 -0
  128. package/src/SectionHeader.tsx +85 -0
  129. package/src/SectionLabel.mdx +42 -0
  130. package/src/SectionLabel.stories.tsx +31 -0
  131. package/src/SectionLabel.tsx +28 -0
  132. package/src/Select.fit.test.ts +97 -0
  133. package/src/Select.mdx +71 -0
  134. package/src/Select.stories.tsx +95 -0
  135. package/src/Select.tsx +123 -15
  136. package/src/Sidebar.mdx +56 -0
  137. package/src/Sidebar.stories.tsx +50 -0
  138. package/src/Sidebar.tsx +32 -0
  139. package/src/Skeleton.mdx +39 -0
  140. package/src/Tabs.mdx +53 -0
  141. package/src/Tabs.stories.tsx +91 -0
  142. package/src/Tabs.tsx +73 -0
  143. package/src/TextInput.mdx +39 -0
  144. package/src/Textarea.mdx +36 -0
  145. package/src/Toast.mdx +54 -0
  146. package/src/Toast.stories.tsx +87 -0
  147. package/src/Toast.tsx +169 -0
  148. package/src/Tooltip.mdx +51 -0
  149. package/src/Tooltip.tsx +4 -2
  150. package/src/TopBar.mdx +62 -0
  151. package/src/TopBar.stories.tsx +96 -0
  152. package/src/TopBar.tsx +57 -0
  153. package/src/cn.test.ts +27 -0
  154. package/src/cn.ts +25 -5
  155. package/src/index.ts +23 -0
  156. package/tailwind-preset.js +4 -3
  157. package/tokens.css +4 -0
@@ -0,0 +1,117 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconMenu2, IconSquareRounded } from '@tabler/icons-react'
3
+ import { AppShell } from './AppShell'
4
+ import { Banner } from './Banner'
5
+ import { EmptyState } from './EmptyState'
6
+ import { IconButton } from './IconButton'
7
+ import { NavItem } from './NavItem'
8
+ import { PersonTrigger } from './PersonTrigger'
9
+ import { Rail } from './Rail'
10
+ import { RailItem } from './RailItem'
11
+ import { SearchInput } from './SearchInput'
12
+ import { Sidebar } from './Sidebar'
13
+
14
+ const meta = {
15
+ title: 'Frame/AppShell',
16
+ component: AppShell,
17
+ parameters: { layout: 'fullscreen' },
18
+ args: { nav: null, children: null },
19
+ argTypes: {
20
+ variant: { control: 'inline-radio', options: ['solid', 'floating'] },
21
+ menu: { control: false },
22
+ logo: { control: false },
23
+ search: { control: false },
24
+ identity: { control: false },
25
+ banner: { control: false },
26
+ nav: { control: false },
27
+ children: { control: false },
28
+ },
29
+ decorators: [(Story) => <div className="h-screen overflow-hidden">{Story()}</div>],
30
+ } satisfies Meta<typeof AppShell>
31
+
32
+ export default meta
33
+ type Story = StoryObj<typeof meta>
34
+
35
+ const identity = <PersonTrigger name="Ana Duarte" />
36
+ const menuButton = (
37
+ <IconButton tooltip="Toggle menu" tooltipPlacement="bottom" aria-label="Toggle menu">
38
+ <IconMenu2 size={16} stroke={1.5} />
39
+ </IconButton>
40
+ )
41
+
42
+ const placeholder = <IconSquareRounded size={16} stroke={1.5} />
43
+
44
+ const sidebar = (
45
+ <Sidebar>
46
+ <NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" active />
47
+ <NavItem href="#" label="Item two" icon={placeholder} count={5} countLabel="5 active" />
48
+ </Sidebar>
49
+ )
50
+
51
+ const rail = (
52
+ <Rail>
53
+ <RailItem href="#" label="Item" icon={placeholder} active />
54
+ <RailItem href="#" label="Item" icon={placeholder} />
55
+ <RailItem href="#" label="Item" icon={placeholder} />
56
+ </Rail>
57
+ )
58
+
59
+ /** The structured frame: solid bar, sidebar, content beside it. */
60
+ export const Solid: Story = {
61
+ render: (args) => (
62
+ <AppShell {...args} logo="Estiva" search={<SearchInput shortcut="⌘ K" className="w-[290px]" />} identity={identity} nav={sidebar}>
63
+ <div className="flex h-full items-center justify-center">
64
+ <EmptyState message="Nothing here yet." />
65
+ </div>
66
+ </AppShell>
67
+ ),
68
+ }
69
+
70
+ /** The banner belongs to the content area — at its top, never across the navigation. */
71
+ export const SolidWithBanner: Story = {
72
+ render: (args) => (
73
+ <AppShell {...args} logo="Estiva" identity={identity} nav={sidebar} banner={<Banner tone="ok">Public key copied.</Banner>}>
74
+ <div className="flex h-full items-center justify-center">
75
+ <EmptyState message="Nothing here yet." />
76
+ </div>
77
+ </AppShell>
78
+ ),
79
+ }
80
+
81
+ /** The floating frame: the bar floats over the top edge, the rail stands on the background, and the content lives in the rounded card. */
82
+ export const Floating: Story = {
83
+ render: (args) => (
84
+ <AppShell
85
+ {...args}
86
+ variant="floating"
87
+ menu={menuButton}
88
+ logo="Estiva"
89
+ search={<SearchInput shortcut="⌘ K" className="w-[290px]" />}
90
+ identity={identity}
91
+ nav={rail}
92
+ >
93
+ <div className="flex h-full items-center justify-center">
94
+ <EmptyState message="Nothing here yet." />
95
+ </div>
96
+ </AppShell>
97
+ ),
98
+ }
99
+
100
+ /** In the floating frame the banner draws inside the card, at its top. */
101
+ export const FloatingWithBanner: Story = {
102
+ render: (args) => (
103
+ <AppShell
104
+ {...args}
105
+ variant="floating"
106
+ menu={menuButton}
107
+ logo="Estiva"
108
+ identity={identity}
109
+ nav={rail}
110
+ banner={<Banner tone="info">A new version is available. Reload when convenient.</Banner>}
111
+ >
112
+ <div className="flex h-full items-center justify-center">
113
+ <EmptyState message="Nothing here yet." />
114
+ </div>
115
+ </AppShell>
116
+ ),
117
+ }
@@ -0,0 +1,77 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+ import { TopBar } from './TopBar'
4
+
5
+ /**
6
+ * The frame of an app, in the two manners that pair with the TopBar's
7
+ * (Katerina, 2026-09-02: the two shells go together — the solid bar with
8
+ * the sidebar, the floating bar with the rail):
9
+ *
10
+ * - `solid` — the structured frame: a solid TopBar, the navigation
11
+ * column, the content beside it, sidebar and main scrolling
12
+ * independently (2026-09-02, verbatim from the structured app).
13
+ * - `floating` — the floating frame: the TopBar floats over the top
14
+ * edge, the rail stands on the app background, and the content lives
15
+ * in the rounded card beside it (2026-09-02, the floating app's frame
16
+ * geometry; its collapse animation and launcher stay in the app).
17
+ *
18
+ * The banner belongs to the content area (her ruling, 2026-09-02): it
19
+ * renders at the top of the main column — inside the card, in the
20
+ * floating manner — never across the navigation.
21
+ *
22
+ * Layout only. What goes in each region is the caller's; this component
23
+ * has no data and no routes.
24
+ */
25
+ export interface AppShellProps {
26
+ variant?: 'solid' | 'floating'
27
+ /** The top bar's menu button, in an app whose navigation collapses. */
28
+ menu?: ReactNode
29
+ /** The top bar's left: the app's logo mark, or its name as text. */
30
+ logo?: ReactNode
31
+ /** The top bar's centre — a search field, when there is one. */
32
+ search?: ReactNode
33
+ /** The top bar's right cluster — an `IdentityMenu`. */
34
+ identity?: ReactNode
35
+ /** A `Banner`, or nothing — drawn at the top of the content area. */
36
+ banner?: ReactNode
37
+ /** The navigation: a `Sidebar` in the solid manner, a `Rail` in the floating one. */
38
+ nav: ReactNode
39
+ children: ReactNode
40
+ }
41
+
42
+ export function AppShell({ variant = 'solid', menu, logo, search, identity, banner, nav, children }: AppShellProps) {
43
+ const bar = <TopBar variant={variant} menu={menu} logo={logo} search={search} right={identity} />
44
+
45
+ if (variant === 'floating') {
46
+ return (
47
+ <div className="relative h-full min-h-0 overflow-hidden bg-bg-base">
48
+ {bar}
49
+ <div className="flex h-full pb-4 pr-4 pt-[52px]">
50
+ {nav}
51
+ <div
52
+ className={cn(
53
+ 'flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-bg-surface',
54
+ 'signal:border signal:border-border-subtle signal:shadow-[inset_0_1px_0_rgba(255,255,255,0.035)]',
55
+ )}
56
+ >
57
+ {banner}
58
+ <main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">{children}</main>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ )
63
+ }
64
+
65
+ return (
66
+ <div className="flex h-full min-h-0 flex-col bg-bg-base text-text-primary">
67
+ {bar}
68
+ <div className="flex min-h-0 flex-1">
69
+ {nav}
70
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
71
+ {banner}
72
+ <main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { initialsFor } from './Avatar'
3
+
4
+ describe('initialsFor', () => {
5
+ it('takes the first letter of the first two words', () => {
6
+ expect(initialsFor('Ana Duarte')).toBe('AD')
7
+ })
8
+
9
+ it('gives a single-word name a single letter', () => {
10
+ expect(initialsFor('Yael')).toBe('Y')
11
+ })
12
+
13
+ it('never shows a symbol — a parenthetical name gets one letter', () => {
14
+ // The bot rendered "C(" in the members pill (Katerina, 2026-09-01).
15
+ expect(initialsFor('Claude (steered by Katerina Kelepouri)')).toBe('C')
16
+ })
17
+
18
+ it('falls back to the first character when no word starts with a letter', () => {
19
+ expect(initialsFor('(bot)')).toBe('(')
20
+ })
21
+ })
package/src/Avatar.mdx ADDED
@@ -0,0 +1,49 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as AvatarStories from './Avatar.stories'
3
+
4
+ <Meta of={AvatarStories} />
5
+
6
+ # Avatar
7
+
8
+ A person's face: their picture, or their initials on a colour that is
9
+ theirs, or — when there is no name to take initials from — a silhouette.
10
+
11
+ <Canvas of={AvatarStories.Sizes} />
12
+
13
+ ## When
14
+
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.
18
+
19
+ ## When not
20
+
21
+ - Several people at once → **AvatarGroup**.
22
+ - The face beside the name → **Person**.
23
+ - The face as a button that opens a menu → **PersonTrigger**.
24
+
25
+ ## How
26
+
27
+ ```tsx
28
+ import { Avatar } from '@estiva-app/ui'
29
+
30
+ <Avatar name={person.name} src={person.picture} size={24} />
31
+ ```
32
+
33
+ - The caller resolves `src`. A picture that fails to load falls back to the
34
+ initials — nothing to handle.
35
+ - Initials come from a **name, never a key**: the first letter of the first
36
+ two words, skipping words that open with a symbol. An unnamed person is
37
+ the silhouette, not a `?` — a question mark reads as an error badge.
38
+ - The colour behind initials is per person, chosen from the name, the same
39
+ everywhere they appear. It is a fallback palette, not a token — themes
40
+ have no say in it.
41
+ - The corner is `rounded-sm` and stays that way. A surface that wants
42
+ rounder geometry wraps the Avatar concentrically — it does not override
43
+ the Avatar's own radius.
44
+
45
+ <Canvas of={AvatarStories.Palette} />
46
+
47
+ ## Props
48
+
49
+ <Controls of={AvatarStories.Initials} />
package/src/Avatar.tsx CHANGED
@@ -31,14 +31,17 @@ export const hueFor = (key: string) => {
31
31
  return HUES[Math.abs(h) % HUES.length]
32
32
  }
33
33
 
34
- /** Peek's rule: the first letter of the first two words. */
35
- export const initialsFor = (name: string) =>
36
- name
37
- .split(' ')
38
- .map((w) => w[0])
39
- .slice(0, 2)
40
- .join('')
41
- .toUpperCase()
34
+ /**
35
+ * Peek's rule, sharpened: the first letter of the first two words — counting
36
+ * only words that begin with a letter or digit, so "Claude (steered by
37
+ * Katerina Kelepouri)" shows "C", never "C(" (Katerina, 2026-09-01: a name
38
+ * carrying such symbols gets a single letter rather than one of them).
39
+ */
40
+ export const initialsFor = (name: string) => {
41
+ const words = name.split(/\s+/).slice(0, 2).filter((w) => /^[\p{L}\p{N}]/u.test(w))
42
+ const initials = words.map((w) => w[0]).join('') || name.trim().charAt(0)
43
+ return initials.toUpperCase()
44
+ }
42
45
 
43
46
  export interface AvatarProps {
44
47
  /** The picture URL, resolved by the caller. A picture that fails to load falls back to the initials. */
@@ -47,7 +50,7 @@ export interface AvatarProps {
47
50
  name?: string
48
51
  /** Alt text for the picture; the name when absent. Also the initials' source when there is no name. */
49
52
  alt?: string
50
- /** Pixels. Peek's scale: 16 · 24 · 32 · 36 (default). */
53
+ /** Pixels. The scale: 16 · 24 · 32 · 36 (default). */
51
54
  size?: number
52
55
  className?: string
53
56
  }
@@ -0,0 +1,39 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as AvatarGroupStories from './AvatarGroup.stories'
3
+
4
+ <Meta of={AvatarGroupStories} />
5
+
6
+ # AvatarGroup
7
+
8
+ Up to three overlapping 24px faces with a surface-coloured ring — the
9
+ member stack for anywhere a group is shown at a glance.
10
+
11
+ <Canvas of={AvatarGroupStories.OverflowShowsThree} />
12
+
13
+ ## When
14
+
15
+ - A compact "who's in this" — a members pill, a row's participants, a
16
+ card's crew. More than three members still draws three; the caller says
17
+ the count elsewhere if it matters.
18
+
19
+ ## When not
20
+
21
+ - One person → **Avatar**.
22
+ - Names need to be readable → a list of **Person** rows.
23
+
24
+ ## How
25
+
26
+ ```tsx
27
+ import { AvatarGroup } from '@estiva-app/ui'
28
+
29
+ <AvatarGroup members={people.map((p) => ({ name: p.name, picture: p.picture }))} />
30
+ ```
31
+
32
+ - A member is a `name` plus an optional `picture` URL, resolved by the
33
+ caller — exactly as Avatar takes its `src`. No picture draws initials.
34
+ - The ring rides on the Avatar itself (one box); don't wrap each face in a
35
+ clipping window — a window minus a border crops the initials.
36
+
37
+ ## Props
38
+
39
+ <Controls of={AvatarGroupStories.ThreeMembers} />
@@ -0,0 +1,56 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { AvatarGroup } from './AvatarGroup'
3
+
4
+ const meta = {
5
+ title: 'Primitives/AvatarGroup',
6
+ component: AvatarGroup,
7
+ args: {
8
+ members: [{ name: 'Ana Duarte' }, { name: 'Ravi Mehta' }, { name: 'Marta Silva' }],
9
+ },
10
+ } satisfies Meta<typeof AvatarGroup>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ export const ThreeMembers: Story = {}
16
+ export const TwoMembers: Story = {
17
+ args: { members: [{ name: 'Ana Duarte' }, { name: 'Ravi Mehta' }] },
18
+ }
19
+ export const One: Story = {
20
+ args: { members: [{ name: 'Ana Duarte' }] },
21
+ }
22
+
23
+ /** Five members, three faces — the stack never grows past three. */
24
+ export const OverflowShowsThree: Story = {
25
+ args: {
26
+ members: [
27
+ { name: 'Ana Duarte' },
28
+ { name: 'Ravi Mehta' },
29
+ { name: 'Marta Silva' },
30
+ { name: 'Jonas Weber' },
31
+ { name: 'Lea Novak' },
32
+ ],
33
+ },
34
+ }
35
+
36
+ /**
37
+ * Inside a members pill with the total count — the shape a conversation
38
+ * header draws over the stack. The pill is the caller's; this is the group
39
+ * doing its job in one.
40
+ */
41
+ export const InMembersPill: Story = {
42
+ args: {
43
+ members: [
44
+ { name: 'Ana Duarte' },
45
+ { name: 'Ravi Mehta' },
46
+ { name: 'Marta Silva' },
47
+ { name: 'Jonas Weber' },
48
+ ],
49
+ },
50
+ render: (args) => (
51
+ <div className="bg-bg-elevated border border-border-default rounded-sm flex gap-2 items-center pl-[2px] pr-2 py-[2px] w-fit">
52
+ <AvatarGroup {...args} />
53
+ <span className="text-caption text-text-secondary">{args.members.length}</span>
54
+ </div>
55
+ ),
56
+ }
@@ -0,0 +1,42 @@
1
+ import { Avatar } from './Avatar'
2
+
3
+ /**
4
+ * Up to three overlapping 24px faces with a `bg-surface` ring — Peek's member
5
+ * stack (2026-09-01), with one generalisation on the way in: a member is a
6
+ * name plus an optional picture URL, resolved by the caller, exactly as
7
+ * Avatar takes its `src`. Peek's version took bare names because its Avatar
8
+ * wrapper resolves pictures itself; a consumer without such a wrapper could
9
+ * never have shown one.
10
+ *
11
+ * The ring sits on the Avatar itself: the old extra wrapper was a 24px
12
+ * window minus a 2px border with a fixed 24px Avatar inside, so
13
+ * overflow-hidden clipped 4px of face — and the right edge of the initials
14
+ * with it, which is how "CO" rendered as "C(". One box, whole letters.
15
+ */
16
+ export interface AvatarGroupMember {
17
+ name: string
18
+ /** Resolved by the caller. Absent draws the initials. */
19
+ picture?: string
20
+ }
21
+
22
+ export interface AvatarGroupProps {
23
+ members: AvatarGroupMember[]
24
+ }
25
+
26
+ export function AvatarGroup({ members }: AvatarGroupProps) {
27
+ const visible = members.slice(0, 3)
28
+ return (
29
+ <div className="flex items-center pr-2">
30
+ {visible.map((member, i) => (
31
+ <Avatar
32
+ key={i}
33
+ size={24}
34
+ name={member.name}
35
+ src={member.picture}
36
+ alt={member.name}
37
+ className="-mr-2 relative border-2 border-bg-surface"
38
+ />
39
+ ))}
40
+ </div>
41
+ )
42
+ }
package/src/Banner.mdx ADDED
@@ -0,0 +1,45 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as BannerStories from './Banner.stories'
3
+
4
+ <Meta of={BannerStories} />
5
+
6
+ # Banner
7
+
8
+ The strip under the header where the app says something happened: one
9
+ line, one of four tones, gone when there is nothing to say.
10
+
11
+ <Canvas of={BannerStories.AllTones} />
12
+
13
+ ## When
14
+
15
+ - The outcome or state of the whole surface: "Public key copied.", the
16
+ reason the last read failed, a read-only notice.
17
+ - **`ok`** confirms, **`info`** informs, **`warning`** cautions,
18
+ **`error`** reports a failure — and only `error` announces itself
19
+ (`role="alert"`); the others are polite (`role="status"`).
20
+ - It belongs to the **content area** — at its top, never across the
21
+ navigation. AppShell's `banner` slot places it exactly there.
22
+
23
+ ## When not
24
+
25
+ - Feedback about one action, briefly → **Toast**.
26
+ - A standing property of one thing → **Chip** on that thing.
27
+ - A notice inside the content flow with its own action — that is a
28
+ different component (not in the package yet).
29
+
30
+ ## How
31
+
32
+ ```tsx
33
+ import { Banner } from '@estiva-app/ui'
34
+
35
+ {notice && <Banner tone={notice.tone}>{notice.text}</Banner>}
36
+ ```
37
+
38
+ - Render it only while there is something to say — an empty strip is not
39
+ a state.
40
+ - One line of plain text, `body-2`. If it needs a button or a second
41
+ line, it has outgrown this component.
42
+
43
+ ## Props
44
+
45
+ <Controls of={BannerStories.Ok} />
@@ -0,0 +1,35 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Banner } from './Banner'
3
+
4
+ const meta = {
5
+ title: 'Feedback/Banner',
6
+ component: Banner,
7
+ parameters: { layout: 'fullscreen' },
8
+ args: { tone: 'ok', children: 'Public key copied.' },
9
+ argTypes: { tone: { control: 'inline-radio', options: ['ok', 'error', 'info', 'warning'] } },
10
+ } satisfies Meta<typeof Banner>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ export const Ok: Story = {}
16
+
17
+ /** An error announces itself (`role="alert"`); the rest are polite. */
18
+ export const Error: Story = { args: { tone: 'error', children: 'The last read failed — reconnecting.' } }
19
+
20
+ export const Info: Story = { args: { tone: 'info', children: 'A new version is available. Reload when convenient.' } }
21
+
22
+ export const Warning: Story = { args: { tone: 'warning', children: 'This workspace is read-only until sign-in.' } }
23
+
24
+ /** All four tones, stacked. */
25
+ export const AllTones: Story = {
26
+ parameters: { controls: { disable: true } },
27
+ render: () => (
28
+ <div className="flex flex-col gap-2">
29
+ <Banner tone="ok">Public key copied.</Banner>
30
+ <Banner tone="info">A new version is available. Reload when convenient.</Banner>
31
+ <Banner tone="warning">This workspace is read-only until sign-in.</Banner>
32
+ <Banner tone="error">The last read failed — reconnecting.</Banner>
33
+ </div>
34
+ ),
35
+ }
package/src/Banner.tsx ADDED
@@ -0,0 +1,35 @@
1
+ import { cn } from './cn'
2
+
3
+ /**
4
+ * The strip under the header where the app says something happened —
5
+ * one line, gone when there is nothing to say (Ship's Banner,
6
+ * 2026-09-02, verbatim; `info` and `warning` added on extraction,
7
+ * Katerina's ruling — the semantic set is four everywhere else).
8
+ *
9
+ * body-2, her ruling (2026-09-01): the caption this once claimed never
10
+ * rendered, so 14px is what it has always been and what she kept.
11
+ * An `error` announces itself (`role="alert"`); the other tones are
12
+ * polite (`role="status"`).
13
+ */
14
+ export type BannerTone = 'ok' | 'error' | 'info' | 'warning'
15
+
16
+ export interface BannerProps {
17
+ tone: BannerTone
18
+ children: string
19
+ className?: string
20
+ }
21
+
22
+ const TONE: Record<BannerTone, string> = {
23
+ ok: 'bg-success-muted text-success-default',
24
+ error: 'bg-error-muted text-error-default',
25
+ info: 'bg-info-muted text-info-default',
26
+ warning: 'bg-warning-muted text-warning-default',
27
+ }
28
+
29
+ export function Banner({ tone, children, className }: BannerProps) {
30
+ return (
31
+ <div role={tone === 'error' ? 'alert' : 'status'} className={cn('px-4 py-2 text-body-2', TONE[tone], className)}>
32
+ {children}
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,52 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as BreadcrumbStories from './Breadcrumb.stories'
3
+
4
+ <Meta of={BreadcrumbStories} />
5
+
6
+ # Breadcrumb
7
+
8
+ A trail: where you are, and the way back up. Crumbs, muted slashes
9
+ between them, and a tooltip on any crumb that has had to truncate.
10
+
11
+ <Canvas of={BreadcrumbStories.OnAnItem} />
12
+
13
+ ## When
14
+
15
+ - The top of a detail page: parents first, the current place last.
16
+ - A crumb with an `href` is a link — even the last one, when a page ends
17
+ its trail on somewhere to go. The last item without one is "you are
18
+ here" (`aria-current="page"`).
19
+ - `mono` sets a crumb in the mono face for a ref or an id — and a mono
20
+ crumb **never gives up width** to a long name beside it; the name
21
+ truncates, the ref stays whole.
22
+
23
+ <Canvas of={BreadcrumbStories.LongName} />
24
+
25
+ ## When not
26
+
27
+ - Switching between sibling views of one thing → **Tabs**.
28
+ - Just a page title → the `h*` type tokens; a one-item trail says
29
+ nothing.
30
+
31
+ ## How
32
+
33
+ ```tsx
34
+ import { Breadcrumb } from '@estiva-app/ui'
35
+
36
+ <Breadcrumb
37
+ items={[
38
+ { label: 'Documents', href: '#/documents' },
39
+ { label: 'Quarterly plan', href: '#/documents/12' },
40
+ { label: 'DOC-12', mono: true },
41
+ ]}
42
+ />
43
+ ```
44
+
45
+ - Truncation is re-measured as the trail resizes; the tooltip appears and
46
+ disappears with the room the trail actually has. Give the trail a
47
+ `min-w-0` container so it can truncate at all.
48
+ - The separators are `aria-hidden` — the trail reads as its places.
49
+
50
+ ## Props
51
+
52
+ <Controls of={BreadcrumbStories.OnAnItem} />
@@ -0,0 +1,42 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Breadcrumb } from './Breadcrumb'
3
+
4
+ /** A trail of places, ending where you are — or on a mono ref. */
5
+ const meta = {
6
+ title: 'Navigation/Breadcrumb',
7
+ component: Breadcrumb,
8
+ args: {
9
+ items: [
10
+ { label: 'Documents', href: '#' },
11
+ { label: 'Quarterly plan', href: '#' },
12
+ { label: 'DOC-12', mono: true },
13
+ ],
14
+ },
15
+ } satisfies Meta<typeof Breadcrumb>
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof meta>
19
+
20
+ export const OnAnItem: Story = {}
21
+
22
+ /** An item with no parent: straight from the list to the ref. */
23
+ export const NoParent: Story = {
24
+ args: { items: [{ label: 'Documents', href: '#' }, { label: 'DOC-12', mono: true }] },
25
+ }
26
+
27
+ /** A container page: the last crumb is a plain name, where you are. */
28
+ export const OnAContainer: Story = {
29
+ args: { items: [{ label: 'Documents', href: '#' }, { label: 'Quarterly plan' }] },
30
+ }
31
+
32
+ /** A long name truncates; the ref stays. Hover the cut-off crumb to read the whole label — a crumb that fits shows no tooltip. */
33
+ export const LongName: Story = {
34
+ decorators: [(Story) => <div className="w-[320px]"><Story /></div>],
35
+ args: {
36
+ items: [
37
+ { label: 'Documents', href: '#' },
38
+ { label: 'Onboarding flow for new workspaces, the whole first ten minutes', href: '#' },
39
+ { label: 'ONB-12', mono: true },
40
+ ],
41
+ },
42
+ }