@open-mercato/ui 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7194.1.ab4fc81f82

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 (49) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/backend/AppShell.js +66 -7
  3. package/dist/backend/AppShell.js.map +2 -2
  4. package/dist/backend/CrudForm.js +14 -8
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/DataTable.js +108 -11
  7. package/dist/backend/DataTable.js.map +2 -2
  8. package/dist/backend/forms/ActionsDropdown.js +35 -5
  9. package/dist/backend/forms/ActionsDropdown.js.map +2 -2
  10. package/dist/backend/forms/index.js.map +2 -2
  11. package/dist/backend/icons/lucideRegistry.generated.js +6 -0
  12. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  13. package/dist/backend/injection/spotIds.js +2 -0
  14. package/dist/backend/injection/spotIds.js.map +2 -2
  15. package/dist/backend/inputs/DurationInput.js +182 -0
  16. package/dist/backend/inputs/DurationInput.js.map +7 -0
  17. package/dist/backend/inputs/EventSelect.js.map +2 -2
  18. package/dist/backend/inputs/index.js +1 -0
  19. package/dist/backend/inputs/index.js.map +2 -2
  20. package/dist/backend/nav/navBadges.js +38 -0
  21. package/dist/backend/nav/navBadges.js.map +7 -0
  22. package/dist/primitives/segmented-control.js +4 -4
  23. package/dist/primitives/segmented-control.js.map +1 -1
  24. package/dist/primitives/sheet.js +21 -11
  25. package/dist/primitives/sheet.js.map +2 -2
  26. package/dist/primitives/textarea.js +54 -4
  27. package/dist/primitives/textarea.js.map +2 -2
  28. package/package.json +3 -3
  29. package/src/backend/AppShell.tsx +106 -2
  30. package/src/backend/CrudForm.tsx +28 -9
  31. package/src/backend/DataTable.tsx +176 -22
  32. package/src/backend/__tests__/CrudForm.density.test.tsx +113 -0
  33. package/src/backend/__tests__/DataTable.propBulkActions.test.tsx +200 -0
  34. package/src/backend/__tests__/DataTable.render.test.tsx +76 -0
  35. package/src/backend/__tests__/navBadges.test.ts +57 -0
  36. package/src/backend/forms/ActionsDropdown.tsx +73 -6
  37. package/src/backend/forms/index.ts +1 -1
  38. package/src/backend/icons/lucideRegistry.generated.tsx +6 -0
  39. package/src/backend/injection/spotIds.ts +8 -0
  40. package/src/backend/inputs/DurationInput.tsx +207 -0
  41. package/src/backend/inputs/EventSelect.tsx +2 -0
  42. package/src/backend/inputs/__tests__/DurationInput.test.tsx +220 -0
  43. package/src/backend/inputs/index.ts +1 -0
  44. package/src/backend/nav/navBadges.ts +61 -0
  45. package/src/primitives/__tests__/segmented-control.test.tsx +2 -2
  46. package/src/primitives/__tests__/textarea.test.tsx +50 -0
  47. package/src/primitives/segmented-control.tsx +7 -7
  48. package/src/primitives/sheet.tsx +7 -1
  49. package/src/primitives/textarea.tsx +77 -4
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ *
4
+ * Host-owned bulk actions (the `bulkActions` prop).
5
+ *
6
+ * `progress/AGENTS.md` requires a server-queued bulk action to return
7
+ * `{ ok, progressJobId }` so the top bar can track it, and `ui/AGENTS.md`
8
+ * forbids a page from building its own progress bar instead. The prop path used
9
+ * to DISCARD that result entirely: no toast, no tracking, and a rejected
10
+ * promise became an unhandled rejection. This file pins the fix — and pins the
11
+ * `void` / `true` / `false` returns to their previous behaviour, because the
12
+ * existing callers flash from their own bulk helper and a second toast would be
13
+ * a regression.
14
+ */
15
+ import * as React from 'react'
16
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
17
+ import type { ColumnDef } from '@tanstack/react-table'
18
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
19
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
20
+ import { DataTable } from '../DataTable'
21
+
22
+ const mockRouterRefresh = jest.fn()
23
+
24
+ jest.mock('next/navigation', () => ({
25
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), refresh: mockRouterRefresh }),
26
+ }))
27
+
28
+ const flashMock = jest.fn()
29
+ jest.mock('../FlashMessages', () => ({
30
+ flash: (...args: unknown[]) => flashMock(...args),
31
+ }))
32
+ jest.mock('../confirm-dialog', () => ({
33
+ useConfirmDialog: () => ({ confirm: jest.fn().mockResolvedValue(true), ConfirmDialogElement: null }),
34
+ }))
35
+
36
+ type Row = { id: string; name: string }
37
+
38
+ class ResizeObserverMock {
39
+ observe() {}
40
+ unobserve() {}
41
+ disconnect() {}
42
+ }
43
+
44
+ function renderTable(props: Record<string, unknown>) {
45
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
46
+ const view = render(
47
+ React.createElement(
48
+ QueryClientProvider as any,
49
+ { client: queryClient },
50
+ React.createElement(
51
+ I18nProvider as any,
52
+ { locale: 'en', dict: {} },
53
+ React.createElement(DataTable as any, props)
54
+ )
55
+ )
56
+ )
57
+ return { ...view, cleanupQueryClient: () => queryClient.clear() }
58
+ }
59
+
60
+ const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
61
+
62
+ async function selectAllAndRun(label: string) {
63
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
64
+ await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
65
+ fireEvent.click(screen.getByRole('button', { name: label }))
66
+ }
67
+
68
+ describe('DataTable prop bulk actions', () => {
69
+ beforeEach(() => {
70
+ ;(globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserverMock }).ResizeObserver =
71
+ ResizeObserverMock
72
+ flashMock.mockReset()
73
+ mockRouterRefresh.mockReset()
74
+ })
75
+
76
+ it('tracks a queued action that returns a progressJobId and says so', async () => {
77
+ const rendered = renderTable({
78
+ columns,
79
+ data: [{ id: 'r1', name: 'Alice' }],
80
+ bulkActions: [
81
+ {
82
+ id: 'replay',
83
+ label: 'Replay',
84
+ onExecute: async () => ({ ok: true, progressJobId: 'job-1' }),
85
+ },
86
+ ],
87
+ })
88
+
89
+ try {
90
+ await selectAllAndRun('Replay')
91
+ await waitFor(() =>
92
+ expect(flashMock).toHaveBeenCalledWith(
93
+ 'Bulk action started. Track progress in the top bar.',
94
+ 'success'
95
+ )
96
+ )
97
+ // A queued action must NOT also trigger an immediate refresh — the work
98
+ // has not happened yet.
99
+ expect(mockRouterRefresh).not.toHaveBeenCalled()
100
+ await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument())
101
+ } finally {
102
+ rendered.cleanupQueryClient()
103
+ }
104
+ })
105
+
106
+ it('surfaces a failure result as an error flash instead of swallowing it', async () => {
107
+ const rendered = renderTable({
108
+ columns,
109
+ data: [{ id: 'r1', name: 'Alice' }],
110
+ bulkActions: [
111
+ { id: 'replay', label: 'Replay', onExecute: async () => ({ ok: false, message: 'Nope' }) },
112
+ ],
113
+ })
114
+
115
+ try {
116
+ await selectAllAndRun('Replay')
117
+ await waitFor(() => expect(flashMock).toHaveBeenCalledWith('Nope', 'error'))
118
+ // A failed action keeps the selection so the operator can retry.
119
+ expect(screen.getByText('1 selected')).toBeInTheDocument()
120
+ } finally {
121
+ rendered.cleanupQueryClient()
122
+ }
123
+ })
124
+
125
+ it('flashes a thrown error rather than leaving an unhandled rejection', async () => {
126
+ const rendered = renderTable({
127
+ columns,
128
+ data: [{ id: 'r1', name: 'Alice' }],
129
+ bulkActions: [
130
+ {
131
+ id: 'replay',
132
+ label: 'Replay',
133
+ onExecute: async () => {
134
+ throw new Error('boom')
135
+ },
136
+ },
137
+ ],
138
+ })
139
+
140
+ try {
141
+ await selectAllAndRun('Replay')
142
+ await waitFor(() => expect(flashMock).toHaveBeenCalledWith('boom', 'error'))
143
+ } finally {
144
+ rendered.cleanupQueryClient()
145
+ }
146
+ })
147
+
148
+ it('reports and refreshes for a synchronous result that completed inline', async () => {
149
+ const rendered = renderTable({
150
+ columns,
151
+ data: [{ id: 'r1', name: 'Alice' }],
152
+ bulkActions: [
153
+ { id: 'replay', label: 'Replay', onExecute: async () => ({ ok: true, affectedCount: 1 }) },
154
+ ],
155
+ })
156
+
157
+ try {
158
+ await selectAllAndRun('Replay')
159
+ await waitFor(() => expect(flashMock).toHaveBeenCalledWith('Bulk action completed.', 'success'))
160
+ await waitFor(() => expect(mockRouterRefresh).toHaveBeenCalled())
161
+ } finally {
162
+ rendered.cleanupQueryClient()
163
+ }
164
+ })
165
+
166
+ it('leaves a void-returning action exactly as it was — no second toast', async () => {
167
+ const onExecute = jest.fn(async () => {})
168
+ const rendered = renderTable({
169
+ columns,
170
+ data: [{ id: 'r1', name: 'Alice' }],
171
+ bulkActions: [{ id: 'delete', label: 'Delete selected', onExecute }],
172
+ })
173
+
174
+ try {
175
+ await selectAllAndRun('Delete selected')
176
+ await waitFor(() => expect(onExecute).toHaveBeenCalled())
177
+ await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument())
178
+ expect(flashMock).not.toHaveBeenCalled()
179
+ expect(mockRouterRefresh).not.toHaveBeenCalled()
180
+ } finally {
181
+ rendered.cleanupQueryClient()
182
+ }
183
+ })
184
+
185
+ it('keeps the selection when the action returns false (a declined confirm)', async () => {
186
+ const rendered = renderTable({
187
+ columns,
188
+ data: [{ id: 'r1', name: 'Alice' }],
189
+ bulkActions: [{ id: 'delete', label: 'Delete selected', onExecute: async () => false }],
190
+ })
191
+
192
+ try {
193
+ await selectAllAndRun('Delete selected')
194
+ await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
195
+ expect(flashMock).not.toHaveBeenCalled()
196
+ } finally {
197
+ rendered.cleanupQueryClient()
198
+ }
199
+ })
200
+ })
@@ -118,6 +118,82 @@ describe('DataTable SSR render', () => {
118
118
  }
119
119
  })
