@estiva-app/ui 0.1.0 → 0.2.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 +36 -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 +150 -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
package/src/Person.mdx ADDED
@@ -0,0 +1,44 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as PersonStories from './Person.stories'
3
+
4
+ <Meta of={PersonStories} />
5
+
6
+ # Person
7
+
8
+ A face beside a name — the inline mention of a human. The gap between face
9
+ and name is one rule, 8px, wherever a person appears.
10
+
11
+ <Canvas of={PersonStories.Sizes} />
12
+
13
+ ## When
14
+
15
+ - A person named inline: an assignee, an author, a participant, a value in
16
+ a property row.
17
+ - Someone with no published name shows the `fallback` — an em dash by
18
+ default, the same mark an empty property uses. Pass `"Anonymous"` where
19
+ the unnamed person is the viewer themself.
20
+
21
+ ## When not
22
+
23
+ - The face alone → **Avatar**.
24
+ - The person as a button that opens a menu → **PersonTrigger**.
25
+ - A person as a row of a picker → **MenuItem** with an Avatar in `leading`
26
+ and a `description`.
27
+
28
+ ## How
29
+
30
+ ```tsx
31
+ import { Person } from '@estiva-app/ui'
32
+
33
+ <Person name={p.name} picture={p.picture} size={20} />
34
+ ```
35
+
36
+ - Hand it strings — a name and a picture URL. It takes no key on purpose:
37
+ a component that accepted one would eventually show one.
38
+ - It sets no text size of its own; the words inherit the caller's, so face
39
+ and words are always set together (the Sizes story shows the pairings).
40
+ - The name truncates; give the container `min-w-0` room to let it.
41
+
42
+ ## Props
43
+
44
+ <Controls of={PersonStories.Named} />
@@ -0,0 +1,43 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Person } from './Person'
3
+
4
+ /** A face beside a name — never a key. */
5
+ const meta = {
6
+ title: 'Primitives/Person',
7
+ component: Person,
8
+ args: { name: 'Ana Duarte', size: 20 },
9
+ } satisfies Meta<typeof Person>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ export const Named: Story = {}
15
+
16
+ /** Nobody has published a name: the silhouette and the em dash, muted — the same mark a property with no value uses. */
17
+ export const Unnamed: Story = {
18
+ args: { name: undefined },
19
+ }
20
+
21
+ /** Where the unnamed person is *you*, pass a word instead of the dash. */
22
+ export const OwnFallback: Story = {
23
+ args: { name: undefined, fallback: 'Anonymous' },
24
+ }
25
+
26
+ /** The text size is the caller's, so face and words are set together. */
27
+ export const Sizes: Story = {
28
+ parameters: { controls: { disable: true } },
29
+ render: () => (
30
+ <div className="flex flex-col items-start gap-3">
31
+ <Person name="Ana Duarte" size={16} className="text-caption" />
32
+ <Person name="Ana Duarte" size={20} className="text-body-2" />
33
+ <Person name="Ravi Mehta" size={22} className="text-body-2" />
34
+ <Person name="Ravi Mehta" size={28} className="text-body-1" />
35
+ </div>
36
+ ),
37
+ }
38
+
39
+ /** A narrow spot: the name truncates, the face never does. */
40
+ export const Truncating: Story = {
41
+ decorators: [(Story) => <div className="w-[120px]"><Story /></div>],
42
+ args: { name: 'Ana Duarte de Almeida e Costa' },
43
+ }
package/src/Person.tsx ADDED
@@ -0,0 +1,39 @@
1
+ import { cn } from './cn'
2
+ import { Avatar } from './Avatar'
3
+
4
+ /**
5
+ * A face beside a name. Ship's Person (2026-09-01), which was Peek's minus
6
+ * the projection type it took its profile from: this one is handed the two
7
+ * strings and nothing else, so it cannot be handed a key. Peek keeps a
8
+ * wrapper that resolves the profile, the way its Avatar wrapper resolves the
9
+ * picture.
10
+ *
11
+ * A face beside a name needs no caption (Peek's rulings), and someone nobody
12
+ * has published a name for gets the `fallback` — an em dash by default, the
13
+ * same mark a property with no value uses — rather than the key back on
14
+ * screen to say it. Pass "Anonymous" where the unnamed person is *you*, and
15
+ * you know who that is.
16
+ *
17
+ * The text size is the caller's, so the face and the words are always set
18
+ * together — see the Sizes story.
19
+ */
20
+ export interface PersonProps {
21
+ name?: string
22
+ picture?: string
23
+ /** Shown in place of a missing name. */
24
+ fallback?: string
25
+ size?: number
26
+ className?: string
27
+ }
28
+
29
+ export function Person({ name, picture, fallback = '—', size = 20, className }: PersonProps) {
30
+ return (
31
+ // gap-2: the face-to-name distance is one rule (8px) wherever a person
32
+ // appears — Katerina, 2026-09-01, set on Person so PersonTrigger and every
33
+ // row agree by construction.
34
+ <span className={cn('flex min-w-0 items-center gap-2', className)}>
35
+ <Avatar name={name} src={picture} size={size} />
36
+ <span className={cn('truncate', !name && 'text-text-muted')}>{name ?? fallback}</span>
37
+ </span>
38
+ )
39
+ }
@@ -0,0 +1,51 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as PersonTriggerStories from './PersonTrigger.stories'
3
+
4
+ <Meta of={PersonTriggerStories} />
5
+
6
+ # PersonTrigger
7
+
8
+ The person, as the button that opens a menu. Two shapes, one component:
9
+ the **row** — face · name · 14px chevron on a 32px button with a hover
10
+ fill — and **`compact`** — the face alone, 36px, no chevron and no hover
11
+ fill, because the face fills the whole control.
12
+
13
+ <Canvas of={PersonTriggerStories.Row} />
14
+
15
+ <Canvas of={PersonTriggerStories.Compact} />
16
+
17
+ ## When
18
+
19
+ - The account trigger in a top bar — either shape.
20
+ - Any control whose meaning is "this person, opened".
21
+ - What it opens is not its business: pair it with **IdentityMenu** for the
22
+ ready-made account menu, or your own **Menu**.
23
+
24
+ ## When not
25
+
26
+ - Showing a person without opening anything → **Person**.
27
+ - Choosing a person from a list → **Select** or **ChipInput**.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { PersonTrigger } from '@estiva-app/ui'
33
+
34
+ <PersonTrigger name={me.name} picture={me.picture} open={open}
35
+ onMouseDown={(e) => e.stopPropagation()} onClick={() => setOpen((v) => !v)} />
36
+ ```
37
+
38
+ - `open` holds the row's hover fill while the menu is up, and feeds
39
+ `aria-expanded`.
40
+ - **The accessible name**: the row shape is named by the visible name —
41
+ do **not** add an `aria-label`, it would override the person. The
42
+ `compact` face has no visible text, so there an `aria-label` is
43
+ required.
44
+ - A toggle trigger must swallow its `mousedown` (as above) or the menu's
45
+ outside-click dismiss turns each press into close-then-reopen — the
46
+ trap is spelled out on the Menu page.
47
+ - It takes a `ref` and every native button prop — anchor your menu on it.
48
+
49
+ ## Props
50
+
51
+ <Controls of={PersonTriggerStories.Row} />
@@ -0,0 +1,30 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { PersonTrigger } from './PersonTrigger'
3
+
4
+ /** The person, as the button that opens the account menu. The menu itself stays in the app. */
5
+ const meta = {
6
+ title: 'Primitives/PersonTrigger',
7
+ component: PersonTrigger,
8
+ args: { name: 'Ana Duarte', open: false, compact: false },
9
+ } satisfies Meta<typeof PersonTrigger>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ /** The row shape — face · name · chevron on a 32px button, Ship's top bar. */
15
+ export const Row: Story = {}
16
+
17
+ /** While what it opens is open, the row holds its hover fill. */
18
+ export const RowOpen: Story = {
19
+ args: { open: true },
20
+ }
21
+
22
+ /** The row for someone with no published name. */
23
+ export const RowUnnamed: Story = {
24
+ args: { name: undefined, fallback: 'Anonymous' },
25
+ }
26
+
27
+ /** The face alone — Peek's top-bar shape. No chevron, no padding, and no hover fill: the face fills the whole control, so a fill would have nowhere to show. */
28
+ export const Compact: Story = {
29
+ args: { compact: true },
30
+ }
@@ -0,0 +1,62 @@
1
+ import type { ComponentPropsWithRef } from 'react'
2
+ import { IconChevronDown } from '@tabler/icons-react'
3
+ import { cn } from './cn'
4
+ import { Avatar } from './Avatar'
5
+ import { Person, type PersonProps } from './Person'
6
+
7
+ /**
8
+ * The person, as the button that opens the account menu.
9
+ *
10
+ * Two shapes, one component (Katerina, 2026-09-01):
11
+ *
12
+ * - the row — face · name · chevron on a 32px button with a hover fill, as
13
+ * Ship's top bar draws it. The chevron is 14px, the small-control size.
14
+ * - `compact` — the face alone, as Peek's top bar draws it: no chevron, no
15
+ * padding, and no hover fill, because the face fills the whole control and
16
+ * a fill would have nowhere to show.
17
+ *
18
+ * What it opens — the menu and everything in it — stays in the app.
19
+ */
20
+ export interface PersonTriggerProps
21
+ extends Omit<PersonProps, 'className'>,
22
+ Omit<ComponentPropsWithRef<'button'>, 'children'> {
23
+ /** Whether what it opens is open — the row shape holds its hover fill while so. */
24
+ open?: boolean
25
+ /** Face only — no chevron, no hover fill. Defaults the face to 36px; the row shape to 22px. */
26
+ compact?: boolean
27
+ }
28
+
29
+ export function PersonTrigger({ name, picture, fallback, size, open = false, compact = false, className, ...props }: PersonTriggerProps) {
30
+ if (compact) {
31
+ return (
32
+ <button
33
+ type="button"
34
+ aria-haspopup="menu"
35
+ aria-expanded={open}
36
+ className={cn('cursor-pointer rounded-full focus:outline-none', className)}
37
+ {...props}
38
+ >
39
+ <Avatar name={name} src={picture} size={size ?? 36} />
40
+ </button>
41
+ )
42
+ }
43
+ return (
44
+ <button
45
+ type="button"
46
+ aria-haspopup="menu"
47
+ aria-expanded={open}
48
+ className={cn(
49
+ // The size is an arbitrary value (the body-2 token): this list goes through
50
+ // cn(), and tw-merge silently drops a custom text-{size} once a
51
+ // text-{colour} follows it. Measured: the name rendered 16px.
52
+ 'flex h-8 cursor-pointer items-center gap-1.5 rounded-md pl-1.5 pr-1.5 text-[14px] leading-[140%] text-text-primary transition-colors hover:bg-bg-hover',
53
+ open && 'bg-bg-hover',
54
+ className,
55
+ )}
56
+ {...props}
57
+ >
58
+ <Person name={name} picture={picture} fallback={fallback} size={size ?? 22} />
59
+ <IconChevronDown size={14} stroke={1.5} className="shrink-0 text-text-muted" />
60
+ </button>
61
+ )
62
+ }
@@ -0,0 +1,51 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as PropertyStories from './Property.stories'
3
+
4
+ <Meta of={PropertyStories} />
5
+
6
+ # Property
7
+
8
+ A labelled property — "Status", "Lead", and whatever else a surface
9
+ declares. Two layouts, one component.
10
+
11
+ <Canvas of={PropertyStories.APanel} />
12
+
13
+ ## When
14
+
15
+ - **`row`** — a details panel: a 68px `caption` label column that lines
16
+ the values up into a column of their own. The label reads `secondary` —
17
+ a property label is content, not a placeholder.
18
+ - **`stacked`** — a rail or form-like column: the label above a
19
+ full-width control, in the 9px `menu` token, uppercase and
20
+ letter-spaced, 12px above its content.
21
+ - An empty value is a muted em dash — show the property, not a blank.
22
+
23
+ <Canvas of={PropertyStories.Stacked} />
24
+
25
+ ## When not
26
+
27
+ - A **section title** → **SectionLabel** / **SectionHeader**. The stacked
28
+ label is deliberately not a SectionLabel — a field label and a section
29
+ title are different voices at different sizes.
30
+ - A labelled **input** in a form → **Field** (label, required mark).
31
+
32
+ ## How
33
+
34
+ ```tsx
35
+ import { Property } from '@estiva-app/ui'
36
+
37
+ <Property label="Status">
38
+ <Chip type="success" label="Done" />
39
+ </Property>
40
+
41
+ <Property label="Assignee" layout="stacked">
42
+ <Select value={assignee} options={people} onChange={setAssignee} />
43
+ </Property>
44
+ ```
45
+
46
+ Any control sits inside — a Chip, a Person, a Select, plain text (the
47
+ WithControls story shows the pairings).
48
+
49
+ ## Props
50
+
51
+ <Controls of={PropertyStories.Row} />
@@ -0,0 +1,91 @@
1
+ import { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { Chip } from './Chip'
4
+ import { Person } from './Person'
5
+ import { Property } from './Property'
6
+ import { Select } from './Select'
7
+
8
+ /** A labelled property — the 68px label column is what lines the values up. */
9
+ const meta = {
10
+ title: 'Primitives/Property',
11
+ component: Property,
12
+ decorators: [(Story) => <div className="w-[260px]"><Story /></div>],
13
+ args: { label: 'Status', children: <Chip type="success" label="Done" /> },
14
+ argTypes: { children: { control: false } },
15
+ } satisfies Meta<typeof Property>
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof meta>
19
+
20
+ export const Row: Story = {}
21
+
22
+ /** A column of rows — the label column keeps the values aligned. */
23
+ export const APanel: Story = {
24
+ parameters: { controls: { disable: true } },
25
+ render: () => (
26
+ <div className="flex flex-col gap-2">
27
+ <Property label="Status">
28
+ <Chip type="success" label="Done" />
29
+ </Property>
30
+ <Property label="Lead">
31
+ <Person name="Ana Duarte" size={16} className="text-[12px] leading-[120%] text-text-primary" />
32
+ </Property>
33
+ <Property label="Updated">
34
+ <span className="text-[12px] leading-[120%] text-text-secondary">2d ago</span>
35
+ </Property>
36
+ </div>
37
+ ),
38
+ }
39
+
40
+ /** Ship's rail layout: the 9px uppercase label above a full-width value. */
41
+ export const Stacked: Story = {
42
+ args: {
43
+ label: 'Assignee',
44
+ layout: 'stacked',
45
+ children: <Person name="Ravi Mehta" size={20} className="text-[14px] leading-[140%] text-text-primary" />,
46
+ },
47
+ }
48
+
49
+ function ControlsDemo() {
50
+ const [status, setStatus] = useState('doing')
51
+ const [owner, setOwner] = useState('ana')
52
+ return (
53
+ <div className="flex flex-col gap-4">
54
+ <Property label="Status" layout="stacked">
55
+ <Select
56
+ ariaLabel="Status"
57
+ value={status}
58
+ onChange={setStatus}
59
+ options={[
60
+ { value: 'todo', label: 'To do' },
61
+ { value: 'doing', label: 'In progress' },
62
+ { value: 'done', label: 'Done' },
63
+ ]}
64
+ />
65
+ </Property>
66
+ <Property label="Owner">
67
+ <Select
68
+ ariaLabel="Owner"
69
+ size="small"
70
+ value={owner}
71
+ onChange={setOwner}
72
+ options={[
73
+ { value: 'ana', label: 'Ana Duarte' },
74
+ { value: 'ravi', label: 'Ravi Mehta' },
75
+ ]}
76
+ />
77
+ </Property>
78
+ </div>
79
+ )
80
+ }
81
+
82
+ /** The property holding a control — a full-width Select under a stacked label (the rail), a small one in a row. */
83
+ export const WithControls: Story = {
84
+ parameters: { controls: { disable: true } },
85
+ render: () => <ControlsDemo />,
86
+ }
87
+
88
+ /** No value: the em dash, muted — the same mark Person uses for the unnamed. */
89
+ export const Empty: Story = {
90
+ args: { label: 'Folder', children: <span className="text-[12px] leading-[120%] text-text-muted">—</span> },
91
+ }
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * A labelled property row — "Status", "Lead", and whatever else a page
6
+ * declares (2026-09-01). Both apps carried one, and they had already
7
+ * drifted: Peek's label was `text-xs` — a Tailwind default with the wrong
8
+ * line-height, not the caption token — and Ship's had grown a layout Peek's
9
+ * lacked. One component, both layouts, the token:
10
+ *
11
+ * - `row` — Peek's: a 68px label column that lines the values up into a
12
+ * column of their own; `text-secondary` rather than muted because a
13
+ * property label is content, not a placeholder.
14
+ * - `stacked` — Ship's rail: the label above a full-width control. The label
15
+ * is the `menu` token (9px / 115% / 500), uppercase and letter-spaced —
16
+ * its own voice, deliberately not SectionLabel's (unmerged, Katerina
17
+ * 2026-09-01). A literal string, not merged, so the token size survives.
18
+ */
19
+ export interface PropertyProps {
20
+ label: string
21
+ layout?: 'row' | 'stacked'
22
+ children: ReactNode
23
+ className?: string
24
+ }
25
+
26
+ export function Property({ label, layout = 'row', children, className }: PropertyProps) {
27
+ if (layout === 'stacked') {
28
+ return (
29
+ // gap-3 — the one label-above-content distance (Katerina, 2026-09-01):
30
+ // Peek's topic-details sections already sit at 12px, Ship's rail was
31
+ // 6px, and unifying means the bigger, calmer one.
32
+ <div className={cn('flex flex-col gap-3', className)}>
33
+ <span className="text-menu uppercase tracking-[0.08em] text-text-secondary">{label}</span>
34
+ {children}
35
+ </div>
36
+ )
37
+ }
38
+ return (
39
+ <div className={cn('flex items-center gap-2', className)}>
40
+ <span className="w-[68px] shrink-0 text-caption text-text-secondary">{label}</span>
41
+ {children}
42
+ </div>
43
+ )
44
+ }
package/src/Rail.tsx ADDED
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * The icon rail: a 64px strip of RailItems (the shell of Peek's NavRail,
6
+ * 2026-09-02 — its entries and their routes stayed in the app).
7
+ *
8
+ * It has no border and no surface of its own — it stands on the app
9
+ * background, beside whatever card or column the app draws. Collapsing
10
+ * is the caller's: the rail does not know about the burger, it only gets
11
+ * given less room (or none) to stand in. Desktop only.
12
+ */
13
+ export interface RailProps {
14
+ /** Names the navigation region for assistive tech. */
15
+ 'aria-label'?: string
16
+ children: ReactNode
17
+ className?: string
18
+ }
19
+
20
+ export function Rail({ 'aria-label': ariaLabel = 'Navigation', children, className }: RailProps) {
21
+ return (
22
+ <nav aria-label={ariaLabel} className={cn('flex w-16 shrink-0 flex-col items-start gap-2 px-2 py-3', className)}>
23
+ {children}
24
+ </nav>
25
+ )
26
+ }
@@ -0,0 +1,50 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as RailItemStories from './RailItem.stories'
3
+
4
+ <Meta of={RailItemStories} />
5
+
6
+ # RailItem
7
+
8
+ One tile of an icon rail: the icon in its hover tile, a 9px label under
9
+ it. **Rail** is its shell — the 64px strip the tiles stand in, with no
10
+ border and no surface of its own.
11
+
12
+ <Canvas of={RailItemStories.InTheRail} />
13
+
14
+ ## When
15
+
16
+ - An app whose navigation is a narrow icon strip beside the content, with
17
+ a handful of top-level places.
18
+ - `active` fills the tile (and some themes give the icon their
19
+ interactive colour), plus `aria-current="page"`.
20
+ - The rail pairs with the **floating TopBar** (AppShell's `floating`
21
+ variant): the menu button in the bar's `menu` slot collapses it, the
22
+ app's name or mark rides in `logo` beside it. Collapsing is the
23
+ caller's — the rail only gets given less room (or none) to stand in.
24
+
25
+ ## When not
26
+
27
+ - Entries that need readable labels or counts → **NavItem** in a
28
+ **Sidebar**.
29
+ - More places than a handful — a rail without room stops being glanceable;
30
+ use the Sidebar.
31
+
32
+ ## How
33
+
34
+ ```tsx
35
+ import { Rail, RailItem } from '@estiva-app/ui'
36
+
37
+ <Rail>
38
+ <RailItem href="#/desk" label="Desk" icon={<IconNote size={16} stroke={1.5} />} active={at('desk')} />
39
+ <RailItem href="#/people" label="People" icon={<IconUsers size={16} stroke={1.5} />} active={at('people')} />
40
+ </Rail>
41
+ ```
42
+
43
+ - Both render plain anchors and a `nav` region. A router app keeps a thin
44
+ wrapper per item: compute `active` from the location, intercept the
45
+ click, navigate.
46
+ - Icons are 16px at stroke 1.5.
47
+
48
+ ## Props
49
+
50
+ <Controls of={RailItemStories.Default} />
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { Rail } from './Rail'
4
+ import { RailItem } from './RailItem'
5
+
6
+ const placeholder = <IconSquareRounded size={16} stroke={1.5} />
7
+
8
+ const meta = {
9
+ title: 'Frame/RailItem',
10
+ component: RailItem,
11
+ args: { label: 'Item', href: '#', active: false, icon: placeholder },
12
+ argTypes: { icon: { control: false } },
13
+ } satisfies Meta<typeof RailItem>
14
+
15
+ export default meta
16
+ type Story = StoryObj<typeof meta>
17
+
18
+ const tile = [(Story: () => React.ReactNode) => <div className="w-16">{Story()}</div>]
19
+
20
+ export const Default: Story = { decorators: tile }
21
+
22
+ /** Active fills the tile — and some themes give the icon their interactive colour. */
23
+ export const Active: Story = { args: { active: true }, decorators: tile }
24
+
25
+ /** The Rail shell as it stands in a frame: on the left, full height, tiles at the top. */
26
+ export const InTheRail: Story = {
27
+ parameters: { layout: 'fullscreen', controls: { disable: true } },
28
+ render: () => (
29
+ <div className="flex h-screen bg-bg-base">
30
+ <Rail>
31
+ <RailItem href="#" label="Item" icon={placeholder} active />
32
+ <RailItem href="#" label="Item" icon={placeholder} />
33
+ <RailItem href="#" label="Item" icon={placeholder} />
34
+ </Rail>
35
+ </div>
36
+ ),
37
+ }
@@ -0,0 +1,53 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * One tile of an icon rail: the icon in its hover tile, a 9px label
6
+ * under it (Peek's NavItem, 2026-09-02, verbatim — minus the router).
7
+ *
8
+ * The original computed its own active state from the route and rendered
9
+ * a router link; neither belongs in a shared component. This one takes
10
+ * `active` and renders an anchor — a router app keeps a thin wrapper
11
+ * that does its own matching and intercepts the click.
12
+ *
13
+ * Active is `bg-selected` on the tile — some themes also give the icon
14
+ * their interactive colour.
15
+ */
16
+ export interface RailItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'> {
17
+ href: string
18
+ /** 16px, stroke 1.5. */
19
+ icon: ReactNode
20
+ label: string
21
+ active?: boolean
22
+ className?: string
23
+ }
24
+
25
+ export function RailItem({ href, icon, label, active = false, className, ...props }: RailItemProps) {
26
+ return (
27
+ <a
28
+ href={href}
29
+ aria-current={active ? 'page' : undefined}
30
+ className={cn('group flex w-full shrink-0 flex-col items-center gap-0.5 px-2 py-0.5', className)}
31
+ {...props}
32
+ >
33
+ <div
34
+ className={cn(
35
+ 'flex items-center justify-center rounded-lg p-2 transition-colors',
36
+ active ? 'bg-bg-selected' : 'group-hover:bg-bg-hover',
37
+ )}
38
+ >
39
+ <span
40
+ className={cn(
41
+ 'flex items-center transition-colors',
42
+ active ? 'text-text-primary signal:text-[color:var(--text-interactive)]' : 'text-text-secondary',
43
+ )}
44
+ >
45
+ {icon}
46
+ </span>
47
+ </div>
48
+ <span className={cn('text-center text-[9px] font-medium leading-[115%]', active ? 'text-text-primary' : 'text-text-secondary')}>
49
+ {label}
50
+ </span>
51
+ </a>
52
+ )
53
+ }
@@ -0,0 +1,39 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as SearchInputStories from './SearchInput.stories'
3
+
4
+ <Meta of={SearchInputStories} />
5
+
6
+ # SearchInput
7
+
8
+ An inset field with a hairline border that strengthens on focus, and an
9
+ optional keyboard hint at the right edge.
10
+
11
+ <Canvas of={SearchInputStories.WithShortcut} />
12
+
13
+ ## When
14
+
15
+ - Filtering a list, live, above the list it filters.
16
+ - As a **launcher affordance**: keep the input `pointer-events-none` and
17
+ open your command surface from a click on the surround — the component
18
+ is the same either way, and `shortcut` shows the way in ("⌘ K").
19
+
20
+ ## When not
21
+
22
+ - General text entry → **TextInput**.
23
+ - Picking entries from a directory → **ChipInput** or **Select**.
24
+
25
+ ## How
26
+
27
+ ```tsx
28
+ import { SearchInput } from '@estiva-app/ui'
29
+
30
+ <SearchInput value={query} onChange={(e) => setQuery(e.target.value)} shortcut="⌘ K" />
31
+ ```
32
+
33
+ - The default placeholder is "Search…" — override it with your own.
34
+ - Every native input prop passes through; give it an `aria-label`
35
+ ("Search") since the placeholder is not a name.
36
+
37
+ ## Props
38
+
39
+ <Controls of={SearchInputStories.Default} />