@estiva-app/ui 0.55.0 → 0.57.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/dist/ChipInput.d.ts.map +1 -1
  2. package/dist/CollapsibleSection.d.ts +8 -2
  3. package/dist/CollapsibleSection.d.ts.map +1 -1
  4. package/dist/CommandPalette.d.ts.map +1 -1
  5. package/dist/ContainerHeader.d.ts +7 -1
  6. package/dist/ContainerHeader.d.ts.map +1 -1
  7. package/dist/IconButton.d.ts.map +1 -1
  8. package/dist/ListColumn.d.ts +2 -0
  9. package/dist/ListColumn.d.ts.map +1 -1
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/NavItem.d.ts.map +1 -1
  12. package/dist/NavTree.d.ts +11 -3
  13. package/dist/NavTree.d.ts.map +1 -1
  14. package/dist/PersonTrigger.d.ts.map +1 -1
  15. package/dist/SectionHeader.d.ts +24 -5
  16. package/dist/SectionHeader.d.ts.map +1 -1
  17. package/dist/SectionLabel.d.ts.map +1 -1
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/SuggestionMenu.d.ts.map +1 -1
  20. package/dist/gates/index.js +1 -1
  21. package/dist/gates/index.js.map +2 -2
  22. package/dist/index.js +138 -77
  23. package/dist/index.js.map +4 -4
  24. package/dist/looks.d.ts +4 -0
  25. package/dist/looks.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/registry.json +98 -26
  28. package/src/ChipInput.test.tsx +8 -0
  29. package/src/ChipInput.tsx +4 -2
  30. package/src/CollapsibleSection.mdx +1 -1
  31. package/src/CollapsibleSection.stories.tsx +14 -1
  32. package/src/CollapsibleSection.test.tsx +24 -0
  33. package/src/CollapsibleSection.tsx +11 -5
  34. package/src/CommandPalette.test.tsx +6 -0
  35. package/src/CommandPalette.tsx +3 -2
  36. package/src/ContainerHeader.mdx +4 -0
  37. package/src/ContainerHeader.stories.tsx +14 -0
  38. package/src/ContainerHeader.test.tsx +8 -0
  39. package/src/ContainerHeader.tsx +34 -6
  40. package/src/IconButton.tsx +2 -1
  41. package/src/ListColumn.test.tsx +8 -0
  42. package/src/ListColumn.tsx +4 -2
  43. package/src/Menu.tsx +5 -4
  44. package/src/NavItem.mdx +3 -1
  45. package/src/NavItem.test.tsx +32 -2
  46. package/src/NavItem.tsx +28 -12
  47. package/src/NavTree.mdx +16 -1
  48. package/src/NavTree.stories.tsx +49 -2
  49. package/src/NavTree.test.tsx +28 -0
  50. package/src/NavTree.tsx +27 -6
  51. package/src/PersonTrigger.tsx +6 -5
  52. package/src/SectionHeader.mdx +10 -3
  53. package/src/SectionHeader.stories.tsx +18 -0
  54. package/src/SectionHeader.test.tsx +89 -0
  55. package/src/SectionHeader.tsx +114 -26
  56. package/src/SectionLabel.tsx +6 -3
  57. package/src/Select.test.tsx +8 -0
  58. package/src/Select.tsx +5 -2
  59. package/src/Sidebar.tsx +1 -1
  60. package/src/SuggestionMenu.stories.tsx +15 -0
  61. package/src/SuggestionMenu.test.tsx +8 -0
  62. package/src/SuggestionMenu.tsx +4 -2
  63. package/src/eslint/no-copied-look.test.ts +2 -2
  64. package/src/gates/app-checks.ts +1 -1
  65. package/src/looks.ts +6 -0
@@ -1,16 +1,17 @@
1
1
  import type { ReactNode } from 'react'
