@startsimpli/ui 0.4.85 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.85",
3
+ "version": "0.4.86",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -43,8 +43,8 @@
43
43
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
44
44
  "react": "^18.0.0 || ^19.0.0",
45
45
  "react-dom": "^18.0.0 || ^19.0.0",
46
- "@startsimpli/auth": "^0.4.31",
47
- "@startsimpli/api": "^0.5.51"
46
+ "@startsimpli/api": "^0.5.53",
47
+ "@startsimpli/auth": "^0.4.32"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "@startsimpli/api": {
@@ -1,6 +1,9 @@
1
- import { render, screen, fireEvent } from '@testing-library/react'
1
+ import { render, screen, fireEvent, within } from '@testing-library/react'
2
+ import { usePathname } from 'next/navigation'
2
3
  import { Sidebar, SidebarLayout } from '../sidebar'
3
4
 
5
+ const mockPathname = usePathname as unknown as jest.Mock
6
+
4
7
  // next/link and next/navigation are mocked via jest.config.js moduleNameMapper
5
8
 
6
9
  const sections = [
@@ -161,6 +164,105 @@ describe('Sidebar', () => {
161
164
  })
162
165
  })
163
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // The rail as PRIMARY nav (startsim-768w.21.3)
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * A control plane puts its whole section list in the rail — a foundry
173
+ * contributes nine scoped links under a titled group. That promotes the rail
174
+ * from "app shortcuts" to the thing you navigate with, and a tab strip used to
175
+ * provide two things for free that the rail owes back:
176
+ *
177
+ * • the current item ANNOUNCED, not just tinted. Colour is not an accessible
178
+ * name; `aria-current="page"` is the standard for "you are here".
179
+ * • "current" decided by path SEGMENT, not by string prefix.
180
+ * '/foundry/x/builds'.startsWith('/foundry/x/build') is true, so a prefix
181
+ * test lights up two peer surfaces at once — and the nine foundry sections
182
+ * include exactly that pair.
183
+ *
184
+ * Plus a section addressable by its title, so a consumer can assert "these
185
+ * links, under this group" without walking up from a heading — and a pathname
186
+ * that may legitimately be null (the hook returns null outside an app-router
187
+ * provider) instead of throwing.
188
+ */
189
+ describe('Sidebar — which link is current (768w.21.3)', () => {
190
+ const railLinks = [
191
+ { href: '/', label: 'Foundries' },
192
+ { href: '/foundry/mcr/build', label: 'Build with AI' },
193
+ { href: '/foundry/mcr/builds', label: 'Builds' },
194
+ { href: '/foundry/mcr/members', label: 'Members' },
195
+ ]
196
+
197
+ const renderAt = (pathname: string | null) => {
198
+ mockPathname.mockReturnValue(pathname)
199
+ return render(<Sidebar appName="Foundry" sections={[{ links: railLinks }]} />)
200
+ }
201
+
202
+ const current = () =>
203
+ screen
204
+ .getAllByRole('link')
205
+ .filter((a) => a.getAttribute('aria-current') === 'page')
206
+ .map((a) => a.getAttribute('href'))
207
+
208
+ afterEach(() => {
209
+ mockPathname.mockReturnValue('/test-path')
210
+ })
211
+
212
+ it('announces the current link with aria-current="page"', () => {
213
+ renderAt('/foundry/mcr/members')
214
+ expect(current()).toEqual(['/foundry/mcr/members'])
215
+ })
216
+
217
+ it('does not let a prefix sibling light up too', () => {
218
+ renderAt('/foundry/mcr/builds')
219
+ expect(current()).toEqual(['/foundry/mcr/builds'])
220
+ })
221
+
222
+ it('keeps a link current on a deeper URL under it', () => {
223
+ renderAt('/foundry/mcr/members/u-42')
224
+ expect(current()).toEqual(['/foundry/mcr/members'])
225
+ })
226
+
227
+ it('marks the root link current only at the root', () => {
228
+ renderAt('/')
229
+ expect(current()).toEqual(['/'])
230
+ })
231
+
232
+ it('renders with no current link rather than throwing on a null pathname', () => {
233
+ // usePathname() is null outside an app-router provider — shared chrome gets
234
+ // rendered in harnesses and tests that have no router.
235
+ expect(() => renderAt(null)).not.toThrow()
236
+ expect(current()).toEqual([])
237
+ })
238
+
239
+ it('still tints the current link, so the marker and the pill agree', () => {
240
+ renderAt('/foundry/mcr/builds')
241
+ expect(screen.getByText('Builds').closest('a')).toHaveClass('bg-primary')
242
+ expect(screen.getByText('Build with AI').closest('a')).not.toHaveClass('bg-primary')
243
+ })
244
+ })
245
+
246
+ describe('Sidebar — titled groups (768w.21.3)', () => {
247
+ const sectionsWithTitle = [
248
+ { links: [{ href: '/', label: 'Foundries' }] },
249
+ { title: 'My Copyright', links: [{ href: '/foundry/mcr/members', label: 'Members' }] },
250
+ ]
251
+
252
+ it('exposes a titled section as a group carrying that name', () => {
253
+ render(<Sidebar appName="Foundry" sections={sectionsWithTitle} />)
254
+ const group = screen.getByRole('group', { name: 'My Copyright' })
255
+ expect(within(group).getByRole('link', { name: 'Members' })).toBeInTheDocument()
256
+ expect(within(group).queryByRole('link', { name: 'Foundries' })).toBeNull()
257
+ })
258
+
259
+ it('keeps the group name while collapsed, when the heading is hidden', () => {
260
+ render(<Sidebar appName="Foundry" sections={sectionsWithTitle} defaultCollapsed />)
261
+ expect(screen.queryByText('My Copyright')).toBeNull()
262
+ expect(screen.getByRole('group', { name: 'My Copyright' })).toBeInTheDocument()
263
+ })
264
+ })
265
+
164
266
  // ---------------------------------------------------------------------------
