@btst/stack 4.0.0 → 4.1.1

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 (116) 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/blog/client/components/shared/page-wrapper.cjs +3 -2
  13. package/dist/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.mjs +3 -2
  14. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-card.cjs +81 -52
  15. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-card.mjs +82 -53
  16. package/dist/packages/stack/src/plugins/blog/client/components/shared/posts-list.cjs +3 -2
  17. package/dist/packages/stack/src/plugins/blog/client/components/shared/posts-list.mjs +3 -2
  18. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.cjs +95 -75
  19. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.mjs +95 -75
  20. package/dist/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.cjs +16 -3
  21. package/dist/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.mjs +16 -3
  22. package/dist/packages/stack/src/plugins/cms/client/components/shared/page-wrapper.cjs +3 -2
  23. package/dist/packages/stack/src/plugins/cms/client/components/shared/page-wrapper.mjs +3 -2
  24. package/dist/packages/stack/src/plugins/cms/utils.cjs +6 -2
  25. package/dist/packages/stack/src/plugins/cms/utils.mjs +6 -3
  26. package/dist/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.cjs +14 -8
  27. package/dist/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.mjs +14 -8
  28. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.cjs +94 -87
  29. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.mjs +94 -87
  30. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.cjs +81 -74
  31. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.mjs +81 -74
  32. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.cjs +3 -2
  33. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.mjs +3 -2
  34. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/pagination.cjs +25 -16
  35. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/pagination.mjs +25 -16
  36. package/dist/packages/stack/src/plugins/form-builder/utils.cjs +6 -2
  37. package/dist/packages/stack/src/plugins/form-builder/utils.mjs +6 -3
  38. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  39. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  40. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  41. package/dist/plugins/ai-chat/client/index.d.cts +2 -2
  42. package/dist/plugins/ai-chat/client/index.d.mts +2 -2
  43. package/dist/plugins/ai-chat/client/index.d.ts +2 -2
  44. package/dist/plugins/blog/api/index.d.cts +2 -2
  45. package/dist/plugins/blog/api/index.d.mts +2 -2
  46. package/dist/plugins/blog/api/index.d.ts +2 -2
  47. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  48. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  49. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  50. package/dist/plugins/blog/client/index.d.cts +20 -2
  51. package/dist/plugins/blog/client/index.d.mts +20 -2
  52. package/dist/plugins/blog/client/index.d.ts +20 -2
  53. package/dist/plugins/blog/query-keys.d.cts +2 -2
  54. package/dist/plugins/blog/query-keys.d.mts +2 -2
  55. package/dist/plugins/blog/query-keys.d.ts +2 -2
  56. package/dist/plugins/cms/api/index.d.cts +1 -1
  57. package/dist/plugins/cms/api/index.d.mts +1 -1
  58. package/dist/plugins/cms/api/index.d.ts +1 -1
  59. package/dist/plugins/cms/client/index.d.cts +18 -0
  60. package/dist/plugins/cms/client/index.d.mts +18 -0
  61. package/dist/plugins/cms/client/index.d.ts +18 -0
  62. package/dist/plugins/cms/query-keys.d.cts +1 -1
  63. package/dist/plugins/cms/query-keys.d.mts +1 -1
  64. package/dist/plugins/cms/query-keys.d.ts +1 -1
  65. package/dist/plugins/form-builder/client/index.d.cts +16 -0
  66. package/dist/plugins/form-builder/client/index.d.mts +16 -0
  67. package/dist/plugins/form-builder/client/index.d.ts +16 -0
  68. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  69. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  70. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  71. package/dist/plugins/ui-builder/client/index.d.cts +1 -1
  72. package/dist/plugins/ui-builder/client/index.d.mts +1 -1
  73. package/dist/plugins/ui-builder/client/index.d.ts +1 -1
  74. package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
  75. package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
  76. package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
  77. package/dist/shared/{stack.CjCSDfWy.d.mts → stack.94kN3pEK.d.mts} +5 -5
  78. package/dist/shared/{stack.BGzgFx3R.d.cts → stack.BTStHVTy.d.cts} +5 -5
  79. package/dist/shared/{stack.Dvy7FmaA.d.mts → stack.BggTHKlo.d.mts} +35 -35
  80. package/dist/shared/{stack.BOZRaVpu.d.cts → stack.CtrnoQgH.d.cts} +35 -35
  81. package/dist/shared/{stack.Dpg_JjIo.d.ts → stack.DfrRYOIl.d.ts} +5 -5
  82. package/dist/shared/{stack.DlBoNqYu.d.ts → stack.lPKwd_v0.d.ts} +35 -35
  83. package/dist/shared/{stack.5_zzCejP.d.cts → stack.rVF0f509.d.cts} +20 -0
  84. package/dist/shared/{stack.5_zzCejP.d.mts → stack.rVF0f509.d.mts} +20 -0
  85. package/dist/shared/{stack.5_zzCejP.d.ts → stack.rVF0f509.d.ts} +20 -0
  86. package/package.json +1 -1
  87. package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +248 -0
  88. package/src/plugins/ai-chat/client/components/chat-input.tsx +6 -1
  89. package/src/plugins/ai-chat/client/components/chat-interface.tsx +3 -0
  90. package/src/plugins/ai-chat/client/components/chat-layout.tsx +9 -2
  91. package/src/plugins/ai-chat/client/components/chat-message.tsx +13 -5
  92. package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +6 -2
  93. package/src/plugins/ai-chat/client/overrides.ts +21 -0
  94. package/src/plugins/blog/__tests__/classnames-overrides.test.tsx +206 -0
  95. package/src/plugins/blog/client/components/shared/page-wrapper.tsx +3 -2
  96. package/src/plugins/blog/client/components/shared/post-card.tsx +32 -12
  97. package/src/plugins/blog/client/components/shared/posts-list.tsx +3 -2
  98. package/src/plugins/blog/client/overrides.ts +18 -0
  99. package/src/plugins/cms/__tests__/classnames-overrides.test.tsx +200 -0
  100. package/src/plugins/cms/client/components/pages/content-list-page.internal.tsx +17 -4
  101. package/src/plugins/cms/client/components/pages/dashboard-page.internal.tsx +14 -3
  102. package/src/plugins/cms/client/components/shared/page-wrapper.tsx +3 -2
  103. package/src/plugins/cms/client/overrides.ts +19 -0
  104. package/src/plugins/form-builder/__tests__/classnames-overrides.test.tsx +312 -0
  105. package/src/plugins/form-builder/client/components/forms/form-renderer.tsx +14 -9
  106. package/src/plugins/form-builder/client/components/pages/form-list-page.internal.tsx +9 -5
  107. package/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +9 -5
  108. package/src/plugins/form-builder/client/components/shared/page-wrapper.tsx +3 -2
  109. package/src/plugins/form-builder/client/components/shared/pagination.tsx +7 -1
  110. package/src/plugins/form-builder/client/overrides.ts +17 -0
  111. package/dist/shared/{stack.CyalJ0j6.d.ts → stack.BMlTZnZp.d.ts} +16 -16
  112. package/dist/shared/{stack.D6Pg-ufX.d.cts → stack.BV0uNV4f.d.cts} +16 -16
  113. package/dist/shared/{stack.Csi-4Mgz.d.cts → stack.CKc_Bais.d.cts} +7 -7
  114. package/dist/shared/{stack.Csi-4Mgz.d.mts → stack.CKc_Bais.d.mts} +7 -7
  115. package/dist/shared/{stack.Csi-4Mgz.d.ts → stack.CKc_Bais.d.ts} +7 -7
  116. package/dist/shared/{stack.B8PG21zX.d.mts → stack.lsbdKRj7.d.mts} +16 -16
