@startsimpli/ui 0.4.57 → 0.4.59

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.57",
3
+ "version": "0.4.59",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -22,6 +22,7 @@
22
22
  "./email-editor": "./src/components/email-editor/index.ts",
23
23
  "./workflows": "./src/components/workflows/index.ts",
24
24
  "./workflows/styles": "./src/components/workflows/workflows.css",
25
+ "./presentations": "./src/components/presentations/index.ts",
25
26
  "./terminal/styles": "./src/components/terminal/xterm.css",
26
27
  "./session": "./src/components/session/index.ts",
27
28
  "./session-console": "./src/components/session/AgentSessionConsole.tsx",
@@ -41,8 +42,8 @@
41
42
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
42
43
  "react": "^18.0.0 || ^19.0.0",
43
44
  "react-dom": "^18.0.0 || ^19.0.0",
44
- "@startsimpli/api": "0.5.44",
45
- "@startsimpli/auth": "0.4.30"
45
+ "@startsimpli/auth": "0.4.30",
46
+ "@startsimpli/api": "0.5.44"
46
47
  },
47
48
  "peerDependenciesMeta": {
48
49
  "@startsimpli/api": {
@@ -155,6 +155,10 @@ export function KanbanBoard<T>({
155
155
  ref={dp.innerRef}
156
156
  {...dp.draggableProps}
157
157
  {...dp.dragHandleProps}
158
+ // @hello-pangea/dnd's DraggableStyle isn't assignable to
159
+ // the radix-augmented CSSProperties; the spread already
160
+ // sets it, this re-asserts the type for consumer tsc.
161
+ style={dp.draggableProps.style as React.CSSProperties}
158
162
  className={cn(ds.isDragging && 'opacity-80')}
159
163
  >
160
164
  {renderCard(item)}
@@ -0,0 +1,114 @@
1
+ import * as React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { DeckBuilderWorkspace } from './DeckBuilderWorkspace'
5
+ import type { DeckBuilder } from './types'
6
+
7
+ /**
8
+ * The composer is pure: it renders whatever the injected `builder` exposes and
9
+ * lights up optional affordances (style chip, reference uploader) only when the
10
+ * builder declares them. These tests drive it with hand-rolled builders — no app
11
+ * import — proving the surface is app-agnostic.
12
+ */
13
+
14
+ function makeBuilder(overrides: Partial<DeckBuilder> = {}): DeckBuilder {
15
+ return {
16
+ deckTitle: '',
17
+ phase: 'empty',
18
+ messages: [],
19
+ slides: [],
20
+ setActiveId: jest.fn(),
21
+ sendMessage: jest.fn(),
22
+ regenerate: jest.fn(),
23
+ ...overrides,
24
+ }
25
+ }
26
+
27
+ function renderWithClient(ui: React.ReactElement) {
28
+ const client = new QueryClient({
29
+ defaultOptions: { queries: { retry: false } },
30
+ })
31
+ return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
32
+ }
33
+
34
+ describe('DeckBuilderWorkspace', () => {
35
+ it('renders the dual-pane surface from a minimal injected builder', () => {
36
+ renderWithClient(<DeckBuilderWorkspace builder={makeBuilder()} />)
37
+
38
+ // Toolbar shows the placeholder title + the empty-outline hint.
39
+ expect(screen.getByText('New presentation')).toBeInTheDocument()
40
+ expect(screen.getByText('Paste an outline to begin')).toBeInTheDocument()
41
+
42
+ // Empty deck pane surfaces its empty-state copy.
43
+ expect(
44
+ screen.getByText(/Your slides will render here as a live deck/i)
45
+ ).toBeInTheDocument()
46
+
47
+ // Phase-empty composer uses the outline placeholder.
48
+ expect(
49
+ screen.getByPlaceholderText(/Paste your outline/i)
50
+ ).toBeInTheDocument()
51
+ })
52
+
53
+ it('routes composer submissions to builder.sendMessage', () => {
54
+ const builder = makeBuilder()
55
+ renderWithClient(<DeckBuilderWorkspace builder={builder} />)
56
+
57
+ const textarea = screen.getByPlaceholderText(/Paste your outline/i)
58
+ // Single-line content submits on bare Enter.
59
+ fireEvent.change(textarea, { target: { value: 'My deck outline' } })
60
+ fireEvent.keyDown(textarea, { key: 'Enter' })
61
+
62
+ expect(builder.sendMessage).toHaveBeenCalledWith('My deck outline')
63
+ })
64
+
65
+ it('shows the derived-style chip only when the builder provides deckStyle', () => {
66
+ const { rerender } = renderWithClient(
67
+ <DeckBuilderWorkspace builder={makeBuilder()} />
68
+ )
69
+ expect(screen.queryByTestId('deck-style-chip')).not.toBeInTheDocument()
70
+
71
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
72
+ rerender(
73
+ <QueryClientProvider client={client}>
74
+ <DeckBuilderWorkspace
75
+ builder={makeBuilder({
76
+ deckStyle: { theme: 'Modern Professional', source: 'reference' },
77
+ })}
78
+ />
79
+ </QueryClientProvider>
80
+ )
81
+ const chip = screen.getByTestId('deck-style-chip')
82
+ expect(chip).toBeInTheDocument()
83
+ expect(chip).toHaveTextContent('Modern Professional')
84
+ // `source: 'reference'` marks it as cloned.
85
+ expect(chip).toHaveTextContent(/cloned/i)
86
+ })
87
+
88
+ it('wires the reference uploader only when the builder supports attachments', () => {
89
+ // Demo-style builder: no attachments capability -> no attach affordance.
90
+ const { unmount } = renderWithClient(
91
+ <DeckBuilderWorkspace builder={makeBuilder()} />
92
+ )
93
+ expect(screen.queryByTestId('chat-attach-input')).not.toBeInTheDocument()
94
+ unmount()
95
+
96
+ // Backend-style builder: supportsAttachments + an injected client -> uploader on.
97
+ const attachments = {
98
+ listAttachments: jest.fn().mockResolvedValue([]),
99
+ uploadAttachment: jest.fn(),
100
+ deleteAttachment: jest.fn(),
101
+ }
102
+ renderWithClient(
103
+ <DeckBuilderWorkspace
104
+ builder={makeBuilder({
105
+ supportsAttachments: true,
106
+ documentId: 'doc-1',
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ attachments: attachments as any,
109
+ })}
110
+ />
111
+ )
112
+ expect(screen.getByTestId('chat-attach-input')).toBeInTheDocument()
113
+ })
114
+ })
@@ -0,0 +1,257 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import { ArrowLeft, Download, FileText, Palette, Presentation, Sparkles } from 'lucide-react'
6
+ import { useQuery } from '@tanstack/react-query'
7
+ import type { Attachment } from '@startsimpli/api'
8
+ import { DualPaneWorkspace } from '../workspace'
9
+ import { ChatThread, ChatComposer } from '../chat'
10
+ import type { ChatMessageData, ChatMessageAttachment } from '../chat/types'
11
+ import { SlideDeckViewer } from '../slide-deck'
12
+ import type { DeckBuilder } from './types'
13
+
14
+ /**
15
+ * Reopen display: if no message already carries attachments (the conversation
16
+ * payload didn't echo them), hang the document's persisted reference attachments
17
+ * off the earliest user message so they reappear in the chat after reopen.
18
+ */
19
+ function hydratePriorAttachments(
20
+ messages: ChatMessageData[],
21
+ prior: Attachment[] | undefined
22
+ ): ChatMessageData[] {
23
+ if (!prior || prior.length === 0) return messages
24
+ if (messages.some((m) => m.attachments && m.attachments.length > 0)) return messages
25
+ const targetIdx = messages.findIndex((m) => m.role === 'user')
26
+ if (targetIdx === -1) return messages
27
+ const chips: ChatMessageAttachment[] = prior
28
+ .filter((a) => a.purpose === 'reference')
29
+ .map((a) => ({
30
+ id: a.id,
31
+ fileUrl: a.fileUrl,
32
+ filename: a.filename,
33
+ contentType: a.contentType,
34
+ }))
35
+ if (chips.length === 0) return messages
36
+ return messages.map((m, i) => (i === targetIdx ? { ...m, attachments: chips } : m))
37
+ }
38
+
39
+ export interface DeckBuilderWorkspaceProps {
40
+ /** Where the back arrow points. Omit to hide it (e.g. on a public /try page). */
41
+ backHref?: string
42
+ /**
43
+ * The orchestrator to render — injected, never imported here. The consuming
44
+ * app wires a demo/no-backend engine or the backend workflow-run consumer;
45
+ * both expose the identical `DeckBuilder` contract. Reference-attachment
46
+ * support (uploader + reopen hydration) rides on `builder.attachments`.
47
+ */
48
+ builder: DeckBuilder
49
+ }
50
+
51
+ /**
52
+ * The chat | slides deck-builder page composer. Composed entirely from generic
53
+ * @startsimpli/ui primitives (`DualPaneWorkspace` + chat + `SlideDeckViewer`)
54
+ * wired to an injected deck-builder orchestrator.
55
+ *
56
+ * Pure + app-agnostic: the orchestrator is a prop (`builder`) so the same
57
+ * surface can be driven by a demo engine or a backend workflow-run consumer,
58
+ * and the reference-attachments client rides on the builder — the app renders
59
+ * this as a thin `<DeckBuilderWorkspace builder={...} />` wrapper.
60
+ */
61
+ export function DeckBuilderWorkspace({ backHref, builder }: DeckBuilderWorkspaceProps) {
62
+ const {
63
+ deckTitle,
64
+ phase,
65
+ messages,
66
+ slides,
67
+ activeId,
68
+ pptxUrl,
69
+ pdfUrl,
70
+ isWorking,
71
+ documentId,
72
+ deckStyle,
73
+ attachments,
74
+ setActiveId,
75
+ sendMessage,
76
+ regenerate,
77
+ deleteSlide,
78
+ moveSlide,
79
+ insertSlide,
80
+ editSlide,
81
+ reorderSlide,
82
+ } = builder
83
+
84
+ // Only a backend-backed engine has a Document for reference uploads to land
85
+ // on AND an attachments client wired up; a demo engine would strand every file
86
+ // as a forever-queued chip, so it gets no uploader (attach affordance hidden).
87
+ const canAttach = builder.supportsAttachments === true && !!attachments
88
+
89
+ // A turn is in progress (agent thinking / slides generating). Prefer the
90
+ // builder's own signal; fall back to slide status for engines that omit it.
91
+ const busy = isWorking ?? slides.some((s) => s.status === 'generating')
92
+
93
+ // Reference attachments are bundled into the shared ChatComposer: pass it the
94
+ // document-scoped uploader and the composer self-manages upload state (chips,
95
+ // downscale, retry, remove). Uploads are document-scoped — for a brand-new deck
96
+ // with no document yet, the composer holds files client-side and flushes them
97
+ // the moment the first turn creates the document and `documentId` arrives. The
98
+ // backend agent gathers references off the Document, so we do NOT thread
99
+ // attachment ids through the turn — landing the upload on `documentId` is enough.
100
+
101
+ // Reopen: surface the document's persisted reference attachments. Fetched off
102
+ // the injected attachments client (mirrors @startsimpli/hooks `useAttachments`
103
+ // — the surface takes react-query as a peer, so keep the query inline rather
104
+ // than couple this package to @startsimpli/hooks). Disabled until a real
105
+ // documentId + client exist. Hydrated onto the earliest user message when the
106
+ // conversation payload didn't already carry them, so they reappear on reopen.
107
+ const { data: priorAttachments } = useQuery({
108
+ queryKey: ['attachments', 'list', documentId ?? ''],
109
+ queryFn: () => attachments!.listAttachments(documentId!),
110
+ enabled: !!attachments && !!documentId && documentId !== 'new',
111
+ })
112
+ const messagesWithAttachments = React.useMemo(
113
+ () => hydratePriorAttachments(messages, priorAttachments),
114
+ [messages, priorAttachments]
115
+ )
116
+
117
+ // Compact display of the deck's derived style: theme + any non-"auto" palette/
118
+ // font. "auto" is the placeholder default, so it's dropped to avoid noise.
119
+ // Style values can be anything from a short label ("Modern Professional") to a
120
+ // multi-sentence prose spec (when cloned from a reference image via vision), so
121
+ // each bit is hard-truncated to a glanceable phrase and the chip is width-
122
+ // capped + single-line — the full spec lives in the hover title.
123
+ const shortBit = (v: string) => {
124
+ const firstPhrase = v.split(/[.;,—]/)[0].trim()
125
+ const s = (firstPhrase || v).trim()
126
+ return s.length > 28 ? `${s.slice(0, 28).trimEnd()}…` : s
127
+ }
128
+ const rawBits = deckStyle
129
+ ? [deckStyle.theme, deckStyle.palette, deckStyle.font]
130
+ .filter((v): v is string => !!v && v !== 'auto')
131
+ .filter((v, i, a) => a.indexOf(v) === i)
132
+ : []
133
+ const styleLabel = rawBits.map(shortBit).join(' · ')
134
+ const styleTitle = [deckStyle?.theme, deckStyle?.palette, deckStyle?.font, deckStyle?.mood]
135
+ .filter((v): v is string => !!v && v !== 'auto')
136
+ .join('\n\n')
137
+ const styleChip = deckStyle && (rawBits.length > 0 || deckStyle.source === 'reference') && (
138
+ <div
139
+ className="hidden max-w-[280px] items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-gray-600 sm:inline-flex"
140
+ data-testid="deck-style-chip"
141
+ title={styleTitle || 'Deck style'}
142
+ >
143
+ {deckStyle.source === 'reference' ? (
144
+ <Sparkles className="h-3.5 w-3.5 shrink-0 text-primary-600" aria-hidden="true" />
145
+ ) : (
146
+ <Palette className="h-3.5 w-3.5 shrink-0 text-gray-400" aria-hidden="true" />
147
+ )}
148
+ <span className="truncate font-medium capitalize">{styleLabel || 'Custom style'}</span>
149
+ {deckStyle.source === 'reference' && (
150
+ <span className="shrink-0 text-primary-600">· cloned</span>
151
+ )}
152
+ </div>
153
+ )
154
+
155
+ const toolbar = (
156
+ <div className="flex items-center justify-between gap-3 bg-white px-4 py-2">
157
+ <div className="flex items-center gap-3">
158
+ {backHref && (
159
+ <Link
160
+ href={backHref}
161
+ className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
162
+ aria-label="Back"
163
+ >
164
+ <ArrowLeft className="h-4 w-4" />
165
+ </Link>
166
+ )}
167
+ <Presentation className="h-5 w-5 text-primary-600" aria-hidden="true" />
168
+ <div className="leading-tight">
169
+ <div className="text-sm font-semibold text-gray-900">{deckTitle || 'New presentation'}</div>
170
+ <div className="text-xs text-gray-400">
171
+ {slides.length > 0 ? `${slides.length} slides · ${phase}` : 'Paste an outline to begin'}
172
+ </div>
173
+ </div>
174
+ </div>
175
+ <div className="flex items-center gap-2">
176
+ {styleChip}
177
+ {pptxUrl && (
178
+ <a
179
+ href={pptxUrl}
180
+ download={`${deckTitle || 'deck'}.pptx.txt`}
181
+ className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm shadow-sm hover:bg-accent"
182
+ >
183
+ <Download className="h-3.5 w-3.5" /> PPTX
184
+ </a>
185
+ )}
186
+ {pdfUrl && (
187
+ <a
188
+ href={pdfUrl}
189
+ download={`${deckTitle || 'deck'}.pdf.txt`}
190
+ className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm shadow-sm hover:bg-accent"
191
+ >
192
+ <FileText className="h-3.5 w-3.5" /> PDF
193
+ </a>
194
+ )}
195
+ </div>
196
+ </div>
197
+ )
198
+
199
+ const chat = (
200
+ <div className="flex h-full min-h-0 flex-col bg-white">
201
+ <ChatThread
202
+ messages={messagesWithAttachments}
203
+ busy={busy}
204
+ busyLabel="Working on your deck…"
205
+ className="flex-1"
206
+ emptyState={<span className="text-sm text-gray-400">Your conversation will appear here.</span>}
207
+ />
208
+ <ChatComposer
209
+ onSubmit={sendMessage}
210
+ busy={busy}
211
+ uploader={canAttach ? attachments : undefined}
212
+ documentId={documentId}
213
+ placeholder={
214
+ phase === 'empty'
215
+ ? 'Paste your outline (title on line 1, then slides)…'
216
+ : 'Reply — “looks good”, “redo slide 2 …”, “compile”…'
217
+ }
218
+ />
219
+ </div>
220
+ )
221
+
222
+ const deck = (
223
+ <SlideDeckViewer
224
+ slides={slides}
225
+ activeId={activeId}
226
+ onActiveChange={setActiveId}
227
+ onRegenerate={regenerate}
228
+ onDelete={deleteSlide}
229
+ onMove={moveSlide}
230
+ onInsert={insertSlide}
231
+ onEdit={editSlide}
232
+ onReorder={reorderSlide}
233
+ emptyState={
234
+ <div className="max-w-sm">
235
+ <Presentation className="mx-auto mb-3 h-10 w-10 text-gray-300" aria-hidden="true" />
236
+ <p className="text-sm text-gray-500">
237
+ Your slides will render here as a live deck — paste an outline in the chat to start.
238
+ </p>
239
+ </div>
240
+ }
241
+ />
242
+ )
243
+
244
+ return (
245
+ <DualPaneWorkspace
246
+ toolbar={toolbar}
247
+ leftLabel="Chat"
248
+ rightLabel="Slides"
249
+ left={chat}
250
+ right={deck}
251
+ collapsible
252
+ initialLeftSize={38}
253
+ storageKey="present-web:builder-split"
254
+ className="h-full"
255
+ />
256
+ )
257
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @startsimpli/ui/presentations — the deck-builder page composer.
3
+ *
4
+ * A dual-pane (chat | live slide deck) surface composed entirely from generic
5
+ * @startsimpli/ui primitives and driven by an injected orchestrator that
6
+ * satisfies the `DeckBuilder` contract. Isolated behind a dedicated subpath so
7
+ * consumers that don't build presentations don't pull it in (mirrors gantt /
8
+ * workflows / collection).
9
+ */
10
+
11
+ export { DeckBuilderWorkspace } from './DeckBuilderWorkspace'
12
+ export type { DeckBuilderWorkspaceProps } from './DeckBuilderWorkspace'
13
+ export type { DeckBuilder, DeckStyle, DeckBuilderPhase } from './types'
@@ -0,0 +1,82 @@
1
+ import type { AttachmentsApi } from '@startsimpli/api'
2
+ import type { ChatMessageData } from '../chat/types'
3
+ import type { SlideData } from '../slide-deck/types'
4
+
5
+ /**
6
+ * @startsimpli/ui/presentations — the deck-builder page composer contract.
7
+ *
8
+ * `DeckBuilder` is the pure view-model interface the `DeckBuilderWorkspace`
9
+ * composer consumes. It is deliberately engine-agnostic: any orchestrator that
10
+ * satisfies this shape (a demo/no-backend engine, or the backend workflow-run
11
+ * consumer) can drive the same dual-pane surface. The concrete orchestrators
12
+ * live in the consuming app (present-web); this package owns only the surface +
13
+ * the contract it renders against.
14
+ */
15
+
16
+ export type DeckBuilderPhase = 'empty' | 'review_first' | 'walking' | 'ready'
17
+
18
+ /**
19
+ * The deck's ONE derived style spec, surfaced for display on the toolbar
20
+ * (theme/palette/font/mood). `source: 'reference'` marks a style cloned from an
21
+ * uploaded reference image (the design-cloning path). Display-only — the
22
+ * orchestrator owns styling.
23
+ */
24
+ export interface DeckStyle {
25
+ theme?: string
26
+ palette?: string
27
+ font?: string
28
+ mood?: string
29
+ source?: string
30
+ }
31
+
32
+ /**
33
+ * The orchestrator contract driving the dual-pane deck builder. Required fields
34
+ * are what every engine must provide; the rest are optional capabilities the
35
+ * surface lights up when present (per-slide actions, export urls, reference
36
+ * attachments, a derived style chip).
37
+ */
38
+ export interface DeckBuilder {
39
+ deckTitle: string
40
+ phase: DeckBuilderPhase
41
+ messages: ChatMessageData[]
42
+ slides: SlideData[]
43
+ activeId?: string
44
+ pptxUrl?: string
45
+ pdfUrl?: string
46
+ /** True while a turn is being processed (agent thinking / slides generating). */
47
+ isWorking?: boolean
48
+ /** The backend-derived style spec, surfaced for a compact toolbar chip. */
49
+ deckStyle?: DeckStyle
50
+ /**
51
+ * True when this engine has a real backend document that reference uploads can
52
+ * land on. A local/demo engine omits it, so the composer suppresses the attach
53
+ * affordance and never strands a file as a forever-queued chip.
54
+ */
55
+ supportsAttachments?: boolean
56
+ /**
57
+ * The active document id (the deck being edited), once one exists. Drives
58
+ * document-scoped reference-attachment uploads + reopen hydration. Undefined
59
+ * for a brand-new deck until the first turn creates it, and for a demo engine.
60
+ */
61
+ documentId?: string
62
+ /**
63
+ * Reference-attachments client (e.g. `@startsimpli/api` `api.attachments`).
64
+ * Ride it on the builder so the composer stays a `{ builder }`-only surface:
65
+ * the composer wires it as the chat composer's uploader and reads persisted
66
+ * references off it on reopen. Omit on engines without a backend document.
67
+ */
68
+ attachments?: AttachmentsApi
69
+ setActiveId: (id: string) => void
70
+ sendMessage: (text: string) => void
71
+ regenerate: (slide: SlideData) => void
72
+ /** Delete a slide (dispatched as an agent turn). Optional — demo engine omits it. */
73
+ deleteSlide?: (slide: SlideData) => void
74
+ /** Move a slide one position left/right (dispatched as an agent turn). Optional. */
75
+ moveSlide?: (slide: SlideData, direction: 'left' | 'right') => void
76
+ /** Insert a blank slide after the given slide (dispatched as an agent turn). Optional. */
77
+ insertSlide?: (afterSlide: SlideData) => void
78
+ /** Edit a slide's title/content then regenerate it (dispatched as an agent turn). Optional. */
79
+ editSlide?: (slide: SlideData, edits: { title?: string; content?: string }) => void
80
+ /** Drag-reorder: move the slide at fromIndex to toIndex (0-based; agent turn). Optional. */
81
+ reorderSlide?: (fromIndex: number, toIndex: number) => void
82
+ }
@@ -3,7 +3,7 @@ import type { Config } from 'tailwindcss'
3
3
  // Presets intentionally omit `content` — each consuming app supplies its own.
4
4
  // tailwindcss exposes PresetsConfig = Partial<Config> for this purpose.
5
5
  const preset: Partial<Config> = {
6
- darkMode: ['class'],
6
+ darkMode: 'class',
7
7
  theme: {
8
8
  extend: {
9
9
  colors: {