@startsimpli/ui 0.4.84 → 0.4.86

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,
@@ -63,7 +71,10 @@ export function Sidebar({
63
71
  collapsed: controlledCollapsed,
64
72
  onCollapsedChange,
65
73
  }: SidebarProps) {
66
- const pathname = usePathname();
74
+ // usePathname() is null outside an app-router provider (harnesses, tests,
75
+ // a pages-router host). Shared chrome renders there too, so treat it as
76
+ // "no current page" rather than throwing on `null.startsWith`.
77
+ const pathname = usePathname() ?? '';
67
78
  const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
68
79
  const collapsed = controlledCollapsed ?? internalCollapsed;
69
80
  const setCollapsed = (next: boolean) => {
@@ -71,12 +82,19 @@ export function Sidebar({
71
82
  onCollapsedChange?.(next);
72
83
  };
73
84
 
74
- const isActivePath = (href: string) => {
75
- if (href === '/') {
76
- return pathname === '/';
77
- }
78
- return pathname.startsWith(href);
79
- };
85
+ /**
86
+ * "You are here", by path SEGMENT rather than by string prefix.
87
+ *
88
+ * A prefix test lights up peers that merely share a stem —
89
+ * `/foundry/x/builds`.startsWith(`/foundry/x/build`) — and once a control
90
+ * plane puts its whole section list in the rail (startsim-768w.21.3) that
91
+ * pair is real: Build with AI and Builds sat next to each other, both lit.
92
+ * Requiring the next character to be a boundary keeps parent links current on
93
+ * their own sub-pages (`/leads` on `/leads/42`) without the collision. `/` is
94
+ * exact by the same rule, since nothing else starts with `//`.
95
+ */
96
+ const isActivePath = (href: string) =>
97
+ pathname === href || pathname.startsWith(`${href}/`);
80
98
 
81
99
  return (
82
100
  <div
@@ -99,12 +117,26 @@ export function Sidebar({
99
117
  </Link>
100
118
  </div>
101
119
 
120
+ {/* Contextual switcher (scope of this rail) */}
121
+ {switcher && !collapsed && (
122
+ <div className="px-4 pt-4">{switcher}</div>
123
+ )}
124
+
102
125
  {/* Navigation */}
103
126
  <nav className="flex-1 overflow-y-auto p-4 space-y-6">
104
127
  {sections.map((section, idx) => (
105
- <div key={idx}>
128
+ // A named group, not an anonymous div: a titled section is a distinct
129
+ // scope ("the sections of THIS foundry"), and the name has to survive
130
+ // the collapsed rail hiding the heading — hence aria-label rather than
131
+ // aria-labelledby (startsim-768w.21.3).
132
+ <div key={idx} role="group" aria-label={section.title}>
106
133
  {section.title && !collapsed && (
107
- <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-3">
134
+ // A group title can be a user-supplied record name (a foundry's
135
+ // branding), so it truncates instead of wrapping the rail open.
136
+ <h3
137
+ className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-3 truncate"
138
+ title={section.title}
139
+ >
108
140
  {section.title}
109
141
  </h3>
110
142
  )}
@@ -115,6 +147,9 @@ export function Sidebar({
115
147
  <Link
116
148
  key={link.href}
117
149
  href={link.href}
150
+ // Colour is not an accessible name — say "you are here".
151
+ aria-current={isActive ? 'page' : undefined}
152
+ title={collapsed ? link.label : undefined}
118
153
  className={cn(
119
154
  'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
120
155
  isActive
@@ -128,7 +163,7 @@ export function Sidebar({
128
163
  {link.icon}
129
164
  </span>
130
165
  ) : null}
131
- {!collapsed && <span>{link.label}</span>}
166
+ {!collapsed && <span className="min-w-0 truncate">{link.label}</span>}
132
167
  </Link>
133
168
  );
134
169
  })}
@@ -18,7 +18,7 @@
18
18
  import * as React from 'react'
19
19
  import { MembersTable } from './MembersTable'
20
20
  import { InviteMemberDialog } from './InviteMemberDialog'
21
- import type { InvitationLite, MemberRow, TeamRole } from './types'
21
+ import type { InvitationLite, MemberRow, MemberSource, TeamRole } from './types'
22
22
  import {
23
23
  MEMBERS_SECTION_DEFAULTS,
24
24
  type MembersSectionClassNames,
@@ -62,9 +62,43 @@ export interface MembersSectionProps {
62
62
  inviteSuccessMessage?: (email: string) => string
63
63
  emptyState?: React.ReactNode
64
64
  helpText?: React.ReactNode
65
+ /**
66
+ * Narrow the list to ONE population (startsim-768w.20.4): the people who were
67
+ * invited, the people who signed themselves up, or the rows the platform
68
+ * seeded.
69
+ *
70
+ * A CONTROLLED prop, not internal state, and the composer never filters
71
+ * `members` itself. That is deliberate: the list is paginated server-side
72
+ * (repo rule 8), so a browser dropping rows from the page it happens to hold
73
+ * would shrink pages and lose people off the end. The host answers the change
74
+ * by re-querying.
75
+ */
76
+ sourceFilter?: MemberSourceFilter
65
77
  classNames?: MembersSectionClassNames
66
78
  }
67
79
 
80
+ /** `all` is the absence of a filter, not a fourth population. */
81
+ export type MemberSourceFilterValue = MemberSource | 'all'
82
+
83
+ export interface MemberSourceFilter {
84
+ value: MemberSourceFilterValue
85
+ onChange: (value: MemberSourceFilterValue) => void
86
+ /** Override the offered options (default: everyone + the three origins). */
87
+ options?: MemberSourceFilterValue[]
88
+ label?: string
89
+ }
90
+
91
+ const SOURCE_FILTER_OPTIONS: MemberSourceFilterValue[] = [
92
+ 'all', 'invited', 'self_signup', 'seed',
93
+ ]
94
+
95
+ const SOURCE_FILTER_LABEL: Record<MemberSourceFilterValue, string> = {
96
+ all: 'Everyone',
97
+ invited: 'Invited',
98
+ self_signup: 'Signed up',
99
+ seed: 'Provisioned',
100
+ }
101
+
68
102
  const defaultDescription = (count: number) =>
69
103
  `${count} ${count === 1 ? 'person has' : 'people have'} access.`
70
104
 
@@ -94,9 +128,11 @@ export function MembersSection({
94
128
  inviteSuccessMessage = (email) => `Invite sent to ${email} — they'll set a password and join`,
95
129
  emptyState,
96
130
  helpText,
131
+ sourceFilter,
97
132
  classNames,
98
133
  }: MembersSectionProps) {
99
134
  const cls = { ...MEMBERS_SECTION_DEFAULTS, ...(classNames ?? {}) }
135
+ const sourceOptions = sourceFilter?.options ?? SOURCE_FILTER_OPTIONS
100
136
 
101
137
  return (
102
138
  <div className={cls.root}>
@@ -125,6 +161,25 @@ export function MembersSection({
125
161
  </div>
126
162
  )}
127
163
  <div className={cls.body}>
164
+ {sourceFilter && (
165
+ <div className={cls.filterBar}>
166
+ <label htmlFor="members-source-filter" className={cls.filterLabel}>
167
+ {sourceFilter.label ?? 'How they joined'}
168
+ </label>
169
+ <select
170
+ id="members-source-filter"
171
+ className={cls.filterSelect}
172
+ value={sourceFilter.value}
173
+ onChange={(e) =>
174
+ sourceFilter.onChange(e.target.value as MemberSourceFilterValue)
175
+ }
176
+ >
177
+ {sourceOptions.map((o) => (
178
+ <option key={o} value={o}>{SOURCE_FILTER_LABEL[o]}</option>
179
+ ))}
180
+ </select>
181
+ </div>
182
+ )}
128
183
  <MembersTable
129
184
  members={members}
130
185
  canManage={canManage}
@@ -25,6 +25,7 @@ import {
25
25
  userDisplayName,
26
26
  userInitials,
27
27
  type MemberRow,
28
+ type MemberSource,
28
29
  type TeamRole,
29
30
  } from './types'
30
31
  import {
@@ -68,6 +69,12 @@ export interface MembersTableProps {
68
69
  */
69
70
  showStatus?: boolean
70
71
  showLastActive?: boolean
72
+ /**
73
+ * Force the "how they joined" column on/off (startsim-768w.20.4). Default:
74
+ * shown when any row carries `source`, so a backend that doesn't record
75
+ * provenance renders exactly as before.
76
+ */
77
+ showSource?: boolean
71
78
  classNames?: MembersTableClassNames
72
79
  /** Restrict role-selector options (e.g. hide 'owner' when caller isn't owner). */
73
80
  availableRoles?: TeamRole[]
@@ -102,6 +109,22 @@ function rowStatus(m: MemberRow): RowStatus | null {
102
109
  return m.inviteStatus ?? null
103
110
  }
104
111
 
112
+ /**
113
+ * How they got here, in words a person reads (startsim-768w.20.4). "self_signup"
114
+ * is a wire value; "Signed up" is what the column says.
115
+ */
116
+ const SOURCE_LABEL: Record<MemberSource, string> = {
117
+ invited: 'Invited',
118
+ self_signup: 'Signed up',
119
+ seed: 'Provisioned',
120
+ }
121
+
122
+ const SOURCE_TITLE: Record<MemberSource, string> = {
123
+ invited: 'Someone on this team added them by email',
124
+ self_signup: 'They created their own account through the app',
125
+ seed: 'Created by the platform when this workspace was set up',
126
+ }
127
+
105
128
  /** Short absolute date. Absolute, not "3 days ago": an owner deciding whether to
106
129
  * resend an invite wants the actual day. */
107
130
  function formatDate(iso: string): string {
@@ -132,6 +155,7 @@ export function MembersTable({
132
155
  emptyState,
133
156
  showStatus,
134
157
  showLastActive,
158
+ showSource,
135
159
  classNames,
136
160
  availableRoles,
137
161
  currentUserId,
@@ -139,13 +163,21 @@ export function MembersTable({
139
163
  const cls = { ...MEMBERS_TABLE_DEFAULTS, ...(classNames ?? {}) }
140
164
  const withStatus = showStatus ?? members.some((m) => rowStatus(m) !== null)
141
165
  const withLastActive = showLastActive ?? members.some((m) => m.lastLoginAt !== undefined)
166
+ const withSource = showSource ?? members.some((m) => m.source !== undefined)
142
167
  const statusClass: Record<RowStatus, string> = {
143
168
  active: cls.statusActive,
144
169
  pending: cls.statusPending,
145
170
  expired: cls.statusExpired,
146
171
  deactivated: cls.statusDeactivated,
147
172
  }
148
- const columnCount = 2 + (withStatus ? 1 : 0) + (withLastActive ? 1 : 0) + (canManage ? 1 : 0)
173
+ const sourceClass: Record<MemberSource, string> = {
174
+ invited: cls.sourceInvited,
175
+ self_signup: cls.sourceSelfSignup,
176
+ seed: cls.sourceSeed,
177
+ }
178
+ const columnCount =
179
+ 2 + (withStatus ? 1 : 0) + (withLastActive ? 1 : 0) + (withSource ? 1 : 0)
180
+ + (canManage ? 1 : 0)
149
181
  // Role + actions hug their content so the Member column takes the slack —
150
182
  // one shared cell class used to split the table down the middle and leave
151
183
  // the controls floating in a gap.
@@ -160,6 +192,9 @@ export function MembersTable({
160
192
  {withStatus && (
161
193
  <th className={tight(cls.th, cls.tightColumn)} scope="col">Status</th>
162
194
  )}
195
+ {withSource && (
196
+ <th className={tight(cls.th, cls.tightColumn)} scope="col">How they joined</th>
197
+ )}
163
198
  {withLastActive && (
164
199
  <th className={tight(cls.th, cls.tightColumn)} scope="col">Last active</th>
165
200
  )}
@@ -246,6 +281,19 @@ export function MembersTable({
246
281
  )}
247
282
  </td>
248
283
  )}
284
+ {withSource && (
285
+ <td className={tight(cls.cell, cls.tightColumn)}>
286
+ {m.source && (
287
+ <span
288
+ className={tight(cls.sourceBadge, sourceClass[m.source])}
289
+ data-member-source={m.source}
290
+ title={SOURCE_TITLE[m.source]}
291
+ >
292
+ {SOURCE_LABEL[m.source]}
293
+ </span>
294
+ )}
295
+ </td>
296
+ )}
249
297
  {withLastActive && (
250
298
  <td className={tight(cls.cell, cls.tightColumn)}>
251
299
  {m.lastLoginAt ? (