@startsimpli/ui 0.4.82 → 0.4.83

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.82",
3
+ "version": "0.4.83",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -16,18 +16,16 @@ import { ComponentType, ReactNode, useEffect, useMemo, useRef } from 'react';
16
16
  import { formatDistanceToNow } from 'date-fns';
17
17
  import { Clock, User } from 'lucide-react';
18
18
  import { EmptyState } from './states';
19
+ import { ActorIdentity, actorInitials, actorLabel } from './ActorIdentity';
20
+ import type { ActorIdentityValue } from './ActorIdentity';
19
21
 
20
22
  /** Severity of an entry, for the marker. Presentation only — never a gate. */
21
23
  export type ActivityTone = 'ok' | 'warn' | 'error' | 'muted';
22
24
 
23
- /** Who did it. Rendered as an initials chip so a feed shows a person rather
24
- * than a bare email string. */
25
- export interface ActivityTimelineActor {
26
- id?: string;
27
- name?: string | null;
28
- email?: string | null;
29
- avatarUrl?: string | null;
30
- }
25
+ /** Who did it. Rendered by the shared `ActorIdentity` chip, so a feed shows a
26
+ * PERSON — name plus the email that pins the name down — and paints it the
27
+ * same way every other attributed surface does (startsim-768w.19.8.1). */
28
+ export type ActivityTimelineActor = ActorIdentityValue;
31
29
 
32
30
  export interface ActivityTimelineItem {
33
31
  id: string;
@@ -101,39 +99,6 @@ function defaultFormatTime(date: string): string {
101
99
  }
102
100
  }
103
101
 
