@startsimpli/ui 0.4.83 → 0.4.85

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.
@@ -0,0 +1,293 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * EntitySwitcher — a searchable "which record am I scoped to?" control
5
+ * (startsim-768w.21.1).
6
+ *
7
+ * Deliberately entity-agnostic: it takes `{ id, label, sublabel?, badge? }`
8
+ * items and hands back an id. It knows nothing about companies, foundries,
9
+ * projects or workspaces, so the same control serves all of them — the caller
10
+ * maps its domain objects into `EntitySwitcherItem[]`.
11
+ *
12
+ * Built on the existing Radix popover + Input primitives; no new dependency.
13
+ *
14
+ * Its first consumer is the dark `bg-gray-900` sidebar rail, so the defaults
15
+ * are translucent-over-dark and inherit the surrounding text colour rather
16
+ * than hard-coding a light-only look. Anything that doesn't suit a given
17
+ * surface is overridable via `triggerClassName` / `contentClassName`.
18
+ *
19
+ * a11y follows the WAI-ARIA combobox + listbox pattern:
20
+ * - trigger is `role="combobox"` with `aria-expanded` / `aria-controls`
21
+ * - the list is `role="listbox"`, rows are `role="option"`
22
+ * - `aria-selected` marks the *current* entity (what the app is scoped to)
23
+ * - `aria-activedescendant` (+ `data-highlighted`) tracks the *keyboard*
24
+ * highlight, which moves with ArrowUp/ArrowDown independently of selection
25
+ */
26
+
27
+ import * as React from 'react'
28
+ import { Check, ChevronsUpDown } from 'lucide-react'
29
+ import { cn } from '../../lib/utils'
30
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
31
+ import { Input } from '../ui/input'
32
+
33
+ export interface EntitySwitcherItem {
34
+ /** Stable identifier handed back to `onSelect`. */
35
+ id: string
36
+ /** Primary display text — also the first thing the filter matches on. */
37
+ label: string
38
+ /** Secondary line (slug, org, subtitle). Filtered on as well. */
39
+ sublabel?: string
40
+ /** Optional trailing adornment (status pill, count, icon). */
41
+ badge?: React.ReactNode
42
+ }
43
+
44
+ export interface EntitySwitcherProps {
45
+ /** Everything the user can switch to. */
46
+ items: EntitySwitcherItem[]
47
+ /** The id currently in scope; drives the trigger label + `aria-selected`. */
48
+ currentId?: string | null
49
+ /** Fired with the chosen item's id. Fires even when it re-picks `currentId`. */
50
+ onSelect: (id: string) => void
51
+ /** Filter input placeholder. */
52
+ placeholder?: string
53
+ /** Accessible name for the filter input. */
54
+ searchLabel?: string
55
+ /** Shown instead of the list when the filter matches nothing. */
56
+ emptyLabel?: string
57
+ /** Trigger text when `currentId` matches no item (or there is none yet). */
58
+ triggerPlaceholder?: string
59
+ /** Set false to drop the filter input (short lists). */
60
+ searchable?: boolean
61
+ /** Override the trigger chrome — e.g. to sit on a light surface. */
62
+ triggerClassName?: string
63
+ /** Override the popover panel chrome (width, padding, colours). */
64
+ contentClassName?: string
65
+ /** Disable the whole control. */
66
+ disabled?: boolean
67
+ }
68
+
69
+ function optionDomId(baseId: string, itemId: string) {
70
+ return `${baseId}-opt-${String(itemId).replace(/[^A-Za-z0-9_-]/g, '_')}`
71
+ }
72
+
73
+ export function EntitySwitcher({
74
+ items,
75
+ currentId,
76
+ onSelect,
77
+ placeholder = 'Search…',
78
+ searchLabel = 'Search',
79
+ emptyLabel = 'No matches',
80
+ triggerPlaceholder = 'Select…',
81
+ searchable = true,
82
+ triggerClassName,
83
+ contentClassName,
84
+ disabled,
85
+ }: EntitySwitcherProps) {
86
+ const baseId = React.useId()
87
+ const listboxId = `${baseId}-listbox`
88
+ const inputId = `${baseId}-filter`
89
+
90
+ const [open, setOpen] = React.useState(false)
91
+ const [query, setQuery] = React.useState('')
92
+ const [highlight, setHighlight] = React.useState(0)
93
+
94
+ const inputRef = React.useRef<HTMLInputElement>(null)
95
+ const listRef = React.useRef<HTMLUListElement>(null)
96
+
97
+ const current = items.find((i) => i.id === currentId)
98
+
99
+ const filtered = React.useMemo(() => {
100
+ const q = query.trim().toLowerCase()
101
+ if (!q) return items
102
+ return items.filter(
103
+ (i) =>
104
+ i.label.toLowerCase().includes(q) ||
105
+ (i.sublabel ? i.sublabel.toLowerCase().includes(q) : false)
106
+ )
107
+ }, [items, query])
108
+
109
+ // Keep the highlight inside the (possibly just-filtered) list.
110
+ React.useEffect(() => {
111
+ setHighlight((h) => (filtered.length === 0 ? 0 : Math.min(h, filtered.length - 1)))
112
+ }, [filtered.length])
113
+
114
+ const handleOpenChange = (next: boolean) => {
115
+ setOpen(next)
116
+ if (next) {
117
+ setQuery('')
118
+ // Open on whatever is currently in scope, so Enter is a no-op re-pick
119
+ // rather than a surprise jump to the first row.
120
+ const idx = items.findIndex((i) => i.id === currentId)
121
+ setHighlight(idx >= 0 ? idx : 0)
122
+ }
123
+ }
124
+
125
+ const choose = (id: string) => {
126
+ onSelect(id)
127
+ setOpen(false)
128
+ }
129
+
130
+ const handleKeyDown = (event: React.KeyboardEvent) => {
131
+ if (event.key === 'ArrowDown') {
132
+ event.preventDefault()
133
+ if (filtered.length === 0) return
134
+ setHighlight((h) => (h + 1) % filtered.length)
135
+ return
136
+ }
137
+ if (event.key === 'ArrowUp') {
138
+ event.preventDefault()
139
+ if (filtered.length === 0) return
140
+ setHighlight((h) => (h - 1 + filtered.length) % filtered.length)
141
+ return
142
+ }
143
+ if (event.key === 'Home') {
144
+ event.preventDefault()
145
+ setHighlight(0)
146
+ return
147
+ }
148
+ if (event.key === 'End') {
149
+ event.preventDefault()
150
+ setHighlight(Math.max(filtered.length - 1, 0))
151
+ return
152
+ }
153
+ if (event.key === 'Enter') {
154
+ const item = filtered[highlight]
155
+ if (!item) return
156
+ event.preventDefault()
157
+ choose(item.id)
158
+ }
159
+ // Escape is left to the popover's dismiss layer, which also restores
160
+ // focus to the trigger.
161
+ }
162
+
163
+ const activeItem = filtered[highlight]
164
+ const activeDescendant = activeItem ? optionDomId(baseId, activeItem.id) : undefined
165
+
166
+ // Keep the highlighted row in view on long lists.
167
+ React.useEffect(() => {
168
+ if (!open || !activeDescendant) return
169
+ const el = listRef.current?.querySelector<HTMLElement>(`#${CSS.escape(activeDescendant)}`)
170
+ el?.scrollIntoView?.({ block: 'nearest' })
171
+ }, [open, activeDescendant])
172
+
173
+ return (
174
+ <Popover open={open} onOpenChange={handleOpenChange}>
175
+ <PopoverTrigger asChild>
176
+ <button
177
+ type="button"
178
+ role="combobox"
179
+ aria-expanded={open}
180
+ aria-controls={listboxId}
181
+ aria-haspopup="listbox"
182
+ disabled={disabled}
183
+ className={cn(
184
+ 'flex w-full items-center justify-between gap-2 rounded-md border border-white/15 bg-white/5 px-3 py-2',
185
+ 'text-left text-sm font-medium text-inherit transition-colors',
186
+ 'hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40',
187
+ 'disabled:cursor-not-allowed disabled:opacity-50',
188
+ triggerClassName
189
+ )}
190
+ >
191
+ <span className="min-w-0 flex-1 truncate">{current ? current.label : triggerPlaceholder}</span>
192
+ <ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-60" aria-hidden="true" />
193
+ </button>
194
+ </PopoverTrigger>
195
+
196
+ <PopoverContent
197
+ align="start"
198
+ className={cn('w-64 p-0', contentClassName)}
199
+ onOpenAutoFocus={(event) => {
200
+ // Focus the filter (or the list) rather than the panel itself, so the
201
+ // very first ArrowDown moves the highlight.
202
+ event.preventDefault()
203
+ if (searchable) inputRef.current?.focus()
204
+ else listRef.current?.focus()
205
+ }}
206
+ onKeyDown={handleKeyDown}
207
+ >
208
+ {searchable && (
209
+ <div className="border-b p-2">
210
+ <label className="sr-only" htmlFor={inputId}>
211
+ {searchLabel}
212
+ </label>
213
+ <Input
214
+ id={inputId}
215
+ ref={inputRef}
216
+ type="text"
217
+ value={query}
218
+ onChange={(e) => setQuery(e.target.value)}
219
+ placeholder={placeholder}
220
+ autoComplete="off"
221
+ aria-autocomplete="list"
222
+ aria-controls={listboxId}
223
+ aria-activedescendant={activeDescendant}
224
+ className="h-8"
225
+ />
226
+ </div>
227
+ )}
228
+
229
+ <ul
230
+ ref={listRef}
231
+ id={listboxId}
232
+ role="listbox"
233
+ tabIndex={searchable ? -1 : 0}
234
+ aria-label={searchLabel}
235
+ aria-activedescendant={searchable ? undefined : activeDescendant}
236
+ className="max-h-64 overflow-y-auto p-1 outline-none"
237
+ >
238
+ {filtered.map((item, index) => {
239
+ const isCurrent = item.id === currentId
240
+ const isHighlighted = index === highlight
241
+ return (
242
+ <li
243
+ key={item.id}
244
+ id={optionDomId(baseId, item.id)}
245
+ role="option"
246
+ aria-selected={isCurrent}
247
+ data-highlighted={isHighlighted}
248
+ onClick={() => choose(item.id)}
249
+ onMouseEnter={() => setHighlight(index)}
250
+ className={cn(
251
+ 'flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm',
252
+ isHighlighted && 'bg-accent text-accent-foreground'
253
+ )}
254
+ >
255
+ <Check
256
+ className={cn('h-4 w-4 flex-shrink-0', isCurrent ? 'opacity-100' : 'opacity-0')}
257
+ aria-hidden="true"
258
+ />
259
+ <span className="min-w-0 flex-1">
260
+ <span className="block truncate">{item.label}</span>
261
+ {item.sublabel && (
262
+ <span
263
+ className={cn(
264
+ 'block truncate text-xs',
265
+ // The sublabel has to follow the row's foreground, not
266
+ // the panel's. `text-muted-foreground` is picked to sit
267
+ // on `bg-popover`; left alone it stays that grey when
268
+ // the row flips to `bg-accent`, and on a saturated
269
+ // accent that lands at ~1.1:1 — browser-verified as
270
+ // washed out to the point of unreadable
271
+ // (startsim-768w.21.1.3). Dimming the accent foreground
272
+ // instead keeps the primary/secondary hierarchy while
273
+ // staying legible on whatever accent the theme sets.
274
+ isHighlighted ? 'text-accent-foreground/80' : 'text-muted-foreground'
275
+ )}
276
+ >
277
+ {item.sublabel}
278
+ </span>
279
+ )}
280
+ </span>
281
+ {item.badge ? <span className="flex-shrink-0">{item.badge}</span> : null}
282
+ </li>
283
+ )
284
+ })}
285
+ </ul>
286
+
287
+ {filtered.length === 0 && (
288
+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyLabel}</div>
289
+ )}
290
+ </PopoverContent>
291
+ </Popover>
292
+ )
293
+ }
@@ -0,0 +1,6 @@
1
+ // Navigation chrome: the app rail, the shell that assembles it, grouped nav
2
+ // sections and the generic entity switcher that scopes the rail.
3
+ export * from './sidebar'
4
+ export * from './app-shell'
5
+ export * from './grouped-nav'
6
+ export * from './entity-switcher'
@@ -27,6 +27,13 @@ export interface SidebarProps {
27
27
  * Navigation sections
28
28
  */
29
29
  sections: SidebarSection[];
30
+ /**
31
+ * Contextual switcher rendered directly under the logo, above the nav —
32
+ * "which record is this rail scoped to?" (e.g. an <EntitySwitcher> over the
33
+ * user's foundries). Hidden while collapsed: a 5rem rail can only show a
34
+ * truncated combobox, which is noise rather than navigation.
35
+ */
36
+ switcher?: ReactNode;
30
37
  /**
31
38
  * User dropdown content (rendered at bottom of sidebar)
32
39
  */
@@ -56,6 +63,7 @@ export interface SidebarProps {
56
63
  export function Sidebar({
57
64
  appName,
58
65
  sections,
66
+ switcher,
59
67
  userDropdown,
60
68
  className,
61
69
  collapsible = true,
@@ -99,6 +107,11 @@ export function Sidebar({
99
107
  </Link>
100
108
  </div>
101
109
 
110
+ {/* Contextual switcher (scope of this rail) */}
111
+ {switcher && !collapsed && (
112
+ <div className="px-4 pt-4">{switcher}</div>
113
+ )}
114
+
102
115
  {/* Navigation */}
103
116
  <nav className="flex-1 overflow-y-auto p-4 space-y-6">
104
117
  {sections.map((section, idx) => (
@@ -38,6 +38,11 @@ export interface MembersSectionProps {
38
38
  onResendInvite?: (userId: string) => void | Promise<void>
39
39
  /** Suspend/restore a membership without deleting it (soft exit). */
40
40
  onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
41
+ /**
42
+ * Drill into one person (startsim-768w.19.6). Passed straight through to the
43
+ * table, which makes each row a click target — the way into MemberDetailPanel.
44
+ */
45
+ onSelectMember?: (userId: string) => void
41
46
  /** When set, the invite dialog is rendered next to the heading. */
42
47
  onInvite?: (input: { email: string; teamId: string; role: TeamRole }) => Promise<InvitationLite | void>
43
48
  onInvited?: (invitation: InvitationLite | void) => void
@@ -74,6 +79,7 @@ export function MembersSection({
74
79
  onRemove,
75
80
  onResendInvite,
76
81
  onSetActive,
82
+ onSelectMember,
77
83
  onInvite,
78
84
  onInvited,
79
85
  availableRoles,
@@ -128,6 +134,7 @@ export function MembersSection({
128
134
  onRemove={onRemove}
129
135
  onResendInvite={onResendInvite}
130
136
  onSetActive={onSetActive}
137
+ onSelectMember={onSelectMember}
131
138
  emptyState={emptyState}
132
139
  classNames={classNames?.table}
133
140
  />
@@ -52,6 +52,14 @@ export interface MembersTableProps {
52
52
  * active and Restore once `isActive === false`.
53
53
  */
54
54
  onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
55
+ /**
56
+ * Drill into one person (startsim-768w.19.6). When set, each row becomes a
57
+ * button-row: clicking it hands back that member's user id so the host can
58
+ * open the detail surface. The row ACTIONS (role, resend, deactivate, remove)
59
+ * deliberately stop the click, so managing a member never doubles as
60
+ * selecting them.
61
+ */
62
+ onSelectMember?: (userId: string) => void
55
63
  /** Custom empty-state slot. Defaults to a generic "No members yet" cell. */
56
64
  emptyState?: React.ReactNode
57
65
  /**
@@ -120,6 +128,7 @@ export function MembersTable({
120
128
  onRemove,
121
129
  onResendInvite,
122
130
  onSetActive,
131
+ onSelectMember,
123
132
  emptyState,
124
133
  showStatus,
125
134
  showLastActive,
@@ -178,8 +187,26 @@ export function MembersTable({
178
187
  const canResend =
179
188
  canManage && !!onResendInvite &&
180
189
  (m.inviteStatus === 'pending' || m.inviteStatus === 'expired')
190
+ const selectable = !!onSelectMember
181
191
  return (
182
- <tr key={m.id} className={cls.row}>
192
+ <tr
193
+ key={m.id}
194
+ className={selectable ? `${cls.row} ${cls.selectableRow}`.trim() : cls.row}
195
+ onClick={selectable ? () => onSelectMember!(m.userId) : undefined}
196
+ onKeyDown={
197
+ selectable
198
+ ? (e) => {
199
+ if (e.key === 'Enter' || e.key === ' ') {
200
+ e.preventDefault()
201
+ onSelectMember!(m.userId)
202
+ }
203
+ }
204
+ : undefined
205
+ }
206
+ tabIndex={selectable ? 0 : undefined}
207
+ role={selectable ? 'button' : undefined}
208
+ aria-label={selectable ? `View ${displayName}` : undefined}
209
+ >
183
210
  <td className={cls.cell}>
184
211
  <div className="flex items-center gap-3">
185
212
  {m.user?.avatarUrl ? (
@@ -228,7 +255,12 @@ export function MembersTable({
228
255
  )}
229
256
  </td>
230
257
  )}
231
- <td className={tight(cls.cell, cls.tightColumn)}>
258
+ {/* Role + actions are CONTROLS, not navigation: a click here
259
+ must change the member, never also select them. */}
260
+ <td
261
+ className={tight(cls.cell, cls.tightColumn)}
262
+ onClick={(e) => e.stopPropagation()}
263
+ >
232
264
  {canManage && onChangeRole ? (
233
265
  <RoleSelector
234
266
  value={m.role}
@@ -240,7 +272,10 @@ export function MembersTable({
240
272
  )}
241
273
  </td>
242
274
  {canManage && (
243
- <td className={tight(cls.cell, cls.tightColumn)}>
275
+ <td
276
+ className={tight(cls.cell, cls.tightColumn)}
277
+ onClick={(e) => e.stopPropagation()}
278
+ >
244
279
  <div className={cls.actions}>
245
280
  {canResend && (
246
281
  <button
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Member detail — "what has this person done" (startsim-768w.19.6 / RED .19.6.1).
3
+ *
4
+ * The live foundry Members tab renders rows nobody can drill into. There is no
5
+ * per-member surface anywhere: last login, activation/invite state, who invited
6
+ * them, when their role last changed and — the actual question — what they have
7
+ * DONE are all unanswerable from the UI, even though the backend has recorded
8
+ * every one of those facts for months.
9
+ *
10
+ * THE DESIGN GUARD, stated once: a member's activity is not a new feed. It is
11
+ * THE one permission-filtered event stream narrowed by actor, so this panel
12
+ * embeds the shared `ActivityFeedPanel` with a client the host pre-scoped to
13
+ * that actor. There is no second timeline, no second client and no level check
14
+ * below this line — packages/ui decides nothing about visibility.
15
+ */
16
+ import * as fs from 'node:fs'
17
+ import * as path from 'node:path'
18
+ import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
19
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
20
+
21
+ import { MembersTable, MembersSection, MemberDetailPanel } from '../index'
22
+ import type { MemberRow } from '../types'
23
+ import type { ActivityPage, ActivityQuery } from '../../activity'
24
+
25
+ // --------------------------------------------------------------------------
26
+ // helpers
27
+ // --------------------------------------------------------------------------
28
+
29
+ function withQuery(ui: React.ReactElement) {
30
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
31
+ return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
32
+ }
33
+
34
+ const activated: MemberRow = {
35
+ id: 'm1',
36
+ userId: 'u1',
37
+ teamId: 'marketing-agents',
38
+ role: 'admin',
39
+ joinedAt: '2026-05-02T09:00:00Z',
40
+ inviteStatus: 'active',
41
+ lastLoginAt: '2026-07-25T18:30:00Z',
42
+ activatedAt: '2026-05-03T11:00:00Z',
43
+ roleChangedAt: '2026-06-14T08:00:00Z',
44
+ invitedByEmail: 'owner@ogmc.ai',
45
+ isActive: true,
46
+ user: { id: 'u1', email: 'nordby@ogmc.ai', firstName: 'Erik', lastName: 'Nordby' },
47
+ }
48
+
49
+ const neverSignedIn: MemberRow = {
50
+ id: 'm2',
51
+ userId: 'u2',
52
+ teamId: 'marketing-agents',
53
+ role: 'member',
54
+ joinedAt: '2026-07-20T00:00:00Z',
55
+ inviteStatus: 'expired',
56
+ lastLoginAt: null,
57
+ inviteExpiresAt: '2026-07-27T00:00:00Z',
58
+ invitedByEmail: 'owner@ogmc.ai',
59
+ isActive: true,
60
+ user: { id: 'u2', email: 'lapsed@ogmc.ai' },
61
+ }
62
+
63
+ /** An activity client the HOST already narrowed to one actor — exactly what the
64
+ * panel receives, and the only reason it needs no filtering of its own. */
65
+ function scopedClient(rows: Array<{ id: string; action: string; created_at: string }>) {
66
+ const calls: ActivityQuery[] = []
67
+ return {
68
+ calls,
69
+ client: {
70
+ list: (params: ActivityQuery): Promise<ActivityPage> => {
71
+ calls.push(params)
72
+ return Promise.resolve({ count: rows.length, next: null, previous: null, results: rows })
73
+ },
74
+ },
75
+ }
76
+ }
77
+
78
+ const oneEvent = [
79
+ { id: 'e1', action: 'member_invited', created_at: '2026-07-25T18:31:00Z' },
80
+ ]
81
+
82
+ // --------------------------------------------------------------------------
83
+ // 1. the panel exists and answers the five questions
84
+ // --------------------------------------------------------------------------
85
+ describe('MemberDetailPanel', () => {
86
+ it('renders identity, last login, invite state and role history', async () => {
87
+ const { client } = scopedClient(oneEvent)
88
+ withQuery(<MemberDetailPanel member={activated} activityClient={client} />)
89
+
90
+ expect(screen.getByText('Erik Nordby')).toBeInTheDocument()
91
+ expect(screen.getByText('nordby@ogmc.ai')).toBeInTheDocument()
92
+
93
+ const facts = screen.getByTestId('member-facts')
94
+ expect(within(facts).getByTestId('fact-last-login')).toHaveTextContent(/2026/)
95
+ expect(within(facts).getByTestId('fact-activated')).toHaveTextContent(/2026/)
96
+ expect(within(facts).getByTestId('fact-invited-by')).toHaveTextContent('owner@ogmc.ai')
97
+ expect(within(facts).getByTestId('fact-role-changed')).toHaveTextContent(/2026/)
98
+ expect(screen.getByTestId('member-invite-status')).toHaveAttribute(
99
+ 'data-invite-status', 'active',
100
+ )
101
+ })
102
+
103
+ it('says so explicitly when someone has never signed in', async () => {
104
+ const { client } = scopedClient([])
105
+ withQuery(<MemberDetailPanel member={neverSignedIn} activityClient={client} />)
106
+ expect(screen.getByTestId('fact-last-login')).toHaveTextContent(/never/i)
107
+ })
108
+
109
+ it('embeds the shared activity feed, driven by the injected scoped client', async () => {
110
+ const { client, calls } = scopedClient(oneEvent)
111
+ withQuery(<MemberDetailPanel member={activated} activityClient={client} />)
112
+
113
+ await waitFor(() => expect(screen.getByTestId('activity-feed-panel')).toBeInTheDocument())
114
+ await waitFor(() => expect(calls.length).toBeGreaterThan(0))
115
+ await waitFor(() =>
116
+ expect(within(screen.getByTestId('activity-timeline')).getByText('Member invited'))
117
+ .toBeInTheDocument(),
118
+ )
119
+ })
120
+
121
+ it('offers Resend for a pending/expired member and calls onResendInvite', async () => {
122
+ const { client } = scopedClient([])
123
+ const onResendInvite = jest.fn()
124
+ withQuery(
125
+ <MemberDetailPanel
126
+ member={neverSignedIn}
127
+ activityClient={client}
128
+ canManage
129
+ onResendInvite={onResendInvite}
130
+ />,
131
+ )
132
+ fireEvent.click(screen.getByRole('button', { name: /resend invite/i }))
133
+ expect(onResendInvite).toHaveBeenCalledWith('u2')
134
+ })
135
+
136
+ it('does not offer Resend to someone who has already activated', async () => {
137
+ const { client } = scopedClient([])
138
+ withQuery(
139
+ <MemberDetailPanel
140
+ member={activated}
141
+ activityClient={client}
142
+ canManage
143
+ onResendInvite={jest.fn()}
144
+ />,
145
+ )
146
+ expect(screen.queryByRole('button', { name: /resend invite/i })).toBeNull()
147
+ })
148
+ })
149
+
150
+ // --------------------------------------------------------------------------
151
+ // 2. the roster row is the way in
152
+ // --------------------------------------------------------------------------
153
+ describe('MembersTable selection', () => {
154
+ it('calls onSelectMember with the user id when a row is clicked', () => {
155
+ const onSelectMember = jest.fn()
156
+ render(<MembersTable members={[activated, neverSignedIn]} onSelectMember={onSelectMember} />)
157
+ fireEvent.click(screen.getByText('nordby@ogmc.ai'))
158
+ expect(onSelectMember).toHaveBeenCalledWith('u1')
159
+ })
160
+
161
+ it('does not select the row when a row ACTION is clicked', () => {
162
+ const onSelectMember = jest.fn()
163
+ const onRemove = jest.fn()
164
+ render(
165
+ <MembersTable
166
+ members={[activated]}
167
+ canManage
168
+ onRemove={onRemove}
169
+ onSelectMember={onSelectMember}
170
+ />,
171
+ )
172
+ fireEvent.click(screen.getByRole('button', { name: /remove/i }))
173
+ expect(onRemove).toHaveBeenCalledWith('u1')
174
+ expect(onSelectMember).not.toHaveBeenCalled()
175
+ })
176
+
177
+ it('still renders the Last active column', () => {
178
+ render(<MembersTable members={[activated, neverSignedIn]} />)
179
+ expect(screen.getByText('Last active')).toBeInTheDocument()
180
+ expect(screen.getByText('Never')).toBeInTheDocument()
181
+ })
182
+
183
+ it('MembersSection passes selection through to the table', () => {
184
+ const onSelectMember = jest.fn()
185
+ render(<MembersSection members={[activated]} onSelectMember={onSelectMember} />)
186
+ fireEvent.click(screen.getByText('nordby@ogmc.ai'))
187
+ expect(onSelectMember).toHaveBeenCalledWith('u1')
188
+ })
189
+ })
190
+
191
+ // --------------------------------------------------------------------------
192
+ // 3. DESIGN GUARD — one feed, one client, no visibility logic
193
+ // --------------------------------------------------------------------------
194
+ describe('member detail design guards', () => {
195
+ const src = () =>
196
+ fs.readFileSync(
197
+ path.resolve(__dirname, '..', 'pages', 'MemberDetailPanel.tsx'),
198
+ 'utf8',
199
+ )
200
+
201
+ it('lives in packages/ui beside the other page composers', () => {
202
+ expect(fs.existsSync(path.resolve(__dirname, '..', 'pages', 'MemberDetailPanel.tsx')))
203
+ .toBe(true)
204
+ })
205
+
206
+ it('embeds the shared ActivityFeedPanel rather than its own timeline', () => {
207
+ expect(src()).toMatch(/ActivityFeedPanel/)
208
+ expect(src()).not.toMatch(/useInfiniteQuery/)
209
+ })
210
+
211
+ it('imports no API client and makes no visibility decision', () => {
212
+ const text = src()
213
+ // The client is INJECTED — packages/ui never takes @startsimpli/api as a
214
+ // dependency. (Naming it in prose is fine; importing it is not.)
215
+ expect(text).not.toMatch(/from\s+['"]@startsimpli\/api/)
216
+ for (const banned of [
217
+ /is_org_admin/, /isOrgAdmin/, /accessLevel/, /resolve_level/,
218
+ /visible_actions/, /\brole\s*===\s*'owner'/,
219
+ ]) {
220
+ expect(`${banned.source}:${banned.test(text)}`).toBe(`${banned.source}:false`)
221
+ }
222
+ })
223
+ })
@@ -8,6 +8,8 @@ export interface MembersTableClassNames {
8
8
  th?: string
9
9
  tbody?: string
10
10
  row?: string
11
+ /** Added to a row when `onSelectMember` makes it a drill-down affordance. */
12
+ selectableRow?: string
11
13
  cell?: string
12
14
  avatar?: string
13
15
  name?: string
@@ -46,6 +48,7 @@ export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
46
48
  th: 'px-4 py-2.5 font-medium',
47
49
  tbody: 'divide-y divide-border',
48
50
  row: 'transition-colors hover:bg-muted/40',
51
+ selectableRow: 'cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
49
52
  cell: 'px-4 py-3 align-middle',
50
53
  avatar:
51
54
  'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary',