@startsimpli/ui 0.4.14 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +457 -398
  2. package/package.json +18 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/chat.test.tsx +129 -0
  5. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  6. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  7. package/src/components/__tests__/workspace.test.tsx +106 -0
  8. package/src/components/account/__tests__/account.test.tsx +5 -32
  9. package/src/components/account/change-password-form.tsx +1 -28
  10. package/src/components/calendar/calendar-view.tsx +31 -0
  11. package/src/components/calendar/index.ts +7 -0
  12. package/src/components/calendar/meetings-list.tsx +202 -0
  13. package/src/components/calendar/upcoming-meetings.tsx +5 -5
  14. package/src/components/chat/ChatComposer.tsx +113 -0
  15. package/src/components/chat/ChatMessage.tsx +81 -0
  16. package/src/components/chat/ChatThread.tsx +57 -0
  17. package/src/components/chat/index.ts +12 -0
  18. package/src/components/chat/types.ts +20 -0
  19. package/src/components/index.ts +13 -0
  20. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  21. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  22. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  23. package/src/components/slide-deck/index.ts +7 -0
  24. package/src/components/slide-deck/types.ts +18 -0
  25. package/src/components/team/DomainClaimCard.tsx +170 -0
  26. package/src/components/team/InviteMemberDialog.tsx +182 -0
  27. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  28. package/src/components/team/MembersTable.tsx +138 -0
  29. package/src/components/team/OrgSwitcher.tsx +68 -0
  30. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  31. package/src/components/team/RoleSelector.tsx +68 -0
  32. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  33. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  34. package/src/components/team/index.ts +57 -0
  35. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  36. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  37. package/src/components/team/members-table-default-class-names.ts +39 -0
  38. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  39. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  40. package/src/components/team/role-selector-default-class-names.ts +11 -0
  41. package/src/components/team/types.ts +97 -0
  42. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  43. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  44. package/src/components/workflows/NodeInspector.tsx +257 -0
  45. package/src/components/workflows/NodePalette.tsx +119 -0
  46. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  47. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  48. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  49. package/src/components/workflows/WorkflowNode.tsx +198 -0
  50. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  51. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  52. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  53. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  54. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  55. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  56. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  57. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  58. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  59. package/src/components/workflows/exec-status.ts +90 -0
  60. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  61. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  62. package/src/components/workflows/index.ts +78 -0
  63. package/src/components/workflows/layout/auto-layout.ts +142 -0
  64. package/src/components/workflows/node-icons.ts +31 -0
  65. package/src/components/workflows/serialization.ts +171 -0
  66. package/src/components/workflows/theme/categories.ts +96 -0
  67. package/src/components/workflows/types.ts +231 -0
  68. package/src/components/workflows/workflows.css +29 -0
  69. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  70. package/src/components/workspace/SplitPane.tsx +174 -0
  71. package/src/components/workspace/index.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -14,6 +14,8 @@
14
14
  "./theme": "./src/theme/index.ts",
15
15
  "./theme/contract": "./theme/contract.css",
16
16
  "./email-editor": "./src/components/email-editor/index.ts",
17
+ "./workflows": "./src/components/workflows/index.ts",
18
+ "./workflows/styles": "./src/components/workflows/workflows.css",
17
19
  "./tailwind": "./tailwind.preset.js"
18
20
  },
