@estiva-app/ui 0.58.0 → 0.60.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 (103) 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/CommandPalette.d.ts.map +1 -1
  9. package/dist/ComposerTriggers.d.ts +13 -1
  10. package/dist/ComposerTriggers.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts.map +1 -1
  12. package/dist/InlineChip.d.ts.map +1 -1
  13. package/dist/Kbd.d.ts.map +1 -1
  14. package/dist/Link.d.ts +1 -1
  15. package/dist/Link.d.ts.map +1 -1
  16. package/dist/MembersDialog.d.ts +57 -0
  17. package/dist/MembersDialog.d.ts.map +1 -0
  18. package/dist/MembersPill.d.ts +22 -0
  19. package/dist/MembersPill.d.ts.map +1 -0
  20. package/dist/NavItem.d.ts +1 -1
  21. package/dist/NavItem.d.ts.map +1 -1
  22. package/dist/RailItem.d.ts +1 -1
  23. package/dist/RailItem.d.ts.map +1 -1
  24. package/dist/SearchInput.d.ts +3 -3
  25. package/dist/Tabs.d.ts +2 -0
  26. package/dist/Tabs.d.ts.map +1 -1
  27. package/dist/TopBar.d.ts +14 -3
  28. package/dist/TopBar.d.ts.map +1 -1
  29. package/dist/editor.js +4 -4
  30. package/dist/editor.js.map +2 -2
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1332 -1143
  34. package/dist/index.js.map +4 -4
  35. package/dist/looks.d.ts +2 -0
  36. package/dist/looks.d.ts.map +1 -1
  37. package/dist/registry/{app-LDGOH6UW.js → app-X2GI4WTU.js} +2 -2
  38. package/dist/registry/app.d.ts.map +1 -1
  39. package/dist/registry/{chunk-TQ6UQER3.js → chunk-V67F474L.js} +8 -3
  40. package/dist/registry/chunk-V67F474L.js.map +7 -0
  41. package/dist/registry/cli.js +1 -1
  42. package/dist/registry/index.js +1 -1
  43. package/package.json +1 -1
  44. package/registry.json +234 -14
  45. package/skill/estiva-ui.md +2 -1
  46. package/src/AppProvider.mdx +69 -0
  47. package/src/AppProvider.stories.tsx +60 -0
  48. package/src/AppProvider.test.tsx +152 -0
  49. package/src/AppProvider.tsx +76 -0
  50. package/src/AppShell.mdx +4 -1
  51. package/src/AppShell.stories.tsx +5 -5
  52. package/src/AppShell.tsx +7 -3
  53. package/src/Avatar.tsx +7 -3
  54. package/src/Breadcrumb.mdx +2 -2
  55. package/src/Card.mdx +1 -1
  56. package/src/Chip.mdx +2 -2
  57. package/src/Chip.tsx +3 -1
  58. package/src/CommandPalette.tsx +2 -1
  59. package/src/ComposerTriggers.mdx +7 -0
  60. package/src/ComposerTriggers.stories.tsx +43 -4
  61. package/src/ComposerTriggers.test.tsx +33 -0
  62. package/src/ComposerTriggers.tsx +17 -5
  63. package/src/IconButton.mdx +2 -2
  64. package/src/IconButton.tsx +4 -1
  65. package/src/InlineChip.mdx +3 -3
  66. package/src/InlineChip.tsx +3 -1
  67. package/src/Kbd.tsx +4 -2
  68. package/src/Link.mdx +5 -3
  69. package/src/Link.tsx +4 -1
  70. package/src/MembersDialog.mdx +69 -0
  71. package/src/MembersDialog.stories.tsx +68 -0
  72. package/src/MembersDialog.test.tsx +69 -0
  73. package/src/MembersDialog.tsx +184 -0
  74. package/src/MembersPill.mdx +45 -0
  75. package/src/MembersPill.stories.tsx +38 -0
  76. package/src/MembersPill.tsx +35 -0
  77. package/src/NavItem.mdx +3 -3
  78. package/src/NavItem.tsx +4 -0
  79. package/src/NavTree.mdx +3 -2
  80. package/src/RailItem.mdx +3 -3
  81. package/src/RailItem.tsx +4 -1
  82. package/src/SearchInput.mdx +3 -4
  83. package/src/SearchInput.tsx +3 -3
  84. package/src/Sidebar.mdx +2 -2
  85. package/src/Tabs.mdx +5 -0
  86. package/src/Tabs.stories.tsx +15 -0
  87. package/src/Tabs.test.tsx +8 -0
  88. package/src/Tabs.tsx +12 -0
  89. package/src/TopBar.mdx +18 -9
  90. package/src/TopBar.stories.tsx +22 -4
  91. package/src/TopBar.test.tsx +97 -0
  92. package/src/TopBar.tsx +45 -4
  93. package/src/index.ts +3 -0
  94. package/src/looks.ts +3 -0
  95. package/src/pages.test.ts +3 -1
  96. package/src/registry/app.test.ts +24 -6
  97. package/src/registry/app.ts +21 -2
  98. package/src/registry/contract.test.ts +1 -1
  99. package/src/registry/gated.test.ts +1 -0
  100. package/src/registry/registry.test.ts +4 -4
  101. package/stories/Choosing.mdx +4 -0
  102. package/dist/registry/chunk-TQ6UQER3.js.map +0 -7
  103. /package/dist/registry/{app-LDGOH6UW.js.map → app-X2GI4WTU.js.map} +0 -0