104
- /** "Ada Lovelace" -> "AL"; "ada@x.com" -> "A". Empty when we know nobody. */
105
- export function actorInitials(actor?: ActivityTimelineActor): string {
106
- const name = (actor?.name ?? '').trim();
107
- if (name) {
108
- return name
109
- .split(/\s+/)
110
- .slice(0, 2)
111
- .map((w) => w[0]?.toUpperCase() ?? '')
112
- .join('');
113
- }
114
- const email = (actor?.email ?? '').trim();
115
- return email ? email[0]!.toUpperCase() : '';
116
- }
117
-
118
- function actorName(actor?: ActivityTimelineActor): string {
119
- return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
120
- }
121
-
122
- /**
123
- * The second half of the identity — the address, when the display name is
124
- * something else (startsim-768w.19.8).
125
- *
126
- * A display name is free text: an account called "QA Foundry" reads exactly
127
- * like the name of a FOUNDRY, so a correct feed looked to its owner like it was
128
- * showing another tenant's activity. The email is the unambiguous handle, and
129
- * it is what turns a name that could be anything into a person. Empty when the
130
- * name IS the email, because printing it twice is noise.
131
- */
132
- function actorHandle(actor?: ActivityTimelineActor): string {
133
- const email = (actor?.email ?? '').trim();
134
- return email && email !== actorName(actor) ? email : '';
135
- }
136
-
137
102
  function dayKey(iso: string): string {
138
103
  const d = new Date(iso);
139
104
  return Number.isNaN(d.getTime()) ? 'unknown' : d.toDateString();
@@ -195,8 +160,7 @@ function TimelineEntry({
195
160
  activityColors[activity.type] ||
196
161
  (tone ? TONE_MARKER[tone] : undefined) ||
197
162
  'bg-muted text-muted-foreground';
198
- const who = actorName(activity.actor);
199
- const handle = actorHandle(activity.actor);
163
+ const who = actorLabel(activity.actor);
200
164
  // With no icon the marker itself becomes the avatar, so the metadata line
201
165
  // shows the name alone rather than the same initials twice.
202
166
  const markerIsAvatar = !Icon && !!initials;
@@ -239,17 +203,7 @@ function TimelineEntry({
239
203
  </div>
240
204
 
241
205
  <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
242
- {who && (
243
- <span className="flex flex-wrap items-center gap-x-1.5">
244
- {initials && !markerIsAvatar && (
245
- <span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
246
- {initials}
247
- </span>
248
- )}
249
- <span>{who}</span>
250
- {handle && <span className="text-xs text-muted-foreground/80">{handle}</span>}
251
- </span>
252
- )}
206
+ <ActorIdentity actor={activity.actor} showInitials={!markerIsAvatar} />
253
207
 
254
208
  {!who && activity.createdBy && (
255
209
  <span className="flex items-center gap-1">
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ActorIdentity — ONE way to say "a person did this" (startsim-768w.19.8.1).
5
+ *
6
+ * A display name is FREE TEXT. It is whatever somebody typed into a profile, so
7
+ * it can read like an org ("QA Foundry" — the name of a different foundry) or
8
+ * like nobody at all ("Admin User"), and a reader has nothing to check it
9
+ * against. Alone it is a label, not an identity: the activity feed printing one
10
+ * is what made a correctly-filtered feed look like cross-tenant contamination
11
+ * (startsim-768w.19.8), and the run attribution on Deployments/Builds printed
12
+ * the same string with the same result.
13
+ *
14
+ * So the rule, in one place: the NAME and the EMAIL are rendered together when
15
+ * there is a real name, and the EMAIL ALONE when the name is missing or is just
16
+ * the address echoed back. Every surface that attributes something to a person
17
+ * renders this component rather than its own arrangement of the same two
18
+ * fields (rule #9).
19
+ */
20
+
21
+ export interface ActorIdentityValue {
22
+ id?: string;
23
+ name?: string | null;
24
+ email?: string | null;
25
+ avatarUrl?: string | null;
26
+ }
27
+
28
+ /** "Ada Lovelace" -> "AL"; "ada@x.com" -> "A". Empty when we know nobody. */
29
+ export function actorInitials(actor?: ActorIdentityValue): string {
30
+ const name = (actor?.name ?? '').trim();
31
+ if (name) {
32
+ return name
33
+ .split(/\s+/)
34
+ .slice(0, 2)
35
+ .map((w) => w[0]?.toUpperCase() ?? '')
36
+ .join('');
37
+ }
38
+ const email = (actor?.email ?? '').trim();
39
+ return email ? email[0]!.toUpperCase() : '';
40
+ }
41
+
42
+ /** What to CALL them: the real name, else the address. Empty when we know
43
+ * nobody — and an empty label is the signal to render nothing at all, rather
44
+ * than a dangling "by". */
45
+ export function actorLabel(actor?: ActorIdentityValue): string {
46
+ return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
47
+ }
48
+
49
+ /** The second half of the identity — the address, when the label is something
50
+ * else. Empty when the label already IS the address, because printing it twice
51
+ * is noise rather than corroboration. */
52
+ export function actorHandle(actor?: ActorIdentityValue): string {
53
+ const email = (actor?.email ?? '').trim();
54
+ return email && email !== actorLabel(actor) ? email : '';
55
+ }
56
+
57
+ export interface ActorIdentityProps {
58
+ actor?: ActorIdentityValue;
59
+ /** Show the initials avatar. Off for a compact one-line attribution, or where
60
+ * the surrounding row already paints the same initials. */
61
+ showInitials?: boolean;
62
+ className?: string;
63
+ initialsClassName?: string;
64
+ handleClassName?: string;
65
+ }
66
+
67
+ export function ActorIdentity({
68
+ actor,
69
+ showInitials = true,
70
+ className = 'flex flex-wrap items-center gap-x-1.5',
71
+ initialsClassName = 'flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground',
72
+ handleClassName = 'text-xs text-muted-foreground/80',
73
+ }: ActorIdentityProps) {
74
+ const label = actorLabel(actor);
75
+ if (!label) return null;
76
+ const initials = showInitials ? actorInitials(actor) : '';
77
+ const handle = actorHandle(actor);
78
+
79
+ return (
80
+ <span data-testid="actor-identity" className={className}>
81
+ {initials && <span className={initialsClassName}>{initials}</span>}
82
+ <span>{label}</span>
83
+ {handle && <span className={handleClassName}>{handle}</span>}
84
+ </span>
85
+ );
86
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * startsim-768w.19.8.1 (RED) — ONE renderer for an attributed identity.
3
+ *
4
+ * The activity feed learned in 19.8 that a display name must never stand alone:
5
+ * it is free text, so an account called "QA Foundry" reads exactly like the name
6
+ * of a DIFFERENT foundry, and a correct feed then looks like a data leak. The
7
+ * fix was to carry the email alongside the name.
8
+ *
9
+ * The very same string is printed on the Deployments and Builds tabs as run
10
+ * attribution, where it reads "by Admin User · Jul 27, 4:36 PM" — a name that
11
+ * identifies nobody, with no handle to check it against. Two surfaces printing
12
+ * the same identity must not paint it two different ways (rule #9), so the chip
13
+ * the activity feed already uses is extracted here and both consume it.
14
+ *
15
+ * The contract, in one line: NAME + EMAIL when there is a real name, EMAIL ALONE
16
+ * when the "name" is absent or is just the address echoed back.
17
+ */
18
+ import { render, screen, within } from '@testing-library/react'
19
+
20
+ import { ActorIdentity, actorHandle, actorInitials, actorLabel } from '../ActorIdentity'
21
+ import { ActivityTimeline } from '../ActivityTimeline'
22
+
23
+ describe('actorLabel / actorHandle — a name is never the whole identity', () => {
24
+ it('keeps the name and the email apart when both are real', () => {
25
+ const actor = { name: 'Ada Owner', email: 'ada@ogmc.ai' }
26
+ expect(actorLabel(actor)).toBe('Ada Owner')
27
+ expect(actorHandle(actor)).toBe('ada@ogmc.ai')
28
+ })
29
+
30
+ it('falls back to the email when there is no real name', () => {
31
+ const actor = { name: '', email: 'ops@ogmc.ai' }
32
+ expect(actorLabel(actor)).toBe('ops@ogmc.ai')
33
+ // ...and does NOT then print the same address twice
34
+ expect(actorHandle(actor)).toBe('')
35
+ })
36
+
37
+ it('drops a name that is only the email echoed back', () => {
38
+ const actor = { name: 'ops@ogmc.ai', email: 'ops@ogmc.ai' }
39
+ expect(actorLabel(actor)).toBe('ops@ogmc.ai')
40
+ expect(actorHandle(actor)).toBe('')
41
+ })
42
+
43
+ it('knows nobody when it is handed nobody', () => {
44
+ expect(actorLabel(undefined)).toBe('')
45
+ expect(actorLabel({ name: null, email: null })).toBe('')
46
+ expect(actorInitials({ name: 'Ada Owner' })).toBe('AO')
47
+ expect(actorInitials({ email: 'ops@ogmc.ai' })).toBe('O')
48
+ })
49
+ })
50
+
51
+ describe('<ActorIdentity>', () => {
52
+ it('renders the person AND the handle that pins them down', () => {
53
+ render(<ActorIdentity actor={{ name: 'Admin User', email: 'admin@ogmc.ai' }} />)
54
+ const chip = within(screen.getByTestId('actor-identity'))
55
+ expect(chip.getByText('Admin User')).toBeInTheDocument()
56
+ expect(chip.getByText('admin@ogmc.ai')).toBeInTheDocument()
57
+ })
58
+
59
+ it('renders the email alone when there is no name — never an empty "by"', () => {
60
+ render(<ActorIdentity actor={{ name: '', email: 'ops@ogmc.ai' }} />)
61
+ const chip = screen.getByTestId('actor-identity')
62
+ expect(chip).toHaveTextContent('ops@ogmc.ai')
63
+ // one occurrence, not the address printed twice
64
+ expect(chip.textContent!.match(/ops@ogmc\.ai/g)).toHaveLength(1)
65
+ })
66
+
67
+ it('renders nothing at all when there is nobody to attribute', () => {
68
+ const { container } = render(<ActorIdentity actor={{ name: null, email: null }} />)
69
+ expect(container).toBeEmptyDOMElement()
70
+ expect(screen.queryByTestId('actor-identity')).toBeNull()
71
+ })
72
+
73
+ it('can drop the initials avatar for a compact one-line attribution', () => {
74
+ render(<ActorIdentity actor={{ name: 'Ada Owner', email: 'ada@ogmc.ai' }} showInitials={false} />)
75
+ const chip = screen.getByTestId('actor-identity')
76
+ expect(chip).toHaveTextContent('Ada Owner')
77
+ expect(within(chip).queryByText('AO')).toBeNull()
78
+ })
79
+ })
80
+
81
+ describe('the activity feed renders the SAME chip', () => {
82
+ it('still shows the person and the email on a timeline row', () => {
83
+ render(
84
+ <ActivityTimeline
85
+ activities={[
86
+ {
87
+ id: 'ev-1',
88
+ type: 'member_invited',
89
+ title: 'Member invited',
90
+ occurredAt: '2026-07-27T16:36:00Z',
91
+ actor: { name: 'QA Foundry', email: 'qa-foundry@startsimpli.com' },
92
+ },
93
+ ]}
94
+ />,
95
+ )
96
+ const row = within(document.querySelector('[data-activity-id="ev-1"]') as HTMLElement)
97
+ const chip = within(row.getByTestId('actor-identity'))
98
+ expect(chip.getByText('QA Foundry')).toBeInTheDocument()
99
+ expect(chip.getByText('qa-foundry@startsimpli.com')).toBeInTheDocument()
100
+ })
101
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * startsim-768w.19.8.1 — the resend pill, verified on the SURFACE, not just in
3
+ * the mapper.
4
+ *
5
+ * `activityNote()` was added in startsim-768w.19.8 so a re-sent invite stops
6
+ * reading as a second, duplicate invite. Its unit coverage asserted the mapper;
7
+ * nobody could confirm the pill actually reaches the DOM of the panel a user
8
+ * looks at, because on the live feed the resend rows sit below the fold. This
9
+ * drives the real `ActivityFeedPanel` — the same component the Activity tab
10
+ * renders — over two invites for the same person, and asserts the second one
11
+ * carries a visible "resend" while the first does not.
12
+ */
13
+ import { render, screen, within } from '@testing-library/react'
14
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
15
+
16
+ import { ActivityFeedPanel, type ActivityEvent } from '../index'
17
+
18
+ function inviteRow(over: Partial<ActivityEvent> & { id: string }): ActivityEvent {
19
+ return {
20
+ action: 'member_invited',
21
+ type: 'member_invited',
22
+ target_type: 'membership',
23
+ target_id: 'dc0d739f-a622-431d-beb2-de169acda594',
24
+ metadata: { email: 'nordby@ogmc.ai', role: 'admin' },
25
+ actor: { id: 'u-1', email: 'qa-foundry@startsimpli.com', name: '' },
26
+ actor_email: 'qa-foundry@startsimpli.com',
27
+ state: null,
28
+ previous_state: null,
29
+ source: 'control_plane',
30
+ reason: '',
31
+ error: '',
32
+ run_id: '',
33
+ run_kind: '',
34
+ created_at: '2026-07-21T12:00:00Z',
35
+ ...over,
36
+ }
37
+ }
38
+
39
+ const RESEND = inviteRow({
40
+ id: 'ev-resend',
41
+ metadata: { email: 'nordby@ogmc.ai', role: 'admin', resend: true },
42
+ created_at: '2026-07-22T12:00:00Z',
43
+ })
44
+ const ORIGINAL = inviteRow({ id: 'ev-original' })
45
+
46
+ function renderPanel(results: ActivityEvent[]) {
47
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
48
+ const client = {
49
+ list: jest.fn().mockResolvedValue({ count: results.length, next: null, previous: null, results }),
50
+ }
51
+ render(
52
+ <QueryClientProvider client={qc}>
53
+ <ActivityFeedPanel client={client} />
54
+ </QueryClientProvider>,
55
+ )
56
+ }
57
+
58
+ describe('a resent invite is visibly not a duplicate', () => {
59
+ it('renders the resend pill on the panel a user actually looks at', async () => {
60
+ renderPanel([RESEND, ORIGINAL])
61
+
62
+ const resendRow = (await screen.findByText('resend')).closest('[data-activity-id]')
63
+ expect(resendRow).toHaveAttribute('data-activity-id', 'ev-resend')
64
+
65
+ const original = document.querySelector('[data-activity-id="ev-original"]') as HTMLElement
66
+ expect(within(original).queryByText('resend')).toBeNull()
67
+ // both still name the invitee rather than the UUID they carry
68
+ expect(within(original).getByText('nordby@ogmc.ai')).toBeInTheDocument()
69
+ })
70
+ })
@@ -153,8 +153,14 @@ export * from './pipeline'
153
153
  // Calendar (month/week/day event grid wrapping react-big-calendar)
154
154
  export * from './calendar'
155
155
 
156
+ // Attributed identity — the ONE chip every "who did this" surface renders, so a
157
+ // display name is never printed alone as if it were an identity
158
+ // (startsim-768w.19.8.1).
159
+ export { ActorIdentity, actorInitials, actorLabel, actorHandle } from './ActorIdentity'
160
+ export type { ActorIdentityProps, ActorIdentityValue } from './ActorIdentity'
161
+
156
162
  // Activity components (timeline, quick log, log dialog)
157
- export { ActivityTimeline, actorInitials, dayHeading } from './ActivityTimeline'
163
+ export { ActivityTimeline, dayHeading } from './ActivityTimeline'
158
164
  export type {
159
165
  ActivityTimelineProps,
160
166
  ActivityTimelineItem,