@estiva-app/ui 0.7.0 → 0.9.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 (63) hide show
  1. package/README.md +23 -6
  2. package/dist/Button.d.ts +12 -5
  3. package/dist/Button.d.ts.map +1 -1
  4. package/dist/Checkbox.d.ts +19 -5
  5. package/dist/Checkbox.d.ts.map +1 -1
  6. package/dist/IconButton.d.ts +9 -2
  7. package/dist/IconButton.d.ts.map +1 -1
  8. package/dist/PersonTrigger.d.ts +2 -1
  9. package/dist/PersonTrigger.d.ts.map +1 -1
  10. package/dist/Tabs.d.ts +4 -1
  11. package/dist/Tabs.d.ts.map +1 -1
  12. package/dist/cn.d.ts +10 -1
  13. package/dist/cn.d.ts.map +1 -1
  14. package/dist/index.js +353 -326
  15. package/dist/index.js.map +4 -4
  16. package/package.json +17 -3
  17. package/src/AppShell.stories.tsx +7 -1
  18. package/src/AppShell.tsx +1 -1
  19. package/src/Avatar.stories.tsx +3 -1
  20. package/src/Banner.stories.tsx +6 -1
  21. package/src/Banner.tsx +2 -2
  22. package/src/Breadcrumb.stories.tsx +3 -0
  23. package/src/Button.mdx +17 -3
  24. package/src/Button.stories.tsx +4 -1
  25. package/src/Button.test.tsx +119 -0
  26. package/src/Button.tsx +37 -23
  27. package/src/Checkbox.mdx +17 -6
  28. package/src/Checkbox.stories.tsx +10 -5
  29. package/src/Checkbox.test.tsx +73 -0
  30. package/src/Checkbox.tsx +52 -25
  31. package/src/Chip.stories.tsx +4 -0
  32. package/src/Chip.tsx +5 -5
  33. package/src/ChipInput.stories.tsx +4 -0
  34. package/src/DialogShell.tsx +1 -1
  35. package/src/EditableText.stories.tsx +6 -1
  36. package/src/IconButton.mdx +12 -1
  37. package/src/IconButton.stories.tsx +9 -4
  38. package/src/IconButton.test.tsx +102 -0
  39. package/src/IconButton.tsx +30 -17
  40. package/src/IdentityMenu.stories.tsx +5 -1
  41. package/src/Kbd.tsx +2 -2
  42. package/src/NavItem.stories.tsx +3 -0
  43. package/src/Person.stories.tsx +3 -0
  44. package/src/PersonTrigger.mdx +9 -1
  45. package/src/PersonTrigger.stories.tsx +3 -0
  46. package/src/PersonTrigger.test.tsx +52 -0
  47. package/src/PersonTrigger.tsx +8 -9
  48. package/src/Select.stories.tsx +7 -2
  49. package/src/Sidebar.stories.tsx +6 -0
  50. package/src/Tabs.mdx +13 -4
  51. package/src/Tabs.test.tsx +117 -0
  52. package/src/Tabs.tsx +45 -34
  53. package/src/Toast.tsx +8 -8
  54. package/src/TopBar.stories.tsx +7 -1
  55. package/src/cn.test.ts +20 -1
  56. package/src/cn.ts +18 -2
  57. package/stories/Choosing.mdx +101 -0
  58. package/stories/DesignTokens.mdx +10 -0
  59. package/stories/GettingStarted.mdx +108 -0
  60. package/stories/Introduction.mdx +36 -0
  61. package/stories/TokensPage.tsx +297 -0
  62. package/tailwind-preset.js +22 -0
  63. package/tokens.css +56 -0