@@ -0,0 +1,97 @@
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('says "Search…" when given no words', () => {
33
+ render(<TopBar onSearch={() => {}} />)
34
+ expect(screen.getByRole('button', { name: /Search…/ })).toBeTruthy()
35
+ })
36
+
37
+ it.each([
38
+ ['Ctrl+K', { ctrlKey: true, key: 'k' }],
39
+ ['Cmd+K', { metaKey: true, key: 'k' }],
40
+ ['Ctrl+Shift+K', { ctrlKey: true, shiftKey: true, key: 'K' }],
41
+ ])('opens the search on %s anywhere', (_name, init) => {
42
+ const onSearch = vi.fn()
43
+ render(<TopBar onSearch={onSearch} />)
44
+ const event = press(init)
45
+ expect(onSearch).toHaveBeenCalledTimes(1)
46
+ expect(event.defaultPrevented).toBe(true)
47
+ })
48
+
49
+ it('calls the latest handler', () => {
50
+ const first = vi.fn()
51
+ const second = vi.fn()
52
+ const { rerender } = render(<TopBar onSearch={first} />)
53
+ rerender(<TopBar onSearch={second} />)
54
+ press({ ctrlKey: true, key: 'k' })
55
+ expect(first).not.toHaveBeenCalled()
56
+ expect(second).toHaveBeenCalledTimes(1)
57
+ })
58
+
59
+ it('leaves K alone without a modifier, and the keys alone without onSearch', () => {
60
+ const onSearch = vi.fn()
61
+ const { unmount } = render(<TopBar onSearch={onSearch} />)
62
+ press({ key: 'k' })
63
+ expect(onSearch).not.toHaveBeenCalled()
64
+ unmount()
65
+ render(<TopBar />)
66
+ expect(press({ ctrlKey: true, key: 'k' }).defaultPrevented).toBe(false)
67
+ expect(screen.queryByRole('button')).toBeNull()
68
+ })
69
+
70
+ it('stops listening once it is gone', () => {
71
+ const onSearch = vi.fn()
72
+ const { unmount } = render(<TopBar onSearch={onSearch} />)
73
+ unmount()
74
+ press({ ctrlKey: true, key: 'k' })
75
+ expect(onSearch).not.toHaveBeenCalled()
76
+ })
77
+
78
+ it("draws the caller's own centre instead, and still answers the keys", () => {
79
+ const onSearch = vi.fn()
80
+ render(<TopBar onSearch={onSearch} search={<span>own</span>} />)
81
+ expect(screen.getByText('own')).toBeTruthy()
82
+ expect(screen.queryByRole('button')).toBeNull()
83
+ press({ ctrlKey: true, key: 'k' })
84
+ expect(onSearch).toHaveBeenCalledTimes(1)
85
+ })
86
+
87
+ it('reaches the bar through AppShell', async () => {
88
+ const onSearch = vi.fn()
89
+ render(
90
+ <AppShell variant="floating" nav={<nav />} onSearch={onSearch} searchPlaceholder="Search Peek...">
91
+ <p>page</p>
92
+ </AppShell>,
93
+ )
94
+ await userEvent.click(screen.getByRole('button', { name: /Search Peek\.\.\./ }))
95
+ expect(onSearch).toHaveBeenCalledTimes(1)
96
+ })
97
+ })
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,13 @@ 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.
24
35
  */