165
267
  // SidebarLayout
166
268
  // ---------------------------------------------------------------------------
@@ -71,7 +71,10 @@ export function Sidebar({
71
71
  collapsed: controlledCollapsed,
72
72
  onCollapsedChange,
73
73
  }: SidebarProps) {
74
- 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() ?? '';
75
78
  const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
76
79
  const collapsed = controlledCollapsed ?? internalCollapsed;
77
80
  const setCollapsed = (next: boolean) => {
@@ -79,12 +82,19 @@ export function Sidebar({
79
82
  onCollapsedChange?.(next);
80
83
  };
81
84
 
82
- const isActivePath = (href: string) => {
83
- if (href === '/') {
84
- return pathname === '/';
85
- }
86
- return pathname.startsWith(href);
87
- };
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}/`);
88
98
 
89
99
  return (
90
100
  <div
@@ -115,9 +125,18 @@ export function Sidebar({
115
125
  {/* Navigation */}
116
126
  <nav className="flex-1 overflow-y-auto p-4 space-y-6">
117
127
  {sections.map((section, idx) => (
118
- <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}>
119
133
  {section.title && !collapsed && (
120
- <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
+ >
121
140
  {section.title}
122
141
  </h3>
123
142
  )}
@@ -128,6 +147,9 @@ export function Sidebar({
128
147
  <Link
129
148
  key={link.href}
130
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}
131
153
  className={cn(
132
154
  'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
133
155
  isActive
@@ -141,7 +163,7 @@ export function Sidebar({
141
163
  {link.icon}
142
164
  </span>
143
165
  ) : null}
144
- {!collapsed && <span>{link.label}</span>}
166
+ {!collapsed && <span className="min-w-0 truncate">{link.label}</span>}
145
167
  </Link>
146
168
  );
147
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 ? (
@@ -0,0 +1,132 @@
1
+ /**
2
+ * RED (startsim-768w.20.4.1): the members list cannot show two populations.
3
+ *
4
+ * Every row renders identically today, so an OPEN foundry's roster puts the
5
+ * three people who RUN the app and the two hundred who merely USE it in one
6
+ * undifferentiated table under the heading "N people can access and manage this
7
+ * foundry". Role does not separate them either — an invited viewer and a
8
+ * self-joined viewer are the same word.
9
+ *
10
+ * The provenance is DATA the backend records (768w.20.4.2). These tests fail
11
+ * until the shared list renders it and offers a way to ask for one population,
12
+ * because per rule 9 "how did this person get here" is a members-list concern,
13
+ * not a foundry-app one — /settings/team has the same two populations the
14
+ * moment any app opens its own door.
15
+ */
16
+
17
+ import { render, screen, fireEvent } from '@testing-library/react'
18
+ import { MembersTable, MembersSection } from '../index'
19
+ import type { MemberRow } from '../types'
20
+
21
+ const rows: MemberRow[] = [
22
+ {
23
+ id: 'm1',
24
+ userId: 'u1',
25
+ teamId: 'qafoundry',
26
+ role: 'owner',
27
+ joinedAt: '2026-01-01T00:00:00Z',
28
+ inviteStatus: 'active',
29
+ source: 'seed',
30
+ user: { id: 'u1', email: 'owner@ogmc.ai' },
31
+ },
32
+ {
33
+ id: 'm2',
34
+ userId: 'u2',
35
+ teamId: 'qafoundry',
36
+ role: 'admin',
37
+ joinedAt: '2026-02-01T00:00:00Z',
38
+ inviteStatus: 'active',
39
+ source: 'invited',
40
+ invitedByEmail: 'owner@ogmc.ai',
41
+ user: { id: 'u2', email: 'nordby@ogmc.ai' },
42
+ },
43
+ {
44
+ id: 'm3',
45
+ userId: 'u3',
46
+ teamId: 'qafoundry',
47
+ role: 'viewer',
48
+ joinedAt: '2026-07-01T00:00:00Z',
49
+ inviteStatus: 'active',
50
+ source: 'self_signup',
51
+ user: { id: 'u3', email: 'fan@example.com' },
52
+ },
53
+ ]
54
+
55
+ describe('MembersTable shows how each person got here (768w.20.4.1)', () => {
56
+ it('renders a joined-how column when the rows carry provenance', () => {
57
+ render(<MembersTable members={rows} />)
58
+ expect(screen.getByRole('columnheader', { name: /how they joined/i })).toBeInTheDocument()
59
+ })
60
+
61
+ it('separates an operator from an end user at a glance', () => {
62
+ const { container } = render(<MembersTable members={rows} />)
63
+ const badges = Array.from(container.querySelectorAll('[data-member-source]'))
64
+ expect(badges.map((b) => b.getAttribute('data-member-source'))).toEqual([
65
+ 'seed',
66
+ 'invited',
67
+ 'self_signup',
68
+ ])
69
+ // Product language, not the wire value — "self_signup" is not a thing a
70
+ // person reads.
71
+ expect(screen.getByText(/signed up/i)).toBeInTheDocument()
72
+ expect(screen.getByText(/invited/i)).toBeInTheDocument()
73
+ })
74
+
75
+ it('drops the column entirely for a backend that does not report it', () => {
76
+ const legacy = rows.map(({ source: _source, ...rest }) => rest)
77
+ render(<MembersTable members={legacy} />)
78
+ expect(screen.queryByRole('columnheader', { name: /how they joined/i })).toBeNull()
79
+ })
80
+ })
81
+
82
+ describe('MembersSection asks the SERVER for one population (768w.20.4.1)', () => {
83
+ it('offers a filter and reports the choice back to the host', () => {
84
+ const onSourceChange = jest.fn()
85
+ render(
86
+ <MembersSection
87
+ members={rows}
88
+ teamId="qafoundry"
89
+ sourceFilter={{ value: 'all', onChange: onSourceChange }}
90
+ />,
91
+ )
92
+ const select = screen.getByLabelText(/how they joined/i) as HTMLSelectElement
93
+ fireEvent.change(select, { target: { value: 'self_signup' } })
94
+ expect(onSourceChange).toHaveBeenCalledWith('self_signup')
95
+ })
96
+
97
+ it('offers exactly the three populations plus "everyone"', () => {
98
+ render(
99
+ <MembersSection
100
+ members={rows}
101
+ teamId="qafoundry"
102
+ sourceFilter={{ value: 'all', onChange: jest.fn() }}
103
+ />,
104
+ )
105
+ const select = screen.getByLabelText(/how they joined/i) as HTMLSelectElement
106
+ expect(Array.from(select.options).map((o) => o.value)).toEqual([
107
+ 'all',
108
+ 'invited',
109
+ 'self_signup',
110
+ 'seed',
111
+ ])
112
+ })
113
+
114
+ it('does not filter the rows itself — the list is paginated', () => {
115
+ // A browser-side filter over one PAGE shrinks pages and loses people off the
116
+ // end (repo rule 8). The composer renders exactly what it was handed.
117
+ render(
118
+ <MembersSection
119
+ members={rows}
120
+ teamId="qafoundry"
121
+ sourceFilter={{ value: 'self_signup', onChange: jest.fn() }}
122
+ />,
123
+ )
124
+ expect(screen.getByText('owner@ogmc.ai')).toBeInTheDocument()
125
+ expect(screen.getByText('fan@example.com')).toBeInTheDocument()
126
+ })
127
+
128
+ it('renders no filter at all when the host does not offer one', () => {
129
+ render(<MembersSection members={rows} teamId="qafoundry" />)
130
+ expect(screen.queryByLabelText(/how they joined/i)).toBeNull()
131
+ })
132
+ })
@@ -13,7 +13,9 @@ export type { MembersTableClassNames } from './members-table-default-class-names
13
13
  export { MEMBERS_TABLE_DEFAULTS } from './members-table-default-class-names'
14
14
 
15
15
  export { MembersSection } from './MembersSection'
16
- export type { MembersSectionProps } from './MembersSection'
16
+ export type {
17
+ MembersSectionProps, MemberSourceFilter, MemberSourceFilterValue,
18
+ } from './MembersSection'
17
19
  export type { MembersSectionClassNames } from './members-section-default-class-names'
18
20
  export { MEMBERS_SECTION_DEFAULTS } from './members-section-default-class-names'
19
21
 
@@ -57,6 +59,7 @@ export {
57
59
  type MemberUserLite,
58
60
  type MemberRow,
59
61
  type InviteStatus,
62
+ type MemberSource,
60
63
  type InvitationLite,
61
64
  type DomainVerificationMethod,
62
65
  type DomainClaimLite,
@@ -12,6 +12,10 @@ export interface MembersSectionClassNames {
12
12
  body?: string
13
13
  errorText?: string
14
14
  helpText?: string
15
+ /** Row holding the list's narrowing controls (startsim-768w.20.4). */
16
+ filterBar?: string
17
+ filterLabel?: string
18
+ filterSelect?: string
15
19
  /** Forwarded to the nested primitives so an app still overrides one map. */
16
20
  table?: MembersTableClassNames
17
21
  inviteDialog?: InviteMemberDialogClassNames
@@ -28,4 +32,8 @@ export const MEMBERS_SECTION_DEFAULTS: Required<
28
32
  body: 'space-y-3',
29
33
  errorText: 'text-sm text-destructive',
30
34
  helpText: 'text-xs text-muted-foreground',
35
+ filterBar: 'flex flex-wrap items-center gap-2',
36
+ filterLabel: 'text-xs font-medium text-muted-foreground',
37
+ filterSelect:
38
+ 'rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground',
31
39
  }
@@ -34,6 +34,13 @@ export interface MembersTableClassNames {
34
34
  lastActiveEmpty?: string
35
35
  /** "Invited by …" line under a member who hasn't activated yet. */
36
36
  invitedBy?: string
37
+ /** Shared shape of the "how they joined" pill (startsim-768w.20.4). */
38
+ sourceBadge?: string
39
+ /** Per-origin colour. The point of the column is that an operator and an
40
+ * end user do not look the same, so these must not collapse to one class. */
41
+ sourceInvited?: string
42
+ sourceSelfSignup?: string
43
+ sourceSeed?: string
37
44
  /** Resend-invite action on a pending/expired row. */
38
45
  resendButton?: string
39
46
  /** Deactivate / Restore action — the soft exit next to the hard Remove. */
@@ -73,6 +80,12 @@ export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
73
80
  lastActive: 'text-muted-foreground',
74
81
  lastActiveEmpty: 'text-muted-foreground/70',
75
82
  invitedBy: 'text-xs text-muted-foreground/80',
83
+ sourceBadge:
84
+ 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
85
+ sourceInvited: 'border-border bg-muted text-muted-foreground',
86
+ // The population an operator most needs to pick out of a long list.
87
+ sourceSelfSignup: 'border-primary/30 bg-primary/10 text-primary',
88
+ sourceSeed: 'border-border bg-muted/60 text-muted-foreground',
76
89
  resendButton:
77
90
  'rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 hover:text-primary disabled:opacity-50',
78
91
  deactivateButton:
@@ -44,6 +44,16 @@ export interface MemberUserLite {
44
44
  */
45
45
  export type InviteStatus = 'active' | 'pending' | 'expired'
46
46
 
47
+ /**
48
+ * HOW someone came to be on this list (startsim-768w.20.4).
49
+ *
50
+ * The distinction `role` cannot make. Once a workspace can be joined without an
51
+ * invite, its member list carries two populations — the handful of people who
52
+ * RUN it and everyone who merely USES it — and an invited viewer reads exactly
53
+ * like a self-joined viewer.
54
+ */
55
+ export type MemberSource = 'invited' | 'self_signup' | 'seed'
56
+
47
57
  export interface MemberRow {
48
58
  id: string
49
59
  userId: string
@@ -54,6 +64,9 @@ export interface MemberRow {
54
64
  user?: MemberUserLite
55
65
  /** Lifecycle state. Absent for backends that don't report it (older list APIs). */
56
66
  inviteStatus?: InviteStatus
67
+ /** How they got here. Absent for backends that don't record it — the table
68
+ * then drops the column rather than rendering an empty one. */
69
+ source?: MemberSource
57
70
  /** Last sign-in, or null for someone who has never signed in. */
58
71
  lastLoginAt?: string | null
59
72
  /** When the outstanding invite link dies. Null once there is no live invite. */