@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
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react'
2
+ import { IconMessage2 } from '@tabler/icons-react'
3
+ import { cn } from './cn'
4
+
5
+ /**
6
+ * Peek's EmptyState (2026-08-28), verbatim: a 16px icon over a centred line
7
+ * of secondary text. The message is the caller's — a shared component has no
8
+ * words of its own for what is missing.
9
+ */
10
+ export interface EmptyStateProps {
11
+ /** 16px, stroke 1.5. A speech bubble when absent. */
12
+ icon?: ReactNode
13
+ message: string
14
+ className?: string
15
+ }
16
+
17
+ export function EmptyState({ icon, message, className }: EmptyStateProps) {
18
+ return (
19
+ <div className={cn('flex flex-col gap-2 items-center', className)}>
20
+ <span className="text-text-secondary">{icon ?? <IconMessage2 size={16} stroke={1.5} />}</span>
21
+ <p className="text-body-2 text-text-secondary text-center">{message}</p>
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,22 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Field } from './Field'
3
+ import { TextInput } from './TextInput'
4
+ import { Textarea } from './Textarea'
5
+
6
+ const meta = {
7
+ title: 'Inputs/Field',
8
+ component: Field,
9
+ parameters: { layout: 'padded' },
10
+ args: { label: 'Title', required: false },
11
+ argTypes: { children: { control: false } },
12
+ decorators: [(Story) => <div className="w-[360px]"><Story /></div>],
13
+ } satisfies Meta<typeof Field>
14
+
15
+ export default meta
16
+ type Story = StoryObj<typeof meta>
17
+
18
+ export const Default: Story = { args: { children: <TextInput placeholder="What is this about?" /> } }
19
+ export const Required: Story = { args: { label: 'Title', required: true, children: <TextInput placeholder="What is this about?" /> } }
20
+ export const WithTextarea: Story = {
21
+ args: { label: 'Resolution message (optional)', children: <Textarea placeholder="Summarise the outcome…" className="h-[109px]" /> },
22
+ }
package/src/Field.tsx ADDED
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * Peek's Field (2026-08-28), verbatim: a label over a control, 8px apart,
5
+ * with a red asterisk when required. The label is the `input-label` type
6
+ * token as a plain class, never merged.
7
+ */
8
+ export interface FieldProps {
9
+ label: string
10
+ required?: boolean
11
+ children: ReactNode
12
+ }
13
+
14
+ export function Field({ label, required = false, children }: FieldProps) {
15
+ return (
16
+ <div className="flex flex-col gap-2">
17
+ <label className={`text-input-label text-text-primary${required ? ' flex items-center' : ''}`}>
18
+ {label}
19
+ {required && <span className="text-error-default ml-0.5">*</span>}
20
+ </label>
21
+ {children}
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSettings } from '@tabler/icons-react'
3
+ import { IconButton } from './IconButton'
4
+
5
+ const meta = {
6
+ title: 'Primitives/IconButton',
7
+ component: IconButton,
8
+ args: { variant: 'muted', disabled: false, children: <IconSettings className="size-4" stroke={1.5} /> },
9
+ argTypes: {
10
+ variant: { control: 'inline-radio', options: ['muted', 'outlined', 'primary'] },
11
+ tooltipPlacement: { control: 'inline-radio', options: ['top', 'bottom'] },
12
+ children: { control: false },
13
+ },
14
+ } satisfies Meta<typeof IconButton>
15
+
16
+ export default meta
17
+ type Story = StoryObj<typeof meta>
18
+
19
+ export const Muted: Story = {}
20
+ export const Outlined: Story = { args: { variant: 'outlined' } }
21
+ export const Primary: Story = { args: { variant: 'primary' } }
22
+ export const Disabled: Story = { args: { variant: 'primary', disabled: true } }
23
+ /** Hover to see the portalled tooltip (top or bottom placement). */
24
+ export const WithTooltip: Story = { args: { tooltip: 'Settings', tooltipPlacement: 'top' } }
25
+
26
+ /** Every variant × enabled/disabled. */
27
+ export const AllVariants: Story = {
28
+ parameters: { controls: { disable: true } },
29
+ render: () => (
30
+ <div className="flex flex-col gap-3">
31
+ {(['muted', 'outlined', 'primary'] as const).map((variant) => (
32
+ <div key={variant} className="flex items-center gap-2">
33
+ <IconButton variant={variant}>
34
+ <IconSettings className="size-4" stroke={1.5} />
35
+ </IconButton>
36
+ <IconButton variant={variant} disabled>
37
+ <IconSettings className="size-4" stroke={1.5} />
38
+ </IconButton>
39
+ <span className="text-[12px] leading-[120%] text-text-muted">{variant}</span>
40
+ </div>
41
+ ))}
42
+ </div>
43
+ ),
44
+ }
@@ -0,0 +1,60 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+ import { WithTooltip } from './Tooltip'
4
+
5
+ /**
6
+ * Peek's IconButton (2026-08-28), verbatim: a square 4px-padded button
7
+ * around a 16px icon, 8px radius, three variants, an optional tooltip that
8
+ * portals above or below. Plus `type="button"` by default (Ship's addition),
9
+ * so it never submits a form by accident.
10
+ */
11
+ export type IconButtonVariant = 'muted' | 'outlined' | 'primary'
12
+
13
+ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
14
+ variant?: IconButtonVariant
15
+ tooltip?: string
16
+ tooltipPlacement?: 'top' | 'bottom'
17
+ /** The icon: 16px, stroke 1.5. */
18
+ children: ReactNode
19
+ }
20
+
21
+ export function IconButton({
22
+ variant = 'muted',
23
+ className,
24
+ children,
25
+ disabled,
26
+ tooltip,
27
+ tooltipPlacement,
28
+ type = 'button',
29
+ ...props
30
+ }: IconButtonProps) {
31
+ const button = (
32
+ <button
33
+ type={type}
34
+ className={cn(
35
+ 'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 cursor-pointer',
36
+ !disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
37
+ !disabled && variant === 'muted' && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
38
+ !disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
39
+ disabled && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
40
+ disabled && variant === 'muted' && 'text-text-disabled',
41
+ disabled && variant === 'outlined' && 'border border-border-default text-text-disabled',
42
+ disabled && 'pointer-events-none cursor-not-allowed',
43
+ className,
44
+ )}
45
+ disabled={disabled}
46
+ {...props}
47
+ >
48
+ {children}
49
+ </button>
50
+ )
51
+
52
+ if (tooltip) {
53
+ return (
54
+ <WithTooltip label={tooltip} placement={tooltipPlacement}>
55
+ {button}
56
+ </WithTooltip>
57
+ )
58
+ }
59
+ return button
60
+ }
@@ -0,0 +1,107 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState, type ComponentProps } from 'react'
3
+ import { Avatar } from './Avatar'
4
+ import { Field } from './Field'
5
+ import { Select } from './Select'
6
+ import { TextInput } from './TextInput'
7
+
8
+ /**
9
+ * A select drawn by the app rather than by the operating system.
10
+ *
11
+ * The trigger is `TextInput`'s field, so a select and a text field sitting in
12
+ * the same form are the same control with different contents.
13
+ *
14
+ * Open one and try the keyboard: arrows move, Home and End jump, Enter and Space
15
+ * pick, Escape closes and puts focus back on the trigger. That is what a native
16
+ * `<select>` gives away for free, and what has to be written by hand the moment
17
+ * you stop using one.
18
+ */
19
+ const meta = {
20
+ title: 'Inputs/Select',
21
+ component: Select,
22
+ parameters: { layout: 'padded' },
23
+ decorators: [(Story) => <div className="w-[360px]"><Story /></div>],
24
+ args: {
25
+ value: 'in_progress',
26
+ onChange: () => {},
27
+ options: [
28
+ { value: 'todo', label: 'Todo' },
29
+ { value: 'in_progress', label: 'In Progress' },
30
+ { value: 'done', label: 'Done' },
31
+ { value: 'cancelled', label: 'Cancelled' },
32
+ ],
33
+ ariaLabel: 'Status',
34
+ },
35
+ argTypes: { size: { control: 'inline-radio', options: ['default', 'small'] } },
36
+ } satisfies Meta<typeof Select>
37
+
38
+ export default meta
39
+ type Story = StoryObj<typeof meta>
40
+
41
+ /** Controlled, so the tick and the trigger follow what you pick. */
42
+ function Demo(props: Omit<ComponentProps<typeof Select>, 'value' | 'onChange'> & { initial?: string }) {
43
+ const { initial = '', ...rest } = props
44
+ const [value, setValue] = useState(initial)
45
+ return <Select {...rest} value={value} onChange={setValue} />
46
+ }
47
+
48
+ /** Field height, matching `TextInput` — the size for a form. */
49
+ export const Default: Story = { render: (args) => <Demo {...args} initial="in_progress" /> }
50
+
51
+ /** Nothing chosen yet: the placeholder is muted, so an empty select does not read as one already holding a value. */
52
+ export const Empty: Story = { render: (args) => <Demo {...args} initial="" placeholder="Choose a status…" /> }
53
+
54
+ /** The dense size, matching `Button`'s small — for property rows, where a full-height field would dominate the label beside it. */
55
+ export const Small: Story = { render: (args) => <Demo {...args} size="small" initial="todo" /> }
56
+
57
+ /** An option may carry a `leading` node — a face beside a person's name — shown in the trigger and in the list. Ship's addition. */
58
+ export const WithLeading: Story = {
59
+ render: (args) => (
60
+ <Demo
61
+ {...args}
62
+ ariaLabel="Assignee"
63
+ initial="ana"
64
+ size="small"
65
+ options={[
66
+ { value: '', label: 'Unassigned' },
67
+ ...['Ana Duarte', 'Ravi Mehta', 'Mara Sato'].map((name) => ({ value: name.split(' ')[0].toLowerCase(), label: name, leading: <Avatar name={name} size={16} /> })),
68
+ ]}
69
+ />
70
+ ),
71
+ }
72
+
73
+ /** In a `Field`, which is how a select appears in a form. */
74
+ export const InAField: Story = {
75
+ render: (args) => (
76
+ <div className="flex flex-col gap-4">
77
+ <Field label="Status">
78
+ <Demo {...args} initial="todo" />
79
+ </Field>
80
+ <Field label="Title" required>
81
+ <TextInput placeholder="What is this about?" />
82
+ </Field>
83
+ </div>
84
+ ),
85
+ }
86
+
87
+ /** Unavailable — the same disabled treatment as every other control. Say why, with a tooltip around it. */
88
+ export const Disabled: Story = { render: (args) => <Demo {...args} initial="done" disabled /> }
89
+
90
+ /** Longer labels than the trigger is wide: the trigger truncates rather than growing; the menu takes the trigger's width as a minimum and grows past it. */
91
+ export const LongLabels: Story = {
92
+ render: (args) => (
93
+ <Demo
94
+ {...args}
95
+ initial="a"
96
+ options={[
97
+ { value: 'a', label: 'Everything that has ever happened in this workspace' },
98
+ { value: 'b', label: 'Only the things an administrator did' },
99
+ ]}
100
+ />
101
+ ),
102
+ }
103
+
104
+ /** Enough options to scroll — the menu caps its height rather than running off. */
105
+ export const ManyOptions: Story = {
106
+ render: (args) => <Demo {...args} initial="p3" options={Array.from({ length: 20 }, (_, i) => ({ value: `p${i}`, label: `Project ${i + 1}` }))} />,
107
+ }
package/src/Select.tsx ADDED
@@ -0,0 +1,195 @@
1
+ import { IconCheck, IconChevronDown } from '@tabler/icons-react'
2
+ import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { cn } from './cn'
5
+
6
+ /**
7
+ * Peek's Select (2026-08-28), verbatim, plus what Ship added: an option may
8
+ * carry a `leading` node — an avatar beside a person's name — shown in the
9
+ * trigger and in the list.
10
+ *
11
+ * A button that opens a portalled listbox under itself: arrows move, Home
12
+ * and End jump, Enter and Space pick, Escape closes and returns focus to the
13
+ * trigger, Tab closes, a click outside closes, a scroll or resize closes
14
+ * (the list is fixed to where the trigger was). Two sizes; `disabled`
15
+ * explains nothing by itself — wrap it in a tooltip that does.
16
+ */
17
+ export interface SelectOption {
18
+ value: string
19
+ label: string
20
+ /** Drawn before the label, 16px — an Avatar, an icon. */
21
+ leading?: ReactNode
22
+ }
23
+
24
+ export interface SelectProps {
25
+ value: string
26
+ onChange: (value: string) => void
27
+ options: SelectOption[]
28
+ size?: 'default' | 'small'
29
+ ariaLabel?: string
30
+ placeholder?: string
31
+ disabled?: boolean
32
+ className?: string
33
+ }
34
+
35
+ export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className }: SelectProps) {
36
+ const id = useId()
37
+ const triggerRef = useRef<HTMLButtonElement>(null)
38
+ const menuRef = useRef<HTMLDivElement>(null)
39
+ const [rect, setRect] = useState<DOMRect | null>(null)
40
+ const open = rect !== null
41
+
42
+ const selectedIndex = useMemo(() => Math.max(0, options.findIndex((o) => o.value === value)), [options, value])
43
+ const [activeIndex, setActiveIndex] = useState(selectedIndex)
44
+
45
+ const close = useCallback((refocus: boolean) => {
46
+ setRect(null)
47
+ if (refocus) triggerRef.current?.focus()
48
+ }, [])
49
+
50
+ const openMenu = useCallback(() => {
51
+ setActiveIndex(selectedIndex)
52
+ setRect(triggerRef.current?.getBoundingClientRect() ?? null)
53
+ }, [selectedIndex])
54
+
55
+ // Outside click and Escape — the two exits every menu has. `mousedown`
56
+ // rather than `click`, so a press that starts outside dismisses at once.
57
+ useEffect(() => {
58
+ if (!open) return
59
+ const onDown = (e: MouseEvent) => {
60
+ if (menuRef.current?.contains(e.target as Node)) return
61
+ if (triggerRef.current?.contains(e.target as Node)) return
62
+ setRect(null)
63
+ }
64
+ const onMove = () => setRect(null)
65
+ document.addEventListener('mousedown', onDown)
66
+ window.addEventListener('resize', onMove)
67
+ window.addEventListener('scroll', onMove, true)
68
+ return () => {
69
+ document.removeEventListener('mousedown', onDown)
70
+ window.removeEventListener('resize', onMove)
71
+ window.removeEventListener('scroll', onMove, true)
72
+ }
73
+ }, [open])
74
+
75
+ const pick = (index: number) => {
76
+ const option = options[index]
77
+ if (!option) return
78
+ onChange(option.value)
79
+ close(true)
80
+ }
81
+
82
+ const onKeyDown = (e: KeyboardEvent) => {
83
+ if (!open) {
84
+ if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
85
+ e.preventDefault()
86
+ openMenu()
87
+ }
88
+ return
89
+ }
90
+ switch (e.key) {
91
+ case 'Escape':
92
+ e.preventDefault()
93
+ close(true)
94
+ break
95
+ case 'ArrowDown':
96
+ e.preventDefault()
97
+ setActiveIndex((i) => Math.min(options.length - 1, i + 1))
98
+ break
99
+ case 'ArrowUp':
100
+ e.preventDefault()
101
+ setActiveIndex((i) => Math.max(0, i - 1))
102
+ break
103
+ case 'Home':
104
+ e.preventDefault()
105
+ setActiveIndex(0)
106
+ break
107
+ case 'End':
108
+ e.preventDefault()
109
+ setActiveIndex(options.length - 1)
110
+ break
111
+ case 'Enter':
112
+ case ' ':
113
+ e.preventDefault()
114
+ pick(activeIndex)
115
+ break
116
+ case 'Tab':
117
+ close(false)
118
+ break
119
+ }
120
+ }
121
+
122
+ const selected = options.find((o) => o.value === value)
123
+
124
+ return (
125
+ <>
126
+ <button
127
+ ref={triggerRef}
128
+ type="button"
129
+ disabled={disabled}
130
+ aria-haspopup="listbox"
131
+ aria-expanded={open}
132
+ aria-label={ariaLabel}
133
+ onClick={() => (open ? close(false) : openMenu())}
134
+ onKeyDown={onKeyDown}
135
+ className={cn(
136
+ 'flex w-full items-center justify-between gap-2 rounded-lg border bg-bg-inset text-left',
137
+ 'border-border-default text-text-primary outline-none transition-colors',
138
+ 'hover:border-border-strong disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
139
+ 'focus-visible:border-border-focus aria-expanded:border-border-focus',
140
+ 'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
141
+ size === 'default' && 'px-3 py-2 text-[14px] leading-[1.4] font-normal',
142
+ size === 'small' && 'h-6 px-2 text-[12px] leading-[1.4] font-normal',
143
+ className,
144
+ )}
145
+ >
146
+ <span className={cn('flex min-w-0 items-center gap-2', !selected && 'text-text-muted')}>
147
+ {selected?.leading && <span className="flex shrink-0 items-center">{selected.leading}</span>}
148
+ <span className="truncate">{selected?.label ?? placeholder}</span>
149
+ </span>
150
+ <IconChevronDown size={size === 'small' ? 14 : 16} stroke={1.5} className="shrink-0 text-text-secondary" />
151
+ </button>
152
+
153
+ {open &&
154
+ rect &&
155
+ createPortal(
156
+ <div
157
+ ref={menuRef}
158
+ role="listbox"
159
+ aria-activedescendant={`${id}-${activeIndex}`}
160
+ tabIndex={-1}
161
+ style={{ top: rect.bottom + 4, left: rect.left, minWidth: rect.width }}
162
+ className="fixed z-50 max-h-72 overflow-y-auto rounded-lg border border-border-default bg-bg-elevated p-1 shadow-lg"
163
+ >
164
+ {options.map((option, index) => (
165
+ <div
166
+ key={option.value}
167
+ id={`${id}-${index}`}
168
+ role="option"
169
+ aria-selected={option.value === value}
170
+ onMouseEnter={() => setActiveIndex(index)}
171
+ onMouseDown={(e) => {
172
+ // The document-level dismiss also listens on mousedown.
173
+ e.preventDefault()
174
+ pick(index)
175
+ }}
176
+ className={cn(
177
+ 'flex h-9 cursor-pointer items-center justify-between gap-2 rounded-lg px-3 transition-colors',
178
+ 'text-[14px] font-normal leading-[1.4] text-text-primary',
179
+ index === activeIndex && 'bg-bg-hover',
180
+ option.value === value && 'font-medium',
181
+ )}
182
+ >
183
+ <span className="flex min-w-0 items-center gap-2">
184
+ {option.leading && <span className="flex shrink-0 items-center">{option.leading}</span>}
185
+ <span className="truncate">{option.label}</span>
186
+ </span>
187
+ {option.value === value && <IconCheck size={16} stroke={1.5} className="shrink-0 text-text-secondary" />}
188
+ </div>
189
+ ))}
190
+ </div>,
191
+ document.body,
192
+ )}
193
+ </>
194
+ )
195
+ }
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
3
+
4
+ const meta = {
5
+ title: 'Feedback/Skeleton',
6
+ component: SkeletonList,
7
+ } satisfies Meta<typeof SkeletonList>
8
+
9
+ export default meta
10
+ type Story = StoryObj<typeof meta>
11
+
12
+ /** A list placeholder — reveals after a 150ms delay so fast loads never flash it. */
13
+ export const List: Story = { decorators: [(Story) => <div className="w-[266px]"><Story /></div>] }
14
+
15
+ /** A single row-shaped placeholder. */
16
+ export const Row: Story = {
17
+ render: () => (
18
+ <div className="w-[266px]">
19
+ <SkeletonRow barWidth={140} />
20
+ </div>
21
+ ),
22
+ }
23
+
24
+ /** The raw building block — size it per use. */
25
+ export const Bar: Story = {
26
+ render: () => (
27
+ <div className="flex w-[266px] flex-col gap-2">
28
+ <SkeletonBar className="h-3.5 w-40" />
29
+ <SkeletonBar className="h-3 w-24" />
30
+ <SkeletonBar className="h-6 w-6" />
31
+ </div>
32
+ ),
33
+ }
@@ -0,0 +1,38 @@
1
+ import type { CSSProperties } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * Peek's Skeleton (2026-08-28), the generic parts only: the bar, a row
6
+ * shaped like a list row, and a list of them. Peek's placeholders shaped
7
+ * like a conversation card or a huddle card know what those are and stay
8
+ * in Peek, built from the bar.
9
+ *
10
+ * A list reveals after 150ms (`animate-skeleton-in`, in the preset) so a
11
+ * fast load never flashes a skeleton — Peek's rule.
12
+ */
13
+ export function SkeletonBar({ className, style }: { className?: string; style?: CSSProperties }) {
14
+ return <div className={cn('bg-bg-inset rounded-sm animate-pulse', className)} style={style} />
15
+ }
16
+
17
+ const ROW_BAR_WIDTHS = [150, 100, 170, 120, 90, 140, 110, 160]
18
+
19
+ /** A 32px row: a 16px square and a bar, like a row with a face and a name. */
20
+ export function SkeletonRow({ barWidth = 130 }: { barWidth?: number }) {
21
+ return (
22
+ <div className="flex items-center gap-2 px-2 h-[32px] rounded-lg">
23
+ <SkeletonBar className="w-4 h-4 shrink-0" />
24
+ <SkeletonBar className="h-3.5" style={{ width: barWidth }} />
25
+ </div>
26
+ )
27
+ }
28
+
29
+ /** Rows of varied widths, so the placeholder does not read as a pattern. */
30
+ export function SkeletonList({ rows = 8, className }: { rows?: number; className?: string }) {
31
+ return (
32
+ <div className={cn('flex flex-col gap-0.5 animate-skeleton-in', className)} aria-hidden>
33
+ {Array.from({ length: rows }, (_, i) => (
34
+ <SkeletonRow key={i} barWidth={ROW_BAR_WIDTHS[i % ROW_BAR_WIDTHS.length]} />
35
+ ))}
36
+ </div>
37
+ )
38
+ }
@@ -0,0 +1,18 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { TextInput } from './TextInput'
3
+
4
+ const meta = {
5
+ title: 'Inputs/TextInput',
6
+ component: TextInput,
7
+ parameters: { layout: 'padded' },
8
+ args: { placeholder: 'What is this about?' },
9
+ // Inputs stretch to fill their container (as inside a Field).
10
+ decorators: [(Story) => <div className="flex w-[360px] flex-col"><Story /></div>],
11
+ } satisfies Meta<typeof TextInput>
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof meta>
15
+
16
+ export const Empty: Story = {}
17
+ export const Filled: Story = { args: { defaultValue: 'Q3 launch planning' } }
18
+ export const Disabled: Story = { args: { defaultValue: 'Read only', disabled: true } }
@@ -0,0 +1,29 @@
1
+ import { forwardRef, type InputHTMLAttributes } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * Peek's TextInput (2026-08-28): the inset field with a 8px radius, 14px
6
+ * text, the focus border in every theme (Katerina, 2026-08-28: Ship's inputs
7
+ * focus like Signal's) — and Signal's glow on top.
8
+ * Plus a disabled look (Ship's addition): the disabled surface and text,
9
+ * no pointer.
10
+ */
11
+ export type TextInputProps = InputHTMLAttributes<HTMLInputElement>
12
+
13
+ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', ...props }, ref) {
14
+ return (
15
+ <input
16
+ ref={ref}
17
+ type={type}
18
+ className={cn(
19
+ 'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
20
+ 'text-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted',
21
+ 'outline-none transition-colors',
22
+ 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
23
+ 'signal:transition-shadow signal:focus:shadow-focus-ring',
24
+ className,
25
+ )}
26
+ {...props}
27
+ />
28
+ )
29
+ })
@@ -0,0 +1,17 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Textarea } from './Textarea'
3
+
4
+ const meta = {
5
+ title: 'Inputs/Textarea',
6
+ component: Textarea,
7
+ parameters: { layout: 'padded' },
8
+ args: { placeholder: 'Summarise the outcome or decision…', className: 'h-[109px]' },
9
+ decorators: [(Story) => <div className="flex w-[360px] flex-col"><Story /></div>],
10
+ } satisfies Meta<typeof Textarea>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ export const Empty: Story = {}
16
+ export const Filled: Story = { args: { defaultValue: 'Shipped the fix in build #482; verified with QA on staging.' } }
17
+ export const Disabled: Story = { args: { defaultValue: 'Read only', disabled: true } }
@@ -0,0 +1,25 @@
1
+ import { forwardRef, type TextareaHTMLAttributes } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * Peek's Textarea (2026-08-28), verbatim: TextInput's look on a textarea
6
+ * that does not resize. Plus a disabled look (Ship's addition).
7
+ */
8
+ export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>
9
+
10
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className, ...props }, ref) {
11
+ return (
12
+ <textarea
13
+ ref={ref}
14
+ className={cn(
15
+ 'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
16
+ 'text-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted',
17
+ 'resize-none outline-none transition-colors',
18
+ 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
19
+ 'signal:transition-shadow signal:focus:shadow-focus-ring',
20
+ className,
21
+ )}
22
+ {...props}
23
+ />
24
+ )
25
+ })