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
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,6 @@ jobs:
- name: Build
run: pnpm build

- name: Build Storybook
run: pnpm build-storybook

worker:
name: Validate Worker
runs-on: ubuntu-latest
Expand Down
82 changes: 82 additions & 0 deletions .github/workflows/storybook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Storybook

on:
push:
branches: [main]
paths:
- "widget/**"
- ".github/workflows/storybook.yml"
pull_request:
branches: [main]
paths:
- "widget/**"
- ".github/workflows/storybook.yml"
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
name: Build Storybook
runs-on: ubuntu-latest
defaults:
run:
working-directory: widget
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: widget/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build Storybook
run: pnpm build-storybook

- uses: actions/upload-artifact@v4
with:
name: storybook-static
path: widget/storybook-static

deploy:
name: Deploy to Cloudflare Pages
# Deploys need the CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID repo
# secrets (already used by the Docs workflow), and a one-time
# `wrangler pages project create claudius-storybook`.
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4

# wrangler-action installs wrangler with the package manager it detects
# from widget/pnpm-lock.yaml, so pnpm must be on PATH in this job too.
- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/download-artifact@v4
with:
name: storybook-static
path: widget/storybook-static

- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: widget
command: pages deploy storybook-static --project-name=claudius-storybook
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

[![npm](https://img.shields.io/npm/v/claudius-chat-widget.svg)](https://www.npmjs.com/package/claudius-chat-widget)
[![create-claudius](https://img.shields.io/npm/v/create-claudius.svg?label=create-claudius)](https://www.npmjs.com/package/create-claudius)
[![Storybook](https://img.shields.io/badge/Storybook-live-ff4785?logo=storybook&logoColor=white)](https://claudius-storybook.pages.dev)
[![license](https://img.shields.io/npm/l/claudius-chat-widget.svg)](LICENSE)

An open-source, embeddable AI chat widget powered by Claude. Drop it into any
Expand Down Expand Up @@ -110,6 +111,10 @@ cd widget && pnpm install && pnpm dev # http://localhost:5173
Tests: `pnpm test` in `widget/` and `worker/`. Details in the
[local development guide](https://claudius-docs.pages.dev/getting-started/local-development/).

Browse the components in Storybook — hosted at
[claudius-storybook.pages.dev](https://claudius-storybook.pages.dev), or run it
locally with `cd widget && pnpm storybook`.

## Tech stack

React 18 + TypeScript + Tailwind (widget) · Cloudflare Workers + Hono +
Expand Down
10 changes: 9 additions & 1 deletion docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,15 @@ export default defineConfig({
},
{
label: "Plugins & Tools",
items: [{ autogenerate: { directory: "plugins" } }],
items: [
{ autogenerate: { directory: "plugins" } },
{
label: "Component Storybook",
link: "https://claudius-storybook.pages.dev",
attrs: { target: "_blank", rel: "noopener noreferrer" },
badge: { text: "live", variant: "success" },
},
],
},
{ label: "RAG", items: [{ autogenerate: { directory: "rag" } }] },
{
Expand Down
18 changes: 18 additions & 0 deletions docs/src/content/docs/getting-started/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ pnpm test # API, validation, rate-limit, analytics tests
The E2E suite mocks `**/api/chat`, so the worker doesn't need to run during
Playwright tests.

## Component Storybook

Every widget component has stories covering its meaningful states — themes
(light / dark / auto), error and empty states, the mobile bottom sheet, RTL
layout, and the proactive greeting. It's a fast feedback loop for UI work and
doubles as living documentation.

```bash
cd widget
pnpm storybook # http://localhost:6006
pnpm build-storybook # static build -> widget/storybook-static
```

The latest `main` is published at
[claudius-storybook.pages.dev](https://claudius-storybook.pages.dev). Use the
toolbar **Theme**, **Locale**, and **Direction** controls to preview each
component across color schemes, languages, and writing direction.

## Repository layout

| Path | What it is |
Expand Down
34 changes: 0 additions & 34 deletions widget/.storybook/preview.ts

This file was deleted.

131 changes: 131 additions & 0 deletions widget/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { Preview, Decorator } from "@storybook/react-vite";
import type { LocaleCode } from "../src/locales";
import "../src/styles.css";

const LOCALE_ITEMS: { value: LocaleCode; title: string }[] = [
{ value: "en", title: "English" },
{ value: "es", title: "Español" },
{ value: "fr", title: "Français" },
{ value: "de", title: "Deutsch" },
];

type ThemeMode = "light" | "dark" | "auto";

/**
* Mirrors ChatWidget's own logic: dark mode is driven by a `data-claudius-dark`
* ancestor (see styles.css), and "auto" follows the OS `prefers-color-scheme`.
*/
function resolveDark(theme: ThemeMode): boolean {
if (theme === "dark") return true;
if (theme === "light") return false;
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}

/**
* Wraps every story in the widget's theming context:
* - `.claudius-root` + `data-claudius-dark` so `--cl-*` tokens resolve exactly
* as they do in production (light is the default; dark flips the tokens).
* - `dir` for RTL coverage.
* - an optional widget-shaped frame for components that normally live inside
* the chat panel, opted into via `parameters.widgetFrame` ("panel" renders
* edge-to-edge like the header/input; "messages" pads like the message list).
*/
const withWidgetTheme: Decorator = (Story, context) => {
const theme = (context.globals.theme as ThemeMode) ?? "light";
const direction = (context.globals.direction as "ltr" | "rtl") ?? "ltr";
const dark = resolveDark(theme);
const frame = context.parameters.widgetFrame as
| "panel"
| "messages"
| undefined;

let inner = <Story />;
if (frame === "panel") {
inner = (
<div className="w-[380px] overflow-hidden rounded-claudius-lg bg-claudius-surface font-body shadow-claudius-elevated">
{inner}
</div>
);
} else if (frame === "messages") {
inner = (
<div className="w-[380px] space-y-3 rounded-claudius-lg bg-claudius-surface p-4 font-body shadow-claudius-elevated">
{inner}
</div>
);
}

return (
<div
className="claudius-root"
data-claudius-dark={dark ? "true" : "false"}
dir={direction}
style={{
minHeight: "100vh",
display: "flex",
alignItems: frame ? "center" : "stretch",
justifyContent: frame ? "center" : "stretch",
padding: frame ? "2rem" : 0,
background: dark ? "#030712" : "#f1f5f9",
}}
>
{inner}
</div>
);
};

const preview: Preview = {
parameters: {
layout: "fullscreen",
controls: {
matchers: { color: /(background|color)$/i, date: /Date$/i },
},
},
initialGlobals: {
locale: "en",
theme: "light",
direction: "ltr",
},
globalTypes: {
locale: {
description: "Widget UI locale",
toolbar: {
title: "Locale",
icon: "globe",
items: LOCALE_ITEMS,
dynamicTitle: true,
},
},
theme: {
description: "Color scheme",
toolbar: {
title: "Theme",
icon: "contrast",
items: [
{ value: "light", title: "Light", icon: "sun" },
{ value: "dark", title: "Dark", icon: "moon" },
{ value: "auto", title: "Auto (OS)", icon: "browser" },
],
dynamicTitle: true,
},
},
direction: {
description: "Text direction",
toolbar: {
title: "Direction",
icon: "transfer",
items: [
{ value: "ltr", title: "LTR" },
{ value: "rtl", title: "RTL" },
],
dynamicTitle: true,
},
},
},
decorators: [withWidgetTheme],
};

export default preview;
35 changes: 35 additions & 0 deletions widget/src/components/ChatHeader.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ChatHeader } from "./ChatHeader";
import { locales, type LocaleCode } from "../locales";

const meta = {
title: "Widget/Header",
component: ChatHeader,
parameters: { widgetFrame: "panel" },
args: {
title: "Support",
subtitle: "Ask me anything",
onClose: fn(),
},
render: (args, { globals }) => (
<ChatHeader
{...args}
closeLabel={locales[globals.locale as LocaleCode].closeChat}
/>
),
} satisfies Meta<typeof ChatHeader>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

// Long titles stay on one line; the avatar uses the first character.
export const LongTitle: Story = {
args: {
title: "PMDS Customer Success Team",
subtitle: "We typically reply within a few minutes",
},
};
Loading
Loading