package/src/Tabs.mdx CHANGED
@@ -43,10 +43,19 @@ import { Tabs } from '@estiva-app/ui'
43
43
  ```
44
44
 
45
45
  - Generic over the id type — `onChange` hands back your union, not a
46
- string.
47
- - It renders `role="tablist"` / `role="tab"` with `aria-selected`;
48
- switching is by click (there is no arrow-key roving), and wiring the
49
- panels — what shows for the active tab — is the caller's.
46
+ string, and only for a person's choice.
47
+ - Built on Base UI Tabs: `role="tablist"` / `role="tab"` with
48
+ `aria-selected`, one Tab stop for the row, arrow keys between the tabs.
49
+ Wiring the panels — what shows for the active tab — is the caller's.
50
+ - `className` lands on the outer box, around the row.
51
+
52
+ ## Keys
53
+
54
+ | Key | Does |
55
+ |---|---|
56
+ | Tab | Into the row, onto the selected tab; out of it on the next press. |
57
+ | ← → | Select the previous / next tab. The ends wrap. |
58
+ | Home / End | Select the first / last tab. |
50
59
 
51
60
  ## Props
52
61
 
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Tabs page claims about the keyboard, pinned. The keys come from
4
+ * Base UI's Tabs (stage 1 of the migration); before it, every tab was its own
5
+ * Tab stop and the arrow keys did nothing.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, render, screen } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { useState } from 'react'
11
+ import { Tabs, type TabDef } from './Tabs'
12
+
13
+ afterEach(cleanup)
14
+
15
+ type Id = 'one' | 'two' | 'three'
16
+ const THREE: TabDef<Id>[] = [
17
+ { id: 'one', label: 'One' },
18
+ { id: 'two', label: 'Two', count: 3 },
19
+ { id: 'three', label: 'Three' },
20
+ ]
21
+
22
+ /** A parent that owns the state, as every caller does. */
23
+ function Harness({ onChange, initial = 'one' }: { onChange?: (id: Id) => void; initial?: Id }) {
24
+ const [active, setActive] = useState<Id>(initial)
25
+ return (
26
+ <>
27
+ <button type="button">Before</button>
28
+ <Tabs
29
+ tabs={THREE}
30
+ active={active}
31
+ onChange={(id) => {
32
+ onChange?.(id)
33
+ setActive(id)
34
+ }}
35
+ />
36
+ <button type="button">After</button>
37
+ </>
38
+ )
39
+ }
40
+
41
+ const tab = (name: string) => screen.getByRole('tab', { name: new RegExp(`^${name}`) })
42
+ const selected = (name: string) => tab(name).getAttribute('aria-selected')
43
+ const tabindex = (name: string) => tab(name).getAttribute('tabindex')
44
+ const focused = () => document.activeElement
45
+
46
+ describe('Tabs', () => {
47
+ it('marks the active tab selected, and makes it the only Tab stop in the row', async () => {
48
+ const user = userEvent.setup()
49
+ render(<Harness />)
50
+ expect(selected('One')).toBe('true')
51
+ expect(selected('Two')).toBe('false')
52
+ expect(tabindex('One')).toBe('0')
53
+ expect(tabindex('Two')).toBe('-1')
54
+ expect(tabindex('Three')).toBe('-1')
55
+
56
+ await user.tab()
57
+ expect(focused()).toBe(screen.getByRole('button', { name: 'Before' }))
58
+ await user.tab()
59
+ expect(focused()).toBe(tab('One'))
60
+ await user.tab()
61
+ expect(focused()).toBe(screen.getByRole('button', { name: 'After' }))
62
+ })
63
+
64
+ it('selects on click and hands back the id', async () => {
65
+ const user = userEvent.setup()
66
+ const onChange = vi.fn()
67
+ render(<Harness onChange={onChange} />)
68
+ await user.click(tab('Two'))
69
+ expect(onChange).toHaveBeenCalledTimes(1)
70
+ expect(onChange).toHaveBeenCalledWith('two')
71
+ expect(selected('Two')).toBe('true')
72
+ expect(selected('One')).toBe('false')
73
+ })
74
+
75
+ it('selects the next and previous tab with the arrow keys, wrapping at the ends', async () => {
76
+ const user = userEvent.setup()
77
+ const onChange = vi.fn()
78
+ render(<Harness onChange={onChange} />)
79
+ await user.click(tab('One'))
80
+ onChange.mockClear()
81
+
82
+ await user.keyboard('{ArrowRight}')
83
+ expect(focused()).toBe(tab('Two'))
84
+ expect(selected('Two')).toBe('true')
85
+ await user.keyboard('{ArrowRight}')
86
+ expect(selected('Three')).toBe('true')
87
+ await user.keyboard('{ArrowRight}')
88
+ expect(selected('One')).toBe('true')
89
+ await user.keyboard('{ArrowLeft}')
90
+ expect(selected('Three')).toBe('true')
91
+ expect(onChange.mock.calls.map(([id]) => id)).toEqual(['two', 'three', 'one', 'three'])
92
+ })
93
+
94
+ it('selects the first and last tab with Home and End', async () => {
95
+ const user = userEvent.setup()
96
+ render(<Harness initial="two" />)
97
+ await user.click(tab('Two'))
98
+ await user.keyboard('{End}')
99
+ expect(selected('Three')).toBe('true')
100
+ await user.keyboard('{Home}')
101
+ expect(selected('One')).toBe('true')
102
+ })
103
+
104
+ it('stays quiet when `active` matches no tab: onChange is for a person, not a fallback', () => {
105
+ const onChange = vi.fn()
106
+ render(<Tabs tabs={THREE} active={'nowhere' as Id} onChange={onChange} />)
107
+ expect(onChange).not.toHaveBeenCalled()
108
+ expect(screen.getAllByRole('tab').map((t) => t.getAttribute('aria-selected'))).toEqual(['false', 'false', 'false'])
109
+ })
110
+
111
+ it('puts className on the outer box, around the row', () => {
112
+ const { container } = render(<Tabs tabs={THREE} active="one" onChange={() => {}} className="mt-4" />)
113
+ const outer = container.firstElementChild as HTMLElement
114
+ expect(outer.classList.contains('mt-4')).toBe(true)
115
+ expect(outer.querySelector('[role="tablist"]')).not.toBeNull()
116
+ })
117
+ })
package/src/Tabs.tsx CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { ReactNode } from 'react'
2
+ import { Tabs as BaseTabs } from '@base-ui/react/tabs'
2
3
  import { cn } from './cn'
3
4
 
4
5
  /**
5
6
  * A row of tabs. Ship's Tabs (2026-09-01), which was Peek's TopicTabs with
6
- * the topic-specific ids taken out.
7
+ * the topic-specific ids taken out; on Base UI Tabs since stage 1 of the
8
+ * migration (2026-09-06), which is where the keyboard comes from: one Tab
9
+ * stop for the row, arrow keys between the tabs.
7
10
  *
8
11
  * A selected tab is a neutral fill (bg-active), not the accent tint —
9
12
  * Katerina's ruling (2026-08-27), extended to every app (2026-09-01). Two
@@ -31,43 +34,51 @@ export interface TabsProps<T extends string> {
31
34
  onChange: (id: T) => void
32
35
  /** `default` 14px; `small` 12px, the denser geometry. */
33
36
  size?: 'default' | 'small'
37
+ /** Lands on the outer box, around the row. */
34
38
  className?: string
35
39
  }
36
40
 
37
41
  export function Tabs<T extends string>({ tabs, active, onChange, size = 'default', className }: TabsProps<T>) {
38
42
  return (
39
- <div role="tablist" className={cn('flex items-center gap-2', className)}>
40
- {tabs.map((tab) => (
41
- <button
42
- key={tab.id}
43
- type="button"
44
- role="tab"
45
- aria-selected={active === tab.id}
46
- onClick={() => onChange(tab.id)}
47
- className={cn(
48
- 'flex cursor-pointer items-center transition-colors',
49
- // Arbitrary sizes (the body-2 and caption tokens): the colour branch below
50
- // follows them through cn(), and tw-merge drops a custom text-{size}
51
- // once a text-{colour} lands after it. Measured: tabs rendered 16px.
52
- // gap: default is Ship's 6px; small keeps Peek's original 4px, or
53
- // "small is Peek's geometry" stops being true.
54
- size === 'default' ? 'gap-1.5 rounded-md px-2 py-1 text-[14px] leading-[140%]' : 'gap-1 rounded px-1.5 py-0.5 text-[12px] leading-[120%]',
55
- active === tab.id ? 'bg-bg-active text-text-primary' : 'text-text-secondary hover:bg-bg-hover',
56
- )}
57
- >
58
- {tab.icon}
59
- {/* Label and count share a baseline: a smaller text centred as a box
60
- (items-center) floats above the label's baseline the digits
61
- read as riding high. Baseline alignment is what makes two sizes
62
- sit on one line. */}
63
- <span className="flex items-baseline">
64
- {tab.label}
65
- {tab.count !== undefined ? (
66
- <span className="ml-2.5 font-mono text-caption tabular-nums text-text-secondary">{tab.count}</span>
67
- ) : null}
68
- </span>
69
- </button>
70
- ))}
71
- </div>
43
+ <BaseTabs.Root
44
+ value={active}
45
+ onValueChange={(value, details) => {
46
+ // Only a person's choice reaches the caller. Base UI also reports its
47
+ // own fallbacks (an `active` that matches no tab), and those carry
48
+ // `null`, which is not a T.
49
+ if (details.reason === 'none') onChange(value as T)
50
+ }}
51
+ className={className}
52
+ >
53
+ <BaseTabs.List activateOnFocus className="flex items-center gap-2">
54
+ {tabs.map((tab) => (
55
+ <BaseTabs.Tab
56
+ key={tab.id}
57
+ value={tab.id}
58
+ className={(state) =>
59
+ cn(
60
+ 'flex cursor-pointer items-center transition-colors',
61
+ // gap: default is Ship's 6px; small keeps Peek's original 4px, or
62
+ // "small is Peek's geometry" stops being true.
63
+ size === 'default' ? 'gap-1.5 rounded-md px-2 py-1 text-body-2' : 'gap-1 rounded px-1.5 py-0.5 text-caption',
64
+ state.active ? 'bg-bg-active text-text-primary' : 'text-text-secondary hover:bg-bg-hover',
65
+ )
66
+ }
67
+ >
68
+ {tab.icon}
69
+ {/* Label and count share a baseline: a smaller text centred as a box
70
+ (items-center) floats above the label's baseline — the digits
71
+ read as riding high. Baseline alignment is what makes two sizes
72
+ sit on one line. */}
73
+ <span className="flex items-baseline">
74
+ {tab.label}
75
+ {tab.count !== undefined ? (
76
+ <span className="ml-2.5 font-mono text-caption tabular-nums text-text-secondary">{tab.count}</span>
77
+ ) : null}
78
+ </span>
79
+ </BaseTabs.Tab>
80
+ ))}
81
+ </BaseTabs.List>
82
+ </BaseTabs.Root>
72
83
  )
73
84
  }
package/src/Toast.tsx CHANGED
@@ -31,19 +31,19 @@ export interface ToastProps {
31
31
 
32
32
  // Signal: every toast is the same dark overlay pill (v3) — the type lives in
33
33
  // the icon color + glow, not the surface.
34
- const SURFACE_BY_TYPE: Record<ToastType, string> = {
34
+ const SURFACE_STYLES: Record<ToastType, string> = {
35
35
  success: 'bg-success-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
36
36
  brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
37
37
  neutral: 'bg-bg-inset border border-border-subtle signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
38
38
  }
39
39
 
40
- const ICON_BY_TYPE: Record<ToastType, string> = {
41
- success: 'signal:text-success-default signal:drop-shadow-[0_0_5px_rgba(63,222,140,0.7)]',
42
- brand: 'signal:text-text-interactive signal:drop-shadow-[0_0_5px_rgba(86,200,255,0.6)]',
40
+ const ICON_STYLES: Record<ToastType, string> = {
41
+ success: 'signal:text-success-default signal:drop-shadow-glow-success',
42
+ brand: 'signal:text-text-interactive signal:drop-shadow-glow-accent',
43
43
  neutral: 'signal:text-text-secondary',
44
44
  }
45
45
 
46
- const ACTION_BORDER_BY_TYPE: Record<ToastType, string> = {
46
+ const ACTION_BORDER_STYLES: Record<ToastType, string> = {
47
47
  success: 'signal:border signal:border-border-default signal:hover:border-border-strong',
48
48
  brand: 'signal:border signal:border-border-default signal:hover:border-border-strong',
49
49
  neutral: 'border border-border-default',
@@ -58,13 +58,13 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
58
58
  // Without an action the label needs real right padding; the action
59
59
  // button brings its own edge, so the tight pr-1 only applies there.
60
60
  hasAction ? 'pr-1 gap-[46px]' : 'pr-3',
61
- SURFACE_BY_TYPE[type],
61
+ SURFACE_STYLES[type],
62
62
  className,
63
63
  )}
64
64
  >
65
65
  <div className="flex items-center gap-2 shrink-0">
66
66
  {leadingIcon && (
67
- <IconCircleCheck size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_BY_TYPE[type])} />
67
+ <IconCircleCheck size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />
68
68
  )}
69
69
  <span className="font-normal text-[14px] leading-[1.4] text-text-primary whitespace-nowrap">{label}</span>
70
70
  </div>
@@ -74,7 +74,7 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
74
74
  onClick={onAction}
75
75
  className={cn(
76
76
  'h-6 flex items-center justify-center gap-1 px-1 py-1 rounded-md shrink-0 transition-colors',
77
- ACTION_BORDER_BY_TYPE[type],
77
+ ACTION_BORDER_STYLES[type],
78
78
  type === 'neutral' ? 'hover:border-border-strong' : 'hover:opacity-80',
79
79
  )}
80
80
  >
@@ -14,7 +14,13 @@ import { TopBar } from './TopBar'
14
14
  const meta = {
15
15
  title: 'Frame/TopBar',
16
16
  component: TopBar,
17
- parameters: { layout: 'fullscreen' },
17
+ parameters: {
18
+ layout: 'fullscreen',
19
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
20
+ // the placeholder content under the bar is muted text, 3.94:1 on --bg-base in
21
+ // signal (AA 4.5:1).
22
+ a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } },
23
+ },
18
24
  argTypes: {
19
25
  variant: { control: 'inline-radio', options: ['solid', 'floating'] },
20
26
  menu: { control: false },
package/src/cn.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import preset from '../tailwind-preset.js'
3
- import { cn, _fontSizeTokens } from './cn'
3
+ import { cn, _boxShadowTokens, _dropShadowTokens, _fontSizeTokens } from './cn'
4
4
 
5
5
  describe('cn knows the type ramp', () => {
6
6
  it('lists exactly the preset fontSize keys', () => {
@@ -25,3 +25,22 @@ describe('cn knows the type ramp', () => {
25
25
  expect(cn('text-left text-body-2 text-text-primary')).toBe('text-left text-body-2 text-text-primary')
26
26
  })
27
27
  })
28
+
29
+ describe('cn knows the shadow tokens', () => {
30
+ it('lists exactly the preset boxShadow and dropShadow keys', () => {
31
+ const extend = (preset as { theme: { extend: { boxShadow: Record<string, unknown>; dropShadow: Record<string, unknown> } } }).theme.extend
32
+ expect([..._boxShadowTokens].sort()).toEqual(Object.keys(extend.boxShadow).sort())
33
+ expect([..._dropShadowTokens].sort()).toEqual(Object.keys(extend.dropShadow).sort())
34
+ })
35
+
36
+ it('treats a shadow token as a shadow: two of them conflict, the last wins', () => {
37
+ expect(cn('shadow-glow-warning', 'shadow-md')).toBe('shadow-md')
38
+ expect(cn('shadow-focus-ring', 'shadow-glow-warning')).toBe('shadow-glow-warning')
39
+ expect(cn('drop-shadow-glow-success', 'drop-shadow-glow-accent')).toBe('drop-shadow-glow-accent')
40
+ })
41
+
42
+ it('keeps a shadow token beside a colour, and under a variant', () => {
43
+ expect(cn('signal:shadow-glow-warning text-text-primary')).toBe('signal:shadow-glow-warning text-text-primary')
44
+ expect(cn('signal:border-warning-outline', 'text-warning-default')).toBe('signal:border-warning-outline text-warning-default')
45
+ })
46
+ })
package/src/cn.ts CHANGED
@@ -21,8 +21,24 @@ const FONT_SIZE_TOKENS = [
21
21
  'btn-default', 'btn-small', 'input-label', 'input-value', 'input-helper', 'chip',
22
22
  ]
23
23
 
24
+ /**
25
+ * The preset's box-shadow and drop-shadow keys, for the same reason: stock
26
+ * tailwind-merge files an unknown `shadow-x` under shadow-COLOUR, so
27
+ * `shadow-focus-ring` beside `shadow-glow-warning` lost one of them, and
28
+ * `shadow-glow-warning` beside `shadow-md` kept both (measured 2026-09-06).
29
+ * `cn.test.ts` pins these to the preset too.
30
+ */
31
+ const BOX_SHADOW_TOKENS = ['sm', 'md', 'lg', 'focus-ring', 'glow-warning', 'highlight-inset']
32
+ const DROP_SHADOW_TOKENS = ['glow-success', 'glow-accent']
33
+
24
34
  const twMerge = extendTailwindMerge({
25
- extend: { classGroups: { 'font-size': [{ text: FONT_SIZE_TOKENS }] } },
35
+ extend: {
36
+ classGroups: {
37
+ 'font-size': [{ text: FONT_SIZE_TOKENS }],
38
+ shadow: [{ shadow: BOX_SHADOW_TOKENS }],
39
+ 'drop-shadow': [{ 'drop-shadow': DROP_SHADOW_TOKENS }],
40
+ },
41
+ },
26
42
  })
27
43
 
28
44
  /** Merge class names, last-one-wins on conflicting Tailwind utilities. */
@@ -30,4 +46,4 @@ export function cn(...inputs: ClassValue[]) {
30
46
  return twMerge(clsx(inputs))
31
47
  }
32
48
 
33
- export { FONT_SIZE_TOKENS as _fontSizeTokens }
49
+ export { FONT_SIZE_TOKENS as _fontSizeTokens, BOX_SHADOW_TOKENS as _boxShadowTokens, DROP_SHADOW_TOKENS as _dropShadowTokens }
@@ -0,0 +1,101 @@
1
+ import { Meta } from '@storybook/addon-docs/blocks'
2
+
3
+ <Meta title="Docs/Choosing a component" />
4
+
5
+ # Choosing a component
6
+
7
+ Find what you need; take the row. When nothing fits, build it in your app —
8
+ and if what you built knows nothing about your product's objects, propose
9
+ it for the package. A component that nearly fits is **forked, not fought**:
10
+ fork freely, owe nothing back, mention it on the package's ticket.
11
+
12
+ ## An action
13
+
14
+ | You need | Reach for |
15
+ |---|---|
16
+ | A labeled action — a verb, clicked once | **Button** — `primary` at most once per surface |
17
+ | An icon-only action | **IconButton** — it requires an `aria-label` |
18
+ | An action in a popover list | **MenuItem**, inside a **Menu** |
19
+ | A destructive action | **Button** `destructive`, guarded by a **ConfirmDialog** |
20
+
21
+ ## A choice
22
+
23
+ | You need | Reach for | Not |
24
+ |---|---|---|
25
+ | One value for a form field | **Select** | a Menu — Select's trigger shows the value |
26
+ | Several values, typed then removable | **ChipInput** | checkboxes in a Menu |
27
+ | Yes or no | **Checkbox** | a toggle we don't have |
28
+ | Switching between views of one thing | **Tabs** | a row of Buttons |
29
+ | View options behind a `…` | **Menu**, `selected` on the current row | Select |
30
+ | Rows of checkboxes that stay open | a list in a **DialogShell** | a Menu — a Checkbox inside a `menuitem` is invalid HTML |
31
+
32
+ ## Typing
33
+
34
+ | You need | Reach for |
35
+ |---|---|
36
+ | One line | **TextInput** |
37
+ | Several lines | **Textarea** |
38
+ | Typing that filters a list | **SearchInput** |
39
+ | A title edited in place | **EditableText** |
40
+ | Label and required-mark around any control | **Field** |
41
+
42
+ ## People
43
+
44
+ | You need | Reach for |
45
+ |---|---|
46
+ | One face | **Avatar** |
47
+ | Several faces, overlapping | **AvatarGroup** |
48
+ | Face and name in a row | **Person** |
49
+ | The face that opens a menu | **PersonTrigger** |
50
+ | Who you are, where you are, sign out | **IdentityMenu** — the account menu, ready-made |
51
+
52
+ ## Floating surfaces
53
+
54
+ | You need | Reach for |
55
+ |---|---|
56
+ | A list of verbs from a trigger | **Menu** — Escape and outside-click come with it |
57
+ | A hint on hover | **Tooltip** / **WithTooltip** |
58
+ | Anything with a title, body and footer | **DialogShell** |
59
+ | "Are you sure?" | **ConfirmDialog** |
60
+
61
+ ## Structure and wayfinding
62
+
63
+ | You need | Reach for |
64
+ |---|---|
65
+ | Label–value rows (a details rail) | **Property** — `row` and `stacked` |
66
+ | A section heading, chevron, hover actions | **SectionHeader** |
67
+ | The uppercase micro-label | **SectionLabel** |
68
+ | A hairline | **Divider** |
69
+ | Where am I, and the way back up | **Breadcrumb** |
70
+ | The key that does this too | **Kbd** — or the `shortcut` prop on **MenuItem**, **Tooltip** and **SearchInput** |
71
+
72
+ ## Feedback
73
+
74
+ | You need | Reach for |
75
+ |---|---|
76
+ | Data is on its way | **Skeleton** — shaped like what will arrive |
77
+ | Nothing to show | **EmptyState** — says what would fill it |
78
+ | It happened, briefly | **Toast**, via `useToast` inside a `ToastProvider` |
79
+ | The whole surface has something to say | **Banner** — the strip under the top bar |
80
+
81
+ ## The frame
82
+
83
+ Two frames, two pairs: the **solid** TopBar goes with the **Sidebar**;
84
+ the **floating** TopBar goes with the **Rail** and the content card.
85
+
86
+ | You need | Reach for |
87
+ |---|---|
88
+ | The structured frame: solid bar, sidebar, content | **AppShell** `solid` |
89
+ | The floating frame: bar over the content, rail, rounded card | **AppShell** `floating` |
90
+ | The 52px bar alone | **TopBar** — `solid` in flow, or `floating` over the content |
91
+ | A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
92
+ | A 64px icon strip | **Rail**, filled with **RailItem** tiles |
93
+ | The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
94
+
95
+ ## Deliberately not in the package
96
+
97
+ - Anything that knows your **product's objects** — its documents, threads,
98
+ files, tickets. Build it in your app, out of these parts, and keep its
99
+ stories in your app's own Storybook.
100
+ - Page layouts — panels inside the content, split views, side rails —
101
+ not here yet; they are the next batch.
@@ -0,0 +1,10 @@
1
+ import { Meta, Unstyled } from '@storybook/addon-docs/blocks'
2
+ import { TokensPage } from './TokensPage'
3
+
4
+ <Meta title="Docs/Design Tokens" />
5
+
6
+ {/* Unstyled: the docs container sets 16px Nunito Sans on every div outside it,
7
+ at the same specificity as a token class, so the page draws its own type. */}
8
+ <Unstyled>
9
+ <TokensPage />
10
+ </Unstyled>
@@ -0,0 +1,108 @@
1
+ import { Meta } from '@storybook/addon-docs/blocks'
2
+
3
+ <Meta title="Docs/Getting started" />
4
+
5
+ # Getting started
6
+
7
+ Wiring a new Estiva app to the package, in six steps. After these, every
8
+ component on these pages works in your app — right theme, right sizes,
9
+ right scrollbars.
10
+
11
+ ## 1 · Install
12
+
13
+ ```bash
14
+ npm install @estiva-app/ui
15
+ ```
16
+
17
+ Peers you provide: `react` / `react-dom` 19, `tailwindcss` 3.4, and
18
+ `@tabler/icons-react` — the icon set every component and example draws from.
19
+
20
+ ## 2 · The Tailwind preset — and the content spread
21
+
22
+ ```js
23
+ // tailwind.config.js
24
+ import estiva, { estivaContent } from '@estiva-app/ui/tailwind-preset'
25
+
26
+ export default {
27
+ presets: [estiva],
28
+ content: [...estivaContent, './index.html', './src/**/*.{ts,tsx}'],
29
+ }
30
+ ```
31
+
32
+ The spread is not optional. Tailwind does not merge `content` from a
33
+ preset, so without it every class only these components use is purged —
34
+ the build succeeds, and components render at the wrong size.
35
+
36
+ ## 3 · The stylesheets
37
+
38
+ ```css
39
+ /* your entry CSS, before anything that uses a token */
40
+ @import '@estiva-app/ui/tokens.css';
41
+ @import '@estiva-app/ui/base.css';
42
+ ```
43
+
44
+ `tokens.css` is the token values, one block per theme. `base.css` is the
45
+ shared chrome that is not a token — the thin, token-coloured scrollbar
46
+ every Estiva app shows.
47
+
48
+ ## 4 · Pick a theme
49
+
50
+ ```html
51
+ <html data-theme="ship">
52
+ ```
53
+
54
+ | Theme | Selected by |
55
+ |---|---|
56
+ | `signal` | `data-theme="signal"` |
57
+ | `ship` | `data-theme="ship"` |
58
+
59
+ A new app starts on one of these. Its own look, when it earns one, is **one
60
+ new block in `tokens.css`** defining every token — the package's test holds
61
+ every theme to the full set, so nothing can half-exist.
62
+
63
+ ## 5 · Merge classes with the package `cn()`
64
+
65
+ ```tsx
66
+ import { cn } from '@estiva-app/ui'
67
+ ```
68
+
69
+ It is tailwind-merge taught this preset's type ramp: token size classes
70
+ (`text-body-2`, `text-caption`) survive merging next to a text colour.
71
+ Stock `twMerge` silently drops them — don't bring your own.
72
+
73
+ ## 6 · First screen
74
+
75
+ ```tsx
76
+ import { Button, Field, TextInput, ToastProvider, useToast } from '@estiva-app/ui'
77
+
78
+ function NewThing() {
79
+ const { showToast } = useToast()
80
+ return (
81
+ <div className="flex max-w-sm flex-col gap-4 p-6">
82
+ <Field label="Name" required>
83
+ <TextInput placeholder="What is it called?" />
84
+ </Field>
85
+ <Button variant="primary" onClick={() => showToast({ label: 'Created' })}>
86
+ Create
87
+ </Button>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ export default function App() {
93
+ return (
94
+ <ToastProvider>
95
+ <NewThing />
96
+ </ToastProvider>
97
+ )
98
+ }
99
+ ```
100
+
101
+ ## Where next
102
+
103
+ - **[Choosing a component](?path=/docs/docs-choosing-a-component--docs)** —
104
+ what to reach for, by what you need.
105
+ - Each component's page: when, when not — with the alternative named — and
106
+ how.
107
+ - The README's rules travel with the package: fork freely; tokens, never
108
+ raw hex; the five popover rules; only offer actions that can succeed.
@@ -0,0 +1,36 @@
1
+ import { Meta } from '@storybook/addon-docs/blocks'
2
+
3
+ <Meta title="Docs/Introduction" />
4
+
5
+ # @estiva-app/ui
6
+
7
+ The shared foundation of every Estiva app's interface: the **design tokens**
8
+ (colour, type, radius, shadow) and a **small set of primitives** built on
9
+ them.
10
+
11
+ **Tokens are the contract. Components are a convenience.** An app must not
12
+ diverge from the token names. It may fork any component, freely, and owes
13
+ nothing back — a fork is information about the seam, not a failure; say so
14
+ on the package's ticket, and never bend your view around a component.
15
+
16
+ ## Start here
17
+
18
+ 1. **[Getting started](?path=/docs/docs-getting-started--docs)** — wire a
19
+ new app to the package, in six steps.
20
+ 2. **[Choosing a component](?path=/docs/docs-choosing-a-component--docs)**
21
+ — what to reach for, by what you need.
22
+ 3. **[Design Tokens](?path=/docs/docs-design-tokens--docs)** — the
23
+ vocabulary everything here is written in.
24
+ 4. Every component's own page says **when** to use it, when **not** — with
25
+ the alternative named — and **how**, with its stories live on the page.
26
+
27
+ Use the **Theme** toolbar anywhere in this Storybook to see every page
28
+ under each of the package's themes: the same token names, a different look.
29
+
30
+ ## What belongs here
31
+
32
+ - In the package: components that know nothing about any product — built
33
+ from tokens, generic over their content, with stories that need no app
34
+ data.
35
+ - In your app: anything that knows your product's objects. Build it there,
36
+ out of these parts, and keep its stories in your app's own Storybook.