@estiva-app/ui 0.6.0 → 0.8.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 (71) hide show
  1. package/README.md +23 -6
  2. package/dist/Button.d.ts +12 -5
  3. package/dist/Button.d.ts.map +1 -1
  4. package/dist/Checkbox.d.ts +19 -5
  5. package/dist/Checkbox.d.ts.map +1 -1
  6. package/dist/IconButton.d.ts +9 -2
  7. package/dist/IconButton.d.ts.map +1 -1
  8. package/dist/PersonTrigger.d.ts +2 -1
  9. package/dist/PersonTrigger.d.ts.map +1 -1
  10. package/dist/Reaction.d.ts +54 -0
  11. package/dist/Reaction.d.ts.map +1 -0
  12. package/dist/Tabs.d.ts +4 -1
  13. package/dist/Tabs.d.ts.map +1 -1
  14. package/dist/cn.d.ts +10 -1
  15. package/dist/cn.d.ts.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +383 -329
  19. package/dist/index.js.map +4 -4
  20. package/package.json +17 -3
  21. package/src/AppShell.stories.tsx +7 -1
  22. package/src/AppShell.tsx +1 -1
  23. package/src/Avatar.stories.tsx +3 -1
  24. package/src/Banner.stories.tsx +6 -1
  25. package/src/Banner.tsx +2 -2
  26. package/src/Breadcrumb.stories.tsx +3 -0
  27. package/src/Button.mdx +17 -3
  28. package/src/Button.stories.tsx +4 -1
  29. package/src/Button.test.tsx +119 -0
  30. package/src/Button.tsx +37 -23
  31. package/src/Checkbox.mdx +17 -6
  32. package/src/Checkbox.stories.tsx +10 -5
  33. package/src/Checkbox.test.tsx +73 -0
  34. package/src/Checkbox.tsx +52 -25
  35. package/src/Chip.stories.tsx +4 -0
  36. package/src/Chip.tsx +5 -5
  37. package/src/ChipInput.stories.tsx +4 -0
  38. package/src/DialogShell.tsx +1 -1
  39. package/src/EditableText.stories.tsx +6 -1
  40. package/src/IconButton.mdx +12 -1
  41. package/src/IconButton.stories.tsx +9 -4
  42. package/src/IconButton.test.tsx +102 -0
  43. package/src/IconButton.tsx +30 -17
  44. package/src/IdentityMenu.stories.tsx +5 -1
  45. package/src/Kbd.tsx +2 -2
  46. package/src/NavItem.stories.tsx +3 -0
  47. package/src/Person.stories.tsx +3 -0
  48. package/src/PersonTrigger.mdx +9 -1
  49. package/src/PersonTrigger.stories.tsx +3 -0
  50. package/src/PersonTrigger.test.tsx +52 -0
  51. package/src/PersonTrigger.tsx +8 -9
  52. package/src/Reaction.mdx +49 -0
  53. package/src/Reaction.stories.tsx +49 -0
  54. package/src/Reaction.tsx +86 -0
  55. package/src/Select.stories.tsx +7 -2
  56. package/src/Sidebar.stories.tsx +6 -0
  57. package/src/Tabs.mdx +13 -4
  58. package/src/Tabs.test.tsx +117 -0
  59. package/src/Tabs.tsx +45 -34
  60. package/src/Toast.tsx +8 -8
  61. package/src/TopBar.stories.tsx +7 -1
  62. package/src/cn.test.ts +20 -1
  63. package/src/cn.ts +18 -2
  64. package/src/index.ts +1 -0
  65. package/stories/Choosing.mdx +101 -0
  66. package/stories/DesignTokens.mdx +10 -0
  67. package/stories/GettingStarted.mdx +108 -0
  68. package/stories/Introduction.mdx +36 -0
  69. package/stories/TokensPage.tsx +293 -0
  70. package/tailwind-preset.js +17 -0
  71. package/tokens.css +48 -0
