@estiva-app/ui 0.1.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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/dist/Avatar.d.ts +16 -0
  4. package/dist/Avatar.d.ts.map +1 -0
  5. package/dist/Button.d.ts +28 -0
  6. package/dist/Button.d.ts.map +1 -0
  7. package/dist/Chip.d.ts +21 -0
  8. package/dist/Chip.d.ts.map +1 -0
  9. package/dist/DialogShell.d.ts +19 -0
  10. package/dist/DialogShell.d.ts.map +1 -0
  11. package/dist/Divider.d.ts +12 -0
  12. package/dist/Divider.d.ts.map +1 -0
  13. package/dist/EmptyState.d.ts +14 -0
  14. package/dist/EmptyState.d.ts.map +1 -0
  15. package/dist/Field.d.ts +13 -0
  16. package/dist/Field.d.ts.map +1 -0
  17. package/dist/IconButton.d.ts +17 -0
  18. package/dist/IconButton.d.ts.map +1 -0
  19. package/dist/Select.d.ts +30 -0
  20. package/dist/Select.d.ts.map +1 -0
  21. package/dist/Skeleton.d.ts +24 -0
  22. package/dist/Skeleton.d.ts.map +1 -0
  23. package/dist/TextInput.d.ts +11 -0
  24. package/dist/TextInput.d.ts.map +1 -0
  25. package/dist/Textarea.d.ts +8 -0
  26. package/dist/Textarea.d.ts.map +1 -0
  27. package/dist/Tooltip.d.ts +22 -0
  28. package/dist/Tooltip.d.ts.map +1 -0
  29. package/dist/cn.d.ts +10 -0
  30. package/dist/cn.d.ts.map +1 -0
  31. package/dist/index.d.ts +26 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +486 -0
  34. package/dist/index.js.map +7 -0
  35. package/package.json +74 -0
  36. package/src/Avatar.stories.tsx +55 -0
  37. package/src/Avatar.tsx +81 -0
  38. package/src/Button.stories.tsx +53 -0
  39. package/src/Button.tsx +67 -0
  40. package/src/Chip.stories.tsx +37 -0
  41. package/src/Chip.tsx +43 -0
  42. package/src/DialogShell.stories.tsx +97 -0
  43. package/src/DialogShell.tsx +65 -0
  44. package/src/Divider.stories.tsx +44 -0
  45. package/src/Divider.tsx +22 -0
  46. package/src/EmptyState.stories.tsx +18 -0
  47. package/src/EmptyState.tsx +24 -0
  48. package/src/Field.stories.tsx +22 -0
  49. package/src/Field.tsx +24 -0
  50. package/src/IconButton.stories.tsx +44 -0
  51. package/src/IconButton.tsx +60 -0
  52. package/src/Select.stories.tsx +107 -0
  53. package/src/Select.tsx +195 -0
  54. package/src/Skeleton.stories.tsx +33 -0
  55. package/src/Skeleton.tsx +38 -0
  56. package/src/TextInput.stories.tsx +18 -0
  57. package/src/TextInput.tsx +29 -0
  58. package/src/Textarea.stories.tsx +17 -0
  59. package/src/Textarea.tsx +25 -0
  60. package/src/Tooltip.stories.tsx +47 -0
  61. package/src/Tooltip.tsx +70 -0
  62. package/src/cn.ts +13 -0
  63. package/src/index.ts +25 -0
  64. package/tailwind-preset.js +162 -0
  65. package/tokens.css +225 -0
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@estiva-app/ui",
3
+ "version": "0.1.0",
4
+ "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "files": [
8
+ "dist",
9
+ "src",
10
+ "tailwind-preset.js",
11
+ "tokens.css",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./tailwind-preset": "./tailwind-preset.js",
21
+ "./tokens.css": "./tokens.css",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "scripts": {
25
+ "build": "node build.mjs && tsc -p tsconfig.build.json",
26
+ "storybook": "storybook dev -p 6008 --no-open",
27
+ "build-storybook": "storybook build",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "typecheck": "tsc --noEmit",
31
+ "prepublishOnly": "npm run build"
32
+ },
33
+ "peerDependencies": {
34
+ "@tabler/icons-react": "^3.41.1",
35
+ "react": "^19.0.0",
36
+ "react-dom": "^19.0.0",
37
+ "tailwindcss": "^3.4.0"
38
+ },
39
+ "devDependencies": {
40
+ "@storybook/addon-docs": "^10.5.10",
41
+ "@storybook/react-vite": "^10.5.10",
42
+ "@tabler/icons-react": "^3.41.1",
43
+ "@types/node": "^24.12.0",
44
+ "@types/react": "^19.2.14",
45
+ "@types/react-dom": "^19.2.3",
46
+ "@vitejs/plugin-react": "^6.0.1",
47
+ "autoprefixer": "^10.4.27",
48
+ "esbuild": "^0.28.0",
49
+ "postcss": "^8.5.8",
50
+ "react": "^19.2.4",
51
+ "react-dom": "^19.2.4",
52
+ "storybook": "^10.5.10",
53
+ "tailwindcss": "^3.4.19",
54
+ "typescript": "~5.9.3",
55
+ "vite": "^8.0.1",
56
+ "vitest": "^4.1.11"
57
+ },
58
+ "dependencies": {
59
+ "clsx": "^2.1.1",
60
+ "tailwind-merge": "^3.5.0"
61
+ },
62
+ "main": "./dist/index.js",
63
+ "types": "./dist/index.d.ts",
64
+ "sideEffects": [
65
+ "*.css"
66
+ ],
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "repository": {
71
+ "type": "git",
72
+ "url": "git+https://github.com/estiva-app/estiva-ui.git"
73
+ }
74
+ }
@@ -0,0 +1,55 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Avatar } from './Avatar'
3
+
4
+ /** A picture, or initials on the person's own colour, or a silhouette. Nothing here comes from an app. */
5
+ const meta = {
6
+ title: 'Primitives/Avatar',
7
+ component: Avatar,
8
+ args: { name: 'Ana Duarte', size: 36 },
9
+ } satisfies Meta<typeof Avatar>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ /** Two words, two letters, on a colour chosen from the name. */
15
+ export const Initials: Story = {}
16
+
17
+ /** No name, no picture: the silhouette. Never a key, never a `?`. */
18
+ export const Unnamed: Story = { args: { name: undefined } }
19
+
20
+ /** A picture — here an inline SVG, so the story needs nothing from anywhere. */
21
+ export const WithPicture: Story = {
22
+ args: {
23
+ src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="%2346c08a"/><circle cx="5" cy="4" r="2" fill="%23fff"/></svg>',
24
+ },
25
+ }
26
+
27
+ /** A picture that fails to load falls back to the initials, not a hole. */
28
+ export const BrokenPicture: Story = { args: { src: 'http://localhost:1/nothing-here.png' } }
29
+
30
+ /** Peek's size scale, plus the smaller ones Ship uses in rows and menus. */
31
+ export const Sizes: Story = {
32
+ parameters: { controls: { disable: true } },
33
+ render: (args) => (
34
+ <div className="flex items-end gap-3">
35
+ {[16, 20, 22, 24, 28, 32, 36].map((size) => (
36
+ <div key={size} className="flex flex-col items-center gap-1">
37
+ <Avatar {...args} size={size} />
38
+ <span className="text-[12px] leading-[120%] text-text-muted">{size}</span>
39
+ </div>
40
+ ))}
41
+ </div>
42
+ ),
43
+ }
44
+
45
+ /** The same eight people always get the same eight colours. */
46
+ export const Palette: Story = {
47
+ parameters: { controls: { disable: true } },
48
+ render: () => (
49
+ <div className="flex gap-2">
50
+ {['Ana Duarte', 'Ravi Mehta', 'Mara Sato', 'Jonas Weber', 'Lin Chen', 'Sofia Rossi', 'Tomás Silva', 'Aiko Tanaka'].map((name) => (
51
+ <Avatar key={name} name={name} />
52
+ ))}
53
+ </div>
54
+ ),
55
+ }
package/src/Avatar.tsx ADDED
@@ -0,0 +1,81 @@
1
+ import { useState } from 'react'
2
+ import { IconUserFilled } from '@tabler/icons-react'
3
+ import { cn } from './cn'
4
+
5
+ /**
6
+ * A person's face: their picture, or their initials on a colour that is
7
+ * theirs, or — when there is no name to take initials from — a silhouette.
8
+ *
9
+ * Peek's Avatar (2026-08-28), made self-contained: Peek's version found the
10
+ * picture itself, by name, from Peek's data (an upload in Convex, else the
11
+ * demo portrait), and switched the initials on only under its Signal theme.
12
+ * Neither belongs in a shared component, so this one takes the picture URL
13
+ * from its caller and draws the initials in every theme. Peek keeps a
14
+ * three-line wrapper that resolves the picture; Ship passes the relay's
15
+ * `kind:0` picture straight in.
16
+ *
17
+ * Initials come from a *name*, never from a key: a component that accepted a
18
+ * pubkey would eventually show one (Ship's ruling, 2026-08-24). An unnamed
19
+ * person is the silhouette — `?` on a bright gradient read as an error badge
20
+ * rather than a person (Peek, FEE-1).
21
+ *
22
+ * The hue is a fallback palette, not a token: it is per person, chosen
23
+ * from the name so the same person is the same colour everywhere, and a
24
+ * theme has no say in it. Peek's eight, verbatim.
25
+ */
26
+ const HUES = ['#56c8ff', '#ff8f6b', '#4ade8c', '#b18cff', '#ffc94d', '#ff7eb0', '#7ea8ff', '#5fdfd6']
27
+
28
+ export const hueFor = (key: string) => {
29
+ let h = 0
30
+ for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0
31
+ return HUES[Math.abs(h) % HUES.length]
32
+ }
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()
42
+
43
+ export interface AvatarProps {
44
+ /** The picture URL, resolved by the caller. A picture that fails to load falls back to the initials. */
45
+ src?: string
46
+ /** The person's name — where the initials and the colour come from. */
47
+ name?: string
48
+ /** Alt text for the picture; the name when absent. Also the initials' source when there is no name. */
49
+ alt?: string
50
+ /** Pixels. Peek's scale: 16 · 24 · 32 · 36 (default). */
51
+ size?: number
52
+ className?: string
53
+ }
54
+
55
+ export function Avatar({ src, name, alt = '', size = 36, className }: AvatarProps) {
56
+ const [broken, setBroken] = useState(false)
57
+ const label = name || alt
58
+ const picture = src && !broken ? src : undefined
59
+ return (
60
+ <div className={cn('rounded-sm overflow-hidden shrink-0 bg-bg-inset', className)} style={{ width: size, height: size }}>
61
+ {picture ? (
62
+ <img src={picture} alt={alt || name || ''} className="w-full h-full object-cover" onError={() => setBroken(true)} />
63
+ ) : label ? (
64
+ <div
65
+ className="w-full h-full flex items-center justify-center font-semibold"
66
+ style={{
67
+ color: '#08121c',
68
+ fontSize: Math.round(size * 0.36),
69
+ background: `linear-gradient(160deg, color-mix(in srgb, ${hueFor(label)} 92%, #fff) 0%, color-mix(in srgb, ${hueFor(label)} 70%, #0b0d11) 100%)`,
70
+ }}
71
+ >
72
+ {initialsFor(label)}
73
+ </div>
74
+ ) : (
75
+ <div className="w-full h-full bg-accent-muted flex items-center justify-center text-text-muted">
76
+ <IconUserFilled size={Math.round(size * 0.5)} />
77
+ </div>
78
+ )}
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconPlus } from '@tabler/icons-react'
3
+ import { Button } from './Button'
4
+
5
+ const meta = {
6
+ title: 'Primitives/Button',
7
+ component: Button,
8
+ args: { children: 'Button', variant: 'muted', size: 'default', disabled: false },
9
+ argTypes: {
10
+ variant: { control: 'inline-radio', options: ['primary', 'outlined', 'muted', 'destructive'] },
11
+ size: { control: 'inline-radio', options: ['default', 'small'] },
12
+ leadingIcon: { control: false },
13
+ },
14
+ } satisfies Meta<typeof Button>
15
+
16
+ export default meta
17
+ type Story = StoryObj<typeof meta>
18
+
19
+ export const Primary: Story = { args: { variant: 'primary' } }
20
+ export const Outlined: Story = { args: { variant: 'outlined' } }
21
+ export const Muted: Story = { args: { variant: 'muted' } }
22
+ /** The muted button in the error colour — for "Delete …" and its kind. Ship's addition. */
23
+ export const Destructive: Story = { args: { variant: 'destructive', children: 'Delete project' } }
24
+ export const Small: Story = { args: { variant: 'primary', size: 'small' } }
25
+ export const WithLeadingIcon: Story = { args: { variant: 'primary', leadingIcon: <IconPlus stroke={1.5} className="size-4" /> } }
26
+ /** Disabled is for a control that is momentarily unavailable — say why, with a tooltip. A control someone may never use is absent, not disabled. */
27
+ export const Disabled: Story = { args: { variant: 'primary', disabled: true } }
28
+
29
+ /** Every variant × size × icon × disabled combination on one canvas. */
30
+ export const AllVariants: Story = {
31
+ parameters: { controls: { disable: true } },
32
+ render: () => (
33
+ <div className="flex flex-col gap-4">
34
+ {(['primary', 'outlined', 'muted', 'destructive'] as const).map((variant) => (
35
+ <div key={variant} className="flex items-center gap-3">
36
+ {(['default', 'small'] as const).map((size) => (
37
+ <div key={size} className="flex items-center gap-2">
38
+ <Button variant={variant} size={size}>
39
+ Button
40
+ </Button>
41
+ <Button variant={variant} size={size} leadingIcon={<IconPlus stroke={1.5} className={size === 'small' ? 'size-3.5' : 'size-4'} />}>
42
+ Button
43
+ </Button>
44
+ <Button variant={variant} size={size} disabled>
45
+ Button
46
+ </Button>
47
+ </div>
48
+ ))}
49
+ </div>
50
+ ))}
51
+ </div>
52
+ ),
53
+ }
package/src/Button.tsx ADDED
@@ -0,0 +1,67 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * Peek's Button (2026-08-28), verbatim, plus what Ship added and Peek should
6
+ * adopt: a `destructive` variant — the muted button in the error colour, for
7
+ * "Delete project" and its kind; a destructive action is a button like any
8
+ * other, not a dotted link — and `type="button"` by default, so a button
9
+ * inside a form submits it only when asked to.
10
+ *
11
+ * Three variants and two sizes: 32px default / 24px small, 6px radius, 500
12
+ * weight. Sizes are spelled as arbitrary values for the reason the README
13
+ * records: tailwind-merge drops a custom `text-{size}` that is followed by a
14
+ * `text-{colour}`.
15
+ *
16
+ * The primary reads in `text-inverse` on the accent — a theme decides what
17
+ * that is (dark on Peek's light accents, light on Ship's dark one). Under
18
+ * Signal it is also semibold, as Peek has it.
19
+ */
20
+ export type ButtonVariant = 'primary' | 'outlined' | 'muted' | 'destructive'
21
+ export type ButtonSize = 'default' | 'small'
22
+
23
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
24
+ variant?: ButtonVariant
25
+ size?: ButtonSize
26
+ /** 16px, stroke 1.5 on the default size; 14px on small. */
27
+ leadingIcon?: ReactNode
28
+ children: ReactNode
29
+ }
30
+
31
+ export function Button({
32
+ variant = 'muted',
33
+ size = 'default',
34
+ leadingIcon,
35
+ className,
36
+ children,
37
+ disabled,
38
+ type = 'button',
39
+ ...props
40
+ }: ButtonProps) {
41
+ const hasLeadingIcon = !!leadingIcon
42
+ return (
43
+ <button
44
+ type={type}
45
+ className={cn(
46
+ 'inline-flex items-center justify-center gap-1 rounded-md transition-colors font-sans font-medium',
47
+ size === 'default' && 'h-8 text-[14px] leading-[14px]',
48
+ size === 'small' && 'h-6 text-[12px] leading-[12px]',
49
+ // Extra right padding beside a leading icon, for optical balance.
50
+ size === 'default' && (hasLeadingIcon ? 'pl-2 pr-3' : 'px-2'),
51
+ size === 'small' && (hasLeadingIcon ? 'pl-1.5 pr-2' : 'px-1.5'),
52
+ !disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse cursor-pointer signal:font-semibold',
53
+ !disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer',
54
+ !disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer',
55
+ !disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer',
56
+ disabled && 'bg-bg-disabled text-text-disabled pointer-events-none',
57
+ disabled && variant === 'outlined' && 'border border-border-default',
58
+ className,
59
+ )}
60
+ disabled={disabled}
61
+ {...props}
62
+ >
63
+ {leadingIcon}
64
+ {children}
65
+ </button>
66
+ )
67
+ }
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconArrowUp, IconMessage2 } from '@tabler/icons-react'
3
+ import { Chip } from './Chip'
4
+
5
+ const meta = {
6
+ title: 'Primitives/Chip',
7
+ component: Chip,
8
+ args: { type: 'neutral', label: 'Label' },
9
+ argTypes: {
10
+ type: { control: 'inline-radio', options: ['neutral', 'brand', 'info', 'warning', 'success', 'error'] },
11
+ leadingIcon: { control: false },
12
+ trailingIcon: { control: false },
13
+ },
14
+ } satisfies Meta<typeof Chip>
15
+
16
+ export default meta
17
+ type Story = StoryObj<typeof meta>
18
+
19
+ export const Neutral: Story = {}
20
+ /** A count in the brand colour — "1 new". */
21
+ export const Brand: Story = { args: { type: 'brand', label: '1 new' } }
22
+ export const WithLeadingIcon: Story = { args: { type: 'info', label: 'Replies', leadingIcon: <IconMessage2 className="size-3" stroke={1.5} /> } }
23
+ export const WithTrailingIcon: Story = { args: { type: 'success', label: 'Sorted', trailingIcon: <IconArrowUp className="size-3" stroke={1.5} /> } }
24
+ /** A count alone. */
25
+ export const CountOnly: Story = { args: { type: 'neutral', label: '2' } }
26
+
27
+ /** All six colour types. */
28
+ export const AllTypes: Story = {
29
+ parameters: { controls: { disable: true } },
30
+ render: () => (
31
+ <div className="flex items-center gap-2">
32
+ {(['neutral', 'brand', 'info', 'warning', 'success', 'error'] as const).map((type) => (
33
+ <Chip key={type} type={type} label={type} />
34
+ ))}
35
+ </div>
36
+ ),
37
+ }
package/src/Chip.tsx ADDED
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * Peek's Chip (2026-08-28), verbatim: a 20px pill in one of six colour
6
+ * types, with room for a 12px icon either side. Under Signal the label is
7
+ * mono and the coloured types carry a hairline, as Peek has it. The icon
8
+ * slots centre their icon (Ship's addition — an icon beside text otherwise
9
+ * rides on the baseline).
10
+ *
11
+ * The label is the `chip` type token, 11px / 500; it is a plain class here,
12
+ * never merged, so tailwind-merge cannot drop it.
13
+ */
14
+ export type ChipType = 'neutral' | 'brand' | 'info' | 'warning' | 'success' | 'error'
15
+
16
+ export interface ChipProps {
17
+ type?: ChipType
18
+ label?: string
19
+ leadingIcon?: ReactNode
20
+ trailingIcon?: ReactNode
21
+ className?: string
22
+ }
23
+
24
+ const typeStyles: Record<ChipType, string> = {
25
+ neutral: 'bg-bg-inset text-text-primary',
26
+ brand: 'bg-accent-muted text-accent-primary signal:border signal:border-[rgba(86,200,255,0.3)]',
27
+ info: 'bg-info-muted text-info-default signal:border signal:border-[rgba(86,200,255,0.3)]',
28
+ warning: 'bg-warning-muted text-warning-default signal:border signal:border-[rgba(255,176,32,0.3)] signal:shadow-[shadow:0_0_5px_rgba(255,176,32,0.4)]',
29
+ success: 'bg-success-muted text-success-default signal:border signal:border-[rgba(63,222,140,0.3)]',
30
+ error: 'bg-error-muted text-error-default signal:border signal:border-[rgba(255,107,107,0.3)]',
31
+ }
32
+
33
+ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, className }: ChipProps) {
34
+ return (
35
+ <div className={cn('inline-flex items-center justify-center gap-1.5 rounded-full max-h-[20px] min-w-[16px] px-2 py-1', typeStyles[type], className)}>
36
+ {leadingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{leadingIcon}</span>}
37
+ {label && (
38
+ <span className="text-chip whitespace-nowrap signal:font-mono signal:text-[10px] signal:font-semibold signal:tracking-[0.02em] signal:tabular-nums">{label}</span>
39
+ )}
40
+ {trailingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{trailingIcon}</span>}
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,97 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { Button } from './Button'
4
+ import { DialogShell } from './DialogShell'
5
+ import { Field } from './Field'
6
+ import { TextInput } from './TextInput'
7
+ import { Textarea } from './Textarea'
8
+
9
+ const meta = {
10
+ title: 'Overlays/DialogShell',
11
+ component: DialogShell,
12
+ parameters: {
13
+ layout: 'fullscreen',
14
+ // Portals a fixed overlay to document.body — render in an iframe on the
15
+ // Docs page so it doesn't escape over the docs content.
16
+ docs: { story: { inline: false, height: '600px' } },
17
+ },
18
+ argTypes: { children: { control: false }, footer: { control: false } },
19
+ args: { title: 'Dialog title', onClose: () => {} },
20
+ } satisfies Meta<typeof DialogShell>
21
+
22
+ export default meta
23
+ type Story = StoryObj<typeof meta>
24
+
25
+ /**
26
+ * The reusable dialog scaffold. A new dialog is just the header title, a body
27
+ * (fields wrapped in `Field`), and a footer of buttons — the portal, backdrop,
28
+ * card, and chrome all come from `DialogShell`.
29
+ */
30
+ export const Default: Story = {
31
+ args: {
32
+ bodyClassName: 'flex flex-col gap-6',
33
+ footer: (
34
+ <>
35
+ <Button variant="muted">Cancel</Button>
36
+ <Button variant="primary">Confirm</Button>
37
+ </>
38
+ ),
39
+ children: (
40
+ <>
41
+ <Field label="Title" required>
42
+ <TextInput placeholder="What is this about?" />
43
+ </Field>
44
+ <Field label="Notes">
45
+ <Textarea placeholder="Optional notes…" className="h-[80px]" />
46
+ </Field>
47
+ </>
48
+ ),
49
+ },
50
+ }
51
+
52
+ /** A minimal confirmation dialog — single line of body, two buttons. */
53
+ export const Confirmation: Story = {
54
+ args: {
55
+ title: 'Delete this?',
56
+ footer: (
57
+ <>
58
+ <Button variant="muted">Cancel</Button>
59
+ <Button variant="destructive">Delete</Button>
60
+ </>
61
+ ),
62
+ children: <p className="text-body-2 leading-[1.4] text-text-primary">This permanently removes it. It cannot be undone.</p>,
63
+ },
64
+ }
65
+
66
+ /** Open it, then close it with the ✕, the backdrop, or Escape. */
67
+ export const OpenAndClose: Story = {
68
+ args: { footer: null, children: null },
69
+ render: (args) => {
70
+ const [open, setOpen] = useState(false)
71
+ return (
72
+ <div className="flex h-screen items-center justify-center">
73
+ <Button variant="primary" onClick={() => setOpen(true)}>
74
+ Open dialog
75
+ </Button>
76
+ {open && (
77
+ <DialogShell
78
+ {...args}
79
+ onClose={() => setOpen(false)}
80
+ footer={
81
+ <>
82
+ <Button variant="muted" onClick={() => setOpen(false)}>
83
+ Cancel
84
+ </Button>
85
+ <Button variant="primary" onClick={() => setOpen(false)}>
86
+ Confirm
87
+ </Button>
88
+ </>
89
+ }
90
+ >
91
+ <p className="text-body-2 text-text-primary">Escape closes this too.</p>
92
+ </DialogShell>
93
+ )}
94
+ </div>
95
+ )
96
+ },
97
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { IconX } from '@tabler/icons-react'
4
+ import { cn } from './cn'
5
+ import { IconButton } from './IconButton'
6
+
7
+ /**
8
+ * Peek's DialogShell (2026-08-28), verbatim: the portal, the backdrop, the
9
+ * 502px card and its chrome — a 48px header with the title and a close
10
+ * button, a body, a 48px footer for the buttons. A dialog is just what goes
11
+ * in the three slots. Plus what Ship added: Escape closes it, and the card
12
+ * says what it is to assistive tech.
13
+ */
14
+ export interface DialogShellProps {
15
+ title: string
16
+ onClose: () => void
17
+ footer: ReactNode
18
+ children: ReactNode
19
+ /** Extra classes on the body (e.g. `flex flex-col gap-6`, or a max height with `overflow-y-auto`). */
20
+ bodyClassName?: string
21
+ width?: number
22
+ }
23
+
24
+ export function DialogShell({ title, onClose, footer, children, bodyClassName, width = 502 }: DialogShellProps) {
25
+ useEffect(() => {
26
+ const onKey = (event: KeyboardEvent) => {
27
+ if (event.key === 'Escape') onClose()
28
+ }
29
+ document.addEventListener('keydown', onKey)
30
+ return () => document.removeEventListener('keydown', onKey)
31
+ }, [onClose])
32
+
33
+ return createPortal(
34
+ <>
35
+ {/* Backdrop */}
36
+ <div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
37
+
38
+ {/* Dialog */}
39
+ <div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
40
+ <div
41
+ role="dialog"
42
+ aria-modal="true"
43
+ aria-label={title}
44
+ className="bg-bg-elevated border border-border-subtle rounded-lg shadow-lg pointer-events-auto flex flex-col overflow-hidden"
45
+ style={{ width }}
46
+ >
47
+ {/* Header */}
48
+ <div className="h-12 flex items-center justify-between pl-5 pr-4 border-b border-border-subtle shrink-0">
49
+ <span className="text-h4 text-text-primary">{title}</span>
50
+ <IconButton tooltip="Close" aria-label="Close" onClick={onClose}>
51
+ <IconX size={16} stroke={1.5} />
52
+ </IconButton>
53
+ </div>
54
+
55
+ {/* Body */}
56
+ <div className={cn('pl-5 pr-4 py-4 border-b border-border-subtle', bodyClassName)}>{children}</div>
57
+
58
+ {/* Footer */}
59
+ <div className="h-12 flex items-center justify-end gap-2 pl-5 pr-4 shrink-0">{footer}</div>
60
+ </div>
61
+ </div>
62
+ </>,
63
+ document.body,
64
+ )
65
+ }
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Divider } from './Divider'
3
+
4
+ /** A hairline in `border-subtle`, inset 12px each side — or standing up between two columns. */
5
+ const meta = {
6
+ title: 'Primitives/Divider',
7
+ component: Divider,
8
+ } satisfies Meta<typeof Divider>
9
+
10
+ export default meta
11
+ type Story = StoryObj<typeof meta>
12
+
13
+ /** Between two blocks on the surface. */
14
+ export const Default: Story = {
15
+ render: () => (
16
+ <div className="w-80 rounded-lg border border-border-default bg-bg-surface py-3">
17
+ <p className="px-3 pb-2 text-body-2 text-text-primary">Above the divider</p>
18
+ <Divider />
19
+ <p className="px-3 pt-2 text-body-2 text-text-primary">Below the divider</p>
20
+ </div>
21
+ ),
22
+ }
23
+
24
+ /** Inside a menu — on the elevated surface, full width (`mx-0`). */
25
+ export const InAMenu: Story = {
26
+ render: () => (
27
+ <div className="w-72 rounded-lg border border-border-default bg-bg-elevated p-2 text-body-2 text-text-primary shadow-lg">
28
+ <div className="px-2 py-1.5">First action</div>
29
+ <Divider className="mx-0 my-2" />
30
+ <div className="px-2 py-1.5">Second action</div>
31
+ </div>
32
+ ),
33
+ }
34
+
35
+ /** Standing between two columns, the height of its row. Ship's addition. */
36
+ export const Vertical: Story = {
37
+ render: () => (
38
+ <div className="flex h-40 w-96 gap-6 text-body-2 text-text-primary">
39
+ <div className="flex-1">Content</div>
40
+ <Divider orientation="vertical" />
41
+ <div className="w-32">Rail</div>
42
+ </div>
43
+ ),
44
+ }
@@ -0,0 +1,22 @@
1
+ import { cn } from './cn'
2
+
3
+ /**
4
+ * Peek's Divider (2026-08-28): a hairline in `border-subtle`, inset 12px each
5
+ * side. Plus what Ship added: `orientation="vertical"` — the same hairline
6
+ * standing up, stretching to its row's height, no inset — and the separator
7
+ * role for assistive tech.
8
+ */
9
+ export interface DividerProps {
10
+ orientation?: 'horizontal' | 'vertical'
11
+ className?: string
12
+ }
13
+
14
+ export function Divider({ orientation = 'horizontal', className }: DividerProps) {
15
+ return (
16
+ <div
17
+ role="separator"
18
+ aria-orientation={orientation}
19
+ className={cn('bg-border-subtle', orientation === 'horizontal' ? 'h-px mx-3' : 'w-px self-stretch shrink-0', className)}
20
+ />
21
+ )
22
+ }
@@ -0,0 +1,18 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconLock } from '@tabler/icons-react'
3
+ import { EmptyState } from './EmptyState'
4
+
5
+ const meta = {
6
+ title: 'Feedback/EmptyState',
7
+ component: EmptyState,
8
+ args: { message: 'Nothing here yet.' },
9
+ argTypes: { icon: { control: false } },
10
+ } satisfies Meta<typeof EmptyState>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ /** The default icon with the caller's words. */
16
+ export const Default: Story = {}
17
+ export const LongerMessage: Story = { args: { message: 'No topics yet. Start one from any conversation.' } }
18
+ export const CustomIcon: Story = { args: { icon: <IconLock size={16} stroke={1.5} />, message: 'Nothing you can read here yet.' } }