120
120
 
121
+ it('renders no expand toggle when expansion props are absent (back-compat)', () => {
122
+ const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
123
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
124
+ try {
125
+ render(
126
+ <QueryClientProvider client={queryClient}>
127
+ <I18nProvider locale="en" dict={{}}>
128
+ <DataTable columns={columns} data={[{ id: '1', name: 'Ada' }]} />
129
+ </I18nProvider>
130
+ </QueryClientProvider>,
131
+ )
132
+ expect(screen.queryByRole('button', { name: /expand row/i })).toBeNull()
133
+ expect(screen.queryByRole('button', { name: /collapse row/i })).toBeNull()
134
+ } finally {
135
+ queryClient.clear()
136
+ }
137
+ })
138
+
139
+ it('shows an expand toggle and reveals sub-rows when expansion is enabled', () => {
140
+ type TreeRow = { id: string; name: string; children?: TreeRow[] }
141
+ const columns: ColumnDef<TreeRow>[] = [{ accessorKey: 'name', header: 'Name' }]
142
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
143
+ try {
144
+ render(
145
+ <QueryClientProvider client={queryClient}>
146
+ <I18nProvider locale="en" dict={{}}>
147
+ <DataTable<TreeRow>
148
+ columns={columns}
149
+ data={[{ id: 'parent', name: 'Parent', children: [{ id: 'child', name: 'Child' }] }]}
150
+ getSubRows={(row) => row.children}
151
+ expandable={(row) => Boolean(row.children?.length)}
152
+ />
153
+ </I18nProvider>
154
+ </QueryClientProvider>,
155
+ )
156
+
157
+ // Child hidden until expanded.
158
+ expect(screen.queryByText('Child')).toBeNull()
159
+ const toggle = screen.getByRole('button', { name: /expand row/i })
160
+ fireEvent.click(toggle)
161
+ expect(screen.getByText('Child')).toBeInTheDocument()
162
+ // Toggle flips to collapse affordance.
163
+ expect(screen.getByRole('button', { name: /collapse row/i })).toBeInTheDocument()
164
+ } finally {
165
+ queryClient.clear()
166
+ }
167
+ })
168
+
169
+ it('uses controlled expanded state and reports changes for lazy loading', () => {
170
+ type TreeRow = { id: string; name: string; children?: TreeRow[] }
171
+ const columns: ColumnDef<TreeRow>[] = [{ accessorKey: 'name', header: 'Name' }]
172
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
173
+ const onExpandedChange = jest.fn()
174
+ try {
175
+ render(
176
+ <QueryClientProvider client={queryClient}>
177
+ <I18nProvider locale="en" dict={{}}>
178
+ <DataTable<TreeRow>
179
+ columns={columns}
180
+ data={[{ id: 'parent', name: 'Parent' }]}
181
+ getSubRows={(row) => row.children}
182
+ expandable={() => true}
183
+ expanded={{}}
184
+ onExpandedChange={onExpandedChange}
185
+ />
186
+ </I18nProvider>
187
+ </QueryClientProvider>,
188
+ )
189
+ // A row with no loaded children still shows a toggle (lazy-load affordance).
190
+ fireEvent.click(screen.getByRole('button', { name: /expand row/i }))
191
+ expect(onExpandedChange).toHaveBeenCalled()
192
+ } finally {
193
+ queryClient.clear()
194
+ }
195
+ })
196
+
121
197
  it('keeps provided row order in manual sorting mode and reports sorting changes', () => {
122
198
  const columns: ColumnDef<Row>[] = [
123
199
  { accessorKey: 'name', header: 'Name' },
@@ -0,0 +1,57 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { setNavBadge, clearNavBadge, useNavBadge } from '../nav/navBadges'
3
+
4
+ const HREF = '/backend/caseload'
5
+ const OTHER = '/backend/traces'
6
+
7
+ // The store is a module singleton; every case starts from an empty slot.
8
+ afterEach(() => {
9
+ clearNavBadge(HREF)
10
+ clearNavBadge(OTHER)
11
+ })
12
+
13
+ describe('nav badges', () => {
14
+ it('publishes a count to subscribers of that href only', () => {
15
+ const target = renderHook(() => useNavBadge(HREF))
16
+ const bystander = renderHook(() => useNavBadge(OTHER))
17
+
18
+ act(() => setNavBadge(HREF, { count: 3, tone: 'attention' }))
19
+
20
+ expect(target.result.current).toEqual({ count: 3, tone: 'attention' })
21
+ expect(bystander.result.current).toBeNull()
22
+ })
23
+
24
+ it('renders nothing for a zero count rather than an empty chip', () => {
25
+ const { result } = renderHook(() => useNavBadge(HREF))
26
+
27
+ act(() => setNavBadge(HREF, { count: 4 }))
28
+ expect(result.current?.count).toBe(4)
29
+
30
+ act(() => setNavBadge(HREF, { count: 0 }))
31
+ expect(result.current).toBeNull()
32
+ })
33
+
34
+ it('clears the slot when the count becomes unknown', () => {
35
+ const { result } = renderHook(() => useNavBadge(HREF))
36
+
37
+ act(() => setNavBadge(HREF, { count: 2 }))
38
+ act(() => clearNavBadge(HREF))
39
+
40
+ expect(result.current).toBeNull()
41
+ })
42
+
43
+ it('ignores a non-finite count instead of rendering NaN', () => {
44
+ const { result } = renderHook(() => useNavBadge(HREF))
45
+
46
+ act(() => setNavBadge(HREF, { count: 5 }))
47
+ act(() => setNavBadge(HREF, { count: Number.NaN }))
48
+
49
+ expect(result.current).toBeNull()
50
+ })
51
+
52
+ it('ignores an empty href', () => {
53
+ const { result } = renderHook(() => useNavBadge(''))
54
+ act(() => setNavBadge('', { count: 7 }))
55
+ expect(result.current).toBeNull()
56
+ })
57
+ })
@@ -19,11 +19,30 @@ export type ActionItem = {
19
19
  disabled?: boolean
20
20
  /** Show a loading spinner instead of the icon */
21
21
  loading?: boolean
22
+ /** Render the item in the destructive token (e.g. Delete, Clear) */
23
+ destructive?: boolean
22
24
  }
23
25
 
26
+ /** A visual divider between groups of items. */
27
+ export type ActionSeparator = {
28
+ /** Optional key; falls back to the array index */
29
+ id?: string
30
+ separator: true
31
+ }
32
+
33
+ /** A muted uppercase section label grouping the items beneath it. */
34
+ export type ActionHeader = {
35
+ /** Optional key; falls back to the array index */
36
+ id?: string
37
+ /** The section label text */
38
+ header: string
39
+ }
40
+
41
+ export type ActionMenuEntry = ActionItem | ActionSeparator | ActionHeader
42
+
24
43
  export type ActionsDropdownProps = {
25
- /** Items to render inside the dropdown */
26
- items: ActionItem[]
44
+ /** Items to render inside the dropdown (may include separators) */
45
+ items: ActionMenuEntry[]
27
46
  /** Button label (default: translated 'Actions') */
28
47
  label?: string
29
48
  /** Trigger style */
@@ -32,6 +51,18 @@ export type ActionsDropdownProps = {
32
51
  ariaLabel?: string
33
52
  /** Button size (default: 'sm') */
34
53
  size?: 'sm' | 'default'
54
+ /** Extra classes for the trigger button (e.g. to match a dense toolbar row) */
55
+ triggerClassName?: string
56
+ /**
57
+ * Leading icon inside the label-mode trigger. Defaults to `Zap`; pass a
58
+ * component to override, or `false` to render just the label + chevron.
59
+ */
60
+ triggerIcon?: React.ComponentType<{ className?: string }> | false
61
+ /**
62
+ * Render a small warning dot on the trigger — for when an item inside the
63
+ * menu needs attention (e.g. required fields still blank).
64
+ */
65
+ attention?: boolean
35
66
  }
36
67
 
37
68
  export function ActionsDropdown({
@@ -40,6 +71,9 @@ export function ActionsDropdown({
40
71
  triggerMode = 'label',
41
72
  ariaLabel,
42
73
  size = 'sm',
74
+ triggerClassName,
75
+ triggerIcon,
76
+ attention = false,
43
77
  }: ActionsDropdownProps) {
44
78
  const t = useT()
45
79
  const [open, setOpen] = React.useState(false)
@@ -133,7 +167,7 @@ export function ActionsDropdown({
133
167
  type="button"
134
168
  variant="outline"
135
169
  size={size}
136
- className={triggerMode === 'icon' ? 'px-2' : undefined}
170
+ className={`${triggerMode === 'icon' ? 'px-2 ' : ''}${triggerClassName ?? ''}`.trim() || undefined}
137
171
  aria-haspopup="menu"
138
172
  aria-expanded={open}
139
173
  aria-label={resolvedAriaLabel}
@@ -150,11 +184,21 @@ export function ActionsDropdown({
150
184
  ) : (
151
185
  <>
152
186
  {resolvedLabel}
153
- <Zap className="size-4 ml-1" />
187
+ {triggerIcon === false
188
+ ? null
189
+ : triggerIcon
190
+ ? React.createElement(triggerIcon, { className: 'size-4 ml-1' })
191
+ : <Zap className="size-4 ml-1" />}
154
192
  <ChevronDown className={`size-3.5 transition-transform duration-150 ${open ? 'rotate-180' : ''}`} />
155
193
  </>
156
194
  )}
157
195
  </Button>
196
+ {attention && (
197
+ <span
198
+ aria-hidden="true"
199
+ className="pointer-events-none absolute right-0.5 top-0.5 size-2 rounded-full bg-status-warning-icon ring-2 ring-background"
200
+ />
201
+ )}
158
202
  {open && anchorRect && createPortal(
159
203
  <div
160
204
  ref={menuRef}
@@ -168,7 +212,26 @@ export function ActionsDropdown({
168
212
  transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,
169
213
  }}
170
214
  >
171
- {items.map((item) => {
215
+ {items.map((item, index) => {
216
+ if ('header' in item) {
217
+ return (
218
+ <div
219
+ key={item.id ?? `header-${index}`}
220
+ className="px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
221
+ >
222
+ {item.header}
223
+ </div>
224
+ )
225
+ }
226
+ if ('separator' in item) {
227
+ return (
228
+ <div
229
+ key={item.id ?? `separator-${index}`}
230
+ role="separator"
231
+ className="my-1 h-px bg-border"
232
+ />
233
+ )
234
+ }
172
235
  const Icon = item.icon
173
236
  return (
174
237
  <Button
@@ -176,7 +239,11 @@ export function ActionsDropdown({
176
239
  type="button"
177
240
  variant="ghost"
178
241
  size="sm"
179
- className="w-full justify-start h-auto min-h-8 py-1.5 whitespace-normal text-left leading-snug"
242
+ className={`w-full justify-start h-auto min-h-8 py-1.5 whitespace-normal text-left leading-snug${
243
+ item.destructive
244
+ ? ' text-destructive hover:text-destructive hover:bg-destructive/10 focus-visible:text-destructive'
245
+ : ''
246
+ }`}
180
247
  role="menuitem"
181
248
  disabled={item.disabled}
182
249
  onClick={() => {
@@ -1,4 +1,4 @@
1
1
  export { FormActionButtons, type FormActionButtonsProps } from './FormActionButtons'
2
- export { ActionsDropdown, type ActionItem, type ActionsDropdownProps } from './ActionsDropdown'
2
+ export { ActionsDropdown, type ActionItem, type ActionSeparator, type ActionHeader, type ActionMenuEntry, type ActionsDropdownProps } from './ActionsDropdown'
3
3
  export { FormHeader, type FormHeaderProps, type FormHeaderEditProps, type FormHeaderDetailProps } from './FormHeader'
4
4
  export { FormFooter, type FormFooterProps } from './FormFooter'
@@ -66,6 +66,7 @@ import {
66
66
  Flag,
67
67
  Flame,
68
68
  FolderTree,
69
+ FunctionSquare,
69
70
  Gauge,
70
71
  GitBranch,
71
72
  GitCompareArrows,
@@ -116,6 +117,7 @@ import {
116
117
  PhoneCall,
117
118
  PieChart,
118
119
  PlusSquare,
120
+ Radio,
119
121
  Receipt,
120
122
  ReceiptText,
121
123
  RefreshCcw,
@@ -161,6 +163,7 @@ import {
161
163
  UserRound,
162
164
  Users,
163
165
  UserX,
166
+ Variable,
164
167
  Wallet,
165
168
  Warehouse,
166
169
  Webhook,
@@ -234,6 +237,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
234
237
  'flag': Flag,
235
238
  'flame': Flame,
236
239
  'folder-tree': FolderTree,
240
+ 'function-square': FunctionSquare,
237
241
  'gauge': Gauge,
238
242
  'git-branch': GitBranch,
239
243
  'git-compare-arrows': GitCompareArrows,
@@ -284,6 +288,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
284
288
  'phone-call': PhoneCall,
285
289
  'pie-chart': PieChart,
286
290
  'plus-square': PlusSquare,
291
+ 'radio': Radio,
287
292
  'receipt': Receipt,
288
293
  'receipt-text': ReceiptText,
289
294
  'refresh-ccw': RefreshCcw,
@@ -329,6 +334,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
329
334
  'user-round': UserRound,
330
335
  'user-x': UserX,
331
336
  'users': Users,
337
+ 'variable': Variable,
332
338
  'wallet': Wallet,
333
339
  'warehouse': Warehouse,
334
340
  'webhook': Webhook,
@@ -16,6 +16,14 @@ export const BACKEND_TOPBAR_ACTIONS_INJECTION_SPOT_ID: InjectionSpotId = 'backen
16
16
  export const BACKEND_SIDEBAR_NAV_INJECTION_SPOT_ID: InjectionSpotId = 'backend:sidebar:nav'
17
17
  export const BACKEND_SIDEBAR_NAV_FOOTER_INJECTION_SPOT_ID: InjectionSpotId = 'backend:sidebar:nav:footer'
18
18
 
19
+ /**
20
+ * Headless mount point for modules that publish live counts onto sidebar nav
21
+ * items through `setNavBadge`. Rendered once per shell and always — a feeder
22
+ * must keep counting while the sidebar is collapsed or off-screen on mobile —
23
+ * and its widgets are expected to render nothing.
24
+ */
25
+ export const BACKEND_NAV_BADGES_INJECTION_SPOT_ID: InjectionSpotId = 'backend:nav:badges'
26
+
19
27
  // Standardized global status spot ids
20
28
  export const GLOBAL_SIDEBAR_STATUS_BADGES_INJECTION_SPOT_ID: InjectionSpotId = 'global:sidebar:status-badges'
21
29
  export const GLOBAL_HEADER_STATUS_INDICATORS_INJECTION_SPOT_ID: InjectionSpotId = 'global:header:status-indicators'