@btst/stack 3.1.2 → 4.1.0

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 (129) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +112 -103
  3. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +112 -103
  4. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +5 -2
  5. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +5 -2
  6. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +99 -88
  7. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +99 -88
  8. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.cjs +12 -3
  9. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.mjs +12 -3
  10. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.cjs +6 -2
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.mjs +6 -2
  12. package/dist/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.cjs +5 -5
  13. package/dist/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.mjs +5 -5
  14. package/dist/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.cjs +3 -2
  15. package/dist/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.mjs +3 -2
  16. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-card.cjs +81 -52
  17. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-card.mjs +82 -53
  18. package/dist/packages/stack/src/plugins/blog/client/components/shared/posts-list.cjs +3 -2
  19. package/dist/packages/stack/src/plugins/blog/client/components/shared/posts-list.mjs +3 -2
  20. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.cjs +95 -75
  21. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.mjs +95 -75
  22. package/dist/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.cjs +16 -3
  23. package/dist/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.mjs +16 -3
  24. package/dist/packages/stack/src/plugins/cms/client/components/shared/page-wrapper.cjs +3 -2
  25. package/dist/packages/stack/src/plugins/cms/client/components/shared/page-wrapper.mjs +3 -2
  26. package/dist/packages/stack/src/plugins/cms/utils.cjs +6 -2
  27. package/dist/packages/stack/src/plugins/cms/utils.mjs +6 -3
  28. package/dist/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.cjs +14 -8
  29. package/dist/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.mjs +14 -8
  30. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.cjs +94 -87
  31. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.mjs +94 -87
  32. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.cjs +81 -74
  33. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.mjs +81 -74
  34. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.cjs +3 -2
  35. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.mjs +3 -2
  36. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/pagination.cjs +25 -16
  37. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/pagination.mjs +25 -16
  38. package/dist/packages/stack/src/plugins/form-builder/utils.cjs +6 -2
  39. package/dist/packages/stack/src/plugins/form-builder/utils.mjs +6 -3
  40. package/dist/packages/stack/src/plugins/kanban/api/getters.cjs +7 -5
  41. package/dist/packages/stack/src/plugins/kanban/api/getters.mjs +7 -5
  42. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +10 -8
  43. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +10 -8
  44. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  45. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  46. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  47. package/dist/plugins/ai-chat/client/index.d.cts +2 -2
  48. package/dist/plugins/ai-chat/client/index.d.mts +2 -2
  49. package/dist/plugins/ai-chat/client/index.d.ts +2 -2
  50. package/dist/plugins/blog/api/index.d.cts +2 -2
  51. package/dist/plugins/blog/api/index.d.mts +2 -2
  52. package/dist/plugins/blog/api/index.d.ts +2 -2
  53. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  54. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  55. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  56. package/dist/plugins/blog/client/index.d.cts +20 -2
  57. package/dist/plugins/blog/client/index.d.mts +20 -2
  58. package/dist/plugins/blog/client/index.d.ts +20 -2
  59. package/dist/plugins/blog/query-keys.d.cts +2 -2
  60. package/dist/plugins/blog/query-keys.d.mts +2 -2
  61. package/dist/plugins/blog/query-keys.d.ts +2 -2
  62. package/dist/plugins/cms/api/index.d.cts +1 -1
  63. package/dist/plugins/cms/api/index.d.mts +1 -1
  64. package/dist/plugins/cms/api/index.d.ts +1 -1
  65. package/dist/plugins/cms/client/index.d.cts +18 -0
  66. package/dist/plugins/cms/client/index.d.mts +18 -0
  67. package/dist/plugins/cms/client/index.d.ts +18 -0
  68. package/dist/plugins/cms/query-keys.d.cts +1 -1
  69. package/dist/plugins/cms/query-keys.d.mts +1 -1
  70. package/dist/plugins/cms/query-keys.d.ts +1 -1
  71. package/dist/plugins/form-builder/client/index.d.cts +16 -0
  72. package/dist/plugins/form-builder/client/index.d.mts +16 -0
  73. package/dist/plugins/form-builder/client/index.d.ts +16 -0
  74. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  75. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  76. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  77. package/dist/plugins/ui-builder/client/index.d.cts +1 -1
  78. package/dist/plugins/ui-builder/client/index.d.mts +1 -1
  79. package/dist/plugins/ui-builder/client/index.d.ts +1 -1
  80. package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
  81. package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
  82. package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
  83. package/dist/shared/{stack.BTStHVTy.d.cts → stack.BGzgFx3R.d.cts} +5 -5
  84. package/dist/shared/{stack.CtrnoQgH.d.cts → stack.BOZRaVpu.d.cts} +35 -35
  85. package/dist/shared/{stack.94kN3pEK.d.mts → stack.CjCSDfWy.d.mts} +5 -5
  86. package/dist/shared/{stack.lPKwd_v0.d.ts → stack.DlBoNqYu.d.ts} +35 -35
  87. package/dist/shared/{stack.DfrRYOIl.d.ts → stack.Dpg_JjIo.d.ts} +5 -5
  88. package/dist/shared/{stack.BggTHKlo.d.mts → stack.Dvy7FmaA.d.mts} +35 -35
  89. package/dist/shared/{stack.5_zzCejP.d.cts → stack.rVF0f509.d.cts} +20 -0
  90. package/dist/shared/{stack.5_zzCejP.d.mts → stack.rVF0f509.d.mts} +20 -0
  91. package/dist/shared/{stack.5_zzCejP.d.ts → stack.rVF0f509.d.ts} +20 -0
  92. package/package.json +11 -7
  93. package/src/__tests__/package-metadata.test.ts +4 -4
  94. package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +248 -0
  95. package/src/plugins/ai-chat/__tests__/hooks.test.tsx +70 -54
  96. package/src/plugins/ai-chat/client/components/chat-input.tsx +6 -1
  97. package/src/plugins/ai-chat/client/components/chat-interface.tsx +3 -0
  98. package/src/plugins/ai-chat/client/components/chat-layout.tsx +9 -2
  99. package/src/plugins/ai-chat/client/components/chat-message.tsx +13 -5
  100. package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +6 -2
  101. package/src/plugins/ai-chat/client/hooks/chat-hooks.tsx +6 -9
  102. package/src/plugins/ai-chat/client/overrides.ts +21 -0
  103. package/src/plugins/blog/__tests__/classnames-overrides.test.tsx +206 -0
  104. package/src/plugins/blog/client/components/shared/page-wrapper.tsx +3 -2
  105. package/src/plugins/blog/client/components/shared/post-card.tsx +32 -12
  106. package/src/plugins/blog/client/components/shared/posts-list.tsx +3 -2
  107. package/src/plugins/blog/client/overrides.ts +18 -0
  108. package/src/plugins/cms/__tests__/classnames-overrides.test.tsx +200 -0
  109. package/src/plugins/cms/client/components/pages/content-list-page.internal.tsx +17 -4
  110. package/src/plugins/cms/client/components/pages/dashboard-page.internal.tsx +14 -3
  111. package/src/plugins/cms/client/components/shared/page-wrapper.tsx +3 -2
  112. package/src/plugins/cms/client/overrides.ts +19 -0
  113. package/src/plugins/form-builder/__tests__/classnames-overrides.test.tsx +312 -0
  114. package/src/plugins/form-builder/client/components/forms/form-renderer.tsx +14 -9
  115. package/src/plugins/form-builder/client/components/pages/form-list-page.internal.tsx +9 -5
  116. package/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +9 -5
  117. package/src/plugins/form-builder/client/components/shared/page-wrapper.tsx +3 -2
  118. package/src/plugins/form-builder/client/components/shared/pagination.tsx +7 -1
  119. package/src/plugins/form-builder/client/overrides.ts +17 -0
  120. package/src/plugins/kanban/__tests__/getters.test.ts +14 -1
  121. package/src/plugins/kanban/api/getters.ts +9 -7
  122. package/src/plugins/kanban/types.ts +4 -6
  123. package/src/plugins/media/client/hooks/use-media.tsx +7 -8
  124. package/dist/shared/{stack.lsbdKRj7.d.mts → stack.B8PG21zX.d.mts} +16 -16
  125. package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
  126. package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
  127. package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
  128. package/dist/shared/{stack.BMlTZnZp.d.ts → stack.CyalJ0j6.d.ts} +16 -16
  129. package/dist/shared/{stack.BV0uNV4f.d.cts → stack.D6Pg-ufX.d.cts} +16 -16