@@ -204,6 +204,26 @@ interface AiChatPluginOverrides {
204
204
  * ```
205
205
  */
206
206
  toolRenderers?: Record<string, ToolCallRenderer>;
207
+ /**
208
+ * Optional class names merged onto specific UI elements.
209
+ * Use these for targeted styling without replacing whole components.
210
+ */
211
+ classNames?: {
212
+ /** Root of `ChatLayout` (full-page or widget mode) */
213
+ container?: string;
214
+ /** Conversation sidebar root */
215
+ sidebar?: string;
216
+ /** Wrapper around the list of messages */
217
+ messageList?: string;
218
+ /** Each message row (user and assistant) */
219
+ message?: string;
220
+ /** The bubble of a user message */
221
+ userMessage?: string;
222
+ /** The bubble of an assistant message */
223
+ assistantMessage?: string;
224
+ /** The composer form at the bottom of the chat */
225
+ input?: string;
226
+ };
207
227
  /**
208
228
  * Called when a route is rendered
209
229
  * @param routeName - Name of the route (e.g., 'chat', 'chatConversation')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btst/stack",
3
- "version": "4.0.0",
3
+ "version": "4.1.1",
4
4
  "description": "A composable, plugin-based library for building full-stack applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,248 @@
1
+ // @vitest-environment jsdom
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ // Core primitives MUST be imported from the package entry (not relative src
7
+ // paths) so they share module identity — and React context — with the plugin
8
+ // components, which resolve `@btst/stack/*` via package self-reference.
9
+ import { StackProvider } from "@btst/stack/context";
10
+ import type { UIMessage } from "ai";
11
+ import { ChatInput } from "../client/components/chat-input";
12
+ import { ChatLayout } from "../client/components/chat-layout";
13
+ import { ChatMessage } from "../client/components/chat-message";
14
+ import { aiChatClientPlugin } from "../client/plugin";
15
+ import type { SerializedConversation } from "../types";
16
+ import { createTestClientStack } from "../../../__tests__/client-stack-test-utils";
17
+
18
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
19
+ (globalThis as any).ResizeObserver ??= class {
20
+ observe() {}
21
+ unobserve() {}
22
+ disconnect() {}
23
+ };
24
+ Element.prototype.scrollIntoView ??= () => {};
25
+
26
+ const mocks = vi.hoisted(() => ({
27
+ useChat: vi.fn(),
28
+ useConversation: vi.fn(),
29
+ useConversations: vi.fn(),
30
+ useRenameConversationForm: vi.fn(),
31
+ useDeleteConversation: vi.fn(),
32
+ useAiChatIdentityPartition: vi.fn(),
33
+ }));
34
+
35
+ vi.mock("@ai-sdk/react", () => ({ useChat: mocks.useChat }));
36
+ vi.mock("../client/hooks/chat-hooks", () => ({
37
+ useAiChatIdentityPartition: mocks.useAiChatIdentityPartition,
38
+ useConversation: mocks.useConversation,
39
+ useConversations: mocks.useConversations,
40
+ useRenameConversationForm: mocks.useRenameConversationForm,
41
+ useDeleteConversation: mocks.useDeleteConversation,
42
+ }));
43
+ vi.mock("../client/context/page-ai-context", () => ({
44
+ usePageAIContext: () => undefined,
45
+ }));
46
+ // Markdown rendering is unrelated to class wiring and drags in highlight/katex.
47
+ vi.mock("@workspace/ui/components/markdown-content", () => ({
48
+ MarkdownContent: ({ content }: { content: string }) => <div>{content}</div>,
49
+ }));
50
+
51
+ const CLASS_NAMES = {
52
+ container: "bg-red-100",
53
+ sidebar: "bg-blue-100",
54
+ messageList: "bg-yellow-50",
55
+ message: "text-purple-700",
56
+ userMessage: "border-4 border-orange-500",
57
+ assistantMessage: "border-4 border-green-500",
58
+ input: "bg-pink-100",
59
+ } as const;
60
+
61
+ const conversation: SerializedConversation = {
62
+ id: "conv-1",
63
+ userId: "owner-1",
64
+ title: "First conversation",
65
+ createdAt: new Date("2024-01-01").toISOString(),
66
+ updatedAt: new Date("2024-01-02").toISOString(),
67
+ };
68
+
69
+ const userMessage: UIMessage = {
70
+ id: "user-1",
71
+ role: "user",
72
+ parts: [{ type: "text", text: "Hi there" }],
73
+ };
74
+ const assistantMessage: UIMessage = {
75
+ id: "assistant-1",
76
+ role: "assistant",
77
+ parts: [{ type: "text", text: "Hello back" }],
78
+ };
79
+
80
+ let container: HTMLDivElement;
81
+ let root: Root;
82
+ let queryClient: QueryClient;
83
+
84
+ beforeEach(() => {
85
+ container = document.createElement("div");
86
+ document.body.appendChild(container);
87
+ root = createRoot(container);
88
+ queryClient = new QueryClient({
89
+ defaultOptions: { queries: { retry: false } },
90
+ });
91
+
92
+ mocks.useAiChatIdentityPartition.mockReturnValue("anonymous");
93
+ mocks.useChat.mockReturnValue({
94
+ messages: [userMessage, assistantMessage],
95
+ sendMessage: vi.fn(),
96
+ status: "ready",
97
+ error: null,
98
+ setMessages: vi.fn(),
99
+ regenerate: vi.fn(),
100
+ addToolOutput: vi.fn(),
101
+ stop: vi.fn(),
102
+ });
103
+ mocks.useConversation.mockReturnValue({
104
+ conversation: null,
105
+ isLoading: false,
106
+ error: null,
107
+ refetch: vi.fn(),
108
+ });
109
+ mocks.useConversations.mockReturnValue({
110
+ conversations: [conversation],
111
+ isLoading: false,
112
+ error: null,
113
+ refetch: vi.fn(),
114
+ });
115
+ mocks.useRenameConversationForm.mockReturnValue({
116
+ submit: vi.fn(),
117
+ isSubmitting: false,
118
+ error: null,
119
+ fieldErrors: {},
120
+ clearErrors: vi.fn(),
121
+ });
122
+ mocks.useDeleteConversation.mockReturnValue({
123
+ mutateAsync: vi.fn(),
124
+ isPending: false,
125
+ });
126
+ });
127
+
128
+ afterEach(async () => {
129
+ await act(async () => {
130
+ root.unmount();
131
+ });
132
+ container.remove();
133
+ vi.clearAllMocks();
134
+ });
135
+
136
+ async function render(ui: React.ReactElement) {
137
+ await act(async () => {
138
+ root.render(
139
+ <QueryClientProvider client={queryClient}>
140
+ <StackProvider
141
+ stack={createTestClientStack(
142
+ { aiChat: aiChatClientPlugin() },
143
+ queryClient,
144
+ )}
145
+ overrides={{ aiChat: { classNames: CLASS_NAMES } }}
146
+ >
147
+ {ui}
148
+ </StackProvider>
149
+ </QueryClientProvider>,
150
+ );
151
+ });
152
+ }
153
+
154
+ function query(selector: string): HTMLElement {
155
+ const el = container.querySelector<HTMLElement>(selector);
156
+ expect(el, `expected to find ${selector}`).toBeTruthy();
157
+ return el!;
158
+ }
159
+
160
+ describe("ai-chat classNames overrides (issue #36)", () => {
161
+ it("applies message, userMessage and assistantMessage to a user message", async () => {
162
+ await render(<ChatMessage message={userMessage} />);
163
+
164
+ const row = query('[data-testid="chat-message"][data-role="user"]');
165
+ expect(row.className).toContain(CLASS_NAMES.message);
166
+ expect(row.className).toContain("justify-end");
167
+
168
+ const bubble = query('[data-testid="chat-message-bubble"]');
169
+ expect(bubble.className).toContain("border-orange-500");
170
+ expect(bubble.className).not.toContain("border-green-500");
171
+ // The role-specific base styling is preserved.
172
+ expect(bubble.className).toContain("bg-primary");
173
+ });
174
+
175
+ it("applies assistantMessage (not userMessage) to an assistant message", async () => {
176
+ await render(<ChatMessage message={assistantMessage} />);
177
+
178
+ const row = query('[data-testid="chat-message"][data-role="assistant"]');
179
+ expect(row.className).toContain(CLASS_NAMES.message);
180
+ expect(row.className).toContain("justify-start");
181
+
182
+ const bubble = query('[data-testid="chat-message-bubble"]');
183
+ expect(bubble.className).toContain("border-green-500");
184
+ expect(bubble.className).not.toContain("border-orange-500");
185
+ expect(bubble.className).toContain("bg-muted");
186
+ });
187
+
188
+ it("applies classNames.input to the composer form", async () => {
189
+ await render(
190
+ <ChatInput
191
+ handleInputChange={() => {}}
192
+ handleSubmit={() => {}}
193
+ isLoading={false}
194
+ />,
195
+ );
196
+
197
+ const form = query('[data-testid="chat-input-form"]');
198
+ expect(form.tagName).toBe("FORM");
199
+ expect(form.className).toContain(CLASS_NAMES.input);
200
+ expect(form.className).toContain("space-y-2");
201
+ });
202
+
203
+ it("applies container, sidebar and messageList in the full layout", async () => {
204
+ await render(<ChatLayout conversationId={conversation.id} />);
205
+
206
+ const layout = query('[data-testid="chat-layout"]');
207
+ expect(layout.className).toContain(CLASS_NAMES.container);
208
+ expect(layout.className).toContain("overflow-hidden");
209
+
210
+ const sidebar = query('[data-testid="chat-sidebar"]');
211
+ expect(sidebar.className).toContain(CLASS_NAMES.sidebar);
212
+ expect(sidebar.className).toContain("border-r");
213
+
214
+ const list = query('[data-testid="chat-message-list"]');
215
+ expect(list.className).toContain(CLASS_NAMES.messageList);
216
+ expect(list.className).toContain("flex-col");
217
+ });
218
+
219
+ it("applies classNames.container to the widget root", async () => {
220
+ await render(<ChatLayout layout="widget" />);
221
+
222
+ const trigger = query('[data-testid="widget-trigger"]');
223
+ const widgetRoot = trigger.parentElement!;
224
+ expect(widgetRoot.className).toContain(CLASS_NAMES.container);
225
+ expect(widgetRoot.className).toContain("items-end");
226
+ });
227
+
228
+ it("leaves base classes untouched when no classNames override is supplied", async () => {
229
+ await act(async () => {
230
+ root.render(
231
+ <QueryClientProvider client={queryClient}>
232
+ <StackProvider
233
+ stack={createTestClientStack(
234
+ { aiChat: aiChatClientPlugin() },
235
+ queryClient,
236
+ )}
237
+ >
238
+ <ChatMessage message={userMessage} />
239
+ </StackProvider>
240
+ </QueryClientProvider>,
241
+ );
242
+ });
243
+
244
+ const bubble = query('[data-testid="chat-message-bubble"]');
245
+ expect(bubble.className).toContain("bg-primary");
246
+ expect(bubble.className).not.toContain("border-orange-500");
247
+ });
248
+ });
@@ -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">
@@ -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
  >