@estiva-app/ui 0.59.0 → 0.60.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 (71) hide show
  1. package/dist/AppProvider.d.ts +47 -0
  2. package/dist/AppProvider.d.ts.map +1 -0
  3. package/dist/AppShell.d.ts +6 -2
  4. package/dist/AppShell.d.ts.map +1 -1
  5. package/dist/Avatar.d.ts +1 -1
  6. package/dist/Avatar.d.ts.map +1 -1
  7. package/dist/Chip.d.ts.map +1 -1
  8. package/dist/IconButton.d.ts.map +1 -1
  9. package/dist/InlineChip.d.ts.map +1 -1
  10. package/dist/Link.d.ts +1 -1
  11. package/dist/Link.d.ts.map +1 -1
  12. package/dist/NavItem.d.ts +1 -1
  13. package/dist/NavItem.d.ts.map +1 -1
  14. package/dist/RailItem.d.ts +1 -1
  15. package/dist/RailItem.d.ts.map +1 -1
  16. package/dist/SearchInput.d.ts +3 -3
  17. package/dist/TopBar.d.ts +19 -3
  18. package/dist/TopBar.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +710 -642
  22. package/dist/index.js.map +4 -4
  23. package/dist/registry/{app-LDGOH6UW.js → app-X2GI4WTU.js} +2 -2
  24. package/dist/registry/app.d.ts.map +1 -1
  25. package/dist/registry/{chunk-TQ6UQER3.js → chunk-V67F474L.js} +8 -3
  26. package/dist/registry/chunk-V67F474L.js.map +7 -0
  27. package/dist/registry/cli.js +1 -1
  28. package/dist/registry/index.js +1 -1
  29. package/package.json +1 -1
  30. package/registry.json +87 -9
  31. package/skill/estiva-ui.md +2 -1
  32. package/src/AppProvider.mdx +69 -0
  33. package/src/AppProvider.stories.tsx +60 -0
  34. package/src/AppProvider.test.tsx +152 -0
  35. package/src/AppProvider.tsx +76 -0
  36. package/src/AppShell.mdx +4 -1
  37. package/src/AppShell.stories.tsx +5 -5
  38. package/src/AppShell.tsx +7 -3
  39. package/src/Avatar.tsx +7 -3
  40. package/src/Breadcrumb.mdx +2 -2
  41. package/src/Card.mdx +1 -1
  42. package/src/Chip.mdx +2 -2
  43. package/src/Chip.tsx +3 -1
  44. package/src/IconButton.mdx +2 -2
  45. package/src/IconButton.tsx +4 -1
  46. package/src/InlineChip.mdx +3 -3
  47. package/src/InlineChip.tsx +3 -1
  48. package/src/Link.mdx +5 -3
  49. package/src/Link.tsx +4 -1
  50. package/src/NavItem.mdx +3 -3
  51. package/src/NavItem.tsx +4 -0
  52. package/src/NavTree.mdx +3 -2
  53. package/src/RailItem.mdx +3 -3
  54. package/src/RailItem.tsx +4 -1
  55. package/src/SearchInput.mdx +3 -4
  56. package/src/SearchInput.tsx +3 -3
  57. package/src/Sidebar.mdx +2 -2
  58. package/src/TopBar.mdx +20 -9
  59. package/src/TopBar.stories.tsx +22 -4
  60. package/src/TopBar.test.tsx +121 -0
  61. package/src/TopBar.tsx +50 -4
  62. package/src/index.ts +1 -0
  63. package/src/pages.test.ts +3 -1
  64. package/src/registry/app.test.ts +24 -6
  65. package/src/registry/app.ts +21 -2
  66. package/src/registry/contract.test.ts +1 -1
  67. package/src/registry/gated.test.ts +1 -0
  68. package/src/registry/registry.test.ts +4 -4
  69. package/stories/Choosing.mdx +2 -0
  70. package/dist/registry/chunk-TQ6UQER3.js.map +0 -7
  71. /package/dist/registry/{app-LDGOH6UW.js.map → app-X2GI4WTU.js.map} +0 -0
@@ -13,9 +13,9 @@ import { BARE_INPUT_CLASSES, FIELD_SHELL_CLASSES } from './looks'
13
13
  * The one product string — Peek's default placeholder "Search Peek..." —
14
14
  * stays in Peek; the default here says only "Search…".
15
15
  *
16
- * In Peek's top bar this is a launcher affordance rather than a live field:
17
- * the input is `pointer-events-none` and clicking the surround opens the
18
- * command launcher. The component is the same either way.
16
+ * The top bar's way into a launcher is not this: `TopBar`'s `onSearch` draws
17
+ * this look as a button and answers Ctrl+K itself (8 October). Peek used to
18
+ * make this field inert and catch the click around it.
19
19
  *
20
20
  * On Base UI's `Input` since stage 3 of the migration (2026-09-07), so a
21
21
  * search field inside a `Field` is labelled by it without the caller wiring