19
21
  "files": [
@@ -24,16 +26,6 @@
24
26
  "publishConfig": {
25
27
  "access": "public"
26
28
  },
27
- "scripts": {
28
- "build": "tsup",
29
- "dev": "tsup --watch",
30
- "type-check": "tsc --noEmit",
31
- "test": "jest",
32
- "test:watch": "jest --watch",
33
- "test:coverage": "jest --coverage",
34
- "lint": "eslint src/**/*.{ts,tsx}",
35
- "clean": "rm -rf dist"
36
- },
37
29
  "peerDependencies": {
38
30
  "@tanstack/react-query": ">=5.0.0",
39
31
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
@@ -47,6 +39,8 @@
47
39
  },
48
40
  "dependencies": {
49
41
  "@hello-pangea/dnd": "^18.0.1",
42
+ "@xyflow/react": "^12.10.2",
43
+ "dagre": "^0.8.5",
50
44
  "@radix-ui/react-accordion": "^1.2.12",
51
45
  "@radix-ui/react-checkbox": "^1.3.3",
52
46
  "@radix-ui/react-collapsible": "^1.1.12",
@@ -80,6 +74,7 @@
80
74
  "@testing-library/react": "^16.3.2",
81
75
  "@testing-library/user-event": "^14.6.1",
82
76
  "@types/jest": "^30.0.0",
77
+ "@types/dagre": "^0.7.52",
83
78
  "@types/node": "^20.19.39",
84
79
  "@types/react": "^19.2.14",
85
80
  "@types/react-big-calendar": "^1.16.3",
@@ -99,5 +94,15 @@
99
94
  "react",
100
95
  "nextjs",
101
96
  "startsimpli"
102
- ]
103
- }
97
+ ],
98
+ "scripts": {
99
+ "build": "tsup",
100
+ "dev": "tsup --watch",
101
+ "type-check": "tsc --noEmit",
102
+ "test": "jest",
103
+ "test:watch": "jest --watch",
104
+ "test:coverage": "jest --coverage",
105
+ "lint": "eslint src/**/*.{ts,tsx}",
106
+ "clean": "rm -rf dist"
107
+ }
108
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Verifies CalendarView forwards the `popup` prop to react-big-calendar so the
3
+ * month grid's "+N more" overflow link opens a popover instead of being inert
4
+ * (raise-simpli-3er). RBC's overflow rendering depends on real cell layout
5
+ * heights, which jsdom does not compute, so we mock RBC's Calendar with a
6
+ * passthrough that surfaces props into the DOM.
7
+ */
8
+ import { render } from '@testing-library/react'
9
+
10
+ const calendarPropSpy = jest.fn()
11
+
12
+ jest.mock('react-big-calendar', () => {
13
+ const actual = jest.requireActual('react-big-calendar')
14
+ return {
15
+ ...actual,
16
+ Calendar: (props: Record<string, unknown>) => {
17
+ calendarPropSpy(props)
18
+ return <div data-testid="rbc-calendar-stub" data-popup={String(props.popup)} />
19
+ },
20
+ }
21
+ })
22
+
23
+ import { CalendarView } from '../calendar/calendar-view'
24
+
25
+ const FIXED_DATE = new Date('2026-04-15T12:00:00Z')
26
+
27
+ describe('CalendarView popup prop', () => {
28
+ beforeEach(() => calendarPropSpy.mockClear())
29
+
30
+ it('defaults popup to true so the "+N more" overflow link opens a popover', () => {
31
+ render(<CalendarView events={[]} defaultDate={FIXED_DATE} defaultView="month" />)
32
+ expect(calendarPropSpy).toHaveBeenCalledTimes(1)
33
+ expect(calendarPropSpy.mock.calls[0][0].popup).toBe(true)
34
+ })
35
+
36
+ it('forwards popup={false} when callers opt out', () => {
37
+ render(
38
+ <CalendarView events={[]} defaultDate={FIXED_DATE} defaultView="month" popup={false} />,
39
+ )
40
+ expect(calendarPropSpy.mock.calls[0][0].popup).toBe(false)
41
+ })
42
+ })
@@ -0,0 +1,129 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { ChatMessage } from '../chat/ChatMessage'
3
+ import { ChatThread } from '../chat/ChatThread'
4
+ import { ChatComposer } from '../chat/ChatComposer'
5
+ import type { ChatMessageData } from '../chat/types'
6
+
7
+ beforeAll(() => {
8
+ // jsdom has no layout; stub scrollIntoView used by autoscroll
9
+ Element.prototype.scrollIntoView = jest.fn()
10
+ })
11
+
12
+ describe('ChatMessage', () => {
13
+ it('renders text content and tags the role', () => {
14
+ const { container } = render(
15
+ <ChatMessage message={{ id: '1', role: 'user', content: 'Hello there' }} />
16
+ )
17
+ expect(screen.getByText('Hello there')).toBeInTheDocument()
18
+ expect(container.querySelector('[data-role="user"]')).toBeInTheDocument()
19
+ })
20
+
21
+ it('marks a streaming assistant message as busy', () => {
22
+ const { container } = render(
23
+ <ChatMessage message={{ id: '2', role: 'assistant', content: 'thinking', status: 'streaming' }} />
24
+ )
25
+ const el = container.querySelector('[data-status="streaming"]')
26
+ expect(el).toBeInTheDocument()
27
+ expect(el).toHaveAttribute('aria-busy', 'true')
28
+ })
29
+
30
+ it('renders a tool/status message with its tool name', () => {
31
+ render(
32
+ <ChatMessage
33
+ message={{ id: '3', role: 'tool', kind: 'tool', toolName: 'generateSlide', content: 'Generating slide 1…' }}
34
+ />
35
+ )
36
+ expect(screen.getByText(/generateSlide/i)).toBeInTheDocument()
37
+ expect(screen.getByText('Generating slide 1…')).toBeInTheDocument()
38
+ })
39
+ })
40
+
41
+ describe('ChatThread', () => {
42
+ const messages: ChatMessageData[] = [
43
+ { id: 'a', role: 'user', content: 'First' },
44
+ { id: 'b', role: 'assistant', content: 'Second' },
45
+ { id: 'c', role: 'assistant', content: 'Third' },
46
+ ]
47
+
48
+ it('renders all messages in order', () => {
49
+ render(<ChatThread messages={messages} />)
50
+ expect(screen.getByText('First')).toBeInTheDocument()
51
+ expect(screen.getByText('Second')).toBeInTheDocument()
52
+ expect(screen.getByText('Third')).toBeInTheDocument()
53
+ })
54
+
55
+ it('shows an empty state placeholder when there are no messages', () => {
56
+ render(<ChatThread messages={[]} emptyState={<div>Start chatting</div>} />)
57
+ expect(screen.getByText('Start chatting')).toBeInTheDocument()
58
+ })
59
+
60
+ it('autoscrolls to the bottom when messages change', () => {
61
+ const { rerender } = render(<ChatThread messages={messages} />)
62
+ rerender(<ChatThread messages={[...messages, { id: 'd', role: 'user', content: 'Fourth' }]} />)
63
+ expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
64
+ })
65
+ })
66
+
67
+ describe('ChatComposer', () => {
68
+ it('submits trimmed text on Enter and clears (uncontrolled)', () => {
69
+ const onSubmit = jest.fn()
70
+ render(<ChatComposer onSubmit={onSubmit} />)
71
+ const textarea = screen.getByRole('textbox')
72
+ fireEvent.change(textarea, { target: { value: ' paste my outline ' } })
73
+ fireEvent.keyDown(textarea, { key: 'Enter' })
74
+ expect(onSubmit).toHaveBeenCalledWith('paste my outline')
75
+ })
76
+
77
+ it('does NOT submit on Shift+Enter (newline)', () => {
78
+ const onSubmit = jest.fn()
79
+ render(<ChatComposer onSubmit={onSubmit} />)
80
+ const textarea = screen.getByRole('textbox')
81
+ fireEvent.change(textarea, { target: { value: 'line one' } })
82
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
83
+ expect(onSubmit).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('does NOT submit on bare Enter when the content is multi-line (pasted outline stays intact)', () => {
87
+ const onSubmit = jest.fn()
88
+ render(<ChatComposer onSubmit={onSubmit} />)
89
+ const textarea = screen.getByRole('textbox')
90
+ fireEvent.change(textarea, { target: { value: 'My Deck\nSlide 1: Problem\nSlide 2: Solution' } })
91
+ fireEvent.keyDown(textarea, { key: 'Enter' })
92
+ expect(onSubmit).not.toHaveBeenCalled()
93
+ })
94
+
95
+ it('submits multi-line content on Cmd/Ctrl+Enter', () => {
96
+ const onSubmit = jest.fn()
97
+ render(<ChatComposer onSubmit={onSubmit} />)
98
+ const textarea = screen.getByRole('textbox')
99
+ fireEvent.change(textarea, { target: { value: 'My Deck\nSlide 1: Problem' } })
100
+ fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
101
+ expect(onSubmit).toHaveBeenCalledWith('My Deck\nSlide 1: Problem')
102
+ })
103
+
104
+ it('does not submit empty/whitespace', () => {
105
+ const onSubmit = jest.fn()
106
+ render(<ChatComposer onSubmit={onSubmit} />)
107
+ const textarea = screen.getByRole('textbox')
108
+ fireEvent.change(textarea, { target: { value: ' ' } })
109
+ fireEvent.keyDown(textarea, { key: 'Enter' })
110
+ expect(onSubmit).not.toHaveBeenCalled()
111
+ })
112
+
113
+ it('submits via the send button', () => {
114
+ const onSubmit = jest.fn()
115
+ render(<ChatComposer onSubmit={onSubmit} />)
116
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'go' } })
117
+ fireEvent.click(screen.getByRole('button', { name: /send/i }))
118
+ expect(onSubmit).toHaveBeenCalledWith('go')
119
+ })
120
+
121
+ it('disables submission while busy', () => {
122
+ const onSubmit = jest.fn()
123
+ render(<ChatComposer onSubmit={onSubmit} busy />)
124
+ const textarea = screen.getByRole('textbox')
125
+ fireEvent.change(textarea, { target: { value: 'hi' } })
126
+ fireEvent.keyDown(textarea, { key: 'Enter' })
127
+ expect(onSubmit).not.toHaveBeenCalled()
128
+ })
129
+ })
@@ -0,0 +1,114 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { MeetingsList, type MeetingsListItem } from '../calendar/meetings-list'
3
+
4
+ const sampleMeetings: MeetingsListItem[] = [
5
+ {
6
+ id: 1,
7
+ title: 'Sequoia partner sync',
8
+ scheduled_at: '2026-04-29T16:00:00Z',
9
+ status: 'confirmed',
10
+ duration_minutes: 30,
11
+ investor_name: 'Roelof',
12
+ investor_email: 'roelof@sequoia.com',
13
+ meeting_link: 'https://meet.google.com/abc',
14
+ },
15
+ {
16
+ id: 2,
17
+ title: 'Acme intro',
18
+ scheduled_at: '2026-04-30T14:00:00Z',
19
+ status: 'pending',
20
+ duration_minutes: 45,
21
+ investor_email: 'partner@acme.vc',
22
+ },
23
+ ]
24
+
25
+ describe('MeetingsList', () => {
26
+ it('renders each meeting title', () => {
27
+ render(<MeetingsList meetings={sampleMeetings} />)
28
+ expect(screen.getByText('Sequoia partner sync')).toBeInTheDocument()
29
+ expect(screen.getByText('Acme intro')).toBeInTheDocument()
30
+ })
31
+
32
+ it('shows status badges per meeting', () => {
33
+ render(<MeetingsList meetings={sampleMeetings} />)
34
+ expect(screen.getByText('Confirmed')).toBeInTheDocument()
35
+ expect(screen.getByText('Pending')).toBeInTheDocument()
36
+ })
37
+
38
+ it('shows duration when provided', () => {
39
+ render(<MeetingsList meetings={sampleMeetings} />)
40
+ expect(screen.getByText(/· 30min/)).toBeInTheDocument()
41
+ expect(screen.getByText(/· 45min/)).toBeInTheDocument()
42
+ })
43
+
44
+ it('renders skeletons when loading', () => {
45
+ const { container } = render(
46
+ <MeetingsList meetings={[]} loading loadingRowCount={6} />
47
+ )
48
+ expect(container.querySelectorAll('.animate-pulse')).toHaveLength(6)
49
+ })
50
+
51
+ it('renders empty state with custom message', () => {
52
+ render(<MeetingsList meetings={[]} emptyMessage="Nothing yet" />)
53
+ expect(screen.getByText('Nothing yet')).toBeInTheDocument()
54
+ // No CTA without onSchedule
55
+ expect(screen.queryByText('Schedule First Meeting')).not.toBeInTheDocument()
56
+ })
57
+
58
+ it('shows the Schedule CTA when onSchedule provided + fires it', () => {
59
+ const onSchedule = jest.fn()
60
+ render(<MeetingsList meetings={[]} onSchedule={onSchedule} />)
61
+ fireEvent.click(screen.getByText('Schedule First Meeting'))
62
+ expect(onSchedule).toHaveBeenCalledTimes(1)
63
+ })
64
+
65
+ it('does NOT render delete button when onDelete is absent', () => {
66
+ render(<MeetingsList meetings={sampleMeetings} />)
67
+ expect(screen.queryByLabelText('Delete meeting')).not.toBeInTheDocument()
68
+ })
69
+
70
+ it('renders delete buttons + fires onDelete with the meeting id', () => {
71
+ const onDelete = jest.fn()
72
+ render(<MeetingsList meetings={sampleMeetings} onDelete={onDelete} />)
73
+ const deleteButtons = screen.getAllByLabelText('Delete meeting')
74
+ expect(deleteButtons).toHaveLength(2)
75
+ fireEvent.click(deleteButtons[0])
76
+ expect(onDelete).toHaveBeenCalledWith(1)
77
+ })
78
+
79
+ it('fires onRowClick with the meeting payload', () => {
80
+ const onRowClick = jest.fn()
81
+ render(<MeetingsList meetings={sampleMeetings} onRowClick={onRowClick} />)
82
+ fireEvent.click(screen.getByText('Sequoia partner sync'))
83
+ expect(onRowClick).toHaveBeenCalledTimes(1)
84
+ expect(onRowClick.mock.calls[0][0].id).toBe(1)
85
+ })
86
+
87
+ it('does not fire onRowClick when delete button is clicked (event bubbling guard)', () => {
88
+ const onRowClick = jest.fn()
89
+ const onDelete = jest.fn()
90
+ render(
91
+ <MeetingsList
92
+ meetings={sampleMeetings}
93
+ onRowClick={onRowClick}
94
+ onDelete={onDelete}
95
+ />
96
+ )
97
+ fireEvent.click(screen.getAllByLabelText('Delete meeting')[0])
98
+ expect(onDelete).toHaveBeenCalledTimes(1)
99
+ expect(onRowClick).not.toHaveBeenCalled()
100
+ })
101
+
102
+ it('renders Join link when meeting_link is set', () => {
103
+ render(<MeetingsList meetings={sampleMeetings} />)
104
+ const joinLink = screen.getByTitle('Join meeting')
105
+ expect(joinLink).toHaveAttribute('href', 'https://meet.google.com/abc')
106
+ })
107
+
108
+ it("does NOT propagate row click when join link is clicked", () => {
109
+ const onRowClick = jest.fn()
110
+ render(<MeetingsList meetings={sampleMeetings} onRowClick={onRowClick} />)
111
+ fireEvent.click(screen.getByTitle('Join meeting'))
112
+ expect(onRowClick).not.toHaveBeenCalled()
113
+ })
114
+ })
@@ -0,0 +1,82 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { SlideCanvas } from '../slide-deck/SlideCanvas'
3
+ import { SlideFilmstrip } from '../slide-deck/SlideFilmstrip'
4
+ import { SlideDeckViewer } from '../slide-deck/SlideDeckViewer'
5
+ import type { SlideData } from '../slide-deck/types'
6
+
7
+ const slides: SlideData[] = [
8
+ { id: 's1', slideNumber: 1, title: 'Title', imageUrl: 'https://x/1.png', status: 'ready' },
9
+ { id: 's2', slideNumber: 2, title: 'Problem', imageUrl: 'https://x/2.png', status: 'ready' },
10
+ { id: 's3', slideNumber: 3, title: 'Solution', status: 'generating' },
11
+ ]
12
+
13
+ describe('SlideCanvas', () => {
14
+ it('renders a full-bleed image in image render mode', () => {
15
+ render(<SlideCanvas slide={slides[0]} />)
16
+ const img = screen.getByRole('img')
17
+ expect(img).toHaveAttribute('src', 'https://x/1.png')
18
+ })
19
+
20
+ it('shows a generating state while the slide is in progress', () => {
21
+ const { container } = render(<SlideCanvas slide={slides[2]} />)
22
+ const el = container.querySelector('[data-status="generating"]')
23
+ expect(el).toBeInTheDocument()
24
+ expect(el).toHaveAttribute('aria-busy', 'true')
25
+ })
26
+
27
+ it('shows an error state with a retry control', () => {
28
+ const onRetry = jest.fn()
29
+ render(<SlideCanvas slide={{ id: 'e', slideNumber: 1, status: 'error' }} onRetry={onRetry} />)
30
+ const retry = screen.getByRole('button', { name: /retry|regenerate/i })
31
+ fireEvent.click(retry)
32
+ expect(onRetry).toHaveBeenCalled()
33
+ })
34
+ })
35
+
36
+ describe('SlideFilmstrip', () => {
37
+ it('renders a thumbnail per slide and marks the active one', () => {
38
+ render(<SlideFilmstrip slides={slides} activeId="s2" onSelect={() => {}} />)
39
+ const thumbs = screen.getAllByRole('button')
40
+ expect(thumbs).toHaveLength(3)
41
+ const active = thumbs.find((t) => t.getAttribute('aria-current') === 'true')
42
+ expect(active).toBeTruthy()
43
+ })
44
+
45
+ it('calls onSelect with the slide id when a thumbnail is clicked', () => {
46
+ const onSelect = jest.fn()
47
+ render(<SlideFilmstrip slides={slides} activeId="s1" onSelect={onSelect} />)
48
+ fireEvent.click(screen.getAllByRole('button')[2])
49
+ expect(onSelect).toHaveBeenCalledWith('s3')
50
+ })
51
+ })
52
+
53
+ describe('SlideDeckViewer', () => {
54
+ it('renders the active slide and the position indicator', () => {
55
+ const { container } = render(<SlideDeckViewer slides={slides} activeId="s2" />)
56
+ expect(screen.getByText('2 / 3')).toBeInTheDocument()
57
+ const stage = container.querySelector('[data-testid="slide-deck-stage"]') as HTMLElement
58
+ expect(stage.querySelector('img')).toHaveAttribute('src', 'https://x/2.png')
59
+ })
60
+
61
+ it('advances the active slide with ArrowRight', () => {
62
+ const onActiveChange = jest.fn()
63
+ const { container } = render(
64
+ <SlideDeckViewer slides={slides} activeId="s1" onActiveChange={onActiveChange} />
65
+ )
66
+ const stage = container.querySelector('[data-testid="slide-deck-stage"]') as HTMLElement
67
+ fireEvent.keyDown(stage, { key: 'ArrowRight' })
68
+ expect(onActiveChange).toHaveBeenCalledWith('s2')
69
+ })
70
+
71
+ it('renders an empty state when there are no slides', () => {
72
+ render(<SlideDeckViewer slides={[]} emptyState={<div>No slides yet</div>} />)
73
+ expect(screen.getByText('No slides yet')).toBeInTheDocument()
74
+ })
75
+
76
+ it('invokes onRegenerate for the active slide', () => {
77
+ const onRegenerate = jest.fn()
78
+ render(<SlideDeckViewer slides={slides} activeId="s1" onRegenerate={onRegenerate} />)
79
+ fireEvent.click(screen.getByRole('button', { name: /regenerate/i }))
80
+ expect(onRegenerate).toHaveBeenCalledWith(slides[0])
81
+ })
82
+ })
@@ -0,0 +1,106 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { SplitPane } from '../workspace/SplitPane'
3
+ import { DualPaneWorkspace } from '../workspace/DualPaneWorkspace'
4
+
5
+ describe('SplitPane', () => {
6
+ it('renders both panes', () => {
7
+ render(<SplitPane first={<div>LEFT</div>} second={<div>RIGHT</div>} />)
8
+ expect(screen.getByText('LEFT')).toBeInTheDocument()
9
+ expect(screen.getByText('RIGHT')).toBeInTheDocument()
10
+ })
11
+
12
+ it('exposes an accessible resizer separator with aria values', () => {
13
+ render(
14
+ <SplitPane
15
+ first={<div>L</div>}
16
+ second={<div>R</div>}
17
+ initialSize={40}
18
+ minSize={20}
19
+ maxSize={80}
20
+ />
21
+ )
22
+ const sep = screen.getByRole('separator')
23
+ expect(sep).toHaveAttribute('aria-orientation', 'vertical')
24
+ expect(sep).toHaveAttribute('aria-valuenow', '40')
25
+ expect(sep).toHaveAttribute('aria-valuemin', '20')
26
+ expect(sep).toHaveAttribute('aria-valuemax', '80')
27
+ })
28
+
29
+ it('grows the first pane on ArrowRight and shrinks on ArrowLeft', () => {
30
+ const onResize = jest.fn()
31
+ render(
32
+ <SplitPane first={<div>L</div>} second={<div>R</div>} initialSize={50} onResize={onResize} />
33
+ )
34
+ const sep = screen.getByRole('separator')
35
+ fireEvent.keyDown(sep, { key: 'ArrowRight' })
36
+ expect(Number(sep.getAttribute('aria-valuenow'))).toBeGreaterThan(50)
37
+ fireEvent.keyDown(sep, { key: 'ArrowLeft' })
38
+ fireEvent.keyDown(sep, { key: 'ArrowLeft' })
39
+ expect(Number(sep.getAttribute('aria-valuenow'))).toBeLessThan(50)
40
+ expect(onResize).toHaveBeenCalled()
41
+ })
42
+
43
+ it('clamps size to min/max', () => {
44
+ render(<SplitPane first={<div>L</div>} second={<div>R</div>} initialSize={22} minSize={20} maxSize={80} />)
45
+ const sep = screen.getByRole('separator')
46
+ // hammer left many times — must not drop below min
47
+ for (let i = 0; i < 20; i++) fireEvent.keyDown(sep, { key: 'ArrowLeft' })
48
+ expect(Number(sep.getAttribute('aria-valuenow'))).toBe(20)
49
+ })
50
+
51
+ it('persists size to localStorage when storageKey is set', () => {
52
+ window.localStorage.clear()
53
+ render(<SplitPane first={<div>L</div>} second={<div>R</div>} initialSize={50} storageKey="sp-test" />)
54
+ const sep = screen.getByRole('separator')
55
+ fireEvent.keyDown(sep, { key: 'ArrowRight' })
56
+ expect(window.localStorage.getItem('sp-test')).not.toBeNull()
57
+ })
58
+ })
59
+
60
+ describe('DualPaneWorkspace', () => {
61
+ it('renders left and right content with labels and an optional toolbar', () => {
62
+ render(
63
+ <DualPaneWorkspace
64
+ leftLabel="Chat"
65
+ rightLabel="Slides"
66
+ toolbar={<div>TOOLBAR</div>}
67
+ left={<div>CHAT_PANE</div>}
68
+ right={<div>SLIDES_PANE</div>}
69
+ layout="split"
70
+ />
71
+ )
72
+ expect(screen.getByText('CHAT_PANE')).toBeInTheDocument()
73
+ expect(screen.getByText('SLIDES_PANE')).toBeInTheDocument()
74
+ expect(screen.getByText('TOOLBAR')).toBeInTheDocument()
75
+ })
76
+
77
+ it('stacks panes when layout="stack"', () => {
78
+ const { container } = render(
79
+ <DualPaneWorkspace left={<div>L</div>} right={<div>R</div>} layout="stack" />
80
+ )
81
+ const root = container.querySelector('[data-layout="stack"]')
82
+ expect(root).toBeInTheDocument()
83
+ // both panes still rendered when stacked
84
+ expect(screen.getByText('L')).toBeInTheDocument()
85
+ expect(screen.getByText('R')).toBeInTheDocument()
86
+ })
87
+
88
+ it('collapses a pane when collapsible and the collapse control is used', () => {
89
+ render(
90
+ <DualPaneWorkspace
91
+ leftLabel="Chat"
92
+ rightLabel="Slides"
93
+ left={<div>CHAT_PANE</div>}
94
+ right={<div>SLIDES_PANE</div>}
95
+ layout="split"
96
+ collapsible
97
+ />
98
+ )
99
+ const collapseLeft = screen.getByRole('button', { name: /collapse chat/i })
100
+ fireEvent.click(collapseLeft)
101
+ expect(screen.queryByText('CHAT_PANE')).not.toBeInTheDocument()
102
+ // right still visible, and an expand affordance appears
103
+ expect(screen.getByText('SLIDES_PANE')).toBeInTheDocument()
104
+ expect(screen.getByRole('button', { name: /expand chat/i })).toBeInTheDocument()
105
+ })
106
+ })
@@ -148,11 +148,11 @@ describe('ChangePasswordForm', () => {
148
148
  jest.clearAllMocks()
149
149
  })
