@estiva-app/ui 0.12.9 → 0.13.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 (55) hide show
  1. package/dist/AttachmentCard.d.ts +52 -0
  2. package/dist/AttachmentCard.d.ts.map +1 -0
  3. package/dist/Card.d.ts +48 -0
  4. package/dist/Card.d.ts.map +1 -0
  5. package/dist/InlineChip.d.ts +59 -0
  6. package/dist/InlineChip.d.ts.map +1 -0
  7. package/dist/Link.d.ts +37 -0
  8. package/dist/Link.d.ts.map +1 -0
  9. package/dist/Menu.d.ts +12 -2
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/Popover.d.ts +17 -2
  12. package/dist/Popover.d.ts.map +1 -1
  13. package/dist/ProgressBar.d.ts +32 -0
  14. package/dist/ProgressBar.d.ts.map +1 -0
  15. package/dist/index.d.ts +5 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +697 -358
  18. package/dist/index.js.map +4 -4
  19. package/package.json +1 -1
  20. package/src/AttachmentCard.mdx +62 -0
  21. package/src/AttachmentCard.stories.tsx +92 -0
  22. package/src/AttachmentCard.test.tsx +172 -0
  23. package/src/AttachmentCard.tsx +324 -0
  24. package/src/Card.mdx +68 -0
  25. package/src/Card.stories.tsx +109 -0
  26. package/src/Card.test.tsx +106 -0
  27. package/src/Card.tsx +115 -0
  28. package/src/EmptyState.mdx +14 -0
  29. package/src/EmptyState.stories.tsx +28 -7
  30. package/src/EmptyState.test.tsx +8 -0
  31. package/src/InlineChip.mdx +56 -0
  32. package/src/InlineChip.stories.tsx +59 -0
  33. package/src/InlineChip.test.tsx +81 -0
  34. package/src/InlineChip.tsx +90 -0
  35. package/src/Link.mdx +59 -0
  36. package/src/Link.stories.tsx +102 -0
  37. package/src/Link.test.tsx +100 -0
  38. package/src/Link.tsx +58 -0
  39. package/src/Menu.test.tsx +20 -0
  40. package/src/Menu.tsx +17 -5
  41. package/src/Person.stories.tsx +1 -1
  42. package/src/PersonTrigger.stories.tsx +1 -1
  43. package/src/Popover.mdx +4 -2
  44. package/src/Popover.stories.tsx +11 -9
  45. package/src/Popover.test.tsx +38 -0
  46. package/src/Popover.tsx +20 -4
  47. package/src/ProgressBar.mdx +47 -0
  48. package/src/ProgressBar.stories.tsx +48 -0
  49. package/src/ProgressBar.test.tsx +62 -0
  50. package/src/ProgressBar.tsx +56 -0
  51. package/src/ReactionPicker.mdx +1 -1
  52. package/src/ReactionPicker.stories.tsx +3 -2
  53. package/src/Toolbar.stories.tsx +2 -1
  54. package/src/index.ts +12 -0
  55. package/stories/Choosing.mdx +5 -0