package/src/Sidebar.mdx CHANGED
@@ -48,8 +48,8 @@ import { Sidebar, NavItem, CollapsibleSection, SectionHeader } from '@estiva-app
48
48
  ```
49
49
 
50
50
  **Every row is a plain anchor, so in a router app each click would reload
51
- the page.** Keep NavItem's rule: a thin wrapper computes `active` from the
52
- location, intercepts the click and navigates in place — see **NavItem**.
51
+ the page** — unless the app gives **AppProvider**'s `navigate`, which
52
+ changes page in place. See **NavItem**.
53
53
 
54
54
  Anything you place directly in the column needs `shrink-0` (both headings
55
55
  above take it in `className`):
package/src/TopBar.mdx CHANGED
@@ -5,7 +5,7 @@ import * as TopBarStories from './TopBar.stories'
5
5
 
6
6
  # TopBar
7
7
 
8
- The 52px top bar: `menu` and `logo` on the left, a `search` slot in the
8
+ The 52px top bar: `menu` and `logo` on the left, the search field in the
9
9
  centre, the identity cluster on the right. Three manners — **`solid`**,
10
10
  in flow with a hairline and a surface; **`inset`**, in flow with neither,
11
11
  standing on the ground; and **`floating`**, an overlay with neither, whose
@@ -27,8 +27,12 @@ bar ignores the pointer so the content underneath stays reachable.
27
27
  - `menu` is the menu button, in an app whose navigation collapses — the
28
28
  collapsing itself stays the caller's. `logo` sits beside it: the app's
29
29
  name as text, or a mark.
30
- - The `search` slot holds its place even while empty, so a search field
31
- drops in later without moving anything.
30
+ - `onSearch` is what searching opens — your command launcher. With it the
31
+ bar draws the search field itself: **SearchInput**'s look, 290px, as a
32
+ button showing `searchPlaceholder` ("Search…") and the shortcut. A click on
33
+ it, and Ctrl+K (Cmd+K on a Mac) anywhere on the page, call `onSearch`.
34
+ - The centre holds its place even while empty, so search drops in later
35
+ without moving anything.
32
36
 
33
37
  <Canvas of={TopBarStories.FloatingNoSearch} />
34
38
 
@@ -37,25 +41,31 @@ bar ignores the pointer so the content underneath stays reachable.
37
41
  - Page-level headers inside the content → not this; this is the frame's
38
42
  bar, once per app. The top of a column is **ContainerHeader**.
39
43
  - Anything the bar would need to know — who you are, what is searched —
40
- stays outside: pass composed slots.
44
+ stays outside: pass composed slots, and what searching opens as `onSearch`.
45
+ - A search field of your own in the centre → only for something the bar's
46
+ does not do; pass it as `search`, which takes the centre (`onSearch` still
47
+ answers Ctrl+K). A field that filters a list sits above that list:
48
+ **SearchInput**.
41
49
 
42
50
  ## How
43
51
 
44
52
  ```tsx
45
- import { TopBar, IdentityMenu, SearchInput } from '@estiva-app/ui'
53
+ import { TopBar, IdentityMenu } from '@estiva-app/ui'
46
54
 
47
55
  <TopBar
48
56
  logo="Estiva"
49
- search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />}
57
+ onSearch={openLauncher}
58
+ searchPlaceholder="Search Estiva…"
50
59
  right={<IdentityMenu me={me} signedIn={signedIn} />}
51
60
  />
