@estiva-app/ui 0.54.0 → 0.56.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 (69) hide show
  1. package/dist/BlockHandle.d.ts +47 -0
  2. package/dist/BlockHandle.d.ts.map +1 -0
  3. package/dist/CollapsibleSection.d.ts +7 -1
  4. package/dist/CollapsibleSection.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/EditorBlockHandle.d.ts +57 -0
  8. package/dist/EditorBlockHandle.d.ts.map +1 -0
  9. package/dist/IconButton.d.ts.map +1 -1
  10. package/dist/ListColumn.d.ts +2 -0
  11. package/dist/ListColumn.d.ts.map +1 -1
  12. package/dist/Menu.d.ts.map +1 -1
  13. package/dist/NavItem.d.ts.map +1 -1
  14. package/dist/NavTree.d.ts +9 -1
  15. package/dist/NavTree.d.ts.map +1 -1
  16. package/dist/PersonTrigger.d.ts.map +1 -1
  17. package/dist/SectionHeader.d.ts +22 -3
  18. package/dist/SectionHeader.d.ts.map +1 -1
  19. package/dist/SectionLabel.d.ts.map +1 -1
  20. package/dist/editor.d.ts +1 -0
  21. package/dist/editor.d.ts.map +1 -1
  22. package/dist/editor.js +127 -11
  23. package/dist/editor.js.map +4 -4
  24. package/dist/gates/index.js +1 -1
  25. package/dist/gates/index.js.map +2 -2
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +831 -624
  29. package/dist/index.js.map +4 -4
  30. package/dist/looks.d.ts +4 -0
  31. package/dist/looks.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/registry.json +302 -23
  34. package/src/BlockHandle.mdx +58 -0
  35. package/src/BlockHandle.stories.tsx +44 -0
  36. package/src/BlockHandle.test.tsx +216 -0
  37. package/src/BlockHandle.tsx +220 -0
  38. package/src/CollapsibleSection.stories.tsx +14 -1
  39. package/src/CollapsibleSection.tsx +10 -4
  40. package/src/ContainerHeader.mdx +4 -0
  41. package/src/ContainerHeader.stories.tsx +14 -0
  42. package/src/ContainerHeader.test.tsx +8 -0
  43. package/src/ContainerHeader.tsx +34 -6
  44. package/src/EditorBlockHandle.mdx +65 -0
  45. package/src/EditorBlockHandle.stories.tsx +81 -0
  46. package/src/EditorBlockHandle.tsx +181 -0
  47. package/src/IconButton.tsx +2 -1
  48. package/src/ListColumn.tsx +4 -2
  49. package/src/Menu.tsx +5 -4
  50. package/src/NavItem.test.tsx +2 -2
  51. package/src/NavItem.tsx +21 -12
  52. package/src/NavTree.mdx +16 -1
  53. package/src/NavTree.stories.tsx +49 -2
  54. package/src/NavTree.test.tsx +7 -0
  55. package/src/NavTree.tsx +21 -4
  56. package/src/PersonTrigger.tsx +6 -5
  57. package/src/SectionHeader.mdx +6 -0
  58. package/src/SectionHeader.stories.tsx +18 -0
  59. package/src/SectionHeader.test.tsx +26 -0
  60. package/src/SectionHeader.tsx +99 -23
  61. package/src/SectionLabel.tsx +6 -3
  62. package/src/Sidebar.tsx +1 -1
  63. package/src/editor.ts +1 -0
  64. package/src/eslint/no-copied-look.test.ts +2 -2
  65. package/src/gates/app-checks.ts +1 -1
  66. package/src/index.ts +1 -0
  67. package/src/looks.ts +6 -0
  68. package/src/registry/registry.test.ts +5 -5
  69. package/stories/Choosing.mdx +2 -0