@@ -177,58 +177,74 @@ describe("useRenameConversationForm", () => {
177
177
  });
178
178
 
179
179
  describe("useDeleteConversation", () => {
180
- it("refreshes the list and evicts only the deleted detail in the starting identity partition", async () => {
181
- const identityPartition = "anonymous" as const;
182
- const otherConversation = { ...conversation, id: "conv-2", title: "Other" };
183
- const otherIdentity = { id: "other-user", role: "user" } as const;
184
- const listKey = buildQueryKey(
185
- "conversations",
186
- "list",
187
- aiChatResources.conversations.queries.list,
188
- [identityPartition],
189
- );
190
- const deletedDetailKey = buildQueryKey(
191
- "conversations",
192
- "detail",
193
- aiChatResources.conversations.queries.detail,
194
- [conversation.id, identityPartition],
195
- );
196
- const otherDetailKey = buildQueryKey(
197
- "conversations",
198
- "detail",
199
- aiChatResources.conversations.queries.detail,
200
- [otherConversation.id, identityPartition],
201
- );
202
- const otherIdentityDetailKey = buildQueryKey(
203
- "conversations",
204
- "detail",
205
- aiChatResources.conversations.queries.detail,
206
- [conversation.id, otherIdentity],
207
- );
208
- let listFetches = 0;
209
- await queryClient.fetchQuery({
210
- queryKey: listKey,
211
- queryFn: async () => {
212
- listFetches += 1;
213
- return [conversation, otherConversation];
214
- },
215
- });
216
- listFetches = 0;
217
- queryClient.setQueryData(deletedDetailKey, conversation);
218
- queryClient.setQueryData(otherDetailKey, otherConversation);
219
- queryClient.setQueryData(otherIdentityDetailKey, conversation);
220
- fetchMock.mockResolvedValue(jsonResponse({ success: true }));
221
- const getMutation = await renderDeleteProbe();
222
-
223
- await act(async () => {
224
- await getMutation().mutateAsync({ id: conversation.id });
225
- });
226
-
227
- expect(listFetches).toBe(1);
228
- expect(queryClient.getQueryData(deletedDetailKey)).toBeUndefined();
229
- expect(queryClient.getQueryData(otherDetailKey)).toEqual(otherConversation);
230
- expect(queryClient.getQueryData(otherIdentityDetailKey)).toEqual(
231
- conversation,
232
- );
233
- });
180
+ it.each(["mutate", "mutateAsync"] as const)(
181
+ "%s forwards callbacks and evicts only the deleted detail in the starting identity partition",
182
+ async (method) => {
183
+ const identityPartition = "anonymous" as const;
184
+ const otherConversation = {
185
+ ...conversation,
186
+ id: "conv-2",
187
+ title: "Other",
188
+ };
189
+ const otherIdentity = { id: "other-user", role: "user" } as const;
190
+ const listKey = buildQueryKey(
191
+ "conversations",
192
+ "list",
193
+ aiChatResources.conversations.queries.list,
194
+ [identityPartition],
195
+ );
196
+ const deletedDetailKey = buildQueryKey(
197
+ "conversations",
198
+ "detail",
199
+ aiChatResources.conversations.queries.detail,
200
+ [conversation.id, identityPartition],
201
+ );
202
+ const otherDetailKey = buildQueryKey(
203
+ "conversations",
204
+ "detail",
205
+ aiChatResources.conversations.queries.detail,
206
+ [otherConversation.id, identityPartition],
207
+ );
208
+ const otherIdentityDetailKey = buildQueryKey(
209
+ "conversations",
210
+ "detail",
211
+ aiChatResources.conversations.queries.detail,
212
+ [conversation.id, otherIdentity],
213
+ );
214
+ let listFetches = 0;
215
+ await queryClient.fetchQuery({
216
+ queryKey: listKey,
217
+ queryFn: async () => {
218
+ listFetches += 1;
219
+ return [conversation, otherConversation];
220
+ },
221
+ });
222
+ listFetches = 0;
223
+ queryClient.setQueryData(deletedDetailKey, conversation);
224
+ queryClient.setQueryData(otherDetailKey, otherConversation);
225
+ queryClient.setQueryData(otherIdentityDetailKey, conversation);
226
+ fetchMock.mockResolvedValue(jsonResponse({ success: true }));
227
+ const getMutation = await renderDeleteProbe();
228
+ const onSuccess = vi.fn();
229
+
230
+ await act(async () => {
231
+ await getMutation()[method]({ id: conversation.id }, { onSuccess });
232
+ await vi.waitFor(() =>
233
+ expect(queryClient.getQueryData(deletedDetailKey)).toBeUndefined(),
234
+ );
235
+ });
236
+
237
+ expect(onSuccess).toHaveBeenCalledTimes(1);
238
+ expect(onSuccess.mock.calls[0]?.[1]).toEqual({ id: conversation.id });
239
+
240
+ expect(listFetches).toBe(1);
241
+ expect(queryClient.getQueryData(deletedDetailKey)).toBeUndefined();
242
+ expect(queryClient.getQueryData(otherDetailKey)).toEqual(
243
+ otherConversation,
244
+ );
245
+ expect(queryClient.getQueryData(otherIdentityDetailKey)).toEqual(
246
+ conversation,
247
+ );
248
+ },
249
+ );
234
250
  });
@@ -93,6 +93,7 @@ export function ChatInput({
93
93
  uploadFile,
94
94
  localization: customLocalization,
95
95
  allowedFileTypes,
96
+ classNames,
96
97
  } = usePluginOverrides<AiChatPluginOverrides, Partial<AiChatPluginOverrides>>(
97
98
  "aiChat",
98
99
  {},
@@ -314,7 +315,11 @@ export function ChatInput({
314
315
  )}
315
316
  </PermissionCheck>
316
317
  )}
317
- <form onSubmit={handleFormSubmit} className="space-y-2">
318
+ <form
319
+ onSubmit={handleFormSubmit}
320
+ className={cn("space-y-2", classNames?.input)}
321
+ data-testid="chat-input-form"
322
+ >
318
323
  {/* Attached Files Preview */}
319
324
  {attachedFiles.length > 0 && (
320
325
  <div className="flex flex-wrap gap-2">
@@ -263,6 +263,7 @@ export function ChatInterface({
263
263
  localization: customLocalization,
264
264
  showAttribution,
265
265
  chatSuggestions,
266
+ classNames,
266
267
  } = usePluginOverrides<AiChatPluginOverrides, Partial<AiChatPluginOverrides>>(
267
268
  "aiChat",
268
269
  { showAttribution: true },
@@ -1139,7 +1140,9 @@ export function ChatInterface({
1139
1140
  className={cn(
1140
1141
  "flex flex-col p-4",
1141
1142
  isWidget ? "max-w-full" : "max-w-3xl mx-auto w-full",
1143
+ classNames?.messageList,
1142
1144
  )}
1145
+ data-testid="chat-message-list"
1143
1146
  >
1144
1147
  {messages.length === 0 ? (
1145
1148
  <div className="flex flex-col h-full min-h-[300px]">
@@ -86,7 +86,7 @@ export function ChatLayout(props: ChatLayoutProps) {
86
86
  onMessagesChange,
87
87
  onClear,
88
88
  } = props;
89
- const { localization } = usePluginOverrides<
89
+ const { localization, classNames } = usePluginOverrides<
90
90
  AiChatPluginOverrides,
91
91
  Partial<AiChatPluginOverrides>
92
92
  >("aiChat", {});
@@ -133,7 +133,13 @@ export function ChatLayout(props: ChatLayoutProps) {
133
133
 
134
134
  if (layout === "widget") {
135
135
  return (
136
- <div className={cn("flex flex-col items-end gap-3", className)}>
136
+ <div
137
+ className={cn(
138
+ "flex flex-col items-end gap-3",
139
+ className,
140
+ classNames?.container,
141
+ )}
142
+ >
137
143
  {/* Chat panel — always mounted to preserve conversation state, hidden when closed */}
138
144
  <div
139
145
  className={cn(
@@ -238,6 +244,7 @@ export function ChatLayout(props: ChatLayoutProps) {
238
244
  className={cn(
239
245
  "flex h-[calc(100vh-4rem)] w-full overflow-hidden",
240
246
  className,
247
+ classNames?.container,
241
248
  )}
242
249
  data-testid="chat-layout"
243
250
  >
@@ -133,11 +133,14 @@ export function ChatMessage({
133
133
  }: ChatMessageProps) {
134
134
  const isUser = message.role === "user";
135
135
 
136
- const { localization: customLocalization, toolRenderers } =
137
- usePluginOverrides<AiChatPluginOverrides, Partial<AiChatPluginOverrides>>(
138
- "aiChat",
139
- {},
140
- );
136
+ const {
137
+ localization: customLocalization,
138
+ toolRenderers,
139
+ classNames,
140
+ } = usePluginOverrides<AiChatPluginOverrides, Partial<AiChatPluginOverrides>>(
141
+ "aiChat",
142
+ {},
143
+ );
141
144
  const { router } = useStack();
142
145
  const Link = router?.Link;
143
146
  const Image = router?.Image;
@@ -265,7 +268,10 @@ export function ChatMessage({
265
268
  "flex gap-3 w-full",
266
269
  isCompact ? "mb-3" : "mb-4",
267
270
  isUser ? "justify-end" : "justify-start",
271
+ classNames?.message,
268
272
  )}
273
+ data-testid="chat-message"
274
+ data-role={message.role}
269
275
  aria-label={
270
276
  isUser
271
277
  ? tr("A11Y_USER_MESSAGE", "aiChat.a11y.userMessage", "Your message")
@@ -295,7 +301,9 @@ export function ChatMessage({
295
301
  "rounded-lg",
296
302
  isCompact ? "px-3 py-2" : "px-4 py-3",
297
303
  isUser ? "bg-primary text-primary-foreground" : "bg-muted",
304
+ isUser ? classNames?.userMessage : classNames?.assistantMessage,
298
305
  )}
306
+ data-testid="chat-message-bubble"
299
307
  >
300
308
  {isUser ? (
301
309
  // User messages: files + images + text (or edit mode)
@@ -67,7 +67,7 @@ export function ChatSidebar({
67
67
  onNewChat,
68
68
  className,
69
69
  }: ChatSidebarProps) {
70
- const { localization: customLocalization } = usePluginOverrides<
70
+ const { localization: customLocalization, classNames } = usePluginOverrides<
71
71
  AiChatPluginOverrides,
72
72
  Partial<AiChatPluginOverrides>
73
73
  >("aiChat", {});
@@ -208,7 +208,11 @@ export function ChatSidebar({
208
208
  return (
209
209
  <div
210
210
  data-testid="chat-sidebar"
211
- className={cn("flex flex-col h-full bg-muted/30 border-r", className)}
211
+ className={cn(
212
+ "flex flex-col h-full bg-muted/30 border-r",
213
+ className,
214
+ classNames?.sidebar,
215
+ )}
212
216
  >
213
217
  {/* Header */}
214
218
  <div className="p-4 border-b">
@@ -146,25 +146,22 @@ function withCurrentHistoryRefresh<TData, TVariables>(
146
146
  refresh: ReturnType<typeof useCurrentHistoryRefresh>,
147
147
  queryKey?: readonly unknown[],
148
148
  afterSuccess?: (
149
- variables: TVariables,
149
+ variables: Parameters<typeof mutation.mutateAsync>[0],
150
150
  startedAs: ReturnType<typeof useAiChatIdentityPartition>,
151
151
  ) => void,
152
152
  ): UseMutationResult<TData, Error, TVariables> {
153
- const mutateAsync: typeof mutation.mutateAsync = async (
154
- variables,
155
- options,
156
- ) => {
153
+ const mutateAsync: typeof mutation.mutateAsync = async (...args) => {
157
154
  const startedAs = refresh.currentPartition();
158
- const result = await mutation.mutateAsync(variables, options);
155
+ const result = await mutation.mutateAsync(...args);
159
156
  try {
160
157
  await refresh.refreshAfterSuccess(startedAs, queryKey);
161
158
  } finally {
162
- afterSuccess?.(variables, startedAs);
159
+ afterSuccess?.(args[0], startedAs);
163
160
  }
164
161
  return result;
165
162
  };
166
- const mutate: typeof mutation.mutate = (variables, options) => {
167
- void mutateAsync(variables, options).catch(() => {});
163
+ const mutate: typeof mutation.mutate = (...args) => {
164
+ void mutateAsync(...args).catch(() => {});
168
165
  };
169
166
  return { ...mutation, mutate, mutateAsync };
170
167
  }
@@ -201,6 +201,27 @@ export interface AiChatPluginOverrides {
201
201
  */
202
202
  toolRenderers?: Record<string, ToolCallRenderer>;
203
203
 
204
+ /**
205
+ * Optional class names merged onto specific UI elements.
206
+ * Use these for targeted styling without replacing whole components.
207
+ */
208
+ classNames?: {
209
+ /** Root of `ChatLayout` (full-page or widget mode) */
210
+ container?: string;
211
+ /** Conversation sidebar root */
212
+ sidebar?: string;
213
+ /** Wrapper around the list of messages */
214
+ messageList?: string;
215
+ /** Each message row (user and assistant) */
216
+ message?: string;
217
+ /** The bubble of a user message */
218
+ userMessage?: string;
219
+ /** The bubble of an assistant message */
220
+ assistantMessage?: string;
221
+ /** The composer form at the bottom of the chat */
222
+ input?: string;
223
+ };
224
+
204
225
  // ============== Lifecycle Hooks (optional) ==============
205
226
 
206
227
  /**
@@ -0,0 +1,206 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ // Core primitives MUST be imported from the package entry (not relative src
6
+ // paths) so they share module identity — and React context — with the blog
7
+ // components, which resolve `@btst/stack/*` via package self-reference.
8
+ import { StackProvider } from "@btst/stack/context";
9
+ import { PageWrapper } from "../client/components/shared/page-wrapper";
10
+ import { PostsList } from "../client/components/shared/posts-list";
11
+ import type { SerializedPost } from "../types";
12
+ import { createTestClientStack } from "../../../__tests__/client-stack-test-utils";
13
+ import { blogClientPlugin } from "../client/plugin";
14
+
15
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ // jsdom lacks these APIs used by Radix / cmdk inside SearchInput
18
+ (globalThis as any).ResizeObserver ??= class {
19
+ observe() {}
20
+ unobserve() {}
21
+ disconnect() {}
22
+ };
23
+ Element.prototype.scrollIntoView ??= () => {};
24
+
25
+ const hooks = vi.hoisted(() => ({
26
+ useSuspensePost: vi.fn(),
27
+ useDeletePost: vi.fn(),
28
+ usePostForm: vi.fn(),
29
+ usePostSearch: vi.fn(),
30
+ useSuspensePosts: vi.fn(),
31
+ useTags: vi.fn(),
32
+ }));
33
+
34
+ vi.mock("../client/hooks/blog-hooks", () => hooks);
35
+
36
+ const CLASS_NAMES = {
37
+ container: "bg-red-100",
38
+ postCard: "bg-blue-100",
39
+ postTitle: "text-purple-700",
40
+ postMeta: "text-green-600",
41
+ tagsList: "bg-yellow-50",
42
+ pagination: "border-4 border-orange-500",
43
+ } as const;
44
+
45
+ const post: SerializedPost = {
46
+ id: "p1",
47
+ title: "Hello World",
48
+ content: "# Hello",
49
+ excerpt: "An excerpt",
50
+ slug: "hello-world",
51
+ published: true,
52
+ image: "",
53
+ tags: [{ id: "t1", name: "Release", slug: "release" }],
54
+ authorId: null,
55
+ publishedAt: new Date("2024-01-01").toISOString(),
56
+ createdAt: new Date("2024-01-01").toISOString(),
57
+ updatedAt: new Date("2024-01-01").toISOString(),
58
+ } as unknown as SerializedPost;
59
+
60
+ let container: HTMLDivElement;
61
+ let root: Root;
62
+
63
+ beforeEach(() => {
64
+ container = document.createElement("div");
65
+ document.body.appendChild(container);
66
+ root = createRoot(container);
67
+
68
+ hooks.usePostSearch.mockReturnValue({ data: [], isLoading: false });
69
+ });
70
+
71
+ afterEach(async () => {
72
+ await act(async () => {
73
+ root.unmount();
74
+ });
75
+ container.remove();
76
+ vi.clearAllMocks();
77
+ });
78
+
79
+ async function render(ui: React.ReactElement) {
80
+ await act(async () => {
81
+ root.render(ui);
82
+ });
83
+ }
84
+
85
+ function withBlogOverrides(children: React.ReactNode) {
86
+ return (
87
+ <StackProvider
88
+ stack={createTestClientStack({ blog: blogClientPlugin() })}
89
+ overrides={{
90
+ blog: {
91
+ uploadImage: async () => "",
92
+ classNames: CLASS_NAMES,
93
+ },
94
+ }}
95
+ >
96
+ {children}
97
+ </StackProvider>
98
+ );
99
+ }
100
+
101
+ function query(selector: string): HTMLElement {
102
+ const el = container.querySelector<HTMLElement>(selector);
103
+ expect(el, `expected to find ${selector}`).toBeTruthy();
104
+ return el!;
105
+ }
106
+
107
+ describe("blog classNames overrides (issue #36)", () => {
108
+ it("applies classNames.container to the page container", async () => {
109
+ await render(
110
+ withBlogOverrides(
111
+ <PageWrapper testId="blog-container">
112
+ <span>content</span>
113
+ </PageWrapper>,
114
+ ),
115
+ );
116
+
117
+ const el = query('[data-testid="blog-container"]');
118
+ expect(el.className).toContain(CLASS_NAMES.container);
119
+ // The override must merge with the layout's own classes, not replace them.
120
+ expect(el.className).toContain("container");
121
+ expect(el.className).toContain("mx-auto");
122
+ });
123
+
124
+ it("applies classNames.postCard, postTitle, postMeta and tagsList inside a post card", async () => {
125
+ await render(withBlogOverrides(<PostsList posts={[post]} />));
126
+
127
+ // postCard — anchored on the Card primitive's structural slot.
128
+ const card = query('[data-slot="card"]');
129
+ expect(card.className).toContain(CLASS_NAMES.postCard);
130
+ expect(card.className).toContain("group");
131
+
132
+ // postTitle — anchored on the CardTitle primitive's structural slot.
133
+ const title = query('[data-slot="card-title"]');
134
+ expect(title.className).toContain(CLASS_NAMES.postTitle);
135
+ expect(title.className).toContain("line-clamp-3");
136
+ expect(title.textContent).toBe(post.title);
137
+
138
+ // postMeta — the wrapper holding the published date.
139
+ const meta = query("time").parentElement!;
140
+ expect(meta.className).toContain(CLASS_NAMES.postMeta);
141
+ // `cn` runs tailwind-merge, so the conflicting `text-muted-foreground`
142
+ // is intentionally replaced by the override; non-conflicting base
143
+ // classes must survive.
144
+ expect(meta.className).toContain("items-center");
145
+ expect(meta.className).not.toContain("text-muted-foreground");
146
+
147
+ // tagsList — the wrapper immediately following the metadata block.
148
+ const tags = meta.nextElementSibling as HTMLElement;
149
+ expect(tags.className).toContain(CLASS_NAMES.tagsList);
150
+ expect(tags.className).toContain("flex-wrap");
151
+ expect(tags.textContent).toContain("Release");
152
+ });
153
+
154
+ it("applies classNames.pagination to the load-more wrapper", async () => {
155
+ await render(
156
+ withBlogOverrides(
157
+ <PostsList posts={[post]} onLoadMore={() => {}} hasMore />,
158
+ ),
159
+ );
160
+
161
+ const button = [...container.querySelectorAll("button")].find((b) =>
162
+ b.textContent?.includes("Load more posts"),
163
+ );
164
+ expect(button, "expected a load-more button").toBeTruthy();
165
+
166
+ const pagination = button!.parentElement!;
167
+ expect(pagination.className).toContain("border-4");
168
+ expect(pagination.className).toContain("border-orange-500");
169
+ expect(pagination.className).toContain("justify-center");
170
+ });
171
+
172
+ it("leaves base classes untouched when no classNames override is supplied", async () => {
173
+ await render(
174
+ <StackProvider
175
+ stack={createTestClientStack({ blog: blogClientPlugin() })}
176
+ overrides={{ blog: { uploadImage: async () => "" } }}
177
+ >
178
+ <PostsList
179
+ posts={[
180
+ {
181
+ ...post,
182
+ tags: [
183
+ ...post.tags!,
184
+ { ...post.tags![0]!, id: "t2", name: "News", slug: "news" },
185
+ ],
186
+ },
187
+ ]}
188
+ onLoadMore={() => {}}
189
+ hasMore
190
+ />
191
+ </StackProvider>,
192
+ );
193
+
194
+ const card = query('[data-slot="card"]');
195
+ expect(card.className).toContain("group");
196
+ expect(card.className).not.toContain(CLASS_NAMES.postCard);
197
+ const metadata = query("time").parentElement!;
198
+ const tags = metadata.parentElement!.querySelectorAll(
199
+ '[data-slot="badge"]',
200
+ );
201
+ expect(tags).toHaveLength(2);
202
+ for (const tag of tags) {
203
+ expect(tag.parentElement).toBe(metadata.parentElement);
204
+ }
205
+ });
206
+ });
@@ -4,6 +4,7 @@ import { usePluginOverrides } from "@btst/stack/context";
4
4
  import { PageWrapper as SharedPageWrapper } from "@workspace/ui/components/page-wrapper";
5
5
  import type { BlogPluginOverrides } from "../../overrides";
6
6
  import { BLOG_PLUGIN_ID } from "../../constants";
7
+ import { cn } from "../../../utils";
7
8
 
8
9
  export function PageWrapper({
9
10
  children,
@@ -14,7 +15,7 @@ export function PageWrapper({
14
15
  className?: string;
15
16
  testId?: string;
16
17
  }) {
17
- const { showAttribution } = usePluginOverrides<
18
+ const { showAttribution, classNames } = usePluginOverrides<
18
19
  BlogPluginOverrides,
19
20
  Partial<BlogPluginOverrides>
20
21
  >(BLOG_PLUGIN_ID, {
@@ -23,7 +24,7 @@ export function PageWrapper({
23
24
 
24
25
  return (
25
26
  <SharedPageWrapper
26
- className={className}
27
+ className={cn(className, classNames?.container)}
27
28
  testId={testId}
28
29
  showAttribution={showAttribution}
29
30
  >
@@ -18,7 +18,7 @@ import { CalendarIcon } from "lucide-react";
18
18
  import type { BlogPluginOverrides } from "../../overrides";
19
19
  import { BLOG_PLUGIN_ID } from "../../constants";
20
20
  import { DefaultLink, DefaultImage } from "./defaults";
21
-
21
+ import { cn } from "../../../utils";
22
22
  // Beautiful gradient color combinations
23
23
  const GRADIENT_PALETTES = [
24
24
  {
@@ -222,7 +222,7 @@ function getGradientFromTitle(title: string) {
222
222
 
223
223
  export function PostCard({ post }: { post: SerializedPost }) {
224
224
  const t = useTranslate();
225
- const { localization } = usePluginOverrides<
225
+ const { localization, classNames } = usePluginOverrides<
226
226
  BlogPluginOverrides,
227
227
  Partial<BlogPluginOverrides>
228
228
  >(BLOG_PLUGIN_ID, {});
@@ -234,9 +234,19 @@ export function PostCard({ post }: { post: SerializedPost }) {
234
234
  const timestamp = post.publishedAt || post.createdAt;
235
235
  const postDate = formatPostDate(timestamp);
236
236
  const gradient = post.image ? null : getGradientFromTitle(post.title);
237
+ const tags = post.tags?.map((tag) => (
238
+ <Badge key={tag.id} variant="secondary" className="text-xs">
239
+ {tag.name}
240
+ </Badge>
241
+ ));
237
242
 
238
243
  return (
239
- <Card className="group relative flex h-full flex-col gap-4 pt-0! pb-4! transition-shadow duration-200 hover:shadow-lg">
244
+ <Card
245
+ className={cn(
246
+ "group relative flex h-full flex-col gap-4 pt-0! pb-4! transition-shadow duration-200 hover:shadow-lg",
247
+ classNames?.postCard,
248
+ )}
249
+ >
240
250
  {/* Image or Placeholder */}
241
251
  <Link
242
252
  href={blogPath}
@@ -281,24 +291,34 @@ export function PostCard({ post }: { post: SerializedPost }) {
281
291
  className="block focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 grow"
282
292
  >
283
293
  <CardHeader className="flex-1">
284
- <CardTitle className="line-clamp-3 text-lg leading-tight transition-colors group-hover:underline">
294
+ <CardTitle
295
+ className={cn(
296
+ "line-clamp-3 text-lg leading-tight transition-colors group-hover:underline",
297
+ classNames?.postTitle,
298
+ )}
299
+ >
285
300
  {post.title}
286
301
  </CardTitle>
287
302
  </CardHeader>
288
303
 
289
304
  <CardContent className="flex flex-1 flex-col gap-2">
290
305
  <div className="flex flex-wrap gap-2">
291
- <div className="flex items-center gap-2 text-muted-foreground text-xs">
306
+ <div
307
+ className={cn(
308
+ "flex items-center gap-2 text-muted-foreground text-xs",
309
+ classNames?.postMeta,
310
+ )}
311
+ >
292
312
  <CalendarIcon className="h-3 w-3" />
293
313
  <time dateTime={timestamp}>{postDate}</time>
294
314
  </div>
295
- {post.tags &&
296
- post.tags.length > 0 &&
297
- post.tags.map((tag) => (
298
- <Badge key={tag.id} variant="secondary" className="text-xs">
299
- {tag.name}
300
- </Badge>
301
- ))}
315
+ {classNames?.tagsList ? (
316
+ <div className={cn("flex flex-wrap gap-2", classNames.tagsList)}>
317
+ {tags}
318
+ </div>
319
+ ) : (
320
+ tags
321
+ )}
302
322
  </div>
303
323
  </CardContent>
304
324
  </Link>
@@ -6,6 +6,7 @@ import { SearchInput } from "./search-input";
6
6
  import type { BlogPluginOverrides } from "../../overrides";
7
7
  import { BLOG_PLUGIN_ID } from "../../constants";
8
8
  import { PostCard as DefaultPostCard } from "./post-card";
9
+ import { cn } from "../../../utils";
9
10
 
10
11
  interface PostsListProps {
11
12
  posts: SerializedPost[];
@@ -21,7 +22,7 @@ export function PostsList({
21
22
  isLoadingMore,
22
23
  }: PostsListProps) {
23
24
  const t = useTranslate();
24
- const { localization, PostCard } =
25
+ const { localization, PostCard, classNames } =
25
26
  usePluginOverrides<BlogPluginOverrides>(BLOG_PLUGIN_ID);
26
27
 
27
28
  const PostCardComponent = PostCard || DefaultPostCard;
@@ -61,7 +62,7 @@ export function PostsList({
61
62
  </div>
62
63
 
63
64
  {onLoadMore && hasMore && (
64
- <div className="flex justify-center">
65
+ <div className={cn("flex justify-center", classNames?.pagination)}>
65
66
  <Button
66
67
  onClick={onLoadMore}
67
68
  disabled={isLoadingMore}