52
61
  ```
53
62
 
54
63
  - Geometry is fixed: 52px, `pl-5 pr-[26px]`, right cluster `gap-[6px]` —
55
64
  don't restyle it per app.
56
- - In `floating`, a launcher-affordance search is the caller's trick: keep
57
- the input `pointer-events-none` and open the launcher from a wrapper's
58
- `mousedown` (see SearchInput's page).
65
+ - `onSearch` is called on every Ctrl+K, open or not: to close the launcher
66
+ on a second Ctrl+K, toggle in your handler.
67
+ - A click on the field leaves focus where it was, so a launcher can act on the
68
+ text box and the selection you were in. Tab still reaches the field.
59
69
  - The frame is desktop-only for now — there is no narrow-screen
60
70
  behaviour; the menu button collapses panels, it does not open a drawer.
61
71
 
@@ -69,6 +79,7 @@ It does these by itself. Write none of them around it.
69
79
  | It does | So you never write | Refused when written by hand |
70
80
  |---|---|---|
71
81
  | Keeps its 52px in a scrolling column | `shrink-0` | |
82
+ | Answers Ctrl+K (Cmd+K) anywhere on the page, given `onSearch` | a `keydown` listener on the window | |
72
83
 
73
84
  ## Props
74
85
 
@@ -1,9 +1,8 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconMenu2 } from '@tabler/icons-react'
3
- import type { ReactNode } from 'react'
3
+ import { useState, type ReactNode } from 'react'
4
4
  import { IconButton } from './IconButton'
5
5
  import { PersonTrigger } from './PersonTrigger'
6
- import { SearchInput } from './SearchInput'
7
6
  import { TopBar } from './TopBar'
8
7
 
9
8
  /**
@@ -26,6 +25,7 @@ const meta = {
26
25
  menu: { control: false },
27
26
  logo: { control: false },
28
27
  search: { control: false },
28
+ onSearch: { control: false },
29
29
  right: { control: false },
30
30
  },
31
31
  } satisfies Meta<typeof TopBar>
@@ -60,11 +60,29 @@ const Behind = ({ children }: { children: ReactNode }) => (
60
60
  export const Solid: Story = {
61
61
  render: (args) => (
62
62
  <Below>
63
- <TopBar {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} right={face} />
63
+ <TopBar {...args} logo="Estiva" onSearch={() => {}} right={face} />
64
64
  </Below>
65
65
  ),
66
66
  }
67
67
 
68
+ /**
69
+ * The bar's own search field: a click on it, or Ctrl+K (Cmd+K on a Mac)
70
+ * anywhere, calls `onSearch` — here it counts, where an app opens its launcher.
71
+ */
72
+ export const Search: Story = {
73
+ render: function Search(args) {
74
+ const [opened, setOpened] = useState(0)
75
+ return (
76
+ <div className="flex h-40 flex-col overflow-hidden bg-bg-base">
77
+ <TopBar {...args} logo="Estiva" onSearch={() => setOpened((n) => n + 1)} searchPlaceholder="Search Estiva…" right={face} />
78
+ <div className="flex flex-1 items-center justify-center text-body-2 text-text-secondary">
79
+ {opened === 0 ? 'Click the field, or press Ctrl+K.' : `Search opened ${opened} ${opened === 1 ? 'time' : 'times'}.`}
80
+ </div>
81
+ </div>
82
+ )
83
+ },
84
+ }
85
+
68
86
  /** No search yet: the centre slot holds its place, so it drops in later without moving anything. */
69
87
  export const SolidNoSearch: Story = {
70
88
  render: (args) => (
@@ -78,7 +96,7 @@ export const SolidNoSearch: Story = {
78
96
  export const Floating: Story = {
79
97
  render: (args) => (
80
98
  <Behind>
81
- <TopBar {...args} variant="floating" menu={menuButton} search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} right={face} />
99
+ <TopBar {...args} variant="floating" menu={menuButton} onSearch={() => {}} right={face} />
82
100
  </Behind>
83
101
  ),
84
102
  }
@@ -0,0 +1,121 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The bar's own search field, pinned: given `onSearch` it draws a button with
4
+ * the words and the shortcut, a click and Ctrl+K or Cmd+K anywhere call it,
5
+ * a `search` of the caller's own still takes the centre, and without either
6
+ * the bar draws nothing there and leaves the keys alone.
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 { AppShell } from './AppShell'
12
+ import { TopBar } from './TopBar'
13
+
14
+ afterEach(cleanup)
15
+
16
+ const press = (init: KeyboardEventInit) => {
17
+ const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
18
+ window.dispatchEvent(event)
19
+ return event
20
+ }
21
+
22
+ describe('TopBar search', () => {
23
+ it('draws a search field that opens the search', async () => {
24
+ const onSearch = vi.fn()
25
+ render(<TopBar onSearch={onSearch} searchPlaceholder="Search Peek..." />)
26
+ const field = screen.getByRole('button', { name: /Search Peek\.\.\./ })
27
+ expect(field.textContent).toMatch(/Ctrl\+K|Cmd\+K/)
28
+ await userEvent.click(field)
29
+ expect(onSearch).toHaveBeenCalledTimes(1)
30
+ })
31
+
32
+ it('leaves focus where it was on a click', async () => {
33
+ const onSearch = vi.fn()
34
+ render(
35
+ <>
36
+ <input aria-label="composer" />
37
+ <TopBar onSearch={onSearch} />
38
+ </>,
39
+ )
40
+ const composer = screen.getByRole('textbox', { name: 'composer' })
41
+ composer.focus()
42
+ await userEvent.click(screen.getByRole('button', { name: /Search…/ }))
43
+ expect(onSearch).toHaveBeenCalledTimes(1)
44
+ expect(document.activeElement).toBe(composer)
45
+ })
46
+
47
+ it('is reached with Tab and pressed with Enter', async () => {
48
+ const onSearch = vi.fn()
49
+ render(<TopBar onSearch={onSearch} />)
50
+ await userEvent.tab()
51
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: /Search…/ }))
52
+ await userEvent.keyboard('{Enter}')
53
+ expect(onSearch).toHaveBeenCalledTimes(1)
54
+ })
55
+
56
+ it('says "Search…" when given no words', () => {
57
+ render(<TopBar onSearch={() => {}} />)
58
+ expect(screen.getByRole('button', { name: /Search…/ })).toBeTruthy()
59
+ })
60
+
61
+ it.each([
62
+ ['Ctrl+K', { ctrlKey: true, key: 'k' }],
63
+ ['Cmd+K', { metaKey: true, key: 'k' }],
64
+ ['Ctrl+Shift+K', { ctrlKey: true, shiftKey: true, key: 'K' }],
65
+ ])('opens the search on %s anywhere', (_name, init) => {
66
+ const onSearch = vi.fn()
67
+ render(<TopBar onSearch={onSearch} />)
68
+ const event = press(init)
69
+ expect(onSearch).toHaveBeenCalledTimes(1)
70
+ expect(event.defaultPrevented).toBe(true)
71
+ })
72
+
73
+ it('calls the latest handler', () => {
74
+ const first = vi.fn()
75
+ const second = vi.fn()
76
+ const { rerender } = render(<TopBar onSearch={first} />)
77
+ rerender(<TopBar onSearch={second} />)
78
+ press({ ctrlKey: true, key: 'k' })
79
+ expect(first).not.toHaveBeenCalled()
80
+ expect(second).toHaveBeenCalledTimes(1)
81
+ })
82
+
83
+ it('leaves K alone without a modifier, and the keys alone without onSearch', () => {
84
+ const onSearch = vi.fn()
85
+ const { unmount } = render(<TopBar onSearch={onSearch} />)
86
+ press({ key: 'k' })
87
+ expect(onSearch).not.toHaveBeenCalled()
88
+ unmount()
89
+ render(<TopBar />)
90
+ expect(press({ ctrlKey: true, key: 'k' }).defaultPrevented).toBe(false)
91
+ expect(screen.queryByRole('button')).toBeNull()
92
+ })
93
+
94
+ it('stops listening once it is gone', () => {
95
+ const onSearch = vi.fn()
96
+ const { unmount } = render(<TopBar onSearch={onSearch} />)
97
+ unmount()
98
+ press({ ctrlKey: true, key: 'k' })
99
+ expect(onSearch).not.toHaveBeenCalled()
100
+ })
101
+
102
+ it("draws the caller's own centre instead, and still answers the keys", () => {
103
+ const onSearch = vi.fn()
104
+ render(<TopBar onSearch={onSearch} search={<span>own</span>} />)
105
+ expect(screen.getByText('own')).toBeTruthy()
106
+ expect(screen.queryByRole('button')).toBeNull()
107
+ press({ ctrlKey: true, key: 'k' })
108
+ expect(onSearch).toHaveBeenCalledTimes(1)
109
+ })
110
+
111
+ it('reaches the bar through AppShell', async () => {
112
+ const onSearch = vi.fn()
113
+ render(
114
+ <AppShell variant="floating" nav={<nav />} onSearch={onSearch} searchPlaceholder="Search Peek...">
115
+ <p>page</p>
116
+ </AppShell>,
117
+ )
118
+ await userEvent.click(screen.getByRole('button', { name: /Search Peek\.\.\./ }))
119
+ expect(onSearch).toHaveBeenCalledTimes(1)
120
+ })
121
+ })
package/src/TopBar.tsx CHANGED
@@ -1,5 +1,9 @@
1
- import type { ReactNode } from 'react'
1
+ import { useEffect, useRef, type ReactNode } from 'react'
2
+ import { Button as BaseButton } from '@base-ui/react/button'
2
3
  import { cn } from './cn'
4
+ import { Kbd } from './Kbd'
5
+ import { FIELD_SHELL_CLASSES } from './looks'
6
+ import { shortcutLabel } from './shortcuts'
3
7
 
4
8
  /**
5
9
  * The 52px top bar, in three manners:
@@ -21,6 +25,18 @@ import { cn } from './cn'
21
25
  * collapses) and `logo` (the mark or name beside it; Katerina,
22
26
  * 2026-09-02). This component does not know who you are or what is being
23
27
  * searched; it only knows where those go.
28
+ *
29
+ * **Search** (Katerina, 8 October: "topbar has a variant with a search input,
30
+ * I assumed it would use it"). Given `onSearch`, the bar draws the search
31
+ * field itself — `SearchInput`'s look, 290px, as a button that opens what the
32
+ * app searches with — and Ctrl+K (Cmd+K on a Mac) presses it from anywhere.
33
+ * Before, every app drew its own field and listened for the keys itself.
34
+ * A `search` of the caller's own still takes the centre.
35
+ *
36
+ * **A click leaves focus where it was**, as Peek's field did: what searching
37
+ * opens often acts on what you were in — Peek's launcher reads the composer
38
+ * you were typing in and its selection — and taking focus would leave it
39
+ * nothing. Tab still reaches the field, and Enter or Space presses it.
24
40
  */
25
41
  export interface TopBarProps {
26
42
  variant?: 'solid' | 'floating' | 'inset'
@@ -28,15 +44,45 @@ export interface TopBarProps {
28
44
  menu?: ReactNode
29
45
  /** Beside the menu button: the app's logo mark, or its name as text. */
30
46
  logo?: ReactNode
31
- /** The centre: a search field, or a launcher affordance — or nothing; the slot holds its place. */
47
+ /** The centre: something of the caller's own. Without it, and with `onSearch`, the bar draws its search field. */
32
48
  search?: ReactNode
49
+ /** What searching opens — the app's command launcher. Called on a click on the search field, and on Ctrl+K (Cmd+K) anywhere. */
50
+ onSearch?: () => void
51
+ /** The words in the bar's own search field. Default "Search…". */
52
+ searchPlaceholder?: string
33
53
  /** The right cluster — an `IdentityMenu` and its neighbours. */
34
54
  right?: ReactNode
35
55
  className?: string
36
56
  }
37
57
 
38
- export function TopBar({ variant = 'solid', menu, logo, search, right, className }: TopBarProps) {
58
+ export function TopBar({ variant = 'solid', menu, logo, search, onSearch, searchPlaceholder = 'Search…', right, className }: TopBarProps) {
39
59
  const floating = variant === 'floating'
60
+ // The latest handler, so the key listener is set once rather than on every render.
61
+ const searchRef = useRef(onSearch)
62
+ useEffect(() => {
63
+ searchRef.current = onSearch
64
+ })
65
+ const searches = onSearch !== undefined
66
+ useEffect(() => {
67
+ if (!searches) return
68
+ const onKey = (event: KeyboardEvent) => {
69
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
70
+ event.preventDefault()
71
+ searchRef.current?.()
72
+ }
73
+ }
74
+ // @estiva-escape(no-hand-rolled-behaviour): Ctrl+K opens search from anywhere on the page, which is a page-wide shortcut and not a floating part; no part listens for a key across the page
75
+ window.addEventListener('keydown', onKey)
76
+ return () => window.removeEventListener('keydown', onKey)
77
+ }, [searches])
78
+ const centre =
79
+ search ??
80
+ (onSearch && (
81
+ <BaseButton type="button" onMouseDown={(event) => event.preventDefault()} onClick={onSearch} className={cn('flex w-[290px] cursor-pointer items-center gap-2 px-3 py-2', FIELD_SHELL_CLASSES)}>
82
+ <span className="min-w-0 flex-1 truncate text-left text-input-value text-text-muted">{searchPlaceholder}</span>
83
+ <Kbd>{shortcutLabel('Mod-K')}</Kbd>
84
+ </BaseButton>
85
+ ))
40
86
  return (
41
87
  <header
42
88
  className={cn(
@@ -53,7 +99,7 @@ export function TopBar({ variant = 'solid', menu, logo, search, right, className
53
99
  {logo && <div className="flex items-center text-body-2-strong text-text-primary">{logo}</div>}
54
100
  </div>
55
101
  )}
56
- <div className={cn('flex min-w-0 flex-1 items-center justify-center', floating && 'pointer-events-auto')}>{search}</div>
102
+ <div className={cn('flex min-w-0 flex-1 items-center justify-center', floating && 'pointer-events-auto')}>{centre}</div>
57
103
  <div className={cn('flex shrink-0 items-center gap-[6px]', floating && 'pointer-events-auto')}>{right}</div>
58
104
  </header>
59
105
  )
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * `src/` still ships in the tarball, so stepping into a component lands on the
10
10
  * TypeScript that produced it.
11
11
  */
12
+ export { AppProvider, type AppProviderProps } from './AppProvider'
12
13
  export { AppShell, type AppShellProps } from './AppShell'
13
14
  export { AttachmentCard, type AttachmentCardProps, type AttachmentCardState } from './AttachmentCard'
14
15
  export { Avatar, hueFor, initialsFor, type AvatarProps } from './Avatar'
package/src/pages.test.ts CHANGED
@@ -37,6 +37,8 @@ const NOT_A_ROW = ['base-ui']
37
37
  const WAITING: string[] = []
38
38
 
39
39
  const NOTHING = 'Nothing. It only draws. Clicks and keys are yours.'
40
+ /** A part that draws nothing at all says so instead: it only holds what the parts below it ask for. */
41
+ const HOLDS_ONLY: Record<string, string> = { AppProvider: 'Nothing. It draws nothing; it holds two answers for the parts below it.' }
40
42
 
41
43
  const dir = new URL('.', import.meta.url)
42
44
  const read = (file: string) => readFileSync(new URL(file, dir), 'utf8').replace(/\r\n/g, '\n')
@@ -134,7 +136,7 @@ describe('the page contract', () => {
134
136
  const body = (section(t, 'What it owns') ?? '').trim()
135
137
  if (!rows.length) {
136
138
  expect(owned, `${page} owns ${owned.join(', ')}; the page says nothing`).toEqual([])
137
- expect(body).toBe(NOTHING)
139
+ expect(body).toBe(HOLDS_ONLY[page] ?? NOTHING)
138
140
  return
139
141
  }
140
142
  const said = rows.flatMap((r) => Object.entries(ROW).filter(([, re]) => re.test(r.does)).map(([id]) => ({ id, ticked: r.ticked, does: r.does })))
@@ -103,7 +103,10 @@ const FIXTURE: Record<string, string> = {
103
103
  ' return <li>{title}</li>',
104
104
  '}',
105
105
  '',
106
- '/** A card that lists its rows and a button. */',
106
+ '/**',
107
+ ' * A card that lists its rows and a button.',
108
+ " * @registry namesake: the fixture's own card",
109
+ ' */',
107
110
  'export function Card({ title }: CardProps) {',
108
111
  ' return <ul><CardRow title={title} /><Button>Go</Button></ul>',
109
112
  '}',
@@ -128,7 +131,7 @@ const FIXTURE: Record<string, string> = {
128
131
  'src/components/Shown.tsx': '/** A part only its story uses. */\nexport function Shown() {\n return <span />\n}\n',
129
132
  'src/components/Shown.stories.tsx': "import { Shown } from './Shown'\nconst meta = { title: 'Shown', component: Shown }\nexport default meta\nexport const Plain = {}\n",
130
133
  'src/components/Wide.tsx': '/**\n * A general strip of things.\n * @registry reusable: general; one screen uses it so far\n */\nexport function Wide() {\n return <div />\n}\n',
131
- 'src/components/EmptyState.tsx': "import { EmptyState as Shared } from '@estiva-app/ui'\n\n/** The package's empty state, with this app's own default words. */\nexport function EmptyState() {\n return <Shared message=\"Nothing here\" />\n}\n",
134
+ 'src/components/EmptyState.tsx': "import { EmptyState as Shared } from '@estiva-app/ui'\n\n/**\n * The package's empty state, with this app's own default words.\n * @registry namesake: the words are this app's\n */\nexport function EmptyState() {\n return <Shared message=\"Nothing here\" />\n}\n",
132
135
  'src/pages/Lazy.tsx': '/** A page loaded only when it is opened. */\nexport default function Lazy() {\n return <main />\n}\n',
133
136
  'src/pages/HomePage.tsx': [
134
137
  "import { lazy } from 'react'",
@@ -287,7 +290,7 @@ describe('what the first descriptions found', () => {
287
290
  const faults = buildAppRegistry({
288
291
  root: app({
289
292
  // Peek's Skeleton.tsx: imported from the package, then exported on a line of its own.
290
- 'src/Skeleton.tsx': "import { SkeletonBar, SkeletonRow, SkeletonList as Shared } from '@estiva-app/ui'\n\n/** Grey rows in the shape of a list, while it loads. */\nexport function SkeletonList() {\n return <div><SkeletonBar /><SkeletonRow /></div>\n}\n\n/** Peek's name for the package's list placeholder. */\nexport const SkeletonSidebarList = Shared\n\nexport { SkeletonBar, SkeletonRow }\n",
293
+ 'src/Skeleton.tsx': "import { SkeletonBar, SkeletonRow, SkeletonList as Shared } from '@estiva-app/ui'\n\n/**\n * Grey rows in the shape of a list, while it loads.\n * @registry namesake: the fixture says so\n */\nexport function SkeletonList() {\n return <div><SkeletonBar /><SkeletonRow /></div>\n}\n\n/** Peek's name for the package's list placeholder. */\nexport const SkeletonSidebarList = Shared\n\nexport { SkeletonBar, SkeletonRow }\n",
291
294
  // A file with its own description at the very top, and one on its part.
292
295
  'src/Row.tsx': "/**\n * Everything about rows.\n */\nimport { useState } from 'react'\n\n/** One row of a list, with a hover state of its own. */\nexport function Row() {\n const [on] = useState(false)\n return <li>{String(on)}</li>\n}\n",
293
296
  // A class, described above itself, with its props from what it extends.
@@ -397,10 +400,10 @@ describe('what the third review found', () => {
397
400
  root: app({
398
401
  'tsconfig.json': '{ "compilerOptions": { "baseUrl": "." } }\n',
399
402
  'src/Choice.tsx': "/** A choice, taken as text or as a number. */\nexport function Choice(props: { value: string }): JSX.Element\nexport function Choice(props: { value: number }): JSX.Element\nexport function Choice(props: { value: string | number }) {\n return <span>{String(props.value)}</span>\n}\n",
400
- 'src/Kbd.tsx': '/** One key, drawn as a keycap. */\nexport function Kbd() {\n return <kbd />\n}\n\n/** Keys in a row. */\nexport function KbdRow() {\n return <span><Kbd /></span>\n}\n',
403
+ 'src/Kbd.tsx': '/**\n * One key, drawn as a keycap.\n * @registry namesake: the fixture says so\n */\nexport function Kbd() {\n return <kbd />\n}\n\n/** Keys in a row. */\nexport function KbdRow() {\n return <span><Kbd /></span>\n}\n',
401
404
  'src/Guard.tsx': "import { Component } from 'react'\n\ninterface Props {\n /** What it guards. */\n label: string\n}\n\n/** Something else in the file. */\nexport function Other() {\n return <i />\n}\n\n/** Catches what crashes below it. */\nexport default class extends Component<Props> {\n render() {\n return <div>{this.props.label}</div>\n }\n}\n",
402
405
  'src/List.tsx': "import { memo } from 'react'\n\n/** One row of the list. */\nexport function Row() {\n return <li />\n}\n\n/** The list, drawing its rows. */\nexport default memo(function List() {\n return <ul><Row /></ul>\n})\n",
403
- 'src/parts/Card.tsx': '/** A card. */\nexport function Card() {\n return <div />\n}\n',
406
+ 'src/parts/Card.tsx': '/**\n * A card.\n * @registry namesake: the fixture says so\n */\nexport function Card() {\n return <div />\n}\n',
404
407
  'src/parts/index.ts': "export * as Parts from './Card'\n",
405
408
  'src/UsesCard.tsx': "import { Parts } from './parts'\n\n/** Draws a card through a namespace. */\nexport function UsesCard() {\n return <Parts.Card />\n}\n",
406
409
  'src/Badge.tsx': '/** A small badge. */\nexport function Badge() {\n return <b />\n}\n',
@@ -414,7 +417,7 @@ describe('what the third review found', () => {
414
417
  // Found by the narrow pass on those fixes.
415
418
  'src/routesTable.ts': 'export const routes: unknown[] = []\n',
416
419
  'src/router.tsx': "import { createBrowserRouter } from 'react-router-dom'\nimport { routes } from './routesTable'\n\nexport default createBrowserRouter(routes)\n",
417
- 'src/Chip.tsx': "import { forwardRef, memo, type ComponentType } from 'react'\n\n/** A chip nothing uses. */\nexport function Chip() {\n return <i />\n}\n\nexport default memo(forwardRef(Chip)) as ComponentType\n",
420
+ 'src/Chip.tsx': "import { forwardRef, memo, type ComponentType } from 'react'\n\n/**\n * A chip nothing uses.\n * @registry namesake: the fixture says so\n */\nexport function Chip() {\n return <i />\n}\n\nexport default memo(forwardRef(Chip)) as ComponentType\n",
418
421
  }),
419
422
  repo: 'third',
420
423
  packageRegistry,
@@ -513,6 +516,21 @@ describe('an app the catalogue refuses', () => {
513
516
  expect(refused({ 'src/One.tsx': '/**\n * One.\n * @registry unused: gone\n */\nexport function One() {\n return <i />\n}\n' })).toMatch(/the kinds a person may write are reusable, one-off, promote-candidate/)
514
517
  })
515
518
 
519
+ it("a part with a package part's name and no reason it is kept", () => {
520
+ const pill = "import { Button } from '@estiva-app/ui'\n\n/** Who is in this. */\nexport function EmptyState() {\n return <Button>3</Button>\n}\n"
521
+ expect(refused({ 'src/EmptyState.tsx': pill })).toContain(
522
+ `EmptyState in src/EmptyState.tsx has the name of the package's EmptyState. Use the package's, or write why this one is kept: "@registry namesake: <reason>" in its /** … */ comment.`,
523
+ )
524
+ // The reason, and nothing else, lets it through; a kind written beside it still counts.
525
+ const kept = pill.replace('/** Who is in this. */', '/**\n * Who is in this.\n * @registry reusable: general\n * @registry namesake: the words are this app\'s\n */')
526
+ expect(refused({ 'src/EmptyState.tsx': kept })).toBe('')
527
+ expect(refused({ 'src/EmptyState.tsx': pill.replace('Who is in this.', 'Who is in this. @registry namesake:') })).toMatch(/has the name of the package's EmptyState/)
528
+ })
529
+
530
+ it("not a pass-on of the package's own part", () => {
531
+ expect(refused({ 'src/EmptyState.tsx': "export { EmptyState } from '@estiva-app/ui'\n" })).toBe('')
532
+ })
533
+
516
534
  it('export * from a package, which hides what it hands on', () => {
517
535
  expect(refused({ 'src/All.tsx': "export * from '@estiva-app/ui'\n" })).toMatch(/hands on everything from '@estiva-app\/ui' with export \*/)
518
536
  })
@@ -31,6 +31,18 @@
31
31
  * * @registry reusable: general; one screen uses it so far
32
32
  * *\/
33
33
  *
34
+ * A part that shares its name with a package part is refused unless the reason
35
+ * it is kept is written beside it, the same way:
36
+ *
37
+ * /**
38
+ * * The package's `Avatar`, handed the picture this app knows about.
39
+ * * @registry namesake: the package cannot know where pictures come from
40
+ * *\/
41
+ *
42
+ * Peek's own MembersPill stayed in two headers for two days after the package
43
+ * gained one (0.58.0, 6 October) — built from the package's parts, so no rule
44
+ * saw it (Katerina, 8 October).
45
+ *
34
46
  * TypeScript is the app's own install, as it is for the package's builder.
35
47
  */
36
48
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
@@ -54,7 +66,9 @@ export interface AppBuildOptions {
54
66
 
55
67
  /** The kinds a person may write beside a part. A pass-on is a fact about its file; so is being used nowhere. */
56
68
  const WRITABLE: EntryClass[] = ['reusable', 'one-off', 'promote-candidate']
57
- const WRITTEN = /^@registry\s+([a-z-]+)\s*:\s*(.+)$/m
69
+ const WRITTEN = /^@registry\s+(?!namesake\b)([a-z-]+)\s*:\s*(.+)$/m
70
+ /** Why a part with a package part's name is kept: `@registry namesake: <reason>`. */
71
+ const NAMESAKE = /^@registry\s+namesake\s*:\s*\S/m
58
72
 
59
73
  const isStory = (file: string) => /\.stories\.[cm]?[jt]sx?$/.test(file)
60
74
  const isTest = (file: string) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)__(tests|mocks)__\//.test(file)
@@ -837,6 +851,11 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
837
851
  reason = `Used in ${usedIn.length} places, and everything it uses is already in the package.`
838
852
  }
839
853
 
854
+ const namesake = namesakes.has(p.name) && !(pass && isPackageImport(pass.specifier) && pass.name === p.name) ? p.name : null
855
+ if (namesake && !(NAMESAKE.test(doc) || (onePart && NAMESAKE.test(header)))) {
856
+ problems.push(`${p.name} in ${p.file} has the name of the package's ${namesake}. Use the package's, or write why this one is kept: "@registry namesake: <reason>" in its /** … */ comment.`)
857
+ }
858
+
840
859
  const shape: Resolved = declared ? module!.resolve(declared.propsType) : { props: [], variants: [] }
841
860
  const facts_: AppFacts = {
842
861
  class: cls,
@@ -845,7 +864,7 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
845
864
  handsOn: pass ? (isPackageImport(pass.specifier) ? pass.name : `${pass.name} from ${pass.specifier}`) : null,
846
865
  usedIn,
847
866
  tiedTo: ties,
848
- packageNamesake: namesakes.has(p.name) && !(pass && isPackageImport(pass.specifier) && pass.name === p.name) ? p.name : null,
867
+ packageNamesake: namesake,
849
868
  defaultExport: p.defaultExport,
850
869
  }
851
870
  const found = pass ? { docsId: null, storyId: null } : storyOf(p)
@@ -70,7 +70,7 @@ const PAGE = [
70
70
  ].join('\n')
71
71
 
72
72
  const part = (name: string, cls = 'reusable') =>
73
- `/**\n * ${name}, for the fixture.\n * @registry ${cls}: the fixture says so\n */\nexport function ${name}() {\n return <span>${name}</span>\n}\n`
73
+ `/**\n * ${name}, for the fixture.\n * @registry ${cls}: the fixture says so\n * @registry namesake: the fixture says so\n */\nexport function ${name}() {\n return <span>${name}</span>\n}\n`
74
74
 
75
75
  describe('a usage page', () => {
76
76
  it('keeps the contract with an opening line and the four sections in order', () => {
@@ -38,6 +38,7 @@ const find = (dir: string) => gatedFindings(buildAppRegistry({ root: dir, repo:
38
38
  const card = `/**
39
39
  * A card.
40
40
  * @registry reusable: the fixture says so
41
+ * @registry namesake: the fixture says so
41
42
  */
