@estiva-app/ui 0.12.10 → 0.13.1

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 (43) 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 +54 -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/ProgressBar.d.ts +32 -0
  10. package/dist/ProgressBar.d.ts.map +1 -0
  11. package/dist/index.d.ts +5 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +704 -355
  14. package/dist/index.js.map +4 -4
  15. package/package.json +1 -1
  16. package/src/AttachmentCard.mdx +62 -0
  17. package/src/AttachmentCard.stories.tsx +92 -0
  18. package/src/AttachmentCard.test.tsx +172 -0
  19. package/src/AttachmentCard.tsx +324 -0
  20. package/src/Card.mdx +74 -0
  21. package/src/Card.stories.tsx +112 -0
  22. package/src/Card.test.tsx +147 -0
  23. package/src/Card.tsx +133 -0
  24. package/src/EmptyState.mdx +14 -0
  25. package/src/EmptyState.stories.tsx +28 -7
  26. package/src/EmptyState.test.tsx +8 -0
  27. package/src/InlineChip.mdx +56 -0
  28. package/src/InlineChip.stories.tsx +59 -0
  29. package/src/InlineChip.test.tsx +81 -0
  30. package/src/InlineChip.tsx +90 -0
  31. package/src/Link.mdx +59 -0
  32. package/src/Link.stories.tsx +102 -0
  33. package/src/Link.test.tsx +100 -0
  34. package/src/Link.tsx +58 -0
  35. package/src/Person.stories.tsx +1 -1
  36. package/src/PersonTrigger.stories.tsx +1 -1
  37. package/src/ProgressBar.mdx +47 -0
  38. package/src/ProgressBar.stories.tsx +48 -0
  39. package/src/ProgressBar.test.tsx +62 -0
  40. package/src/ProgressBar.tsx +56 -0
  41. package/src/ReactionPicker.stories.tsx +1 -1
  42. package/src/index.ts +12 -0
  43. package/stories/Choosing.mdx +5 -0
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the InlineChip page claims, pinned: without `href` it is a label, with
4
+ * one it is a link a router can take; every tone keeps the shape and its own
5
+ * size and colour through `cn()`; the icon sits in its 16px box; and the class
6
+ * function a string-only renderer uses gives the same classes as the component.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import userEvent from '@testing-library/user-event'
11
+ import { INLINE_CHIP_CLASSES, INLINE_CHIP_TONE_CLASSES, InlineChip, inlineChipClassName } from './InlineChip'
12
+
13
+ afterEach(cleanup)
14
+
15
+ const classesOf = (el: Element) => el.getAttribute('class')?.split(' ').filter(Boolean) ?? []
16
+
17
+ describe('InlineChip', () => {
18
+ it('without href it is a label, not a link', () => {
19
+ render(<InlineChip>Label</InlineChip>)
20
+ const chip = screen.getByText('Label')
21
+ expect(chip.tagName).toBe('SPAN')
22
+ expect(screen.queryByRole('link')).toBeNull()
23
+ })
24
+
25
+ it('with href it is a link, and a router app takes the click with the href intact', async () => {
26
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
27
+ render(
28
+ <InlineChip href="/somewhere" onClick={onClick}>
29
+ Label
30
+ </InlineChip>,
31
+ )
32
+ const link = screen.getByRole('link', { name: 'Label' })
33
+ await userEvent.click(link)
34
+ expect(onClick).toHaveBeenCalledTimes(1)
35
+ expect(link.getAttribute('href')).toBe('/somewhere')
36
+ })
37
+
38
+ it('is one line high and pinned to the top of the line, in every tone', () => {
39
+ for (const tone of Object.keys(INLINE_CHIP_TONE_CLASSES) as (keyof typeof INLINE_CHIP_TONE_CLASSES)[]) {
40
+ const classes = inlineChipClassName(tone).split(' ')
41
+ expect(classes).toContain('align-top')
42
+ expect(classes).toContain('mx-0.5')
43
+ // One height only: 1.4em of the body size, or the same 19.6px written out where the text is smaller.
44
+ const heights = classes.filter((c) => c.startsWith('h-'))
45
+ expect(heights).toEqual([tone === 'quiet' ? 'h-[19.6px]' : 'h-[1.4em]'])
46
+ }
47
+ })
48
+
49
+ it('each tone keeps its size and its colour through cn()', () => {
50
+ const neutral = inlineChipClassName('neutral').split(' ')
51
+ expect(neutral).toContain('text-body-2')
52
+ expect(neutral).toContain('text-text-primary')
53
+ const person = inlineChipClassName('person').split(' ')
54
+ expect(person).toContain('text-body-2')
55
+ expect(person).toContain('text-accent-primary')
56
+ const urgent = inlineChipClassName('urgent').split(' ')
57
+ expect(urgent).toContain('text-warning-default')
58
+ // quiet is smaller: its caption size replaces the body size, and its colour survives beside it
59
+ const quiet = inlineChipClassName('quiet').split(' ')
60
+ expect(quiet).toContain('text-caption')
61
+ expect(quiet).not.toContain('text-body-2')
62
+ expect(quiet).toContain('text-text-muted')
63
+ })
64
+
65
+ it('the component and the class function give the same classes', () => {
66
+ render(
67
+ <InlineChip tone="person" className="max-w-[24ch]">
68
+ Label
69
+ </InlineChip>,
70
+ )
71
+ expect(classesOf(screen.getByText('Label'))).toEqual(inlineChipClassName('person', 'max-w-[24ch]').split(' '))
72
+ expect(INLINE_CHIP_CLASSES.split(' ').every((c) => classesOf(screen.getByText('Label')).includes(c))).toBe(true)
73
+ })
74
+
75
+ it('draws the icon first, in its 16px box', () => {
76
+ render(<InlineChip icon={<svg data-testid="icon" />}>Label</InlineChip>)
77
+ const box = screen.getByTestId('icon').parentElement!
78
+ expect(classesOf(box)).toEqual(expect.arrayContaining(['size-4', 'shrink-0']))
79
+ expect(box.parentElement!.firstElementChild).toBe(box)
80
+ })
81
+ })
@@ -0,0 +1,90 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * A word in a sentence that stands for something: a person, a place, a thing
6
+ * written into running text. One line high, level with the words around it.
7
+ *
8
+ * The shape is Peek's inline chip (D67, Peek PR #206, 2026-09-13), class for
9
+ * class, moved into the package so both apps draw one (UIG-27). Three things
10
+ * changed on the way, all ruled by Katerina:
11
+ *
12
+ * - the person tone is `person`, not `mention` — every chip here is a
13
+ * mention of something;
14
+ * - the alignment is a class (`align-top h-[1.4em]`), not a `style` object,
15
+ * so it is part of the class list like every other size in the package;
16
+ * - a chip that leads somewhere takes `href` and `onClick`, the way `NavItem`
17
+ * does: the package draws an anchor, the app's router takes the click.
18
+ *
19
+ * **Why `align-top` and `1.4em`.** `1.4em` is the body text's line height,
20
+ * so the chip is exactly one line tall; `vertical-align: top` pins it to the
21
+ * line box, which keeps the line at 19.6px and the chip's words level with
22
+ * the sentence's, with or without an icon. Measured in Peek on 2026-09-13:
23
+ * `text-bottom` made the line 21.2px and lifted the chip's words 1.6px;
24
+ * `baseline` lifted them 3.8px once an icon came first.
25
+ */
26
+
27
+ /** The shape every tone shares. Exported for a rich-text editor that renders chips from strings. */
28
+ export const INLINE_CHIP_CLASSES =
29
+ 'inline-flex h-[1.4em] items-center gap-1 rounded-sm px-1 mx-0.5 align-top text-body-2 font-normal select-none'
30
+
31
+ /** What a chip is made of, by what it stands for. */
32
+ export const INLINE_CHIP_TONE_CLASSES = {
33
+ /** A thing or a place — anything that is not a person. */
34
+ neutral: 'bg-bg-active text-text-primary',
35
+ /** A person. */
36
+ person: 'bg-accent-muted text-accent-primary',
37
+ /** A person, called urgently. */
38
+ urgent: 'bg-warning-muted text-warning-default',
39
+ /**
40
+ * A reference nobody could resolve — still a mention, just an anonymous one.
41
+ * Its height is the body line's 19.6px written out, because `1.4em` here is
42
+ * the caption's em: 16.8px, with the letters 1.8px above the sentence's
43
+ * baseline. At 19.6px they sit 0.4px off it (measured 2026-09-14; Peek's
44
+ * copy of this chip still has the 16.8px box).
45
+ */
46
+ quiet: 'h-[19.6px] bg-bg-active text-text-muted font-mono text-caption',
47
+ } as const
48
+
49
+ export type InlineChipTone = keyof typeof INLINE_CHIP_TONE_CLASSES
50
+
51
+ /** The chip's classes for a tone, with anything the caller adds. For renderers that cannot use the component. */
52
+ export function inlineChipClassName(tone: InlineChipTone, className?: string) {
53
+ return cn(INLINE_CHIP_CLASSES, INLINE_CHIP_TONE_CLASSES[tone], className)
54
+ }
55
+
56
+ export interface InlineChipProps extends Omit<ComponentPropsWithRef<'a'>, 'children'> {
57
+ /** What the chip stands for. Default `neutral`. */
58
+ tone?: InlineChipTone
59
+ /** Drawn before the label in a 16px box: a 16px icon, or a 14px one it centres. */
60
+ icon?: ReactNode
61
+ /**
62
+ * Where the chip leads. With it the chip is an anchor; without it, a span.
63
+ * A router app passes `onClick` too, and navigates there itself.
64
+ */
65
+ href?: string
66
+ children: ReactNode
67
+ }
68
+
69
+ export function InlineChip({ tone = 'neutral', icon, href, className, children, ...props }: InlineChipProps) {
70
+ const body = (
71
+ <>
72
+ {icon && <span className="flex size-4 shrink-0 items-center justify-center text-text-secondary">{icon}</span>}
73
+ {children}
74
+ </>
75
+ )
76
+ const classes = inlineChipClassName(tone, className)
77
+ // Without somewhere to go it is a label, and a label is not a link.
78
+ if (href === undefined) {
79
+ return (
80
+ <span className={classes} {...(props as ComponentPropsWithRef<'span'>)}>
81
+ {body}
82
+ </span>
83
+ )
84
+ }
85
+ return (
86
+ <a href={href} className={classes} {...props}>
87
+ {body}
88
+ </a>
89
+ )
90
+ }
package/src/Link.mdx ADDED
@@ -0,0 +1,59 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LinkStories from './Link.stories'
3
+
4
+ <Meta of={LinkStories} />
5
+
6
+ # Link
7
+
8
+ A link, in one of four looks. It is a real anchor with a real address, and
9
+ the app decides how it navigates.
10
+
11
+ <Canvas of={LinkStories.AllVariants} />
12
+
13
+ ## When
14
+
15
+ - **`text`** — a link written inside text: the info colour, always
16
+ underlined, dimming on hover.
17
+ - **`quiet`** — a title or a time that is also a link. It takes the colour and
18
+ size of the text it sits in, and underlines on hover.
19
+ - **`underlined`** — a short "open it there" beside a note. It takes the
20
+ note's colour, is always underlined, and brightens on hover.
21
+ - **`plain`** — a box or a row that is a link as a whole. No look of its own:
22
+ what it wraps draws itself.
23
+ - `external` — another site or app: it opens in a new tab, with
24
+ `noopener noreferrer`.
25
+
26
+ ## When not
27
+
28
+ - An action — something happens, nothing is navigated to → **Button**.
29
+ - A row of a sidebar → **NavItem**. A tile of a rail → **RailItem**.
30
+ - The trail back up → **Breadcrumb**.
31
+ - A word standing for a person or a thing, drawn in a small box → **InlineChip**
32
+ with `href`.
33
+
34
+ ## How
35
+
36
+ ```tsx
37
+ import { Link } from '@estiva-app/ui'
38
+
39
+ <p className="text-body-2">
40
+ Read <Link href="https://example.com" external>the guide</Link> first.
41
+ </p>
42
+
43
+ <h3 className="text-body-2 font-semibold text-text-primary">
44
+ <Link href={itemUrl} variant="quiet" onClick={navigate}>Item one</Link>
45
+ </h3>
46
+ ```
47
+
48
+ - It renders a plain anchor and passes every anchor prop through. A router app
49
+ passes `onClick`, prevents the default and navigates; the `href` stays a real
50
+ address, so a modified click or a new tab still works.
51
+ - `quiet` and `underlined` set no size and no colour at rest: put them inside
52
+ the text whose look they should take.
53
+ - `plain` wraps a block: give the Link `className="block"` and let the child
54
+ draw the border, the fill and the hover.
55
+ - If your router also exports a `Link`, rename one of them on import.
56
+
57
+ ## Props
58
+
59
+ <Controls of={LinkStories.Text} />
@@ -0,0 +1,102 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Link } from './Link'
3
+
4
+ const meta = {
5
+ title: 'Navigation/Link',
6
+ component: Link,
7
+ args: { href: '#', variant: 'text', external: false, children: 'a link' },
8
+ argTypes: {
9
+ variant: { control: 'inline-radio', options: ['text', 'quiet', 'underlined', 'plain'] },
10
+ },
11
+ } satisfies Meta<typeof Link>
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof meta>
15
+
16
+ /** Inside written text: the info colour, always underlined. Hover dims it. */
17
+ export const Text: Story = {
18
+ render: (args) => (
19
+ <p className="max-w-[480px] text-body-2 text-text-primary">
20
+ A sentence can hold <Link {...args} /> in the middle of it, and the words around it read on.
21
+ </p>
22
+ ),
23
+ }
24
+
25
+ /** A title, or a small time, that is also a link: it keeps its text's colour and size, and underlines on hover. */
26
+ export const Quiet: Story = {
27
+ args: { variant: 'quiet', children: 'Item one' },
28
+ render: (args) => (
29
+ <div className="flex flex-col gap-2">
30
+ <p className="text-body-2 font-semibold text-text-primary">
31
+ <Link {...args} />
32
+ </p>
33
+ <p className="text-caption text-text-secondary">
34
+ <Link {...args}>2:14 PM</Link>
35
+ </p>
36
+ </div>
37
+ ),
38
+ }
39
+
40
+ /** Beside a note, a short way to open the thing somewhere else: the note's colour, always underlined. Hover brightens it. */
41
+ export const Underlined: Story = {
42
+ args: { variant: 'underlined', external: true, children: 'Open it there ↗' },
43
+ render: (args) => (
44
+ <p className="max-w-[480px] text-caption text-text-secondary">
45
+ This can’t be shown here. <Link {...args} />
46
+ </p>
47
+ ),
48
+ }
49
+
50
+ /** No look of its own: what it wraps draws itself — a box, a row. */
51
+ export const Plain: Story = {
52
+ args: { variant: 'plain', children: undefined },
53
+ render: (args) => (
54
+ <Link {...args} className="block w-[280px]">
55
+ <div className="rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary transition-colors hover:border-border-strong">
56
+ Item one
57
+ </div>
58
+ </Link>
59
+ ),
60
+ }
61
+
62
+ /** Another site or app: a new tab, and `noopener noreferrer`. It looks the same; the difference is where it opens. */
63
+ export const External: Story = {
64
+ args: { external: true, children: 'a page elsewhere' },
65
+ render: (args) => (
66
+ <p className="max-w-[480px] text-body-2 text-text-primary">
67
+ Read <Link {...args} /> in a new tab.
68
+ </p>
69
+ ),
70
+ }
71
+
72
+ /** The four looks, each where it belongs. */
73
+ export const AllVariants: Story = {
74
+ parameters: { controls: { disable: true } },
75
+ render: () => (
76
+ <div className="flex max-w-[480px] flex-col gap-4">
77
+ <p className="text-body-2 text-text-primary">
78
+ <span className="text-text-secondary">text — </span>a sentence with <Link href="#">a link</Link> in it.
79
+ </p>
80
+ <p className="text-body-2 font-semibold text-text-primary">
81
+ <span className="font-normal text-text-secondary">quiet — </span>
82
+ <Link href="#" variant="quiet">
83
+ Item one
84
+ </Link>
85
+ </p>
86
+ <p className="text-caption text-text-secondary">
87
+ underlined — This can’t be shown here.{' '}
88
+ <Link href="#" variant="underlined" external>
89
+ Open it there ↗
90
+ </Link>
91
+ </p>
92
+ <div className="flex flex-col gap-1">
93
+ <span className="text-body-2 text-text-secondary">plain —</span>
94
+ <Link href="#" variant="plain" className="block w-[280px]">
95
+ <div className="rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary transition-colors hover:border-border-strong">
96
+ Item one
97
+ </div>
98
+ </Link>
99
+ </div>
100
+ </div>
101
+ ),
102
+ }
@@ -0,0 +1,100 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Link page claims, pinned: it is a real anchor with a real address;
4
+ * `external` opens a new tab that cannot reach back; a router app takes the
5
+ * click through `onClick` and the address survives it; each look is its own
6
+ * class list and `plain` carries none; a ref reaches the anchor.
7
+ */
8
+ import { createRef } from 'react'
9
+ import { afterEach, describe, expect, it, vi } from 'vitest'
10
+ import { cleanup, render, screen } from '@testing-library/react'
11
+ import userEvent from '@testing-library/user-event'
12
+ import { Link } from './Link'
13
+
14
+ afterEach(cleanup)
15
+
16
+ const classesOf = (el: Element) => el.getAttribute('class')?.split(' ').filter(Boolean) ?? []
17
+
18
+ describe('Link', () => {
19
+ it('is an anchor with its address', () => {
20
+ render(<Link href="/documents">Documents</Link>)
21
+ const link = screen.getByRole('link', { name: 'Documents' })
22
+ expect(link.tagName).toBe('A')
23
+ expect(link.getAttribute('href')).toBe('/documents')
24
+ })
25
+
26
+ it('stays in the same tab unless it is external', () => {
27
+ render(<Link href="/documents">Documents</Link>)
28
+ const link = screen.getByRole('link', { name: 'Documents' })
29
+ expect(link.getAttribute('target')).toBeNull()
30
+ expect(link.getAttribute('rel')).toBeNull()
31
+ })
32
+
33
+ it('external opens a new tab that cannot reach back into this page', () => {
34
+ render(
35
+ <Link href="https://example.com" external>
36
+ Example
37
+ </Link>,
38
+ )
39
+ const link = screen.getByRole('link', { name: 'Example' })
40
+ expect(link.getAttribute('target')).toBe('_blank')
41
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer')
42
+ })
43
+
44
+ it('a router app takes the click through onClick, and the href stays a real address', async () => {
45
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
46
+ render(
47
+ <Link href="/documents" onClick={onClick}>
48
+ Documents
49
+ </Link>,
50
+ )
51
+ await userEvent.click(screen.getByRole('link', { name: 'Documents' }))
52
+ expect(onClick).toHaveBeenCalledTimes(1)
53
+ expect(screen.getByRole('link', { name: 'Documents' }).getAttribute('href')).toBe('/documents')
54
+ })
55
+
56
+ it('text is the info colour, always underlined, and is the default', () => {
57
+ render(<Link href="#">Label</Link>)
58
+ const classes = classesOf(screen.getByRole('link'))
59
+ expect(classes).toContain('text-info-default')
60
+ expect(classes).toContain('underline')
61
+ expect(classes).toContain('underline-offset-2')
62
+ })
63
+
64
+ it('quiet sets no colour and no size of its own, and underlines on hover', () => {
65
+ render(
66
+ <Link href="#" variant="quiet">
67
+ Label
68
+ </Link>,
69
+ )
70
+ expect(classesOf(screen.getByRole('link'))).toEqual(['hover:underline'])
71
+ })
72
+
73
+ it('underlined sets no colour at rest, is always underlined, and brightens on hover', () => {
74
+ render(
75
+ <Link href="#" variant="underlined">
76
+ Label
77
+ </Link>,
78
+ )
79
+ expect(classesOf(screen.getByRole('link'))).toEqual(['underline', 'underline-offset-2', 'hover:text-text-primary'])
80
+ })
81
+
82
+ it('plain carries no class at all, so what it wraps draws itself', () => {
83
+ render(
84
+ <Link href="#" variant="plain">
85
+ Label
86
+ </Link>,
87
+ )
88
+ expect(classesOf(screen.getByRole('link'))).toEqual([])
89
+ })
90
+
91
+ it('a ref reaches the anchor', () => {
92
+ const ref = createRef<HTMLAnchorElement>()
93
+ render(
94
+ <Link href="#" ref={ref}>
95
+ Label
96
+ </Link>,
97
+ )
98
+ expect(ref.current?.tagName).toBe('A')
99
+ })
100
+ })
package/src/Link.tsx ADDED
@@ -0,0 +1,58 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * A link: an anchor with one of four looks, and nothing else of its own.
6
+ *
7
+ * It exists because both apps hand-wrote every link they have — 18 raw `<a>`
8
+ * on 13 September 2026, in three jobs (UIG-27). The looks are the ones those
9
+ * links already had, reduced by Katerina's ruling:
10
+ *
11
+ * - `text` — a link inside written text: the info colour, always underlined,
12
+ * dimming on hover. The look a link in a body of text already had.
13
+ * - `quiet` — a title or a timestamp that is also a link. It takes the colour
14
+ * and size of the text it sits in and underlines on hover. Two looks that
15
+ * differed only in their text became this one (her ruling, 13 September).
16
+ * - `underlined` — a short "open it elsewhere" beside a note. It takes the
17
+ * note's colour, is always underlined, and brightens on hover. A dotted
18
+ * and a solid version became this one, solid (her ruling, 13 September).
19
+ * - `plain` — no look at all: a card or a row that is a link draws itself.
20
+ *
21
+ * **Navigation is the app's.** The package cannot know a router, so like
22
+ * `NavItem` it renders a real anchor and passes every anchor prop through: a
23
+ * router app hands in `onClick`, prevents the default and navigates, and the
24
+ * `href` stays a real address for a modified click or a new tab.
25
+ */
26
+ export type LinkVariant = 'text' | 'quiet' | 'underlined' | 'plain'
27
+
28
+ const VARIANT_CLASSES: Record<LinkVariant, string> = {
29
+ text: 'text-info-default underline underline-offset-2 hover:opacity-80',
30
+ quiet: 'hover:underline',
31
+ underlined: 'underline underline-offset-2 hover:text-text-primary',
32
+ plain: '',
33
+ }
34
+
35
+ export interface LinkProps extends ComponentPropsWithRef<'a'> {
36
+ href: string
37
+ /** Default `text`. */
38
+ variant?: LinkVariant
39
+ /**
40
+ * Another site, or another app: opens in a new tab, with `noopener
41
+ * noreferrer` so the page it opens cannot reach back into this one.
42
+ */
43
+ external?: boolean
44
+ children: ReactNode
45
+ }
46
+
47
+ export function Link({ href, variant = 'text', external = false, className, children, ...props }: LinkProps) {
48
+ return (
49
+ <a
50
+ href={href}
51
+ {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
52
+ className={cn(VARIANT_CLASSES[variant], className)}
53
+ {...props}
54
+ >
55
+ {children}
56
+ </a>
57
+ )
58
+ }
@@ -3,7 +3,7 @@ import { Person } from './Person'
3
3
 
4
4
  /** A face beside a name — never a key. */
5
5
  const meta = {
6
- title: 'Primitives/Person',
6
+ title: 'Components/Person',
7
7
  component: Person,
8
8
  args: { name: 'Ana Duarte', size: 20 },
9
9
  } satisfies Meta<typeof Person>
@@ -3,7 +3,7 @@ import { PersonTrigger } from './PersonTrigger'
3
3
 
4
4
  /** The person, as the button that opens the account menu. The menu itself stays in the app. */
5
5
  const meta = {
6
- title: 'Primitives/PersonTrigger',
6
+ title: 'Components/PersonTrigger',
7
7
  component: PersonTrigger,
8
8
  args: { name: 'Ana Duarte', open: false, compact: false },
9
9
  } satisfies Meta<typeof PersonTrigger>
@@ -0,0 +1,47 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ProgressBarStories from './ProgressBar.stories'
3
+
4
+ <Meta of={ProgressBarStories} />
5
+
6
+ # ProgressBar
7
+
8
+ How much of something is done: a thin rounded track with a success-coloured
9
+ fill.
10
+
11
+ <Canvas of={ProgressBarStories.WithWords} />
12
+
13
+ ## When
14
+
15
+ - **`default`** — 6px, the success colour: a bar someone reads, with the words
16
+ printed beside it.
17
+ - **`quiet`** — 4px, the muted success colour: a glance on the same line as a
18
+ count.
19
+
20
+ <Canvas of={ProgressBarStories.Quiet} />
21
+
22
+ ## When not
23
+
24
+ - Something still loading, with no amount to show → **Skeleton**.
25
+ - A state with a verdict rather than an amount → **Chip**.
26
+
27
+ ## How
28
+
29
+ ```tsx
30
+ import { ProgressBar } from '@estiva-app/ui'
31
+
32
+ <ProgressBar value={done} max={total} label="Items done" />
33
+ <span className="text-caption text-text-muted">{done} of {total} done</span>
34
+ ```
35
+
36
+ - `label` is required: it is the bar's accessible name. A screen reader hears
37
+ the name and the share as a percentage; print the counts beside the bar for
38
+ everyone else.
39
+ - A value above `max` fills the bar and no further. `max={0}` is an empty bar.
40
+ - It is built on Base UI's `Progress`, which owns the `progressbar` role and
41
+ its numbers. Do not nest it inside a button: the bar's name would become part
42
+ of the button's.
43
+ - `className` is for placement: a width, a margin, `flex-1` in a row.
44
+
45
+ ## Props
46
+
47
+ <Controls of={ProgressBarStories.Default} />
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { ProgressBar } from './ProgressBar'
3
+
4
+ const meta = {
5
+ title: 'Feedback/ProgressBar',
6
+ component: ProgressBar,
7
+ args: { value: 10, max: 14, label: 'Items done', variant: 'default' },
8
+ argTypes: { variant: { control: 'inline-radio', options: ['default', 'quiet'] } },
9
+ decorators: [(Story) => <div className="w-[346px]">{Story()}</div>],
10
+ } satisfies Meta<typeof ProgressBar>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ /** 6px, the success colour: a bar someone reads. */
16
+ export const Default: Story = {}
17
+
18
+ /** The bar, then the words that say it. */
19
+ export const WithWords: Story = {
20
+ render: (args) => (
21
+ <div className="flex flex-col gap-2">
22
+ <ProgressBar {...args} />
23
+ <span className="text-caption tabular-nums text-text-muted">
24
+ {args.value} of {args.max} done
25
+ </span>
26
+ </div>
27
+ ),
28
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled: the words are
29
+ // muted caption text, 3.94:1 in signal (computed 2026-09-14; AA 4.5:1) — the same
30
+ // number NavItem's count is excepted for.
31
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
32
+ }
33
+
34
+ /** 4px, the muted success colour: a glance, on the same line as a count — a caption label, 12px before the bar. */
35
+ export const Quiet: Story = {
36
+ args: { variant: 'quiet', value: 3, max: 4 },
37
+ render: (args) => (
38
+ <div className="flex items-center gap-3">
39
+ <span className="shrink-0 text-caption text-text-secondary">
40
+ Items ({args.value}/{args.max})
41
+ </span>
42
+ <ProgressBar {...args} className="flex-1" />
43
+ </div>
44
+ ),
45
+ }
46
+
47
+ export const Empty: Story = { args: { value: 0, max: 14 } }
48
+ export const Complete: Story = { args: { value: 14, max: 14 } }