Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ Render the generated `<Toaster />` in your root layout and start from a clean co
Run the released generator from the application root:

```bash
pnpm dlx @btst/codegen@4.0.0 init \
pnpm dlx @btst/codegen@4.1.0 init \
--framework nextjs \
--adapter memory \
--plugins blog
```

The generator shows detected-file and conflict prompts before it writes. It installs `@btst/stack@4.0.0`, `next-themes@0.4.6`, registers Blog on the backend and client, mounts the API and page routes, adds the plugin CSS, and wires the shared provider.
The generator shows detected-file and conflict prompts before it writes. It installs `@btst/stack@4.1.0`, `next-themes@0.4.6`, registers Blog on the backend and client, mounts the API and page routes, adds the plugin CSS, and wires the shared provider.

Set the trusted local origins in `.env.local`:

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ below lists them explicitly.
Run this command from the existing application root:

```bash
npx @btst/codegen@4.0.0 init \
npx @btst/codegen@4.1.0 init \
--framework nextjs \
--adapter memory \
--plugins blog
Expand Down
10 changes: 10 additions & 0 deletions docs/content/docs/plugins/ai-chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,16 @@ Configure AI Chat-specific overrides and route lifecycle hooks. All lifecycle ho

<AutoTypeTable path="../packages/stack/src/plugins/ai-chat/client/overrides.ts" name="AiChatPluginOverrides" />

Use `classNames` in `StackProvider.overrides.aiChat` to style built-in UI slots. Classes merge with the defaults; conflicting Tailwind utilities use your override. Omitting a slot preserves its default styling.

```tsx
overrides={{
aiChat: {
classNames: { userMessage: "bg-slate-900 text-white", input: "space-y-4" },
},
}}
```

**Example usage:**

```tsx
Expand Down
10 changes: 10 additions & 0 deletions docs/content/docs/plugins/blog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,16 @@ Configure Blog-specific components, slots, localization, and route lifecycle hoo

<AutoTypeTable path="../packages/stack/src/plugins/blog/client/overrides.ts" name="BlogPluginOverrides" />

Use `classNames` in `StackProvider.overrides.blog` to style built-in UI slots. Classes merge with the defaults; conflicting Tailwind utilities use your override. Omitting a slot preserves its default styling.

```tsx
overrides={{
blog: {
classNames: { postTitle: "font-serif text-3xl", postCard: "shadow-lg" },
},
}}
```

**Example usage:**