42
43
  export function Card({ title, onReply, onShare }: { title: string; onReply?: () => void; onShare?: () => void }) {
43
44
  return (
@@ -48,19 +48,19 @@ 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 133 names over 72 files: 103 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, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, and ShowMore on 7 October)', () => {
51
+ it('counts 134 names over 73 files: 104 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, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, and AppProvider on 8 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: 103, helper: 29, hook: 1 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(72)
57
+ expect(kinds).toEqual({ component: 104, helper: 29, hook: 1 })
58
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(73)
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(68)
63
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(69)
64
64
  expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(65)
65
65
  })
66
66
 
@@ -122,10 +122,12 @@ the **floating** TopBar goes with the **Rail** and the content card.
122
122
  | The structured frame: solid bar, sidebar, content | **AppShell** `solid` |
123
123
  | The floating frame: bar over the content, rail, rounded card | **AppShell** `floating` |
124
124
  | The 52px bar alone | **TopBar** — `solid` in flow, or `floating` over the content |
125
+ | A search field in the bar that opens your launcher, with Ctrl+K | the TopBar's `onSearch` — the bar draws the field |
125
126
  | A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
126
127
  | Groups of rows in that column, rows under rows, from one nested list | **NavTree**, inside the **Sidebar** |
127
128
  | A 64px icon strip | **Rail**, filled with **RailItem** tiles |
128
129
  | The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
130
+ | Where pictures come from, and how to change page without a reload, said once | **AppProvider**, around the whole app |
129
131
  | The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |
130
132
  | A panel of a page that is not a list — details, a thread, what is open — with a body that must never be cut off | **Panel** — a header, a body that scrolls whenever it is taller, and a `footer` that stays put |
131
133