package/src/Card.mdx ADDED
@@ -0,0 +1,68 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as CardStories from './Card.stories'
3
+
4
+ <Meta of={CardStories} />
5
+
6
+ # Card
7
+
8
+ A box that stands for one thing — drawn as its frame: 8px corners, a fill and a
9
+ hairline. What goes inside, and the space around it, is yours.
10
+
11
+ <Canvas of={CardStories.AllFills} />
12
+
13
+ ## When
14
+
15
+ A box is a card when it stands for **one thing you could name**, and there are
16
+ many of it or you treat the whole box as that thing — open it, point at it,
17
+ move it.
18
+
19
+ - **`surface`** — on the page. The default hairline.
20
+ - **`elevated`** — on something already filled, like a column of cards. The
21
+ default hairline.
22
+ - **`inset`** — inside something filled, like a message. The subtle hairline.
23
+ - **`none`** — a hairline alone, the subtle one.
24
+ - `href` — the whole card is a link; its hairline goes one step stronger on
25
+ hover.
26
+ - `hover="fill"` — a card in a feed: it lights up when pointed at, so the light
27
+ says which one you are on. `quietUntilHover` hides its hairline until then.
28
+ - `selected` — the one you are on. `active` — the one being changed in place.
29
+ Neither answers the pointer.
30
+ - `attention` — a hairline that asks to be looked at: `accent` for something
31
+ new, `warning` for something urgent.
32
+ - `unreadable` — it stands for something that could not be read: the hairline
33
+ is dashed.
34
+
35
+ <Canvas of={CardStories.InAFeed} />
36
+
37
+ ## When not
38
+
39
+ - You type in it → **TextInput**, **Textarea**, or your editor's box — a field,
40
+ not a card.
41
+ - It frames an area holding many different things → a panel, or
42
+ **DialogShell**.
43
+ - It is small and sits in a line → **Chip**, **InlineChip**, or **Button**.
44
+ - It stands in while the card loads → **Skeleton**, shaped like the card.
45
+ - It tells the reader something rather than being a thing → **Banner**.
46
+
47
+ ## How
48
+
49
+ ```tsx
50
+ import { Card } from '@estiva-app/ui'
51
+
52
+ <Card href={itemUrl} onClick={navigate} className="flex flex-col gap-2 p-3">
53
+ <span className="text-body-2-strong text-text-primary">Item one</span>
54
+ <span className="text-caption text-text-secondary">A line about the item.</span>
55
+ </Card>
56
+ ```
57
+
58
+ - It draws no padding and no layout of its own: put them in `className`, or let
59
+ the content pad itself.
60
+ - With `href` it renders `Link`'s anchor, with every anchor prop passed through,
61
+ so a router app takes the click and the address stays real.
62
+ - Without `href`, a card that responds to a click gets `cursor-pointer` from its
63
+ `hover` and your `onClick`. Controls inside it should stop their own clicks
64
+ from reaching the card.
65
+
66
+ ## Props
67
+
68
+ <Controls of={CardStories.Surface} />
@@ -0,0 +1,109 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Card } from './Card'
3
+
4
+ const Body = ({ title = 'Item one', line = 'A line about the item.' }: { title?: string; line?: string }) => (
5
+ <div className="flex flex-col gap-1">
6
+ <span className="text-body-2-strong text-text-primary">{title}</span>
7
+ <span className="text-caption text-text-secondary">{line}</span>
8
+ </div>
9
+ )
10
+
11
+ const meta = {
12
+ title: 'Primitives/Card',
13
+ component: Card,
14
+ args: { fill: 'surface', className: 'w-[280px] p-3', children: <Body /> },
15
+ argTypes: {
16
+ fill: { control: 'inline-radio', options: ['surface', 'elevated', 'inset', 'none'] },
17
+ hover: { control: 'inline-radio', options: ['none', 'hairline', 'fill'] },
18
+ attention: { control: 'inline-radio', options: [undefined, 'accent', 'warning'] },
19
+ children: { control: false },
20
+ },
21
+ } satisfies Meta<typeof Card>
22
+
23
+ export default meta
24
+ type Story = StoryObj<typeof meta>
25
+
26
+ /** On the page: the surface fill and the default hairline. */
27
+ export const Surface: Story = {}
28
+
29
+ /** On something already filled — a column of cards: the elevated fill and the default hairline. */
30
+ export const Elevated: Story = {
31
+ args: { fill: 'elevated', className: 'p-2' },
32
+ render: (args) => (
33
+ <div className="flex w-[300px] flex-col gap-2 rounded-lg border border-border-default bg-bg-surface p-2">
34
+ <Card {...args} />
35
+ <Card {...args} children={<Body title="Item two" />} />
36
+ </div>
37
+ ),
38
+ }
39
+
40
+ /** Inside something filled: the inset fill and the subtle hairline. */
41
+ export const Inset: Story = {
42
+ args: { fill: 'inset' },
43
+ render: (args) => (
44
+ <div className="flex w-[320px] flex-col gap-2 rounded-lg bg-bg-surface p-4">
45
+ <span className="text-body-2 text-text-primary">Text, then a card inside it.</span>
46
+ <Card {...args} />
47
+ </div>
48
+ ),
49
+ }
50
+
51
+ /** A hairline alone: the subtle one, no fill. */
52
+ export const HairlineOnly: Story = { args: { fill: 'none' } }
53
+
54
+ /** The whole card is a link: point at it and its hairline goes one step stronger. */
55
+ export const AsALink: Story = { args: { href: '#' } }
56
+
57
+ /** Cards in a feed: each lights up when pointed at, with no hairline until then. One is selected, one asks to be looked at, one is urgent. */
58
+ export const InAFeed: Story = {
59
+ parameters: { controls: { disable: true } },
60
+ render: () => (
61
+ <div className="flex w-[320px] flex-col gap-2">
62
+ <Card hover="fill" quietUntilHover onClick={() => {}} className="p-3">
63
+ <Body />
64
+ </Card>
65
+ <Card hover="fill" quietUntilHover selected onClick={() => {}} className="p-3">
66
+ <Body title="Item two" line="Selected." />
67
+ </Card>
68
+ <Card hover="fill" quietUntilHover attention="accent" onClick={() => {}} className="p-3">
69
+ <Body title="Item three" line="Something new in it." />
70
+ </Card>
71
+ <Card hover="fill" quietUntilHover attention="warning" onClick={() => {}} className="p-3">
72
+ <Body title="Item four" line="Something urgent in it." />
73
+ </Card>
74
+ </div>
75
+ ),
76
+ }
77
+
78
+ /** Being changed in place: the selected fill and the accent hairline. */
79
+ export const Active: Story = { args: { active: true, hover: 'fill' } }
80
+
81
+ /** It stands for something that could not be read: the hairline is dashed. */
82
+ export const Unreadable: Story = {
83
+ args: { unreadable: true, children: <span className="text-body-2 text-text-secondary">This can’t be shown here.</span> },
84
+ }
85
+
86
+ /** The four fills, each where it belongs. */
87
+ export const AllFills: Story = {
88
+ parameters: { controls: { disable: true } },
89
+ render: () => (
90
+ <div className="flex flex-wrap items-start gap-4">
91
+ <Card className="w-[200px] p-3">
92
+ <Body title="surface" line="On the page." />
93
+ </Card>
94
+ <div className="rounded-lg bg-bg-surface p-2">
95
+ <Card fill="elevated" className="w-[200px] p-3">
96
+ <Body title="elevated" line="On a filled column." />
97
+ </Card>
98
+ </div>
99
+ <div className="rounded-lg bg-bg-surface p-2">
100
+ <Card fill="inset" className="w-[200px] p-3">
101
+ <Body title="inset" line="Inside something filled." />
102
+ </Card>
103
+ </div>
104
+ <Card fill="none" className="w-[200px] p-3">
105
+ <Body title="none" line="A hairline alone." />
106
+ </Card>
107
+ </div>
108
+ ),
109
+ }
@@ -0,0 +1,106 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Card page claims, pinned (Katerina's rulings, 14 September): every
4
+ * card has 8px corners and a hairline that follows its fill; a card with an
5
+ * href is a link whose hairline goes one step stronger on hover; a feed card
6
+ * lights up; the one you are on, or the one being changed, does not answer the
7
+ * pointer; attention recolours the hairline; a card that cannot be read is
8
+ * dashed; and the caller's className — placement, padding, layout — lands last.
9
+ */
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { Card } from './Card'
14
+
15
+ afterEach(cleanup)
16
+
17
+ const classesOf = (el: Element) => el.getAttribute('class')?.split(' ').filter(Boolean) ?? []
18
+ const card = (ui: React.ReactElement) => {
19
+ render(ui)
20
+ return screen.getByText('Item one').closest('div, a')!
21
+ }
22
+
23
+ describe('Card', () => {
24
+ it('has 8px corners and a hairline in every fill', () => {
25
+ for (const fill of ['surface', 'elevated', 'inset', 'none'] as const) {
26
+ const classes = classesOf(card(<Card fill={fill}>Item one</Card>))
27
+ expect(classes).toContain('rounded-lg')
28
+ expect(classes).toContain('border')
29
+ cleanup()
30
+ }
31
+ })
32
+
33
+ it('the hairline follows the fill: default on surface and elevated, subtle on inset and none', () => {
34
+ const expected = { surface: ['bg-bg-surface', 'border-border-default'], elevated: ['bg-bg-elevated', 'border-border-default'], inset: ['bg-bg-inset', 'border-border-subtle'], none: ['border-border-subtle'] } as const
35
+ for (const [fill, classes] of Object.entries(expected)) {
36
+ const actual = classesOf(card(<Card fill={fill as keyof typeof expected}>Item one</Card>))
37
+ for (const c of classes) expect(actual).toContain(c)
38
+ if (fill === 'none') expect(actual.some((c) => c.startsWith('bg-'))).toBe(false)
39
+ cleanup()
40
+ }
41
+ })
42
+
43
+ it('is still by default: nothing answers the pointer, and nothing eases', () => {
44
+ const classes = classesOf(card(<Card>Item one</Card>))
45
+ expect(classes.some((c) => c.startsWith('hover:'))).toBe(false)
46
+ expect(classes).not.toContain('transition-colors')
47
+ cleanup()
48
+ expect(classesOf(card(<Card href="#">Item one</Card>))).toContain('transition-colors')
49
+ })
50
+
51
+ it('with an href it is a link, its hairline one step stronger on hover, and a router takes the click', async () => {
52
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
53
+ render(
54
+ <Card href="/somewhere" onClick={onClick as never}>
55
+ Item one
56
+ </Card>,
57
+ )
58
+ const link = screen.getByRole('link', { name: 'Item one' })
59
+ expect(link.getAttribute('href')).toBe('/somewhere')
60
+ expect(classesOf(link)).toContain('hover:border-border-strong')
61
+ await userEvent.click(link)
62
+ expect(onClick).toHaveBeenCalledTimes(1)
63
+ cleanup()
64
+ expect(classesOf(card(<Card fill="inset" href="#">Item one</Card>))).toContain('hover:border-border-default')
65
+ })
66
+
67
+ it('a feed card lights up on hover, and can keep its hairline hidden until then', () => {
68
+ const classes = classesOf(card(<Card hover="fill" quietUntilHover onClick={() => {}}>Item one</Card>))
69
+ expect(classes).toContain('border-transparent')
70
+ expect(classes).toContain('hover:bg-bg-hover')
71
+ expect(classes).toContain('hover:border-border-default')
72
+ expect(classes).toContain('cursor-pointer')
73
+ })
74
+
75
+ it('the one you are on, and the one being changed, do not answer the pointer', () => {
76
+ const selected = classesOf(card(<Card hover="fill" quietUntilHover selected>Item one</Card>))
77
+ expect(selected).toContain('bg-bg-selected')
78
+ expect(selected).toContain('border-border-subtle')
79
+ expect(selected.some((c) => c.startsWith('hover:'))).toBe(false)
80
+ cleanup()
81
+ const active = classesOf(card(<Card hover="fill" active attention="accent">Item one</Card>))
82
+ expect(active).toContain('border-accent-primary')
83
+ expect(active).not.toContain('border-accent-muted')
84
+ expect(active.some((c) => c.startsWith('hover:'))).toBe(false)
85
+ })
86
+
87
+ it('attention recolours the hairline, at rest and on hover', () => {
88
+ const classes = classesOf(card(<Card hover="fill" quietUntilHover attention="warning">Item one</Card>))
89
+ expect(classes).toContain('border-warning-muted')
90
+ expect(classes).toContain('hover:border-warning-muted')
91
+ expect(classes).not.toContain('border-transparent')
92
+ })
93
+
94
+ it('a card that cannot be read is dashed, and keeps its fill', () => {
95
+ const classes = classesOf(card(<Card unreadable>Item one</Card>))
96
+ expect(classes).toContain('border-dashed')
97
+ expect(classes).toContain('bg-bg-surface')
98
+ })
99
+
100
+ it("the caller's className lands last", () => {
101
+ const classes = classesOf(card(<Card href="#" className="flex flex-col gap-2 p-3">Item one</Card>))
102
+ expect(classes).toContain('flex')
103
+ expect(classes).not.toContain('block')
104
+ expect(classes).toContain('p-3')
105
+ })
106
+ })
package/src/Card.tsx ADDED
@@ -0,0 +1,115 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+ import { Link } from './Link'
4
+
5
+ /**
6
+ * A box that stands for one thing — a conversation, a project, a file, an
7
+ * object from another app — drawn as its frame: 8px corners, a fill, a
8
+ * hairline. What goes inside, and the space around it, is the caller's.
9
+ *
10
+ * Both apps drew their cards by hand: 18 of them, sorted by Katerina from
11
+ * photographs on 14 September (UIG-27, docs/GATES.md §0). Her rulings:
12
+ *
13
+ * - **The fill is chosen by what the card sits on**: `surface` on the page,
14
+ * `elevated` on something already filled, `inset` inside a message, `none`
15
+ * for a hairline alone.
16
+ * - **The hairline follows the fill**: the default hairline on `surface` and
17
+ * `elevated`, the subtle one on `inset` and `none`. Every card has 8px
18
+ * corners.
19
+ * - **A card you can pick answers the pointer.** `hairline`: the hairline one
20
+ * step stronger — what a card that is a link does. `fill`: the card lights
21
+ * up and its hairline shows — what a card in a feed does, where the light
22
+ * says which one you are on.
23
+ * - **A card that cannot be read has a dashed hairline**, in both apps.
24
+ *
25
+ * No padding of its own: the cards measured 0 to 12px, and a card with none
26
+ * was one whose content pads itself.
27
+ */
28
+ export type CardFill = 'surface' | 'elevated' | 'inset' | 'none'
29
+ export type CardHover = 'none' | 'hairline' | 'fill'
30
+ export type CardAttention = 'accent' | 'warning'
31
+
32
+ const FILL_CLASSES: Record<CardFill, string> = {
33
+ surface: 'bg-bg-surface border-border-default',
34
+ elevated: 'bg-bg-elevated border-border-default',
35
+ inset: 'bg-bg-inset border-border-subtle',
36
+ none: 'border-border-subtle',
37
+ }
38
+
39
+ /** One step stronger than the fill's own hairline. */
40
+ const HAIRLINE_HOVER_CLASSES: Record<CardFill, string> = {
41
+ surface: 'hover:border-border-strong',
42
+ elevated: 'hover:border-border-strong',
43
+ inset: 'hover:border-border-default',
44
+ none: 'hover:border-border-default',
45
+ }
46
+
47
+ const ATTENTION_CLASSES: Record<CardAttention, string> = {
48
+ accent: 'border-accent-muted hover:border-accent-muted',
49
+ warning: 'border-warning-muted hover:border-warning-muted',
50
+ }
51
+
52
+ export interface CardProps extends ComponentPropsWithRef<'div'> {
53
+ /** What the card sits on. Default `surface`. */
54
+ fill?: CardFill
55
+ /** The whole card is a link: an anchor, and its hairline answers the pointer. Navigation stays the app's, as with `Link`. */
56
+ href?: string
57
+ /** How it answers the pointer. Default `hairline` with an `href`, `none` without. */
58
+ hover?: CardHover
59
+ /** No hairline until it is pointed at — a row in a feed that shows its edge only when you are on it. */
60
+ quietUntilHover?: boolean
61
+ /** The one you are on: the selected fill, a subtle hairline, and no hover. */
62
+ selected?: boolean
63
+ /** Being changed in place: the selected fill with the accent hairline, and no hover. */
64
+ active?: boolean
65
+ /** A hairline that asks to be looked at — something new (`accent`), or urgent (`warning`). */
66
+ attention?: CardAttention
67
+ /** It stands for something that could not be read: the hairline is dashed. */
68
+ unreadable?: boolean
69
+ children: ReactNode
70
+ }
71
+
72
+ export function Card({
73
+ fill = 'surface',
74
+ href,
75
+ hover = href ? 'hairline' : 'none',
76
+ quietUntilHover = false,
77
+ selected = false,
78
+ active = false,
79
+ attention,
80
+ unreadable = false,
81
+ className,
82
+ children,
83
+ ...props
84
+ }: CardProps) {
85
+ const still = selected || active
86
+ const classes = cn(
87
+ 'rounded-lg border',
88
+ // A card that never changes has nothing to ease between, and the apps' still cards carry no transition.
89
+ hover !== 'none' && 'transition-colors',
90
+ FILL_CLASSES[fill],
91
+ quietUntilHover && 'border-transparent',
92
+ !still && hover === 'hairline' && HAIRLINE_HOVER_CLASSES[fill],
93
+ !still && hover === 'fill' && 'hover:bg-bg-hover hover:border-border-default',
94
+ !still && hover !== 'none' && !href && props.onClick && 'cursor-pointer',
95
+ selected && 'bg-bg-selected border-border-subtle',
96
+ active && 'bg-bg-selected border-accent-primary',
97
+ !active && attention && ATTENTION_CLASSES[attention],
98
+ unreadable && 'border-dashed',
99
+ href && 'block',
100
+ className,
101
+ )
102
+ if (href !== undefined) {
103
+ const { ref, ...anchorProps } = props as ComponentPropsWithRef<'a'>
104
+ return (
105
+ <Link href={href} variant="plain" ref={ref} className={classes} {...anchorProps}>
106
+ {children}
107
+ </Link>
108
+ )
109
+ }
110
+ return (
111
+ <div className={classes} {...props}>
112
+ {children}
113
+ </div>
114
+ )
115
+ }
@@ -52,6 +52,20 @@ import { EmptyState } from '@estiva-app/ui'
52
52
  <EmptyState scope="section" message="No comments yet." />
53
53
  ```
54
54
 
55
+ **Put a section's empty state inside the box its rows live in, and give it no
56
+ padding.** Write the box once; inside it goes either the rows or the empty
57
+ state. The box's padding then places both, and the line starts where the first
58
+ row would. An empty state drawn *instead of* the box has to copy the box's
59
+ padding by hand, and is left behind the day the box changes.
60
+
61
+ <Canvas of={EmptyStateStories.InsideTheRowsBox} />
62
+
63
+ ```tsx
64
+ <div className="flex flex-col gap-2 p-4">
65
+ {items.length ? items.map((item) => <Row key={item.id} {...item} />) : <EmptyState scope="section" message="No items yet." />}
66
+ </div>
67
+ ```
68
+
55
69
  The message is the caller's — a shared component has no words of its own
56
70
  for what is missing. A hand-written "Nothing here" paragraph or span in a
57
71
  section is the `section` manner, and becomes it.
@@ -1,22 +1,43 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite'
1
+ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconLock } from '@tabler/icons-react'
3
3
  import { EmptyState } from './EmptyState'
4
4
 
5
+ // A box with a hairline, so where the state sits inside its room can be seen: a page's in the middle both ways, a section's at the top left.
6
+ const inBox: Decorator = (Story) => <div className="flex h-[280px] w-[480px] flex-col rounded-lg border border-border-default p-4">{Story()}</div>
7
+
5
8
  const meta = {
6
9
  title: 'Feedback/EmptyState',
7
10
  component: EmptyState,
8
11
  args: { message: 'Nothing here yet.' },
9
12
  argTypes: { icon: { control: false } },
10
- // A box with a hairline, so where the state sits inside its room can be seen: a page's in the middle both ways, a section's at the top left.
11
- decorators: [(Story) => <div className="flex h-[280px] w-[480px] flex-col rounded-lg border border-border-default p-4">{Story()}</div>],
12
13
  } satisfies Meta<typeof EmptyState>
13
14
 
14
15
  export default meta
15
16
  type Story = StoryObj<typeof meta>
16
17
 
17
18
  /** The `page` manner: the default icon over the caller's words, in the middle of its box both ways. */
18
- export const Page: Story = {}
19
+ export const Page: Story = { decorators: [inBox] }
19
20
  /** The `section` manner: the words alone, left-aligned — one line in a page that has other things on it. */
20
- export const Section: Story = { args: { scope: 'section' } }
21
- export const LongerMessage: Story = { args: { message: 'No items yet. Add one from any list.' } }
22
- export const CustomIcon: Story = { args: { icon: <IconLock size={16} stroke={1.5} />, message: 'Nothing you can read here yet.' } }
21
+ export const Section: Story = { args: { scope: 'section' }, decorators: [inBox] }
22
+ export const LongerMessage: Story = { args: { message: 'No items yet. Add one from any list.' }, decorators: [inBox] }
23
+ export const CustomIcon: Story = { args: { icon: <IconLock size={16} stroke={1.5} />, message: 'Nothing you can read here yet.' }, decorators: [inBox] }
24
+
25
+ /** One box, written once, holding the rows or the empty state: its padding places both, so the line starts where the first row does. */
26
+ export const InsideTheRowsBox: Story = {
27
+ args: { scope: 'section' },
28
+ parameters: { controls: { disable: true } },
29
+ render: (args) => {
30
+ const box = 'flex h-[160px] w-[260px] flex-col gap-2 rounded-lg border border-border-default p-4'
31
+ return (
32
+ <div className="flex gap-4">
33
+ <div className={box}>
34
+ <p className="text-body-2 text-text-primary">Item one</p>
35
+ <p className="text-body-2 text-text-primary">Item two</p>
36
+ </div>
37
+ <div className={box}>
38
+ <EmptyState {...args} />
39
+ </div>
40
+ </div>
41
+ )
42
+ },
43
+ }
@@ -31,6 +31,14 @@ describe('EmptyState', () => {
31
31
  expect(line.className).not.toContain('text-center')
32
32
  })
33
33
 
34
+ it('a section carries no space of its own, so the box it sits in decides where it starts', () => {
35
+ // Katerina, 2026-09-14: the box is written once and holds the rows or the empty
36
+ // state; the empty state is never padded. So it may carry no padding or margin.
37
+ render(<EmptyState scope="section" message="Nothing here yet." />)
38
+ const classes = screen.getByText('Nothing here yet.').className.split(' ')
39
+ expect(classes.filter((c) => /^-?[pm][xytrbl]?-/.test(c))).toEqual([])
40
+ })
41
+
34
42
  it('keeps the caller icon for a page', () => {
35
43
  const { container } = render(<EmptyState icon={<i data-testid="own" />} message="Nothing here yet." />)
36
44
  expect(container.querySelector('[data-testid="own"]')).not.toBeNull()
@@ -0,0 +1,56 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as InlineChipStories from './InlineChip.stories'
3
+
4
+ <Meta of={InlineChipStories} />
5
+
6
+ # InlineChip
7
+
8
+ A word in a sentence that stands for something — a person, a place, a thing —
9
+ drawn as a small tinted box exactly one line high, with its words level with
10
+ the text around it.
11
+
12
+ <Canvas of={InlineChipStories.InRunningText} />
13
+
14
+ ## When
15
+
16
+ - **`neutral`** — anything that is not a person.
17
+ - **`person`** — a person, named in the text.
18
+ - **`urgent`** — a person, called urgently.
19
+ - **`quiet`** — a reference nobody could resolve: smaller, mono and muted, so it
20
+ stays visible without reading as an error.
21
+ - `icon` — a 16px icon, or a 14px one it centres, before the label.
22
+ - `href` — the chip leads somewhere. Without it the chip is a label.
23
+
24
+ ## When not
25
+
26
+ - A status, a count or a category standing on its own → **Chip** — a 20px
27
+ pill with a verdict colour, not a word in a sentence.
28
+ - A value picked into a field, with a ✕ to remove it → **InputChip**, inside
29
+ **ChipInput**.
30
+ - A link written as words, with no box → **Link**.
31
+
32
+ ## How
33
+
34
+ ```tsx
35
+ import { InlineChip } from '@estiva-app/ui'
36
+
37
+ <p className="text-body-2">
38
+ Ask <InlineChip tone="person" href={personUrl} onClick={openInApp}>@Ana Duarte</InlineChip> to look.
39
+ </p>
40
+ ```
41
+
42
+ - It belongs inside running text at the body size. `h-[1.4em]` is one line of
43
+ that text, and `align-top` pins the chip to the line, so a line with a chip
44
+ is the same height as a line without one.
45
+ - It renders a plain anchor when it has an `href`. A router app passes
46
+ `onClick`, prevents the default and navigates; the `href` stays a real
47
+ address for a modified click or a new tab. Inside something that opens on
48
+ click, stop the event there so the chip's click is not also the container's.
49
+ - A long label: pass `className="max-w-[24ch]"` and wrap the text in
50
+ `<span className="truncate">`.
51
+ - A rich-text editor that renders chips from strings cannot use a component.
52
+ `inlineChipClassName(tone)` gives it the same classes.
53
+
54
+ ## Props
55
+
56
+ <Controls of={InlineChipStories.Neutral} />
@@ -0,0 +1,59 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { InlineChip } from './InlineChip'
4
+
5
+ const meta = {
6
+ title: 'Components/InlineChip',
7
+ component: InlineChip,
8
+ args: { tone: 'neutral', children: 'Label' },
9
+ argTypes: {
10
+ tone: { control: 'inline-radio', options: ['neutral', 'person', 'urgent', 'quiet'] },
11
+ icon: { control: false },
12
+ href: { control: 'text' },
13
+ },
14
+ // A chip is a word in a sentence, so every canvas sets it in one, at the body text's size.
15
+ render: (args) => (
16
+ <p className="max-w-[480px] text-body-2 text-text-primary">
17
+ The sentence runs up to <InlineChip {...args} /> and carries on after it, on the same line.
18
+ </p>
19
+ ),
20
+ } satisfies Meta<typeof InlineChip>
21
+
22
+ export default meta
23
+ type Story = StoryObj<typeof meta>
24
+
25
+ /** Anything that is not a person. */
26
+ export const Neutral: Story = {}
27
+
28
+ // axe color-contrast is off on the stories below that draw the person or quiet
29
+ // tone, until PLAN.md stage 0.10 is ruled. Computed 2026-09-14: the person
30
+ // chip reads 2.70:1 in ship (the accent on its wash — the brand Chip's number)
31
+ // and the quiet chip 3.25:1 in signal (muted on the active fill); AA is 4.5:1.
32
+ const contrastDeferred = { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } }
33
+
34
+ export const Person: Story = { args: { tone: 'person', children: '@Ana Duarte' }, parameters: contrastDeferred }
35
+
36
+ /** A person, called urgently. */
37
+ export const Urgent: Story = { args: { tone: 'urgent', children: '@Ana Duarte' } }
38
+
39
+ /** A reference nobody could resolve: smaller, mono, muted — still there, never an error. */
40
+ export const Quiet: Story = { args: { tone: 'quiet', children: '3f9a…c21e' }, parameters: contrastDeferred }
41
+
42
+ /** The icon sits first, in a 16px box. */
43
+ export const WithIcon: Story = { args: { icon: <IconSquareRounded size={16} stroke={1.5} />, children: 'Item one' } }
44
+
45
+ /** With `href` the chip is a link; without one it is a label. */
46
+ export const AsLink: Story = { args: { href: '#', children: 'Item one' } }
47
+
48
+ /** Every tone in one paragraph that wraps: each line stays one line high, and the chips' words sit level with the text around them. */
49
+ export const InRunningText: Story = {
50
+ parameters: { controls: { disable: true }, ...contrastDeferred },
51
+ render: () => (
52
+ <p className="max-w-[360px] text-body-2 text-text-primary">
53
+ A paragraph can hold <InlineChip>Item one</InlineChip> beside{' '}
54
+ <InlineChip icon={<IconSquareRounded size={16} stroke={1.5} />}>Item two</InlineChip>, ask{' '}
55
+ <InlineChip tone="person">@Ana Duarte</InlineChip> or <InlineChip tone="urgent">@Ana Duarte</InlineChip> to look, and
56
+ point at <InlineChip tone="quiet">3f9a…c21e</InlineChip> when nothing more is known — and every line keeps its height.
57
+ </p>
58
+ ),
59
+ }