25
36
  export interface TopBarProps {
26
37
  variant?: 'solid' | 'floating' | 'inset'
@@ -28,15 +39,45 @@ export interface TopBarProps {
28
39
  menu?: ReactNode
29
40
  /** Beside the menu button: the app's logo mark, or its name as text. */
30
41
  logo?: ReactNode
31
- /** The centre: a search field, or a launcher affordance — or nothing; the slot holds its place. */
42
+ /** The centre: something of the caller's own. Without it, and with `onSearch`, the bar draws its search field. */
32
43
  search?: ReactNode
44
+ /** What searching opens — the app's command launcher. Called on a click on the search field, and on Ctrl+K (Cmd+K) anywhere. */
45
+ onSearch?: () => void
46
+ /** The words in the bar's own search field. Default "Search…". */
47
+ searchPlaceholder?: string
33
48
  /** The right cluster — an `IdentityMenu` and its neighbours. */
34
49
  right?: ReactNode
35
50
  className?: string
36
51
  }
37
52
 
38
- export function TopBar({ variant = 'solid', menu, logo, search, right, className }: TopBarProps) {
53
+ export function TopBar({ variant = 'solid', menu, logo, search, onSearch, searchPlaceholder = 'Search…', right, className }: TopBarProps) {
39
54
  const floating = variant === 'floating'
55
+ // The latest handler, so the key listener is set once rather than on every render.
56
+ const searchRef = useRef(onSearch)
57
+ useEffect(() => {
58
+ searchRef.current = onSearch
59
+ })
60
+ const searches = onSearch !== undefined
61
+ useEffect(() => {
62
+ if (!searches) return
63
+ const onKey = (event: KeyboardEvent) => {
64
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
65
+ event.preventDefault()
66
+ searchRef.current?.()
67
+ }
68
+ }
69
+ // @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
70
+ window.addEventListener('keydown', onKey)
71
+ return () => window.removeEventListener('keydown', onKey)
72
+ }, [searches])
73
+ const centre =
74
+ search ??
75
+ (onSearch && (
76
+ <BaseButton type="button" onClick={onSearch} className={cn('flex w-[290px] cursor-pointer items-center gap-2 px-3 py-2', FIELD_SHELL_CLASSES)}>
77
+ <span className="min-w-0 flex-1 truncate text-left text-input-value text-text-muted">{searchPlaceholder}</span>
78
+ <Kbd>{shortcutLabel('Mod-K')}</Kbd>
79
+ </BaseButton>
80
+ ))
40
81
  return (
41
82
  <header
42
83
  className={cn(
@@ -53,7 +94,7 @@ export function TopBar({ variant = 'solid', menu, logo, search, right, className
53
94
  {logo && <div className="flex items-center text-body-2-strong text-text-primary">{logo}</div>}
54
95
  </div>
55
96
  )}
56
- <div className={cn('flex min-w-0 flex-1 items-center justify-center', floating && 'pointer-events-auto')}>{search}</div>
97
+ <div className={cn('flex min-w-0 flex-1 items-center justify-center', floating && 'pointer-events-auto')}>{centre}</div>
57
98
  <div className={cn('flex shrink-0 items-center gap-[6px]', floating && 'pointer-events-auto')}>{right}</div>
58
99
  </header>
59
100
  )
package/src/index.ts CHANGED
@@ -9,10 +9,13 @@
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'
15
16
  export { AvatarGroup, type AvatarGroupMember, type AvatarGroupProps } from './AvatarGroup'
17
+ export { MembersPill, type MembersPillProps } from './MembersPill'
18
+ export { MembersDialog, type MembersDialogCandidate, type MembersDialogMember, type MembersDialogProps, type MembersDialogSelfAction } from './MembersDialog'
16
19
  export { Banner, type BannerProps, type BannerTone } from './Banner'
17
20
  export { BlockHandle, blockAtY, type BlockHandleProps, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
18
21
  export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button'
package/src/looks.ts CHANGED
@@ -43,6 +43,9 @@ export const COUNT_TEXT_CLASSES = 'font-mono text-caption tabular-nums text-text
43
43
  /** A chip's words: Chip's label and Reaction's count. */
44
44
  export const CHIP_TEXT_CLASSES = 'text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums'
45
45
 
46
+ /** Small secondary words that turn mono under signal: Kbd's key, CommandPalette's footer and MembersPill's count. */
47
+ export const MONO_CAPTION_CLASSES = 'text-caption text-text-secondary signal:font-mono signal:text-small'
48
+
46
49
  /** Words typed straight into an input with no box of its own: SearchInput and ChipInput. */
47
50
  export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:text-text-muted outline-none'
48
51
 
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 131 names over 70 files: 101 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, 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: 101, helper: 29, hook: 1 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(70)
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(66)
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
 
@@ -51,6 +51,8 @@ fork freely, owe nothing back, mention it on the package's ticket.
51
51
  |---|---|
52
52
  | One face | **Avatar** |
53
53
  | Several faces, overlapping | **AvatarGroup** |
54
+ | Who is in it, at a glance, opening the list | **MembersPill** |
55
+ | Who is in it, with Join, Leave and adding people | **MembersDialog** |
54
56
  | Face and name in a row | **Person** |
55
57
  | The face that opens a menu | **PersonTrigger** |
56
58
  | Who you are, where you are, sign out | **IdentityMenu** — the account menu, ready-made |
@@ -120,10 +122,12 @@ the **floating** TopBar goes with the **Rail** and the content card.
120
122
  | The structured frame: solid bar, sidebar, content | **AppShell** `solid` |
121
123
  | The floating frame: bar over the content, rail, rounded card | **AppShell** `floating` |
122
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 |
123
126
  | A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
124
127
  | Groups of rows in that column, rows under rows, from one nested list | **NavTree**, inside the **Sidebar** |
125
128
  | A 64px icon strip | **Rail**, filled with **RailItem** tiles |
126
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 |
127
131
  | The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |
128
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 |
129
133