150
150
 
151
- it('renders all three password fields', () => {
151
+ it('renders both password fields (current + new, no confirm — startsim-nbq)', () => {
152
152
  render(<ChangePasswordForm {...defaultPasswordProps} />)
153
153
  expect(screen.getByLabelText('Current password')).toBeInTheDocument()
154
154
  expect(screen.getByLabelText('New password')).toBeInTheDocument()
155
- expect(screen.getByLabelText('Confirm new password')).toBeInTheDocument()
155
+ expect(screen.queryByLabelText('Confirm new password')).not.toBeInTheDocument()
156
156
  })
157
157
 
158
158
  it('submit button is disabled when fields are empty', () => {
@@ -165,7 +165,6 @@ describe('ChangePasswordForm', () => {
165
165
 
166
166
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
167
167
  changeInput(screen.getByLabelText('New password'), 'short')
168
- changeInput(screen.getByLabelText('Confirm new password'), 'short')
169
168
 
170
169
  expect(screen.getByRole('button', { name: /change password/i })).toBeDisabled()
171
170
  })
@@ -182,40 +181,21 @@ describe('ChangePasswordForm', () => {
182
181
  expect(screen.queryByText('Must be at least 8 characters.')).not.toBeInTheDocument()
183
182
  })
184
183
 
185
- it('submit button is disabled when passwords do not match', () => {
184
+ it('submit button is enabled when current + new (>=8) are filled', () => {
186
185
  render(<ChangePasswordForm {...defaultPasswordProps} />)
187
186
 
188
187
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
189
188
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
190
- changeInput(screen.getByLabelText('Confirm new password'), 'different123')
191
-
192
- expect(screen.getByRole('button', { name: /change password/i })).toBeDisabled()
193
- })
194
-
195
- it('shows mismatch hint when confirm does not match', () => {
196
- render(<ChangePasswordForm {...defaultPasswordProps} />)
197
- changeInput(screen.getByLabelText('New password'), 'newpassword1')
198
- changeInput(screen.getByLabelText('Confirm new password'), 'different123')
199
- expect(screen.getByText('Passwords do not match.')).toBeInTheDocument()
200
- })
201
-
202
- it('submit button is enabled when all fields are valid', () => {
203
- render(<ChangePasswordForm {...defaultPasswordProps} />)
204
-
205
- changeInput(screen.getByLabelText('Current password'), 'oldpassword')
206
- changeInput(screen.getByLabelText('New password'), 'newpassword1')
207
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
208
189
 
209
190
  expect(screen.getByRole('button', { name: /change password/i })).toBeEnabled()
210
191
  })
211
192
 
212
- it('calls onSubmit with correct payload', async () => {
193
+ it('calls onSubmit with { old_password, new_password } — no confirm', async () => {
213
194
  const onSubmit = jest.fn().mockResolvedValue(undefined)
214
195
  render(<ChangePasswordForm onSubmit={onSubmit} />)
215
196
 
216
197
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
217
198
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
218
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
219
199
 
220
200
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
221
201
 
@@ -223,7 +203,6 @@ describe('ChangePasswordForm', () => {
223
203
  expect(onSubmit).toHaveBeenCalledWith({
224
204
  old_password: 'oldpassword',
225
205
  new_password: 'newpassword1',
226
- new_password_confirm: 'newpassword1',
227
206
  })
228
207
  })
229
208
  })
@@ -234,7 +213,6 @@ describe('ChangePasswordForm', () => {
234
213
 
235
214
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
236
215
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
237
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
238
216
 
239
217
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
240
218
 
@@ -244,7 +222,6 @@ describe('ChangePasswordForm', () => {
244
222
 
245
223
  expect(screen.getByLabelText('Current password')).toHaveValue('')
246
224
  expect(screen.getByLabelText('New password')).toHaveValue('')
247
- expect(screen.getByLabelText('Confirm new password')).toHaveValue('')
248
225
  })
249
226
 
250
227
  it('calls onSuccess callback after successful submit', async () => {
@@ -254,7 +231,6 @@ describe('ChangePasswordForm', () => {
254
231
 
255
232
  changeInput(screen.getByLabelText('Current password'), 'oldpassword')
256
233
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
257
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
258
234
 
259
235
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
260
236
 
@@ -269,7 +245,6 @@ describe('ChangePasswordForm', () => {
269
245
 
270
246
  changeInput(screen.getByLabelText('Current password'), 'wrongold')
271
247
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
272
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
273
248
 
274
249
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
275
250
 
@@ -284,7 +259,6 @@ describe('ChangePasswordForm', () => {
284
259
 
285
260
  changeInput(screen.getByLabelText('Current password'), 'wrongold')
286
261
  changeInput(screen.getByLabelText('New password'), 'newpassword1')
287
- changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
288
262
 
289
263
  fireEvent.click(screen.getByRole('button', { name: /change password/i }))
290
264
 
@@ -293,11 +267,10 @@ describe('ChangePasswordForm', () => {
293
267
  })
294
268
  })
295
269
 
296
- it('disables all inputs when disabled prop is true', () => {
270
+ it('disables both inputs when disabled prop is true', () => {
297
271
  render(<ChangePasswordForm {...defaultPasswordProps} disabled />)
298
272
  expect(screen.getByLabelText('Current password')).toBeDisabled()
299
273
  expect(screen.getByLabelText('New password')).toBeDisabled()
300
- expect(screen.getByLabelText('Confirm new password')).toBeDisabled()
301
274
  })
302
275
 
303
276
  it('renders card heading', () => {