@@ -0,0 +1,49 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { fn } from 'storybook/test'
3
+ import { Reaction } from './Reaction'
4
+
5
+ const meta = {
6
+ title: 'Components/Reaction',
7
+ component: Reaction,
8
+ args: { emoji: '👍', count: 2, 'aria-label': 'Makes sense, 2', onClick: fn() },
9
+ } satisfies Meta<typeof Reaction>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ /** Somebody else's reaction: the inset fill and a hairline, like a neutral Chip. */
15
+ export const Default: Story = {}
16
+
17
+ /**
18
+ * Yours.
19
+ *
20
+ * The state a reaction has and a chip does not — the accent's muted tint and an
21
+ * accent edge, so a glance separates "two people, one of them me" from "two
22
+ * people". `aria-pressed` carries the same fact to assistive tech.
23
+ */
24
+ export const Pressed: Story = { args: { pressed: true } }
25
+
26
+ /** A row of them, as a message carries them. */
27
+ export const Row: Story = {
28
+ render: (args) => (
29
+ <div className="flex flex-wrap items-center gap-1">
30
+ <Reaction {...args} emoji="👍" count={3} aria-label="Makes sense, 3" pressed />
31
+ <Reaction {...args} emoji="🎉" count={1} aria-label="Congrats, 1" />
32
+ <Reaction {...args} emoji="🚀" count={12} aria-label="Let's go!, 12" />
33
+ </div>
34
+ ),
35
+ }
36
+
37
+ /** A count with two digits, so the pill grows rather than clipping. */
38
+ export const WideCount: Story = { args: { count: 128, 'aria-label': 'Makes sense, 128' } }
39
+
40
+ /** Momentarily unavailable — signing in, or a write already in flight. */
41
+ export const Disabled: Story = { args: { disabled: true } }
42
+
43
+ /**
44
+ * An emoji from outside any curated set.
45
+ *
46
+ * Another client may publish anything, and a reaction a consumer cannot name is
47
+ * still one it must draw rather than hide — so nothing here validates the glyph.
48
+ */
49
+ export const UnknownEmoji: Story = { args: { emoji: '🦆', count: 1, 'aria-label': '🦆, 1' } }
@@ -0,0 +1,86 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * A reaction: an emoji, how many people chose it, and whether you are one of
6
+ * them.
7
+ *
8
+ * **This exists because both apps had already built it, and neither could use
9
+ * the components that were here.** A reaction is a pill with a count that
10
+ * toggles — `Chip` is a pill and rules itself out for anything clickable,
11
+ * `Button` is clickable and is a 6px-radius rectangle, and `IconButton` has
12
+ * nowhere to put the count. So Peek re-typed Chip's class list with a border
13
+ * added, and Ship reached for a small Button with the count as its label. The
14
+ * two apps' reactions do not look alike today, and neither is what was drawn.
15
+ *
16
+ * ## The one thing it does that nothing else here does
17
+ *
18
+ * **It says the reaction is yours.** That is the state a reaction has and a
19
+ * chip does not: `pressed` fills it with the accent's muted tint and gives it
20
+ * an accent edge, so a glance separates "two people, one of them me" from "two
21
+ * people". Ship approximated it as `outlined` versus `muted` — measured at a
22
+ * 1px hairline against no border at all — which is legible and is not the
23
+ * signal Peek's accent fill gives.
24
+ *
25
+ * It is a real `<button>` with `aria-pressed`, so the state reaches assistive
26
+ * tech as a toggle rather than as a colour.
27
+ *
28
+ * ## `aria-label` is required, and the reason is specific
29
+ *
30
+ * The emoji is decorative here — it is `aria-hidden`, because a glyph read
31
+ * aloud is noise and its spoken name differs per screen reader — and the only
32
+ * visible text is the count. Without a label the control is announced as
33
+ * **"2"**, which is what Ship shipped and its own tests caught. Pass the
34
+ * meaning and the count: `"Makes sense, 2"`.
35
+ *
36
+ * ## Geometry
37
+ *
38
+ * Chip's pill, at a control's height: fully rounded, 24px to match `Button`
39
+ * `small`, the same 8px horizontal padding, the `chip` type token for the
40
+ * count so it sits at 11px/500 like every other count in the system.
41
+ */
42
+ export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
43
+ /** The emoji, drawn decoratively — name the control with `aria-label`. */
44
+ emoji: ReactNode
45
+ /** How many people reacted. Drawn as-is; `0` is not a reaction and is not drawn. */
46
+ count: number
47
+ /** You are one of them: the accent tint and edge, and `aria-pressed`. */
48
+ pressed?: boolean
49
+ /**
50
+ * Names the control. **Required** — the emoji is decorative and the count is
51
+ * the only visible text, so without this it is announced as a bare number.
52
+ */
53
+ 'aria-label': string
54
+ }
55
+
56
+ export function Reaction({ emoji, count, pressed = false, className, type, ...props }: ReactionProps) {
57
+ return (
58
+ <button
59
+ type={type ?? 'button'}
60
+ aria-pressed={pressed}
61
+ className={cn(
62
+ // Chip's pill at a control's height, so a reaction and a status chip
63
+ // read as the same family — 24px matches Button `small`.
64
+ 'inline-flex h-6 items-center justify-center gap-1.5 rounded-full px-2',
65
+ 'border transition-colors',
66
+ 'disabled:cursor-not-allowed disabled:opacity-50',
67
+ pressed
68
+ ? 'border-accent-primary bg-accent-muted text-accent-primary hover:border-accent-hover'
69
+ : 'border-border-default bg-bg-inset text-text-primary hover:border-border-strong hover:bg-bg-hover',
70
+ className,
71
+ )}
72
+ {...props}
73
+ >
74
+ {/* Decorative: the control is named by `aria-label`, and a glyph read
75
+ aloud is noise. 16px so the emoji is legible at chip scale. */}
76
+ <span aria-hidden="true" className="shrink-0 text-[16px] leading-none">
77
+ {emoji}
78
+ </span>
79
+ {/* `text-chip` is a plain class, never merged — the same guard Chip uses,
80
+ because tailwind-merge drops a token size that follows a text colour. */}
81
+ <span className="text-chip signal:font-mono signal:text-[10px] signal:font-semibold signal:tabular-nums">
82
+ {count}
83
+ </span>
84
+ </button>
85
+ )
86
+ }
@@ -49,7 +49,12 @@ function Demo(props: Omit<ComponentProps<typeof Select>, 'value' | 'onChange'> &
49
49
  export const Default: Story = { render: (args) => <Demo {...args} initial="in_progress" /> }
50
50
 
51
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…" /> }
52
+ export const Empty: Story = {
53
+ render: (args) => <Demo {...args} initial="" placeholder="Choose a status…" />,
54
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
55
+ // the placeholder is muted text, 3.21:1 on the field in signal (AA 4.5:1).
56
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
57
+ }
53
58
 
54
59
  /** The dense size, matching `Button`'s small — for property rows, where a full-height field would dominate the label beside it. */
55
60
  export const Small: Story = { render: (args) => <Demo {...args} size="small" initial="todo" /> }
@@ -181,7 +186,7 @@ export const LongLabelInPropertyRow: Story = {
181
186
  render: (args) => (
182
187
  <div className="w-[300px] rounded-lg border border-border-subtle p-3">
183
188
  <div className="flex items-center gap-2">
184
- <span className="w-[68px] shrink-0 text-xs text-text-secondary">Lead</span>
189
+ <span className="w-[68px] shrink-0 text-caption text-text-secondary">Lead</span>
185
190
  <Demo
186
191
  {...args}
187
192
  size="small"
@@ -20,6 +20,9 @@ type Story = StoryObj<typeof meta>
20
20
 
21
21
  /** Entries, then a labelled group — the heading is a SectionLabel in a 32px row. */
22
22
  export const Composed: Story = {
23
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
24
+ // the count chip is muted text, 3.06:1 on the active row in signal (AA 4.5:1).
25
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
23
26
  render: (args) => (
24
27
  <Sidebar {...args}>
25
28
  <NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" active />
@@ -36,6 +39,9 @@ export const Composed: Story = {
36
39
 
37
40
  /** The column scrolls on its own — a long list never scrolls the frame away. */
38
41
  export const Scrolls: Story = {
42
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
43
+ // the count chips are muted text, 3.78:1 on --bg-surface in signal (AA 4.5:1).
44
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
39
45
  render: (args) => (
40
46
  <Sidebar {...args}>
41
47
  <NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" />
package/src/Tabs.mdx CHANGED
@@ -43,10 +43,19 @@ import { Tabs } from '@estiva-app/ui'
43
43
  ```
44
44
 
45
45
  - Generic over the id type — `onChange` hands back your union, not a
46
- string.
47
- - It renders `role="tablist"` / `role="tab"` with `aria-selected`;
48
- switching is by click (there is no arrow-key roving), and wiring the
49
- panels — what shows for the active tab — is the caller's.
46
+ string, and only for a person's choice.
47
+ - Built on Base UI Tabs: `role="tablist"` / `role="tab"` with
48
+ `aria-selected`, one Tab stop for the row, arrow keys between the tabs.
49
+ Wiring the panels — what shows for the active tab — is the caller's.
50
+ - `className` lands on the outer box, around the row.
51
+
52
+ ## Keys
53
+
54
+ | Key | Does |
55
+ |---|---|
56
+ | Tab | Into the row, onto the selected tab; out of it on the next press. |
57
+ | ← → | Select the previous / next tab. The ends wrap. |
58
+ | Home / End | Select the first / last tab. |
50
59
 
51
60
  ## Props
52
61
 
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Tabs page claims about the keyboard, pinned. The keys come from
4
+ * Base UI's Tabs (stage 1 of the migration); before it, every tab was its own
5
+ * Tab stop and the arrow keys did nothing.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, render, screen } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { useState } from 'react'
11
+ import { Tabs, type TabDef } from './Tabs'
12
+
13
+ afterEach(cleanup)
14
+
15
+ type Id = 'one' | 'two' | 'three'
16
+ const THREE: TabDef<Id>[] = [
17
+ { id: 'one', label: 'One' },
18
+ { id: 'two', label: 'Two', count: 3 },
19
+ { id: 'three', label: 'Three' },
20
+ ]
21
+
22
+ /** A parent that owns the state, as every caller does. */
23
+ function Harness({ onChange, initial = 'one' }: { onChange?: (id: Id) => void; initial?: Id }) {
24
+ const [active, setActive] = useState<Id>(initial)
25
+ return (
26
+ <>
27
+ <button type="button">Before</button>
28
+ <Tabs
29
+ tabs={THREE}
30
+ active={active}
31
+ onChange={(id) => {
32
+ onChange?.(id)
33
+ setActive(id)
34
+ }}
35
+ />
36
+ <button type="button">After</button>
37
+ </>
38
+ )
39
+ }
40
+
41
+ const tab = (name: string) => screen.getByRole('tab', { name: new RegExp(`^${name}`) })
42
+ const selected = (name: string) => tab(name).getAttribute('aria-selected')
43
+ const tabindex = (name: string) => tab(name).getAttribute('tabindex')
44
+ const focused = () => document.activeElement
45
+
46
+ describe('Tabs', () => {
47
+ it('marks the active tab selected, and makes it the only Tab stop in the row', async () => {
48
+ const user = userEvent.setup()
49
+ render(<Harness />)
50
+ expect(selected('One')).toBe('true')
51
+ expect(selected('Two')).toBe('false')
52
+ expect(tabindex('One')).toBe('0')
53
+ expect(tabindex('Two')).toBe('-1')
54
+ expect(tabindex('Three')).toBe('-1')
55
+
56
+ await user.tab()
57
+ expect(focused()).toBe(screen.getByRole('button', { name: 'Before' }))
58
+ await user.tab()
59
+ expect(focused()).toBe(tab('One'))
60
+ await user.tab()
61
+ expect(focused()).toBe(screen.getByRole('button', { name: 'After' }))
62
+ })
63
+
64
+ it('selects on click and hands back the id', async () => {
65
+ const user = userEvent.setup()
66
+ const onChange = vi.fn()
67
+ render(<Harness onChange={onChange} />)
68
+ await user.click(tab('Two'))
69
+ expect(onChange).toHaveBeenCalledTimes(1)
70
+ expect(onChange).toHaveBeenCalledWith('two')
71
+ expect(selected('Two')).toBe('true')
72
+ expect(selected('One')).toBe('false')
73
+ })
74
+
75
+ it('selects the next and previous tab with the arrow keys, wrapping at the ends', async () => {
76
+ const user = userEvent.setup()
77
+ const onChange = vi.fn()
78
+ render(<Harness onChange={onChange} />)
79
+ await user.click(tab('One'))
80
+ onChange.mockClear()
81
+
82
+ await user.keyboard('{ArrowRight}')
83
+ expect(focused()).toBe(tab('Two'))
84
+ expect(selected('Two')).toBe('true')
85
+ await user.keyboard('{ArrowRight}')
86
+ expect(selected('Three')).toBe('true')
87
+ await user.keyboard('{ArrowRight}')
88
+ expect(selected('One')).toBe('true')
89
+ await user.keyboard('{ArrowLeft}')
90
+ expect(selected('Three')).toBe('true')
91
+ expect(onChange.mock.calls.map(([id]) => id)).toEqual(['two', 'three', 'one', 'three'])
92
+ })
93
+
94
+ it('selects the first and last tab with Home and End', async () => {
95
+ const user = userEvent.setup()
96
+ render(<Harness initial="two" />)
97
+ await user.click(tab('Two'))
98
+ await user.keyboard('{End}')
99
+ expect(selected('Three')).toBe('true')
100
+ await user.keyboard('{Home}')
101
+ expect(selected('One')).toBe('true')
102
+ })
103
+
104
+ it('stays quiet when `active` matches no tab: onChange is for a person, not a fallback', () => {
105
+ const onChange = vi.fn()
106
+ render(<Tabs tabs={THREE} active={'nowhere' as Id} onChange={onChange} />)
107
+ expect(onChange).not.toHaveBeenCalled()
108
+ expect(screen.getAllByRole('tab').map((t) => t.getAttribute('aria-selected'))).toEqual(['false', 'false', 'false'])
109
+ })
110
+
111
+ it('puts className on the outer box, around the row', () => {
112
+ const { container } = render(<Tabs tabs={THREE} active="one" onChange={() => {}} className="mt-4" />)
113
+ const outer = container.firstElementChild as HTMLElement
114
+ expect(outer.classList.contains('mt-4')).toBe(true)
115
+ expect(outer.querySelector('[role="tablist"]')).not.toBeNull()
116
+ })
117
+ })
package/src/Tabs.tsx CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { ReactNode } from 'react'
2
+ import { Tabs as BaseTabs } from '@base-ui/react/tabs'
2
3
  import { cn } from './cn'
3
4
 
4
5
  /**
5
6
  * A row of tabs. Ship's Tabs (2026-09-01), which was Peek's TopicTabs with
6
- * the topic-specific ids taken out.
7
+ * the topic-specific ids taken out; on Base UI Tabs since stage 1 of the
8
+ * migration (2026-09-06), which is where the keyboard comes from: one Tab
9
+ * stop for the row, arrow keys between the tabs.
7
10
  *
8
11
  * A selected tab is a neutral fill (bg-active), not the accent tint —
9
12
  * Katerina's ruling (2026-08-27), extended to every app (2026-09-01). Two
@@ -31,43 +34,51 @@ export interface TabsProps<T extends string> {
31
34
  onChange: (id: T) => void
32
35
  /** `default` 14px; `small` 12px, the denser geometry. */
33
36
  size?: 'default' | 'small'
37
+ /** Lands on the outer box, around the row. */
34
38
  className?: string
35
39
  }
36
40
 
37
41
  export function Tabs<T extends string>({ tabs, active, onChange, size = 'default', className }: TabsProps<T>) {
38
42
  return (
39
- <div role="tablist" className={cn('flex items-center gap-2', className)}>
40
- {tabs.map((tab) => (
41
- <button
42
- key={tab.id}
43
- type="button"
44
- role="tab"
45
- aria-selected={active === tab.id}
46
- onClick={() => onChange(tab.id)}
47
- className={cn(
48
- 'flex cursor-pointer items-center transition-colors',
49
- // Arbitrary sizes (the body-2 and caption tokens): the colour branch below
50
- // follows them through cn(), and tw-merge drops a custom text-{size}
51
- // once a text-{colour} lands after it. Measured: tabs rendered 16px.
52
- // gap: default is Ship's 6px; small keeps Peek's original 4px, or
53
- // "small is Peek's geometry" stops being true.
54
- size === 'default' ? 'gap-1.5 rounded-md px-2 py-1 text-[14px] leading-[140%]' : 'gap-1 rounded px-1.5 py-0.5 text-[12px] leading-[120%]',
55
- active === tab.id ? 'bg-bg-active text-text-primary' : 'text-text-secondary hover:bg-bg-hover',
56
- )}
57
- >
58
- {tab.icon}
59
- {/* Label and count share a baseline: a smaller text centred as a box
60
- (items-center) floats above the label's baseline the digits
61
- read as riding high. Baseline alignment is what makes two sizes
62
- sit on one line. */}
63
- <span className="flex items-baseline">
64
- {tab.label}
65
- {tab.count !== undefined ? (
66
- <span className="ml-2.5 font-mono text-caption tabular-nums text-text-secondary">{tab.count}</span>
67
- ) : null}
68
- </span>
69
- </button>
70
- ))}
71
- </div>
43
+ <BaseTabs.Root
44
+ value={active}
45
+ onValueChange={(value, details) => {
46
+ // Only a person's choice reaches the caller. Base UI also reports its
47
+ // own fallbacks (an `active` that matches no tab), and those carry
48
+ // `null`, which is not a T.
49
+ if (details.reason === 'none') onChange(value as T)
50
+ }}
51
+ className={className}
52
+ >
53
+ <BaseTabs.List activateOnFocus className="flex items-center gap-2">
54
+ {tabs.map((tab) => (
55
+ <BaseTabs.Tab
56
+ key={tab.id}
57
+ value={tab.id}
58
+ className={(state) =>
59
+ cn(
60
+ 'flex cursor-pointer items-center transition-colors',
61
+ // gap: default is Ship's 6px; small keeps Peek's original 4px, or
62
+ // "small is Peek's geometry" stops being true.
63
+ size === 'default' ? 'gap-1.5 rounded-md px-2 py-1 text-body-2' : 'gap-1 rounded px-1.5 py-0.5 text-caption',
64
+ state.active ? 'bg-bg-active text-text-primary' : 'text-text-secondary hover:bg-bg-hover',
65
+ )
66
+ }
67
+ >
68
+ {tab.icon}
69
+ {/* Label and count share a baseline: a smaller text centred as a box
70
+ (items-center) floats above the label's baseline — the digits
71
+ read as riding high. Baseline alignment is what makes two sizes
72
+ sit on one line. */}
73
+ <span className="flex items-baseline">
74
+ {tab.label}
75
+ {tab.count !== undefined ? (
76
+ <span className="ml-2.5 font-mono text-caption tabular-nums text-text-secondary">{tab.count}</span>
77
+ ) : null}
78
+ </span>
79
+ </BaseTabs.Tab>
80
+ ))}
81
+ </BaseTabs.List>
82
+ </BaseTabs.Root>
72
83
  )
73
84
  }
package/src/Toast.tsx CHANGED
@@ -31,19 +31,19 @@ export interface ToastProps {
31
31
 
32
32
  // Signal: every toast is the same dark overlay pill (v3) — the type lives in
33
33
  // the icon color + glow, not the surface.
34
- const SURFACE_BY_TYPE: Record<ToastType, string> = {
34
+ const SURFACE_STYLES: Record<ToastType, string> = {
35
35
  success: 'bg-success-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
36
36
  brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
37
37
  neutral: 'bg-bg-inset border border-border-subtle signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
38
38
  }
39
39
 
40
- const ICON_BY_TYPE: Record<ToastType, string> = {
41
- success: 'signal:text-success-default signal:drop-shadow-[0_0_5px_rgba(63,222,140,0.7)]',
42
- brand: 'signal:text-text-interactive signal:drop-shadow-[0_0_5px_rgba(86,200,255,0.6)]',
40
+ const ICON_STYLES: Record<ToastType, string> = {
41
+ success: 'signal:text-success-default signal:drop-shadow-glow-success',
42
+ brand: 'signal:text-text-interactive signal:drop-shadow-glow-accent',
43
43
  neutral: 'signal:text-text-secondary',
44
44
  }
45
45
 
46
- const ACTION_BORDER_BY_TYPE: Record<ToastType, string> = {
46
+ const ACTION_BORDER_STYLES: Record<ToastType, string> = {
47
47
  success: 'signal:border signal:border-border-default signal:hover:border-border-strong',
48
48
  brand: 'signal:border signal:border-border-default signal:hover:border-border-strong',
49
49
  neutral: 'border border-border-default',
@@ -58,13 +58,13 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
58
58
  // Without an action the label needs real right padding; the action
59
59
  // button brings its own edge, so the tight pr-1 only applies there.
60
60
  hasAction ? 'pr-1 gap-[46px]' : 'pr-3',
61
- SURFACE_BY_TYPE[type],
61
+ SURFACE_STYLES[type],
62
62
  className,
63
63
  )}
64
64
  >
65
65
  <div className="flex items-center gap-2 shrink-0">
66
66
  {leadingIcon && (
67
- <IconCircleCheck size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_BY_TYPE[type])} />
67
+ <IconCircleCheck size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />
68
68
  )}
69
69
  <span className="font-normal text-[14px] leading-[1.4] text-text-primary whitespace-nowrap">{label}</span>
70
70
  </div>
@@ -74,7 +74,7 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
74
74
  onClick={onAction}
75
75
  className={cn(
76
76
  'h-6 flex items-center justify-center gap-1 px-1 py-1 rounded-md shrink-0 transition-colors',
77
- ACTION_BORDER_BY_TYPE[type],
77
+ ACTION_BORDER_STYLES[type],
78
78
  type === 'neutral' ? 'hover:border-border-strong' : 'hover:opacity-80',
79
79
  )}
80
80
  >
@@ -14,7 +14,13 @@ import { TopBar } from './TopBar'
14
14
  const meta = {
15
15
  title: 'Frame/TopBar',
16
16
  component: TopBar,
17
- parameters: { layout: 'fullscreen' },
17
+ parameters: {
18
+ layout: 'fullscreen',
19
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
20
+ // the placeholder content under the bar is muted text, 3.94:1 on --bg-base in
21
+ // signal (AA 4.5:1).
22
+ a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } },
23
+ },
18
24
  argTypes: {
19
25
  variant: { control: 'inline-radio', options: ['solid', 'floating'] },
20
26
  menu: { control: false },
package/src/cn.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import preset from '../tailwind-preset.js'
3
- import { cn, _fontSizeTokens } from './cn'
3
+ import { cn, _boxShadowTokens, _dropShadowTokens, _fontSizeTokens } from './cn'
4
4
 
5
5
  describe('cn knows the type ramp', () => {
6
6
  it('lists exactly the preset fontSize keys', () => {
@@ -25,3 +25,22 @@ describe('cn knows the type ramp', () => {
25
25
  expect(cn('text-left text-body-2 text-text-primary')).toBe('text-left text-body-2 text-text-primary')
26
26
  })
27
27
  })
28
+
29
+ describe('cn knows the shadow tokens', () => {
30
+ it('lists exactly the preset boxShadow and dropShadow keys', () => {
31
+ const extend = (preset as { theme: { extend: { boxShadow: Record<string, unknown>; dropShadow: Record<string, unknown> } } }).theme.extend
32
+ expect([..._boxShadowTokens].sort()).toEqual(Object.keys(extend.boxShadow).sort())
33
+ expect([..._dropShadowTokens].sort()).toEqual(Object.keys(extend.dropShadow).sort())
34
+ })
35
+
36
+ it('treats a shadow token as a shadow: two of them conflict, the last wins', () => {
37
+ expect(cn('shadow-glow-warning', 'shadow-md')).toBe('shadow-md')
38
+ expect(cn('shadow-focus-ring', 'shadow-glow-warning')).toBe('shadow-glow-warning')
39
+ expect(cn('drop-shadow-glow-success', 'drop-shadow-glow-accent')).toBe('drop-shadow-glow-accent')
40
+ })
41
+
42
+ it('keeps a shadow token beside a colour, and under a variant', () => {
43
+ expect(cn('signal:shadow-glow-warning text-text-primary')).toBe('signal:shadow-glow-warning text-text-primary')
44
+ expect(cn('signal:border-warning-outline', 'text-warning-default')).toBe('signal:border-warning-outline text-warning-default')
45
+ })
46
+ })
package/src/cn.ts CHANGED
@@ -21,8 +21,24 @@ const FONT_SIZE_TOKENS = [
21
21
  'btn-default', 'btn-small', 'input-label', 'input-value', 'input-helper', 'chip',
22
22
  ]
23
23
 
24
+ /**
25
+ * The preset's box-shadow and drop-shadow keys, for the same reason: stock
26
+ * tailwind-merge files an unknown `shadow-x` under shadow-COLOUR, so
27
+ * `shadow-focus-ring` beside `shadow-glow-warning` lost one of them, and
28
+ * `shadow-glow-warning` beside `shadow-md` kept both (measured 2026-09-06).
29
+ * `cn.test.ts` pins these to the preset too.
30
+ */
31
+ const BOX_SHADOW_TOKENS = ['sm', 'md', 'lg', 'focus-ring', 'glow-warning', 'highlight-inset']
32
+ const DROP_SHADOW_TOKENS = ['glow-success', 'glow-accent']
33
+
24
34
  const twMerge = extendTailwindMerge({
25
- extend: { classGroups: { 'font-size': [{ text: FONT_SIZE_TOKENS }] } },
35
+ extend: {
36
+ classGroups: {
37
+ 'font-size': [{ text: FONT_SIZE_TOKENS }],
38
+ shadow: [{ shadow: BOX_SHADOW_TOKENS }],
39
+ 'drop-shadow': [{ 'drop-shadow': DROP_SHADOW_TOKENS }],
40
+ },
41
+ },
26
42
  })
27
43
 
28
44
  /** Merge class names, last-one-wins on conflicting Tailwind utilities. */
@@ -30,4 +46,4 @@ export function cn(...inputs: ClassValue[]) {
30
46
  return twMerge(clsx(inputs))
31
47
  }
32
48
 
33
- export { FONT_SIZE_TOKENS as _fontSizeTokens }
49
+ export { FONT_SIZE_TOKENS as _fontSizeTokens, BOX_SHADOW_TOKENS as _boxShadowTokens, DROP_SHADOW_TOKENS as _dropShadowTokens }
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ export { Sidebar, type SidebarProps } from './Sidebar'
42
42
  export { TopBar, type TopBarProps } from './TopBar'
43
43
  export { PersonTrigger, type PersonTriggerProps } from './PersonTrigger'
44
44
  export { Property, type PropertyProps } from './Property'
45
+ export { Reaction, type ReactionProps } from './Reaction'
45
46
  export { SearchInput, type SearchInputProps } from './SearchInput'
46
47
  export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
47
48
  export { SectionLabel } from './SectionLabel'