```tsx
Expand Down
10 changes: 10 additions & 0 deletions docs/content/docs/plugins/cms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,16 @@ Configure CMS-specific overrides and route lifecycle hooks:

<AutoTypeTable path="../packages/stack/src/plugins/cms/client/overrides.ts" name="CMSPluginOverrides" />

Use `classNames` in `StackProvider.overrides.cms` to style built-in UI slots. Classes merge with the defaults; conflicting Tailwind utilities use your override. Omitting a slot preserves its default styling.

```tsx
overrides={{
cms: {
classNames: { contentTypeCard: "shadow-lg", tableRow: "hover:bg-muted" },
},
}}
```

### Schema Converter Utilities (`@btst/stack/plugins/cms/client`)

The CMS plugin re-exports schema converter utilities for converting between Zod schemas and JSON Schema. These are useful when working with content types programmatically:
Expand Down
11 changes: 11 additions & 0 deletions docs/content/docs/plugins/form-builder.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -634,9 +634,20 @@ Creates the client plugin with routes and SSR loaders.

#### FormBuilderPluginOverrides

Set `classNames` in `StackProvider.overrides.formBuilder` for targeted styling. Classes merge with defaults, with conflicting Tailwind utilities resolved in favor of your override. `table`, `tableRow`, and `pagination` apply to both the forms list and submissions list. `form` styles the `FormRenderer` root, including loading, error, and success states, and merges after its `className` prop.

```tsx
overrides={{
formBuilder: {
classNames: { table: "shadow-sm", form: "max-w-xl mx-auto" },
},
}}
```

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `fieldComponents` | `Record<string, ComponentType>` | No | Custom field components |
| `classNames` | `{ container?, table?, tableRow?, pagination?, form? }` | No | Class names merged onto specific UI elements |
| `localization` | `Partial<FormBuilderLocalization>` | No | Custom labels |
| `showAttribution` | `boolean` | No | Show BTST attribution |
| `onRouteRender` | `(route, context) => void` | No | Lifecycle hook |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/codegen",
"version": "4.0.0",
"version": "4.1.0",
"description": "BTST project scaffolding and CLI passthrough commands.",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/__tests__/package-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("installInitDependencies", () => {
});

const installArguments = execa.mock.calls[0]?.[1] as string[];
expect(installArguments).toContain("@btst/stack@4.0.0");
expect(installArguments).toContain("@btst/stack@4.1.0");
expect(installArguments).toContain("next-themes@0.4.6");
expect(installArguments).toContain("@btst/adapter-drizzle@3.0.0");
expect(installArguments).toContain("drizzle-orm@0.45.2");
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/package-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function installInitDependencies(input: {
});

const packages = [
"@btst/stack@4.0.0",
"@btst/stack@4.1.0",
"@btst/yar@1.3.2",
"@tanstack/react-query@5.102.0",
"@tanstack/query-core@5.102.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
"version": "4.0.0",
"version": "4.1.0",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
Expand Down
12 changes: 6 additions & 6 deletions packages/stack/registry/btst-ai-chat.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions packages/stack/registry/btst-blog.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions packages/stack/registry/btst-cms.json

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions packages/stack/registry/btst-form-builder.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Core primitives MUST be imported from the package entry (not relative src
// paths) so they share module identity — and React context — with the plugin
// components, which resolve `@btst/stack/*` via package self-reference.
import { StackProvider } from "@btst/stack/context";
import type { UIMessage } from "ai";
import { ChatInput } from "../client/components/chat-input";
import { ChatLayout } from "../client/components/chat-layout";
import { ChatMessage } from "../client/components/chat-message";
import { aiChatClientPlugin } from "../client/plugin";
import type { SerializedConversation } from "../types";
import { createTestClientStack } from "../../../__tests__/client-stack-test-utils";

(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
(globalThis as any).ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
};
Element.prototype.scrollIntoView ??= () => {};

const mocks = vi.hoisted(() => ({
useChat: vi.fn(),
useConversation: vi.fn(),
useConversations: vi.fn(),
useRenameConversationForm: vi.fn(),
useDeleteConversation: vi.fn(),
useAiChatIdentityPartition: vi.fn(),
}));

vi.mock("@ai-sdk/react", () => ({ useChat: mocks.useChat }));
vi.mock("../client/hooks/chat-hooks", () => ({
useAiChatIdentityPartition: mocks.useAiChatIdentityPartition,
useConversation: mocks.useConversation,
useConversations: mocks.useConversations,
useRenameConversationForm: mocks.useRenameConversationForm,
useDeleteConversation: mocks.useDeleteConversation,
}));
vi.mock("../client/context/page-ai-context", () => ({
usePageAIContext: () => undefined,
}));
// Markdown rendering is unrelated to class wiring and drags in highlight/katex.
vi.mock("@workspace/ui/components/markdown-content", () => ({
MarkdownContent: ({ content }: { content: string }) => <div>{content}</div>,
}));

const CLASS_NAMES = {
container: "bg-red-100",
sidebar: "bg-blue-100",
messageList: "bg-yellow-50",
message: "text-purple-700",
userMessage: "border-4 border-orange-500",
assistantMessage: "border-4 border-green-500",
input: "bg-pink-100",
} as const;

const conversation: SerializedConversation = {
id: "conv-1",
userId: "owner-1",
title: "First conversation",
createdAt: new Date("2024-01-01").toISOString(),
updatedAt: new Date("2024-01-02").toISOString(),
};

const userMessage: UIMessage = {
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Hi there" }],
};
const assistantMessage: UIMessage = {
id: "assistant-1",
role: "assistant",
parts: [{ type: "text", text: "Hello back" }],
};

let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

mocks.useAiChatIdentityPartition.mockReturnValue("anonymous");
mocks.useChat.mockReturnValue({
messages: [userMessage, assistantMessage],
sendMessage: vi.fn(),
status: "ready",
error: null,
setMessages: vi.fn(),
regenerate: vi.fn(),
addToolOutput: vi.fn(),
stop: vi.fn(),
});
mocks.useConversation.mockReturnValue({
conversation: null,
isLoading: false,
error: null,
refetch: vi.fn(),
});
mocks.useConversations.mockReturnValue({
conversations: [conversation],
isLoading: false,
error: null,
refetch: vi.fn(),
});
mocks.useRenameConversationForm.mockReturnValue({
submit: vi.fn(),
isSubmitting: false,
error: null,
fieldErrors: {},
clearErrors: vi.fn(),
});
mocks.useDeleteConversation.mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
});
});

afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
vi.clearAllMocks();
});

async function render(ui: React.ReactElement) {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<StackProvider
stack={createTestClientStack(
{ aiChat: aiChatClientPlugin() },
queryClient,
)}
overrides={{ aiChat: { classNames: CLASS_NAMES } }}
>
{ui}
</StackProvider>
</QueryClientProvider>,
);
});
}

function query(selector: string): HTMLElement {
const el = container.querySelector<HTMLElement>(selector);
expect(el, `expected to find ${selector}`).toBeTruthy();
return el!;
}

describe("ai-chat classNames overrides (issue #36)", () => {
it("applies message, userMessage and assistantMessage to a user message", async () => {
await render(<ChatMessage message={userMessage} />);

const row = query('[data-testid="chat-message"][data-role="user"]');
expect(row.className).toContain(CLASS_NAMES.message);
expect(row.className).toContain("justify-end");

const bubble = query('[data-testid="chat-message-bubble"]');
expect(bubble.className).toContain("border-orange-500");
expect(bubble.className).not.toContain("border-green-500");
// The role-specific base styling is preserved.
expect(bubble.className).toContain("bg-primary");
});

it("applies assistantMessage (not userMessage) to an assistant message", async () => {
await render(<ChatMessage message={assistantMessage} />);

const row = query('[data-testid="chat-message"][data-role="assistant"]');
expect(row.className).toContain(CLASS_NAMES.message);
expect(row.className).toContain("justify-start");

const bubble = query('[data-testid="chat-message-bubble"]');
expect(bubble.className).toContain("border-green-500");
expect(bubble.className).not.toContain("border-orange-500");
expect(bubble.className).toContain("bg-muted");
});

it("applies classNames.input to the composer form", async () => {
await render(
<ChatInput
handleInputChange={() => {}}
handleSubmit={() => {}}
isLoading={false}
/>,
);

const form = query('[data-testid="chat-input-form"]');
expect(form.tagName).toBe("FORM");
expect(form.className).toContain(CLASS_NAMES.input);
expect(form.className).toContain("space-y-2");
});

it("applies container, sidebar and messageList in the full layout", async () => {
await render(<ChatLayout conversationId={conversation.id} />);

const layout = query('[data-testid="chat-layout"]');
expect(layout.className).toContain(CLASS_NAMES.container);
expect(layout.className).toContain("overflow-hidden");

const sidebar = query('[data-testid="chat-sidebar"]');
expect(sidebar.className).toContain(CLASS_NAMES.sidebar);
expect(sidebar.className).toContain("border-r");

const list = query('[data-testid="chat-message-list"]');
expect(list.className).toContain(CLASS_NAMES.messageList);
expect(list.className).toContain("flex-col");
});

it("applies classNames.container to the widget root", async () => {
await render(<ChatLayout layout="widget" />);

const trigger = query('[data-testid="widget-trigger"]');
const widgetRoot = trigger.parentElement!;
expect(widgetRoot.className).toContain(CLASS_NAMES.container);
expect(widgetRoot.className).toContain("items-end");
});

it("leaves base classes untouched when no classNames override is supplied", async () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<StackProvider
stack={createTestClientStack(
{ aiChat: aiChatClientPlugin() },
queryClient,
)}
>
<ChatMessage message={userMessage} />
</StackProvider>
</QueryClientProvider>,
);
});

const bubble = query('[data-testid="chat-message-bubble"]');
expect(bubble.className).toContain("bg-primary");
expect(bubble.className).not.toContain("border-orange-500");
});
});
Loading
Loading