package/src/NavTree.tsx CHANGED
@@ -52,6 +52,10 @@ export interface NavTreeGroup {
52
52
  trailing?: ReactNode
53
53
  /** Beside the title, shown on hover or focus: "Open", typically. */
54
54
  actions?: SectionAction[]
55
+ /** The group's "More options" menu, after its actions: `MenuItem`s — a folder's Rename, Archive. */
56
+ menu?: ReactNode
57
+ /** 16px, before the title — a folder's; the arrow takes its place on hover. */
58
+ icon?: ReactNode
55
59
  defaultOpen?: boolean
56
60
  /** The caller owns whether the group is open. */
57
61
  open?: boolean
@@ -146,6 +150,8 @@ function Group({ group, selected, onSelect, storageKey }: { group: NavTreeGroup
146
150
  loading={group.nodes === null}
147
151
  trailing={group.trailing}
148
152
  actions={group.actions}
153
+ menu={group.menu}
154
+ icon={group.icon}
149
155
  defaultOpen={group.defaultOpen ?? holds(group.nodes, selected)}
150
156
  open={group.open}
151
157
  onOpenChange={group.onOpenChange}
@@ -167,6 +173,10 @@ export interface NavTreeSectionProps {
167
173
  trailing?: ReactNode
168
174
  /** Beside the title, shown on hover or focus: "Open", typically. */
169
175
  actions?: SectionAction[]
176
+ /** The group's "More options" menu, after its actions: `MenuItem`s. */
177
+ menu?: ReactNode
178
+ /** 16px, before the title; the arrow takes its place on hover. */
179
+ icon?: ReactNode
170
180
  /** Open unless told otherwise. */
171
181
  defaultOpen?: boolean
172
182
  /** The caller owns whether the group is open. */
@@ -189,13 +199,17 @@ export interface NavTreeSectionProps {
189
199
  * `groups`; an app whose groups each read their own data draws them itself,
190
200
  * as `NavTree`'s children.
191
201
  */
192
- export function NavTreeSection({ id, title, message, loading = false, trailing, actions, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) {
202
+ export function NavTreeSection({ id, title, message, loading = false, trailing, actions, menu, icon, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) {
193
203
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
194
204
  useEffect(() => () => clearTimeout(timer.current), [])
195
205
  const intent = onIntent
196
206
  return (
197
207
  <div
198
- className="shrink-0"
208
+ // 4px between groups, none above the first: a folded group's heading
209
+ // sits 38px from the next (Katerina, 5 October: 42 was too far from rows
210
+ // 33 apart; halved), and the list starts 16px under its header, as every
211
+ // list column does (on the first group it read 20, Topics against People).
212
+ className="mt-1 shrink-0 first:mt-0"
199
213
  data-nav-group={id}
200
214
  onPointerEnter={intent ? () => { clearTimeout(timer.current); timer.current = setTimeout(intent, NAV_TREE_INTENT_MS) } : undefined}
201
215
  onPointerLeave={intent ? () => clearTimeout(timer.current) : undefined}
@@ -210,8 +224,11 @@ export function NavTreeSection({ id, title, message, loading = false, trailing,
210
224
  storageKey={storageKey}
211
225
  trailing={trailing}
212
226
  actions={actions}
213
- className="mt-2 shrink-0"
214
- contentClassName="gap-px"
227
+ menu={menu}
228
+ icon={icon}
229
+ className="shrink-0"
230
+ // 2px between rows, as a list column's and a menu's (Katerina, 5 October).
231
+ contentClassName="gap-0.5"
215
232
  >
216
233
  {message !== undefined ? <Line message={message} /> : loading ? <SkeletonList rows={3} /> : children}
217
234
  </CollapsibleSection>
@@ -2,6 +2,7 @@ import type { ComponentPropsWithRef } from 'react'
2
2
  import { Button as BaseButton } from '@base-ui/react/button'
3
3
  import { IconChevronDown } from '@tabler/icons-react'
4
4
  import { cn } from './cn'
5
+ import { MENU_TRIGGER_CLASSES } from './looks'
5
6
  import { Avatar } from './Avatar'
6
7
  import { Person, type PersonProps } from './Person'
7
8
 
@@ -66,13 +67,13 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
66
67
  aria-haspopup="menu"
67
68
  aria-expanded={open}
68
69
  className={cn(
69
- 'flex h-8 min-h-8 cursor-pointer items-center gap-1.5 rounded-md pl-1.5 pr-1.5 text-body-2 text-text-primary transition-colors hover:bg-bg-hover',
70
+ 'flex h-8 min-h-8 items-center gap-1.5 pl-1.5 pr-1.5 text-body-2 text-text-primary',
71
+ MENU_TRIGGER_CLASSES,
70
72
  open && 'bg-bg-hover',
71
73
  // Under a `Menu` the open state is Base UI's, not a prop: the trigger
72
- // carries `data-popup-open` while its menu is up, and sets its own
73
- // `aria-expanded`. Both spellings hold the fill, so this works whether
74
- // the caller drives it or the menu does.
75
- 'data-[popup-open]:bg-bg-hover',
74
+ // carries `data-popup-open` while its menu is up (MENU_TRIGGER_CLASSES
75
+ // holds the fill for it), and sets its own `aria-expanded`; `open`
76
+ // holds it for a caller that drives it.
76
77
  className,
77
78
  )}
78
79
  {...props}
@@ -24,6 +24,12 @@ title the toggle — that is where a section folds.
24
24
 
25
25
  <Canvas of={SectionHeaderStories.PersistentActions} />
26
26
 
27
+ - `menu` gives the section a "More options" ⋮ after its actions, with what
28
+ acts on the whole section — a folder's Rename, Archive. It is **NavItem**'s
29
+ ⋮, revealed with the actions, and stays while its menu is open.
30
+
31
+ <Canvas of={SectionHeaderStories.WithMenu} />
32
+
27
33
  - `look="quiet"`: a small grey label over a list whose rows are rows, not
28
34
  headings — "Folders" over the folders — with its action held on screen.
29
35
  It never lights up under the pointer; its button does.
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconPlus, IconSortDescending } from '@tabler/icons-react'
3
3
  import { Chip } from './Chip'
4
+ import { MenuItem, MenuSeparator } from './Menu'
4
5
  import { SectionHeader } from './SectionHeader'
5
6
 
6
7
  /** The 32px row a section starts with. Hover it: the row fills, and its actions appear. A section that folds is CollapsibleSection, whose header this is. */
@@ -49,6 +50,23 @@ export const StillOnHover: Story = {
49
50
  },
50
51
  }
51
52
 
53
+ /** `menu`: the section's "More options", after its actions and revealed with them. The row keeps its hover look while the menu is open. */
54
+ export const WithMenu: Story = {
55
+ args: {
56
+ chevron: true,
57
+ isExpanded: true,
58
+ onToggle: () => {},
59
+ menu: (
60
+ <>
61
+ <MenuItem label="Add" />
62
+ <MenuItem label="Rename" />
63
+ <MenuSeparator />
64
+ <MenuItem label="Delete" destructive />
65
+ </>
66
+ ),
67
+ },
68
+ }
69
+
52
70
  /** A count beside the title, held on screen while the actions come and go. */
53
71
  export const WithTrailing: Story = {
54
72
  args: {
@@ -7,7 +7,9 @@
7
7
  import { afterEach, describe, expect, it, vi } from 'vitest'
8
8
  import { cleanup, render, screen } from '@testing-library/react'
9
9
  import userEvent from '@testing-library/user-event'
10
+ import { MenuItem } from './Menu'
10
11
  import { SectionHeader } from './SectionHeader'
12
+ import { UnreadDot } from './UnreadDot'
11
13
 
12
14
  afterEach(cleanup)
13
15
 
@@ -71,4 +73,28 @@ describe('SectionHeader, the trailing slot', () => {
71
73
  // Not part of the hover reveal either — a count is information, not an affordance.
72
74
  expect(count.closest('.opacity-0')).toBe(null)
73
75
  })
76
+
77
+ it('a dot sits in the last button’s place and steps aside for it; a count stays beside the buttons', () => {
78
+ const { container } = render(
79
+ <SectionHeader title="Section" trailing={<UnreadDot />} actions={[{ icon: <span />, tooltip: 'Open', onClick: () => {} }]} menu={<MenuItem label="Rename" />} />,
80
+ )
81
+ const dot = container.querySelector('[data-unread]')!
82
+ // Inside the buttons' box, over the last one — not a slot of its own before them.
83
+ expect(dot.closest('[class~="group/acts"]')).not.toBe(null)
84
+ expect(dot.parentElement!.className).toContain('group-hover:opacity-0')
85
+ expect(screen.getByRole('button', { name: 'More options for Section' })).not.toBeNull()
86
+ })
87
+
88
+ it('an icon shows at rest, and folding, the arrow is in its place', () => {
89
+ const { container } = render(<SectionHeader title="Section" chevron icon={<span data-icon />} onToggle={() => {}} />)
90
+ const icon = container.querySelector('[data-icon]')!
91
+ expect(icon.parentElement!.className).toContain('group-hover:opacity-0')
92
+ // The arrow is the icon's sibling, hidden until the pointer comes.
93
+ expect(icon.parentElement!.parentElement!.querySelector('svg')!.getAttribute('class')).toContain('group-hover:opacity-100')
94
+ })
95
+
96
+ it('a dot with no buttons sits on their axis all the same', () => {
97
+ const { container } = render(<SectionHeader title="Section" trailing={<UnreadDot />} />)
98
+ expect(container.querySelector('[data-unread]')!.parentElement!.className).toContain('-mr-1')
99
+ })
74
100
  })
@@ -1,10 +1,12 @@
1
- import type { ReactNode } from 'react'
2
- import { IconChevronRight } from '@tabler/icons-react'
1
+ import { isValidElement, type ReactNode } from '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'
9
+ import { UnreadDot } from './UnreadDot'
8
10
 
9
11
  /**
10
12
  * The 32px row a section starts with (Peek's SectionHeader, 2026-09-01):
@@ -45,10 +47,19 @@ export interface SectionHeaderProps {
45
47
  title: string
46
48
  /** Collapsible: draws the chevron and makes the title a button that toggles. */
47
49
  chevron?: boolean
50
+ /**
51
+ * 16px, stroke 1.5, before the title — a folder's (Katerina, 5 October:
52
+ * Peek's Folders, the standard heading with a folder icon). With `chevron`
53
+ * the icon is what shows at rest and the arrow takes its place under the
54
+ * pointer or the keyboard, as a NavItem's icon turns into its arrow.
55
+ */
56
+ icon?: ReactNode
48
57
  isExpanded?: boolean
49
58
  onToggle?: () => void
50
59
  /**
51
- * Beside the title and always visible, before the actions — a count.
60
+ * Beside the title and always visible, before the actions — a count. An
61
+ * `UnreadDot` instead sits in the last action's place (the ⋮'s, with a
62
+ * `menu`) and steps aside while the actions show, as a NavItem's dot does.
52
63
  *
53
64
  * The actions come and go with the hover; this does not, because a count is
54
65
  * information rather than an affordance. Peek's Screener header carries its
@@ -58,6 +69,16 @@ export interface SectionHeaderProps {
58
69
  trailing?: ReactNode
59
70
  /** Right-aligned, in the order given. */
60
71
  actions?: SectionAction[]
72
+ /**
73
+ * The heading's "More options" menu: `MenuItem`s — what acts on the whole
74
+ * section, a folder's Rename or Archive (Katerina, 5 October: Peek's Folders
75
+ * page goes, and its folder actions move onto the folder's heading). The ⋮
76
+ * is NavItem's, after the actions and revealed with them; it stays while its
77
+ * menu is open.
78
+ */
79
+ menu?: ReactNode
80
+ /** The ⋮'s accessible name. Default "More options for" the title. */
81
+ menuLabel?: string
61
82
  /** `hover` reveals the actions while the row is hovered or focused; `always` keeps them. */
62
83
  showActions?: 'hover' | 'always'
63
84
  /**
@@ -82,23 +103,51 @@ export interface SectionHeaderProps {
82
103
  className?: string
83
104
  }
84
105
 
85
- export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
106
+ export function SectionHeader({ title, chevron = false, icon, isExpanded = true, onToggle, trailing, actions, menu, menuLabel, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
86
107
  const row = look === 'row'
108
+ /*
109
+ One arrow for everything that folds, a NavItem's 16px, and the title 8px
110
+ after it as a row's label is (Katerina, 5 October: a folder heading and
111
+ the rows under it fold with one arrow; then every folding heading, Peek's
112
+ Starred too). It was 12px with a 4px gap; with an icon, the arrow now
113
+ fills the icon's place exactly.
114
+ */
115
+ const acts = (actions && actions.length > 0) || menu != null
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
+ const dot = isValidElement(trailing) && trailing.type === UnreadDot
120
+ const shared = acts && showActions === 'hover' && dot
121
+ const reveal = showActions === 'hover' && 'opacity-0 transition-opacity group-hover:opacity-100 has-[:focus-visible]:opacity-100 has-[[aria-expanded=true]]:opacity-100'
87
122
  const titleElement = useRender({
88
123
  render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
89
124
  props: {
90
125
  // `text-left`: a button centres its text. `h-full` and `flex-1`: the
91
126
  // whole row up to the actions is the hit target, as it was when the
92
127
  // 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'),
128
+ // An icon is a row's: NavItem's 8px after it.
129
+ className: cn('flex h-full min-w-0 flex-1 items-center text-left', row || chevron || icon != null ? 'gap-2' : 'gap-1'),
94
130
  children: (
95
131
  <>
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
- />
132
+ {icon != null ? (
133
+ <span className="relative flex size-4 shrink-0 items-center justify-center text-text-secondary">
134
+ <span className={cn('flex', chevron && 'transition-opacity group-hover:opacity-0 group-has-[:focus-visible]:opacity-0')}>{icon}</span>
135
+ {chevron && (
136
+ <IconChevronRight
137
+ size={16}
138
+ stroke={1.5}
139
+ className={cn('absolute opacity-0 transition-[opacity,transform] duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100', isExpanded && 'rotate-90')}
140
+ />
141
+ )}
142
+ </span>
143
+ ) : (
144
+ chevron && (
145
+ <IconChevronRight
146
+ size={16}
147
+ stroke={1.5}
148
+ className={cn('shrink-0 transition-transform duration-150', !row && 'text-text-secondary', isExpanded && 'rotate-90')}
149
+ />
150
+ )
102
151
  )}
103
152
  {look === 'quiet' ? (
104
153
  <span className="min-w-0 truncate text-caption text-text-muted">{title}</span>
@@ -124,7 +173,10 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
124
173
  // lights up, a fixed heading over rows does not (2026-09-09, the
125
174
  // Sidebar's fixed group).
126
175
  // 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',
176
+ hover === 'fill' && look !== 'quiet' && (chevron || acts) && 'hover:bg-bg-hover',
177
+ // While its menu is open the pointer is on the menu, not the row: the
178
+ // row keeps its hover look, as a NavItem's does.
179
+ hover === 'fill' && look !== 'quiet' && menu != null && 'has-[[data-section-menu]_[aria-expanded=true]]:bg-bg-hover',
128
180
  className,
129
181
  )}
130
182
  >
@@ -132,23 +184,47 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
132
184
  {/* Outside the title button, like the actions: a chip inside a button
133
185
  would be part of the button's accessible name and part of its hit
134
186
  target, and the count is neither. */}
135
- {trailing != null && <div className="flex shrink-0 items-center">{trailing}</div>}
136
- {actions && actions.length > 0 && (
187
+ {trailing != null && !shared && (
188
+ // A dot with no buttons to share a place with still sits on their
189
+ // axis — the rows' dots and ⋮ (`-mr-1`, as the buttons are pulled).
190
+ <div className={cn('flex shrink-0 items-center', dot && !acts && '-mr-1')}>{trailing}</div>
191
+ )}
192
+ {acts && (
137
193
  <div
138
194
  // `-mr-1`: an IconButton is 24px around a 16px icon, so at the row's
139
195
  // `px-2` its icon ended 12px from the edge while a NavItem's number
140
196
  // ends 8px from it; pulled 4px, the icon and the number share a
141
197
  // 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
- )}
198
+ className="group/acts relative -mr-1 flex shrink-0 items-center"
146
199
  >
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
- ))}
200
+ <div className={cn('flex items-center gap-1', reveal)}>
201
+ {actions?.map((action) => (
202
+ <IconButton key={action.tooltip} tooltip={action.tooltip} aria-label={action.tooltip} onClick={action.onClick}>
203
+ {action.icon}
204
+ </IconButton>
205
+ ))}
206
+ {menu != null && (
207
+ <div data-section-menu className="flex items-center">
208
+ <Menu
209
+ align="right"
210
+ trigger={
211
+ <IconButton tooltip="More options" aria-label={menuLabel ?? `More options for ${title}`}>
212
+ <IconDotsVertical size={16} stroke={1.5} />
213
+ </IconButton>
214
+ }
215
+ >
216
+ {menu}
217
+ </Menu>
218
+ </div>
219
+ )}
220
+ </div>
221
+ {shared && (
222
+ // The dot in the last button's 24px place, on the axis of its icon;
223
+ // gone while the buttons show.
224
+ <div className="pointer-events-none absolute inset-y-0 right-0 flex w-6 items-center justify-center transition-opacity group-hover:opacity-0 group-has-[:focus-visible]/acts:opacity-0 group-has-[[aria-expanded=true]]/acts:opacity-0">
225
+ {trailing}
226
+ </div>
227
+ )}
152
228
  </div>
153
229
  )}
154
230
  </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',
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>
package/src/editor.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
11
11
  export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
12
+ export { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
12
13
  export {
13
14
  PersonMention,
14
15
  UrgentPersonMention,
@@ -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/index.ts CHANGED
@@ -14,6 +14,7 @@ export { AttachmentCard, type AttachmentCardProps, type AttachmentCardState } fr
14
14
  export { Avatar, hueFor, initialsFor, type AvatarProps } from './Avatar'
15
15
  export { AvatarGroup, type AvatarGroupMember, type AvatarGroupProps } from './AvatarGroup'
16
16
  export { Banner, type BannerProps, type BannerTone } from './Banner'
17
+ export { BlockHandle, blockAtY, type BlockHandleProps, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
17
18
  export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button'
18
19
  export { Card, type CardAttention, type CardFill, type CardHover, type CardProps } from './Card'
19
20
  export { Checkbox, type CheckboxProps } from './Checkbox'
package/src/looks.ts CHANGED
@@ -45,6 +45,12 @@ export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:
45
45
 
46
46
  /** A sidebar row's shape and its words at rest, brightening under the pointer: NavItem, and SectionHeader's `row` (a folder among rows). */
47
47
  // 8px corners, a MenuItem row's (Katerina, 2 October).
48
+ /** A quiet icon control: secondary, brightening on a hover fill — IconButton's `muted`. */
49
+ export const MUTED_CONTROL_CLASSES = 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
50
+
51
+ /** 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. */
52
+ export const MENU_TRIGGER_CLASSES = 'cursor-pointer rounded-md transition-colors hover:bg-bg-hover data-[popup-open]:bg-bg-hover'
53
+
48
54
  export const SIDEBAR_ROW_CLASSES = 'rounded-lg transition-colors'
49
55
  export const SIDEBAR_ROW_TEXT_CLASSES = 'text-text-secondary hover:text-text-primary'
50
56
 
@@ -48,20 +48,20 @@ describe('the registry builds', () => {
48
48
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
49
49
  })
50
50
 
51
- it('counts 122 names over 67 files: 98 components, 23 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October)', () => {
51
+ it('counts 130 names over 69 files: 100 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, and moveBlock and blockDropCursor, the drop in the gutter, on 5 October)', () => {
52
52
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
53
53
  // The ten `EDITOR_EXTENSIONS` count as components by their names; they are
54
54
  // editor extensions, documented on SelectionToolbar's, BlockNodes' and
55
55
  // ComposerTriggers' pages.
56
56
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
57
- expect(kinds).toEqual({ component: 98, helper: 23, hook: 1 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(67)
57
+ expect(kinds).toEqual({ component: 100, helper: 29, hook: 1 })
58
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(69)
59
59
  })
60
60
 
61
61
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
62
62
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
63
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(63)
64
- expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(59)
63
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(65)
64
+ expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(65)
65
65
  })
66
66
 
67
67
  it('takes a name with a page of its own from the page, and a name without one from the code', () => {
@@ -70,6 +70,8 @@ fork freely, owe nothing back, mention it on the package's ticket.
70
70
  | Bold, italic and the like over selected text in an editor | **SelectionToolbar**, from `@estiva-app/ui/editor` — it follows the selection by itself |
71
71
  | A composer's `@`, `!@`, `[` and `/` — mention a person, reference something, format or insert | **ComposerTriggers** — `PersonMention`, `UrgentPersonMention`, `CaptionedReference`, `ReferenceTrigger` and `SlashCommands` from `@estiva-app/ui/editor`; the app passes its people, its items and its own `/` rows |
72
72
  | An editor that saves a block document and must keep its block ids | **BlockNodes** — `BlockId`, `UnknownBlock`, `ReferenceNode` and `AttachmentNode` from `@estiva-app/ui/editor`, beside StarterKit and TableKit |
73
+ | Move, turn, duplicate or delete one block of a document being edited | **EditorBlockHandle**, from `@estiva-app/ui/editor`, with your rows after its own |
74
+ | Offer a link or a comment on one block of a document being read | **BlockHandle** — your rows only, and no Tiptap loaded |
73
75
  | Anything with a title, body and footer | **DialogShell** |
74
76
  | "Are you sure?" | **ConfirmDialog** |
75
77
  | A picture full screen, from a thumbnail | **Lightbox** — an **AttachmentCard** opens one by itself |