2
- import { IconChevronRight } from '@tabler/icons-react'
2
+ import { IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
3
3
  import { useRender } from '@base-ui/react/use-render'
4
4
  import { cn } from './cn'
5
5
  import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
6
6
  import { IconButton } from './IconButton'
7
+ import { Menu } from './Menu'
7
8
  import { SectionLabel } from './SectionLabel'
8
9
 
9
10
  /**
10
11
  * The 32px row a section starts with (Peek's SectionHeader, 2026-09-01):
11
12
  * a SectionLabel, an optional collapse chevron that makes the title the
12
- * toggle, and actions that appear while the row is hovered or focused, as
13
- * IconButtons with tooltips.
13
+ * toggle, and actions that appear while the row is hovered or keyboard-focused,
14
+ * as IconButtons with tooltips.
14
15
  *
15
16
  * Three things changed on 2026-09-09, for `CollapsibleSection`:
16
17
  *
@@ -45,10 +46,19 @@ export interface SectionHeaderProps {
45
46
  title: string
46
47
  /** Collapsible: draws the chevron and makes the title a button that toggles. */
47
48
  chevron?: boolean
49
+ /**
50
+ * 16px, stroke 1.5, before the title — a folder's (Katerina, 5 October:
51
+ * Peek's Folders, the standard heading with a folder icon). With `chevron`
52
+ * the icon is what shows at rest and the arrow takes its place under the
53
+ * pointer or the keyboard, as a NavItem's icon turns into its arrow.
54
+ */
55
+ icon?: ReactNode
48
56
  isExpanded?: boolean
49
57
  onToggle?: () => void
50
58
  /**
51
- * Beside the title and always visible, before the actions — a count.
59
+ * Beside the title and always visible, before the actions — a count. An
60
+ * `UnreadDot` instead sits in the last action's place (the ⋮'s, with a
61
+ * `menu`) and steps aside while the actions show, as a NavItem's dot does.
52
62
  *
53
63
  * The actions come and go with the hover; this does not, because a count is
54
64
  * information rather than an affordance. Peek's Screener header carries its
@@ -58,7 +68,17 @@ export interface SectionHeaderProps {
58
68
  trailing?: ReactNode
59
69
  /** Right-aligned, in the order given. */
60
70
  actions?: SectionAction[]
61
- /** `hover` reveals the actions while the row is hovered or focused; `always` keeps them. */
71
+ /**
72
+ * The heading's "More options" menu: `MenuItem`s — what acts on the whole
73
+ * section, a folder's Rename or Archive (Katerina, 5 October: Peek's Folders
74
+ * page goes, and its folder actions move onto the folder's heading). The ⋮
75
+ * is NavItem's, after the actions and revealed with them; it stays while its
76
+ * menu is open.
77
+ */
78
+ menu?: ReactNode
79
+ /** The ⋮'s accessible name. Default "More options for" the title. */
80
+ menuLabel?: string
81
+ /** `hover` reveals the actions while the row is hovered or keyboard-focused (always on a screen with no hover); `always` keeps them. */
62
82
  showActions?: 'hover' | 'always'
63
83
  /**
64
84
  * `fill` lights the row under the pointer when it does something (a toggle,
@@ -82,23 +102,74 @@ export interface SectionHeaderProps {
82
102
  className?: string
83
103
  }
84
104
 
85
- export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
105
+ export function SectionHeader({ title, chevron = false, icon, isExpanded = true, onToggle, trailing, actions, menu, menuLabel, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
86
106
  const row = look === 'row'
107
+ /*
108
+ One arrow for everything that folds, a NavItem's 16px, and the title 8px
109
+ after it as a row's label is (Katerina, 5 October: a folder heading and
110
+ the rows under it fold with one arrow; then every folding heading, Peek's
111
+ Starred too). It was 12px with a 4px gap; with an icon, the arrow now
112
+ fills the icon's place exactly.
113
+ */
114
+ const acts = (actions && actions.length > 0) || menu != null
115
+ /*
116
+ A dot sits in the place of the last button and steps aside while the
117
+ buttons show — the rows' dot and ⋮ share a place the same way (Katerina,
118
+ 5 October). A count stays beside them: it is information (09-09).
119
+
120
+ "A dot" is whatever holds an `UnreadDot`, found by the `data-unread` it
121
+ draws, so the CSS decides (`has-[[data-unread]]`) rather than a check of
122
+ the element's type: that missed a dot in a tooltip or in an app's own
123
+ wrapper, which then sat beside the buttons as a count does. With no
124
+ buttons it sits on their axis all the same (`-mr-1`, as they are pulled).
125
+ In the last button's place it is the 24px slot at `right-1`, where the
126
+ buttons' box ends, gone while they show — on a screen with no hover, then,
127
+ always.
128
+ */
129
+ const trailingClasses = !acts
130
+ ? 'has-[[data-unread]]:-mr-1'
131
+ : showActions === 'hover' &&
132
+ 'has-[[data-unread]]:pointer-events-none has-[[data-unread]]:absolute has-[[data-unread]]:inset-y-0 has-[[data-unread]]:right-1 has-[[data-unread]]:w-6 has-[[data-unread]]:justify-center has-[[data-unread]]:transition-opacity group-hover:has-[[data-unread]]:opacity-0 group-has-[:focus-visible]:has-[[data-unread]]:opacity-0 group-has-[[data-section-menu]_[aria-expanded=true]]:has-[[data-unread]]:opacity-0 [@media(hover:none)]:has-[[data-unread]]:opacity-0'
133
+ /*
134
+ The buttons show under the pointer, while the keyboard is anywhere in the
135
+ row — the title's toggle included, as a NavItem's ⋮ shows once its link
136
+ has keyboard focus; it was only one of the buttons themselves — and while
137
+ the menu is open. `aria-expanded` is asked of the buttons' own box: the
138
+ toggle says it too, and an open section would keep them for good.
139
+
140
+ A screen with no hover (a phone, a tablet) shows them always: nothing
141
+ there can point at the row, so hidden they could never be reached.
142
+ */
143
+ const reveal = showActions === 'hover' && 'opacity-0 transition-opacity group-hover:opacity-100 group-has-[:focus-visible]:opacity-100 has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100'
87
144
  const titleElement = useRender({
88
145
  render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
89
146
  props: {
90
147
  // `text-left`: a button centres its text. `h-full` and `flex-1`: the
91
148
  // whole row up to the actions is the hit target, as it was when the
92
149
  // row itself carried the click.
93
- className: cn('flex h-full min-w-0 flex-1 items-center text-left', row ? 'gap-2' : 'gap-1'),
150
+ // An icon is a row's: NavItem's 8px after it.
151
+ className: cn('flex h-full min-w-0 flex-1 items-center text-left', row || chevron || icon != null ? 'gap-2' : 'gap-1'),
94
152
  children: (
95
153
  <>
96
- {chevron && (
97
- <IconChevronRight
98
- size={row ? 16 : 12}
99
- stroke={1.5}
100
- className={cn('shrink-0 transition-transform duration-150', !row && 'text-text-secondary', isExpanded && 'rotate-90')}
101
- />
154
+ {icon != null ? (
155
+ <span className="relative flex size-4 shrink-0 items-center justify-center text-text-secondary">
156
+ <span className={cn('flex', chevron && 'transition-opacity group-hover:opacity-0 group-has-[:focus-visible]:opacity-0')}>{icon}</span>
157
+ {chevron && (
158
+ <IconChevronRight
159
+ size={16}
160
+ stroke={1.5}
161
+ className={cn('absolute opacity-0 transition-[opacity,transform] duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100', isExpanded && 'rotate-90')}
162
+ />
163
+ )}
164
+ </span>
165
+ ) : (
166
+ chevron && (
167
+ <IconChevronRight
168
+ size={16}
169
+ stroke={1.5}
170
+ className={cn('shrink-0 transition-transform duration-150', !row && 'text-text-secondary', isExpanded && 'rotate-90')}
171
+ />
172
+ )
102
173
  )}
103
174
  {look === 'quiet' ? (
104
175
  <span className="min-w-0 truncate text-caption text-text-muted">{title}</span>
@@ -116,7 +187,8 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
116
187
  return (
117
188
  <div
118
189
  className={cn(
119
- 'group flex h-[32px] shrink-0 items-center gap-1 px-2',
190
+ // `relative`: a dot in the last button's place is placed against the row.
191
+ 'group relative flex h-[32px] shrink-0 items-center gap-1 px-2',
120
192
  !row && 'transition-colors',
121
193
  // A row is NavItem's shape and colour: 6px corners, secondary words that brighten on hover.
122
194
  row ? cn(SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES) : 'rounded-lg',
@@ -124,7 +196,10 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
124
196
  // lights up, a fixed heading over rows does not (2026-09-09, the
125
197
  // Sidebar's fixed group).
126
198
  // A quiet label never lights up: it is a label; its button does.
127
- hover === 'fill' && look !== 'quiet' && (chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
199
+ hover === 'fill' && look !== 'quiet' && (chevron || acts) && 'hover:bg-bg-hover',
200
+ // While its menu is open the pointer is on the menu, not the row: the
201
+ // row keeps its hover look, as a NavItem's does.
202
+ hover === 'fill' && look !== 'quiet' && menu != null && 'has-[[data-section-menu]_[aria-expanded=true]]:bg-bg-hover',
128
203
  className,
129
204
  )}
130
205
  >
@@ -132,23 +207,36 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
132
207
  {/* Outside the title button, like the actions: a chip inside a button
133
208
  would be part of the button's accessible name and part of its hit
134
209
  target, and the count is neither. */}
135
- {trailing != null && <div className="flex shrink-0 items-center">{trailing}</div>}
136
- {actions && actions.length > 0 && (
210
+ {trailing != null && <div className={cn('flex shrink-0 items-center', trailingClasses)}>{trailing}</div>}
211
+ {acts && (
137
212
  <div
138
213
  // `-mr-1`: an IconButton is 24px around a 16px icon, so at the row's
139
214
  // `px-2` its icon ended 12px from the edge while a NavItem's number
140
215
  // ends 8px from it; pulled 4px, the icon and the number share a
141
216
  // right edge (Katerina, 2026-09-09; measured 4px off, then 0).
142
- className={cn(
143
- '-mr-1 flex shrink-0 items-center gap-1',
144
- showActions === 'hover' && 'opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100',
145
- )}
217
+ className="-mr-1 flex shrink-0 items-center"
146
218
  >
147
- {actions.map((action) => (
148
- <IconButton key={action.tooltip} tooltip={action.tooltip} aria-label={action.tooltip} onClick={action.onClick}>
149
- {action.icon}
150
- </IconButton>
151
- ))}
219
+ <div className={cn('flex items-center gap-1', reveal)}>
220
+ {actions?.map((action) => (
221
+ <IconButton key={action.tooltip} tooltip={action.tooltip} aria-label={action.tooltip} onClick={action.onClick}>
222
+ {action.icon}
223
+ </IconButton>
224
+ ))}
225
+ {menu != null && (
226
+ <div data-section-menu className="flex items-center">
227
+ <Menu
228
+ align="right"
229
+ trigger={
230
+ <IconButton tooltip="More options" aria-label={menuLabel ?? `More options for ${title}`}>
231
+ <IconDotsVertical size={16} stroke={1.5} />
232
+ </IconButton>
233
+ }
234
+ >
235
+ {menu}
236
+ </Menu>
237
+ </div>
238
+ )}
239
+ </div>
152
240
  </div>
153
241
  )}
154
242
  </div>
@@ -45,9 +45,12 @@ export function SectionLabel({
45
45
  return (
46
46
  <span
47
47
  className={cn(
48
- 'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
49
- // Leaf's section labels are Signal's type (Katerina, 28 September),
50
- // but a group that folds keeps Ship's (Katerina, 2 October).
48
+ 'text-h5 leading-3',
49
+ // Ship's type in every theme but Leaf, whose section labels are the
50
+ // micro-label (Katerina, 28 September) unless the group folds (2
51
+ // October). Signal dropped its micro-label on 5 October: every one of
52
+ // Peek's section labels is Ship's type, folding or not, so Desk's
53
+ // Screener and its Urgent and Open work read as one.
51
54
  !folds && 'leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest',
52
55
  tone === 'secondary' ? 'text-text-secondary' : tone === 'muted' ? 'text-text-muted' : 'text-text-primary',
53
56
  truncate && 'min-w-0 truncate',
@@ -104,6 +104,14 @@ describe('Select', () => {
104
104
  expect(screen.getByRole('combobox', { name: 'Status' }).getAttribute('aria-expanded')).toBe('true')
105
105
  })
106
106
 
107
+ it('puts 2px between options, as between a menu’s rows', async () => {
108
+ const user = userEvent.setup()
109
+ render(<Controlled initial="todo" />)
110
+ await user.click(screen.getByRole('combobox', { name: 'Status' }))
111
+ const [first] = await screen.findAllByRole('option')
112
+ expect(first.parentElement!.className.split(' ')).toContain('gap-0.5')
113
+ })
114
+
107
115
  it('opens from the keyboard, moves with arrows, picks with Enter, and gives focus back', async () => {
108
116
  const user = userEvent.setup()
109
117
  const onChange = vi.fn()
package/src/Select.tsx CHANGED
@@ -174,8 +174,11 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
174
174
  * to the right side". The rows keep their 4px inset, because the
175
175
  * padding that was the panel's is the content's; and the cap loses
176
176
  * its `- 0.5rem`, because that padding is inside the box that
177
- * scrolls now, so 288px stays 288px. */}
178
- <ScrollArea viewportClassName="max-h-[min(288px,var(--available-height))]" contentClassName="flex flex-col p-2">
177
+ * scrolls now, so 288px stays 288px.
178
+ *
179
+ * `gap-0.5`: 2px between the options, as between a menu's rows —
180
+ * they touched, while every menu had moved to 2px (0.56.0). */}
181
+ <ScrollArea viewportClassName="max-h-[min(288px,var(--available-height))]" contentClassName="flex flex-col gap-0.5 p-2">
179
182
  {options.map((option) => (
180
183
  <BaseSelect.Item
181
184
  key={option.value}
package/src/Sidebar.tsx CHANGED
@@ -35,7 +35,7 @@ export function Sidebar({ 'aria-label': ariaLabel = 'Workspace', children, class
35
35
  {/* The rows scroll in a ScrollArea: the bar takes no width, so the
36
36
  column's padding reads the same with a long list as with a short
37
37
  one (Katerina, 2026-09-08). The padding and the gap are the box's. */}
38
- <ScrollArea className="min-h-0 flex-1" contentClassName="flex flex-col gap-px px-2.5 py-3">
38
+ <ScrollArea className="min-h-0 flex-1" contentClassName="flex flex-col gap-0.5 px-2.5 py-3">
39
39
  {children}
40
40
  </ScrollArea>
41
41
  </nav>
@@ -83,6 +83,21 @@ export const Default: Story = {
83
83
  ),
84
84
  }
85
85
 
86
+ /** No heading: rows the editor ranks itself, 2px apart as under one. */
87
+ export const NoHeading: Story = {
88
+ render: () => (
89
+ <AtACaret<Command>
90
+ ariaLabel="Commands"
91
+ width="w-[300px]"
92
+ maxHeight="max-h-[400px]"
93
+ sections={[{ items: FORMAT }]}
94
+ itemKey={(c) => c.id}
95
+ row={commandRow}
96
+ onSelect={() => {}}
97
+ />
98
+ ),
99
+ }
100
+
86
101
  /** Tall rows: a face, a second line, and Enter's hint on the highlighted one. */
87
102
  export const TallRows: Story = {
88
103
  render: () => (
@@ -48,6 +48,14 @@ describe('SuggestionMenu', () => {
48
48
  expect(screen.getByRole('group', { name: 'Second' })).toBeTruthy()
49
49
  })
50
50
 
51
+ it('puts 2px between rows, with a heading or without one', async () => {
52
+ draw({ sections: [{ items: ['One', 'Two'] }, { label: 'Second', items: ['Three'] }] })
53
+ const list = await screen.findByRole('listbox')
54
+ // The rows with no heading are the listbox's own; under a heading, MenuSection's.
55
+ expect(list.className.split(' ')).toContain('gap-0.5')
56
+ expect(screen.getByRole('group', { name: 'Second' }).firstElementChild!.className.split(' ')).toContain('gap-0.5')
57
+ })
58
+
51
59
  it('draws nothing when there is nothing to show', () => {
52
60
  draw({ sections: [{ label: 'First', items: [] }] })
53
61
  expect(screen.queryByRole('listbox')).toBeNull()
@@ -177,8 +177,10 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
177
177
 
178
178
  return (
179
179
  <Popover anchor={rect} open={rect !== null} side="top" align="left" className={width} maxHeight={maxHeight} ariaLabel={ariaLabel}>
180
- {/* The list the editor drives from its caret: a listbox, because Base UI's lists need a text field of their own. */}
181
- <div id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col">
180
+ {/* The list the editor drives from its caret: a listbox, because Base UI's lists need a text field of their own.
181
+ `gap-0.5`: 2px between rows, as a menu's — a section with a heading
182
+ had them from `MenuSection`, and rows without one touched. */}
183
+ <div id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col gap-0.5">
182
184
  {shown.map((section, i) => (
183
185
  <div key={section.label ?? i} className="contents">
184
186
  {i > 0 && <MenuSeparator />}
@@ -27,7 +27,7 @@ const write = (path: string, text: string) => {
27
27
  mkdirSync(dirname(join(root, path)), { recursive: true })
28
28
  writeFileSync(join(root, path), text)
29
29
  }
30
- const SECTION_LABEL = 'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-secondary'
30
+ const SECTION_LABEL = 'text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary'
31
31
  const registry = (entries: { name: string; sourceFile: string; looks: string[] }[]) => JSON.stringify({ schemaVersion: 2, entries })
32
32
  write('app/package.json', '{ "name": "app" }')
33
33
  write(
@@ -78,7 +78,7 @@ tester.run('no-copied-look', noCopiedLook, {
78
78
  {
79
79
  name: 'the same look, the words in another order and one class more',
80
80
  filename: app,
81
- code: probe('<span className="text-text-secondary signal:tracking-widest signal:uppercase text-h5 signal:text-small signal:font-mono leading-3 shrink-0">x</span>'),
81
+ code: probe('<span className="text-text-secondary leaf:tracking-widest leaf:uppercase text-h5 leaf:text-small leaf:font-mono leading-3 shrink-0">x</span>'),
82
82
  errors: [{ messageId: 'usePart' }],
83
83
  },
84
84
  {
@@ -235,7 +235,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
235
235
  { what: 'a title= is an error naming WithTooltip', run: probe(`import { Button } from '@estiva-app/ui'\n${component('<Button title="Delete">x</Button>')}`, 'error', 'WithTooltip') },
236
236
  ]),
237
237
  ticket('UIG-25', [
238
- { what: "SectionLabel's class list, typed by hand, is a warning", run: probe(component('<span className="text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-secondary">Label</span>'), 'warning', 'SectionLabel') },
238
+ { what: "SectionLabel's class list, typed by hand, is a warning", run: probe(component('<span className="text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary">Label</span>'), 'warning', 'SectionLabel') },
239
239
  ]),
240
240
  ticket('UIG-27', [
241
241
  { what: 'the installed package has Link', run: () => h.contains(installed, /\bLink\b/, 'the installed @estiva-app/ui exports Link') },
package/src/looks.ts CHANGED
@@ -48,6 +48,12 @@ export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:
48
48
  export const SIDEBAR_ROW_CLASSES = 'rounded-lg transition-colors'
49
49
  export const SIDEBAR_ROW_TEXT_CLASSES = 'text-text-secondary hover:text-text-primary'
50
50
 
51
+ /** A quiet icon control: secondary, brightening on a hover fill — IconButton's `muted`. */
52
+ export const MUTED_CONTROL_CLASSES = 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
53
+
54
+ /** A control that opens a menu: a hover fill, held while its menu is up — PersonTrigger's row, and a ContainerHeader title with a menu. */
55
+ export const MENU_TRIGGER_CLASSES = 'cursor-pointer rounded-md transition-colors hover:bg-bg-hover data-[popup-open]:bg-bg-hover'
56
+
51
57
  /**
52
58
  * A panel that slides open and shut on Base UI's Collapsible: Base UI measures
53
59
  * it and writes its height to a variable, `auto` again once the slide ends, and