From 60789f9ed6fb6f2a25a87752e602c6e4113cf19f Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 11 Jun 2026 01:37:28 -0400 Subject: [PATCH 1/9] docs: add theming v2 design and implementation plan (#73) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-06-11-theming-v2-design.md | 105 ++++++++++++++++++ .../2026-06-11-theming-v2-implementation.md | 47 ++++++++ 2 files changed, 152 insertions(+) create mode 100644 docs/plans/2026-06-11-theming-v2-design.md create mode 100644 docs/plans/2026-06-11-theming-v2-implementation.md diff --git a/docs/plans/2026-06-11-theming-v2-design.md b/docs/plans/2026-06-11-theming-v2-design.md new file mode 100644 index 0000000..adc4ee8 --- /dev/null +++ b/docs/plans/2026-06-11-theming-v2-design.md @@ -0,0 +1,105 @@ +# Theming v2 Design (issue #73) + +Design tokens via CSS custom properties, schema-validated theme files, four +built-in themes, a theme editor, and a documented migration path from the +`accentColor`-only API. + +## Constraints + +- **No breaking changes.** The CDN `@1` channel auto-updates production sites. + `theme: "light" | "dark" | "auto"`, `accentColor`, and the existing + `--claudius-*` custom properties must keep working unchanged. +- Default appearance must be pixel-identical before/after the refactor; the + existing 220 widget tests are the regression net. + +## Token set (`--cl-*`) + +| Group | Tokens | Notes | +|-------|--------|-------| +| Colors | `accent`, `accent-text`, `surface`, `surface-muted`, `text`, `text-muted`, `border`, `user-bubble`, `user-bubble-text`, `assistant-bubble`, `assistant-bubble-text`, `error`, `error-surface`, `scrim` | `user-bubble` defaults to `var(--cl-color-accent)`; `assistant-bubble` to `surface-muted` (fixes the wart where `accentColor` recolored the header but not user bubbles) | +| Radii | `sm` (8px), `md` (12px), `lg` (16px), `full` (9999px), `tail` (2px) | window/bubbles=lg, buttons/inputs=md, bubble tail=tail | +| Shadows | `elevated` (window), `floating` (toggle), `floating-hover` | | +| Fonts | `heading`, `body` | carried over from `--claudius-font-*` | + +**Dark mode:** light values are token defaults; `[data-claudius-dark="true"]` +reassigns each color token to `var(--cl-color--dark, )`. +The wrapper splits into an outer div carrying inline theme vars and an inner +div carrying `data-claudius-dark`, so the attribute rule beats inherited +inline light values. Today's hard-coded `dark:` utilities become the `-dark` +defaults, keeping default dark mode pixel-identical. + +**Legacy aliases:** Tailwind palette entries become fallback chains, e.g. +`var(--claudius-primary, var(--cl-color-accent, #2563eb))` — anyone setting +`--claudius-*` externally keeps working and still wins. + +## API + +```ts +type ClaudiusThemeInput = + | "light" | "dark" | "auto" // existing: mode only + | "default" | "minimal" | "playful" | "corporate" // built-in themes + | ClaudiusTheme // inline token object + | (string & {}); // URL to a theme JSON + +interface ClaudiusTheme { + $schema?: string; + name?: string; + colorScheme?: "light" | "dark" | "auto"; // defaults to "light" + colors?: Partial>; // camelCase keys + colorsDark?: Partial>; // dark overrides + radii?: Partial>; + shadows?: Partial>; + fonts?: Partial>; +} +``` + +- Strings `light|dark|auto` keep their exact current meaning (mode only). +- Built-in names resolve to exported theme objects (`builtinThemes`); each + carries its own `colorScheme`. Combining a built-in with a different mode: + `theme={{ ...builtinThemes.corporate, colorScheme: "dark" }}`. +- Any other string is treated as a URL: fetched, JSON-parsed, structurally + checked. On fetch/parse/shape failure: console.error and fall back to the + default theme — the widget never breaks because a theme file is down. +- `accentColor` still works and **wins over** the theme's accent (it is the + older, more specific contract). Internally it now sets `--cl-color-accent`. +- Web component: `theme` attribute accepts mode strings, built-in names, and + URLs (attributes can't express objects; documented). +- Package exports gain `ClaudiusTheme` and `builtinThemes`. + +## Built-in themes + +- `default` — empty overrides (the baked-in defaults) +- `minimal` — monochrome: near-black accent, square-ish radii (4/6/10px), hairline shadows +- `playful` — violet accent, pill radii (16/20/24px), soft colored shadows, rounded font stack +- `corporate` — navy accent, tight radii (4/6/8px), subtle shadows, neutral grays + +## Schema + +JSON Schema draft-07 (same dialect as `clients/_schema.json`), source of truth +at `widget/src/theme/theme.v1.schema.json`, committed copy served from +`docs/public/schema/theme.v1.json` → published at +`https://claudius-docs.pages.dev/schema/theme.v1.json` (the issue's +`claudius.dev` host doesn't exist yet; same "or equivalent" precedent as #74 — +when the domain is attached the path carries over). A widget test asserts the +two files are byte-identical (drift guard, no cross-package build coupling). +All leaf values: non-empty strings (CSS color syntax is too varied to regex); +`additionalProperties: false` everywhere catches typos. Runtime widget +validation is a lightweight structural check (no AJV in the bundle); AJV is a +widget devDependency for tests, which validate all four built-ins against the +schema. + +## Theme editor + +A custom docs-site page at `/theme-editor/` (Starlight `` + +vanilla JS island): grouped controls for all tokens (light + dark), a live +preview pane — a static widget replica styled exclusively by the same +`--cl-*` tokens — and export as download/copy of a JSON file containing only +non-default values plus `$schema`. Built-ins selectable as starting points. +The issue places the editor on the playground site, which doesn't exist yet +(#48/#83); the docs site is its interim home, and the editor moves when the +playground lands. + +## Out of scope + +- Playground site itself (#48/#83); per-tenant theme storage; CLI validation + of theme files (possible follow-up to `pnpm claudius validate`). diff --git a/docs/plans/2026-06-11-theming-v2-implementation.md b/docs/plans/2026-06-11-theming-v2-implementation.md new file mode 100644 index 0000000..15cc933 --- /dev/null +++ b/docs/plans/2026-06-11-theming-v2-implementation.md @@ -0,0 +1,47 @@ +# Theming v2 Implementation Plan (issue #73) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Design-token theming per `2026-06-11-theming-v2-design.md`, fully backward compatible. + +**Architecture:** Tokens defined in `styles.css` (light defaults + dark reassignment block), consumed via Tailwind theme mappings; a small theme module (`widget/src/theme/`) resolves the `theme` prop union into CSS vars applied inline on a new outer wrapper; schema + docs + editor ride on the docs site. + +**Tech Stack:** Tailwind 3 custom properties, Vitest (+ AJV for schema tests), Astro/Starlight custom page. + +--- + +### Task 1: Token foundation (no behavior change) + +**Files:** `widget/src/styles.css`, `widget/tailwind.config.ts`, all `widget/src/components/*.tsx` (class refactor), `widget/src/components/ChatWidget.tsx` (wrapper split). + +1. Add to `styles.css`: `:where()` -scoped token defaults? No — tokens must inherit into the widget subtree only: define defaults on the outer wrapper class `.claudius-root` (light values) and a `[data-claudius-dark="true"]` block reassigning color tokens to `var(--cl-color-X-dark, )`. +2. Rewrite `tailwind.config.ts`: semantic classes (`claudius-surface`, `claudius-text`, …) → `var(--cl-*)`; keep legacy `claudius-*` entries as `var(--claudius-X, var(--cl-*, default))` chains; radii/shadows/fonts from tokens. +3. Component sweep: replace every hard-coded color/`dark:` pair with token classes (mapping table in design doc); `rounded-2xl`→`rounded-claudius-lg`, `shadow-2xl`→`shadow-claudius-elevated`, etc. +4. ChatWidget: split wrapper (`
` outer, inner keeps `data-claudius-dark`); `accentColor` now sets `--cl-color-accent`. +5. Run `pnpm test` (220 green), `pnpm lint`, `pnpm typecheck`, `pnpm build`. Commit: `refactor(widget): move all visual styles onto --cl-* design tokens`. + +### Task 2: Theme engine (TDD) + +**Files:** `widget/src/theme/types.ts`, `themes.ts` (builtins), `resolve.ts` (`resolveThemeInput`, `themeToCssVars`), `__tests__/resolve.test.ts`, `__tests__/themes.test.ts`; export from `widget/src/index.ts`. + +Tests first: mode strings → `{mode, theme: undefined}`; builtin names → builtin object; objects pass through; `themeToCssVars` maps camelCase→`--cl-color-kebab` (+`-dark` from `colorsDark`, radii/shadows/fonts groups); unknown keys warned & skipped; non-string values skipped. Commit per red-green cycle. + +### Task 3: theme prop + URL fetch (TDD) + +**Files:** `widget/src/theme/useTheme.ts` (hook: resolves input, fetches URLs with AbortController, falls back to default on any failure with one console.error), `ChatWidget.tsx` wiring (mode resolution: object `colorScheme` feeds existing light/dark/auto logic), `embed.tsx` (config + attribute pass-through), tests in `hooks/__tests__/` and `__tests__/embed.test.tsx`. + +Key tests: `theme="corporate"` sets vars on wrapper; URL success applies fetched tokens; URL 404/invalid-JSON/bad-shape → defaults + error logged; `accentColor` overrides theme accent; `theme="dark"` regression (mode only, attr set); web component `theme="minimal"` works. + +### Task 4: Schema + validation tests + +**Files:** `widget/src/theme/theme.v1.schema.json`, copy at `docs/public/schema/theme.v1.json`, `widget/src/theme/__tests__/schema.test.ts` (AJV devDep: all builtins + a kitchen-sink fixture validate; bad fixtures fail; docs copy byte-identical). + +### Task 5: Docs + theme editor + +**Files:** `docs/src/content/docs/configuration/theming.md` (rewrite: tokens table, theme prop forms, builtins gallery, schema link), `docs/src/content/docs/migration/accent-color-to-themes.md`, `docs/src/pages/theme-editor.astro` (+ sidebar link in `docs/astro.config.mjs`), `docs/src/content/docs/api/widget.md` (type updates). + +Editor: token controls grouped (colors light/dark tabs, radii, shadows, fonts), static preview replica driven by the same tokens, export = JSON of non-default values + `$schema`, builtin starting points. `pnpm build` green. + +### Task 6: Verify + PR + +Widget: test/lint/typecheck/build. Worker tests (untouched). Docs build. Push `73-theming-v2-design-tokens`, PR body includes `Closes #73` (criteria fully met by the PR per [[feedback-close-issues-via-pr-keyword]] — schema URL goes live on merge via docs auto-deploy). From 1eb6ed9efc529513cbd793877021ee36ee865de0 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 11 Jun 2026 01:45:16 -0400 Subject: [PATCH 2/9] refactor(widget): move all visual styles onto --cl-* design tokens Every color, radius, shadow, and font now resolves through a --cl-* custom property. Dark mode reassigns color tokens via the [data-claudius-dark] block instead of per-class dark: variants. Legacy --claudius-* properties stay first in each var() chain so existing external overrides keep winning. Default light/dark appearance is unchanged; accentColor now feeds --cl-color-accent (and therefore also the user bubble, which previously ignored it). Co-Authored-By: Claude Fable 5 --- widget/src/components/ChatInput.tsx | 8 +- widget/src/components/ChatMessage.tsx | 8 +- widget/src/components/ChatSources.tsx | 16 ++-- widget/src/components/ChatToggleButton.tsx | 2 +- widget/src/components/ChatWidget.tsx | 95 ++++++++++--------- widget/src/components/ChatWindow.tsx | 30 +++--- widget/src/components/GreetingBubble.tsx | 4 +- widget/src/components/SourceIcon.tsx | 8 +- .../components/__tests__/ChatWidget.test.tsx | 4 +- .../components/__tests__/ChatWindow.test.tsx | 2 +- .../components/__tests__/SourceIcon.test.tsx | 4 +- widget/src/styles.css | 44 +++++++++ widget/tailwind.config.ts | 76 +++++++++++---- 13 files changed, 198 insertions(+), 103 deletions(-) diff --git a/widget/src/components/ChatInput.tsx b/widget/src/components/ChatInput.tsx index e2be292..496f323 100644 --- a/widget/src/components/ChatInput.tsx +++ b/widget/src/components/ChatInput.tsx @@ -53,7 +53,7 @@ export function ChatInput({ return (
diff --git a/widget/src/components/GreetingBubble.tsx b/widget/src/components/GreetingBubble.tsx index 6f07d62..230e56b 100644 --- a/widget/src/components/GreetingBubble.tsx +++ b/widget/src/components/GreetingBubble.tsx @@ -30,7 +30,7 @@ export function GreetingBubble({ @@ -41,7 +41,7 @@ export function GreetingBubble({ onDismiss(); }} aria-label={dismissLabel} - className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full text-slate-500 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-[var(--claudius-primary,#2563eb)] dark:text-slate-300 dark:hover:bg-slate-700" + className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-claudius-full text-claudius-text-muted hover:bg-claudius-surface-muted focus:outline-none focus:ring-2 focus:ring-claudius-accent" > diff --git a/widget/src/components/SourceIcon.tsx b/widget/src/components/SourceIcon.tsx index 0e203c4..19589be 100644 --- a/widget/src/components/SourceIcon.tsx +++ b/widget/src/components/SourceIcon.tsx @@ -16,10 +16,10 @@ export const SourceIcon = memo(function SourceIcon({ onClick={onClick} aria-label="View sources" title="View sources" - className={`group relative flex h-7 w-7 items-center justify-center rounded-full transition-colors ${ + className={`group relative flex h-7 w-7 items-center justify-center rounded-claudius-full transition-colors ${ isActive - ? "bg-claudius-primary text-white" - : "text-claudius-gray hover:bg-claudius-light hover:text-claudius-dark dark:hover:bg-gray-700" + ? "bg-claudius-accent text-claudius-accent-text" + : "text-claudius-text-muted hover:bg-claudius-surface-muted hover:text-claudius-text" }`} > - + {count} diff --git a/widget/src/components/__tests__/ChatWidget.test.tsx b/widget/src/components/__tests__/ChatWidget.test.tsx index dacffbd..f6e89ff 100644 --- a/widget/src/components/__tests__/ChatWidget.test.tsx +++ b/widget/src/components/__tests__/ChatWidget.test.tsx @@ -177,7 +177,9 @@ describe("ChatWidget - theme=auto", () => { // Wrapper starts with the "light" data attribute since the OS media // query reports matches=false initially. - const wrapper = container.firstChild as HTMLElement; + const wrapper = container.querySelector( + "[data-claudius-dark]", + ) as HTMLElement; expect(wrapper.getAttribute("data-claudius-dark")).toBe("false"); // Confirm the auto-mode listener was actually registered, not just the diff --git a/widget/src/components/__tests__/ChatWindow.test.tsx b/widget/src/components/__tests__/ChatWindow.test.tsx index f8a9e3f..551c598 100644 --- a/widget/src/components/__tests__/ChatWindow.test.tsx +++ b/widget/src/components/__tests__/ChatWindow.test.tsx @@ -344,7 +344,7 @@ describe("ChatWindow - mobile bottom sheet", () => { ); // Drag handle is a small rounded bar const handle = container.querySelector( - "[aria-hidden='true'] .rounded-full", + "[aria-hidden='true'] .rounded-claudius-full", ); expect(handle).toBeInTheDocument(); }); diff --git a/widget/src/components/__tests__/SourceIcon.test.tsx b/widget/src/components/__tests__/SourceIcon.test.tsx index 6b350e4..e822569 100644 --- a/widget/src/components/__tests__/SourceIcon.test.tsx +++ b/widget/src/components/__tests__/SourceIcon.test.tsx @@ -26,12 +26,12 @@ describe("SourceIcon", () => { it("applies active styling when isActive is true", () => { render(); const button = screen.getByRole("button"); - expect(button.className).toContain("bg-claudius-primary"); + expect(button.className).toContain("bg-claudius-accent"); }); it("applies inactive styling when isActive is false", () => { render(); const button = screen.getByRole("button"); - expect(button.className).not.toContain("bg-claudius-primary"); + expect(button.className).not.toContain("bg-claudius-accent"); }); }); diff --git a/widget/src/styles.css b/widget/src/styles.css index 8f502e1..c50bc0f 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -2,6 +2,50 @@ @tailwind components; @tailwind utilities; +/* Dark mode: reassign each color token to its -dark counterpart. A theme's + `colors` are emitted as both --cl-color-X and --cl-color-X-dark (unless + `colorsDark` overrides X), so themed tokens carry into dark mode while + untouched tokens fall back to the default dark palette below. */ +[data-claudius-dark="true"] { + --cl-color-accent: var(--cl-color-accent-dark, #2563eb); + --cl-color-accent-text: var(--cl-color-accent-text-dark, #ffffff); + --cl-color-accent-soft: var( + --cl-color-accent-soft-dark, + rgb(255 255 255 / 0.2) + ); + --cl-color-accent-text-muted: var( + --cl-color-accent-text-muted-dark, + rgb(255 255 255 / 0.7) + ); + --cl-color-surface: var(--cl-color-surface-dark, #111827); + --cl-color-surface-muted: var(--cl-color-surface-muted-dark, #1f2937); + --cl-color-text: var(--cl-color-text-dark, #f3f4f6); + --cl-color-text-muted: var(--cl-color-text-muted-dark, #9ca3af); + --cl-color-border: var(--cl-color-border-dark, #374151); + --cl-color-user-bubble: var( + --cl-color-user-bubble-dark, + var(--cl-color-accent) + ); + --cl-color-user-bubble-text: var(--cl-color-user-bubble-text-dark, #ffffff); + --cl-color-assistant-bubble: var( + --cl-color-assistant-bubble-dark, + var(--cl-color-surface-muted) + ); + --cl-color-assistant-bubble-text: var( + --cl-color-assistant-bubble-text-dark, + #e5e7eb + ); + --cl-color-field: var(--cl-color-field-dark, #1f2937); + --cl-color-error: var(--cl-color-error-dark, #f87171); + --cl-color-error-surface: var( + --cl-color-error-surface-dark, + rgb(127 29 29 / 0.3) + ); + --cl-color-error-text: var(--cl-color-error-text-dark, #ffffff); + --cl-color-link: var(--cl-color-link-dark, #60a5fa); + --cl-color-scrim: var(--cl-color-scrim-dark, rgb(0 0 0 / 0.5)); +} + /* Bottom sheet slide-up animation */ .claudius-bottom-sheet { transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); diff --git a/widget/tailwind.config.ts b/widget/tailwind.config.ts index 5f32d02..080e146 100644 --- a/widget/tailwind.config.ts +++ b/widget/tailwind.config.ts @@ -1,5 +1,14 @@ import type { Config } from "tailwindcss"; +/** + * All visual decisions resolve through --cl-* design tokens (see + * src/theme/). The var() fallback values below ARE the default theme's + * light palette; [data-claudius-dark="true"] in styles.css reassigns the + * color tokens for dark mode. + * + * Legacy --claudius-* custom properties (the pre-token public surface) sit + * first in each chain so existing external overrides keep winning. + */ export default { darkMode: ["selector", '[data-claudius-dark="true"]'], content: ["./src/**/*.{ts,tsx}"], @@ -7,27 +16,62 @@ export default { extend: { colors: { claudius: { - primary: "var(--claudius-primary, #2563eb)", - dark: "var(--claudius-dark, #1e293b)", - light: "var(--claudius-light, #f1f5f9)", - gray: "var(--claudius-gray, #64748b)", - border: "var(--claudius-border, #e2e8f0)", - "user-bg": "var(--claudius-user-bg, #2563eb)", - "user-text": "var(--claudius-user-text, #ffffff)", - "assistant-bg": "var(--claudius-assistant-bg, #f1f5f9)", - "assistant-text": "var(--claudius-assistant-text, #1e293b)", - error: "var(--claudius-error, #dc2626)", - "error-bg": "var(--claudius-error-bg, #fef2f2)", + accent: "var(--claudius-primary, var(--cl-color-accent, #2563eb))", + "accent-text": "var(--cl-color-accent-text, #ffffff)", + "accent-soft": "var(--cl-color-accent-soft, rgb(255 255 255 / 0.2))", + "accent-text-muted": + "var(--cl-color-accent-text-muted, rgb(255 255 255 / 0.7))", + surface: "var(--cl-color-surface, #ffffff)", + "surface-muted": + "var(--claudius-light, var(--cl-color-surface-muted, #f1f5f9))", + text: "var(--claudius-dark, var(--cl-color-text, #1e293b))", + "text-muted": + "var(--claudius-gray, var(--cl-color-text-muted, #64748b))", + border: "var(--claudius-border, var(--cl-color-border, #e2e8f0))", + "user-bubble": + "var(--claudius-user-bg, var(--cl-color-user-bubble, var(--cl-color-accent, #2563eb)))", + "user-bubble-text": + "var(--claudius-user-text, var(--cl-color-user-bubble-text, #ffffff))", + "assistant-bubble": + "var(--claudius-assistant-bg, var(--cl-color-assistant-bubble, var(--cl-color-surface-muted, #f1f5f9)))", + "assistant-bubble-text": + "var(--claudius-assistant-text, var(--cl-color-assistant-bubble-text, var(--cl-color-text, #1e293b)))", + field: "var(--cl-color-field, #ffffff)", + error: "var(--claudius-error, var(--cl-color-error, #dc2626))", + "error-surface": + "var(--claudius-error-bg, var(--cl-color-error-surface, #fef2f2))", + "error-text": "var(--cl-color-error-text, #ffffff)", + link: "var(--cl-color-link, currentColor)", + scrim: "var(--cl-color-scrim, rgb(0 0 0 / 0.5))", }, }, fontFamily: { - heading: ["var(--claudius-font-heading, system-ui)", "sans-serif"], - body: ["var(--claudius-font-body, system-ui)", "sans-serif"], + heading: [ + "var(--claudius-font-heading, var(--cl-font-heading, system-ui))", + "sans-serif", + ], + body: [ + "var(--claudius-font-body, var(--cl-font-body, system-ui))", + "sans-serif", + ], }, borderRadius: { - card: "var(--claudius-radius-card, 16px)", - button: "var(--claudius-radius-button, 12px)", - bubble: "var(--claudius-radius-bubble, 16px)", + "claudius-sm": "var(--cl-radius-sm, 8px)", + "claudius-md": + "var(--claudius-radius-button, var(--cl-radius-md, 12px))", + "claudius-lg": "var(--claudius-radius-card, var(--cl-radius-lg, 16px))", + "claudius-bubble": + "var(--claudius-radius-bubble, var(--cl-radius-lg, 16px))", + "claudius-tail": "var(--cl-radius-tail, 2px)", + "claudius-full": "var(--cl-radius-full, 9999px)", + }, + boxShadow: { + "claudius-elevated": + "var(--cl-shadow-elevated, 0 25px 50px -12px rgb(0 0 0 / 0.25))", + "claudius-floating": + "var(--cl-shadow-floating, 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1))", + "claudius-floating-hover": + "var(--cl-shadow-floating-hover, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1))", }, }, }, From b4d8b177f7a44c43bf08a0430948dd02f590e75e Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 11 Jun 2026 01:47:35 -0400 Subject: [PATCH 3/9] feat(widget): add theme engine with four built-in themes Co-Authored-By: Claude Fable 5 --- widget/src/theme/__tests__/resolve.test.ts | 148 +++++++++++++++++++++ widget/src/theme/index.ts | 22 +++ widget/src/theme/resolve.ts | 101 ++++++++++++++ widget/src/theme/themes.ts | 99 ++++++++++++++ widget/src/theme/types.ts | 72 ++++++++++ 5 files changed, 442 insertions(+) create mode 100644 widget/src/theme/__tests__/resolve.test.ts create mode 100644 widget/src/theme/index.ts create mode 100644 widget/src/theme/resolve.ts create mode 100644 widget/src/theme/themes.ts create mode 100644 widget/src/theme/types.ts diff --git a/widget/src/theme/__tests__/resolve.test.ts b/widget/src/theme/__tests__/resolve.test.ts new file mode 100644 index 0000000..59a0b08 --- /dev/null +++ b/widget/src/theme/__tests__/resolve.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { resolveThemeInput, themeToCssVars } from "../resolve"; +import { builtinThemes } from "../themes"; +import type { ClaudiusTheme } from "../types"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("resolveThemeInput", () => { + it("defaults to light mode with no theme", () => { + expect(resolveThemeInput(undefined)).toEqual({ mode: "light" }); + }); + + it.each(["light", "dark", "auto"] as const)( + "treats %s as a mode-only input (existing API)", + (mode) => { + expect(resolveThemeInput(mode)).toEqual({ mode }); + }, + ); + + it.each(["default", "minimal", "playful", "corporate"] as const)( + "resolves built-in theme name %s", + (name) => { + const result = resolveThemeInput(name); + expect(result.theme).toBe(builtinThemes[name]); + expect(result.mode).toBe(builtinThemes[name].colorScheme ?? "light"); + expect(result.url).toBeUndefined(); + }, + ); + + it("treats other strings as theme URLs", () => { + expect(resolveThemeInput("https://example.com/theme.json")).toEqual({ + mode: "light", + url: "https://example.com/theme.json", + }); + expect(resolveThemeInput("/themes/acme.json")).toEqual({ + mode: "light", + url: "/themes/acme.json", + }); + }); + + it("passes theme objects through, honoring colorScheme", () => { + const theme: ClaudiusTheme = { + colorScheme: "dark", + colors: { accent: "#ff0000" }, + }; + expect(resolveThemeInput(theme)).toEqual({ mode: "dark", theme }); + }); + + it("defaults object colorScheme to light", () => { + const theme: ClaudiusTheme = { colors: { accent: "#ff0000" } }; + expect(resolveThemeInput(theme)).toEqual({ mode: "light", theme }); + }); +}); + +describe("themeToCssVars", () => { + it("maps camelCase color keys to kebab-case tokens, mirrored into dark", () => { + const vars = themeToCssVars({ + colors: { accent: "#ff0000", userBubbleText: "#00ff00" }, + }); + expect(vars).toEqual({ + "--cl-color-accent": "#ff0000", + "--cl-color-accent-dark": "#ff0000", + "--cl-color-user-bubble-text": "#00ff00", + "--cl-color-user-bubble-text-dark": "#00ff00", + }); + }); + + it("lets colorsDark override the dark mirror per token", () => { + const vars = themeToCssVars({ + colors: { accent: "#ff0000" }, + colorsDark: { accent: "#990000", surface: "#000000" }, + }); + expect(vars["--cl-color-accent"]).toBe("#ff0000"); + expect(vars["--cl-color-accent-dark"]).toBe("#990000"); + // colorsDark-only keys emit only the -dark var + expect(vars["--cl-color-surface-dark"]).toBe("#000000"); + expect(vars["--cl-color-surface"]).toBeUndefined(); + }); + + it("maps radii, shadows, and fonts groups", () => { + const vars = themeToCssVars({ + radii: { sm: "2px", md: "4px", lg: "8px", full: "999px", tail: "1px" }, + shadows: { + elevated: "0 1px 2px black", + floating: "none", + floatingHover: "0 0 1px red", + }, + fonts: { heading: "Georgia, serif", body: "Arial, sans-serif" }, + }); + expect(vars["--cl-radius-sm"]).toBe("2px"); + expect(vars["--cl-radius-md"]).toBe("4px"); + expect(vars["--cl-radius-lg"]).toBe("8px"); + expect(vars["--cl-radius-full"]).toBe("999px"); + expect(vars["--cl-radius-tail"]).toBe("1px"); + expect(vars["--cl-shadow-elevated"]).toBe("0 1px 2px black"); + expect(vars["--cl-shadow-floating"]).toBe("none"); + expect(vars["--cl-shadow-floating-hover"]).toBe("0 0 1px red"); + expect(vars["--cl-font-heading"]).toBe("Georgia, serif"); + expect(vars["--cl-font-body"]).toBe("Arial, sans-serif"); + }); + + it("warns on and skips unknown token keys", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const vars = themeToCssVars({ + colors: { accent: "#fff", banana: "#yellow" } as never, + }); + expect(vars["--cl-color-banana"]).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("banana"), + ); + }); + + it("skips non-string values without crashing", () => { + const vars = themeToCssVars({ + colors: { accent: 42 } as never, + }); + expect(vars).toEqual({}); + }); + + it("returns an empty object for an empty theme", () => { + expect(themeToCssVars({})).toEqual({}); + }); +}); + +describe("builtinThemes", () => { + it("exposes exactly the four documented themes", () => { + expect(Object.keys(builtinThemes).sort()).toEqual([ + "corporate", + "default", + "minimal", + "playful", + ]); + }); + + it("default theme has no token overrides (the baked-in defaults)", () => { + expect(themeToCssVars(builtinThemes.default)).toEqual({}); + }); + + it("every built-in produces only valid --cl- variables", () => { + for (const theme of Object.values(builtinThemes)) { + for (const key of Object.keys(themeToCssVars(theme))) { + expect(key).toMatch(/^--cl-(color|radius|shadow|font)-[a-z-]+$/); + } + } + }); +}); diff --git a/widget/src/theme/index.ts b/widget/src/theme/index.ts new file mode 100644 index 0000000..74c1f43 --- /dev/null +++ b/widget/src/theme/index.ts @@ -0,0 +1,22 @@ +export { + THEME_COLOR_TOKENS, + THEME_RADIUS_TOKENS, + THEME_SHADOW_TOKENS, + THEME_FONT_TOKENS, +} from "./types"; +export type { + ClaudiusTheme, + ClaudiusThemeInput, + BuiltinThemeName, + ThemeColorToken, + ThemeRadiusToken, + ThemeShadowToken, + ThemeFontToken, +} from "./types"; +export { builtinThemes, isBuiltinThemeName } from "./themes"; +export { + resolveThemeInput, + themeToCssVars, + type ResolvedThemeInput, + type ColorSchemeMode, +} from "./resolve"; diff --git a/widget/src/theme/resolve.ts b/widget/src/theme/resolve.ts new file mode 100644 index 0000000..853e6b6 --- /dev/null +++ b/widget/src/theme/resolve.ts @@ -0,0 +1,101 @@ +import { + THEME_COLOR_TOKENS, + THEME_RADIUS_TOKENS, + THEME_SHADOW_TOKENS, + THEME_FONT_TOKENS, + type ClaudiusTheme, + type ClaudiusThemeInput, +} from "./types"; +import { builtinThemes, isBuiltinThemeName } from "./themes"; + +export type ColorSchemeMode = "light" | "dark" | "auto"; + +const MODES: ReadonlySet = new Set(["light", "dark", "auto"]); + +export interface ResolvedThemeInput { + mode: ColorSchemeMode; + theme?: ClaudiusTheme; + /** Set when the input is a URL to a theme JSON file (fetched separately). */ + url?: string; +} + +/** + * Classifies the `theme` option. Mode strings keep their original meaning; + * built-in names map to their theme objects; any other string is a URL. + */ +export function resolveThemeInput( + input: ClaudiusThemeInput | undefined, +): ResolvedThemeInput { + if (input === undefined) { + return { mode: "light" }; + } + if (typeof input === "string") { + if (MODES.has(input)) { + return { mode: input as ColorSchemeMode }; + } + if (isBuiltinThemeName(input)) { + const theme = builtinThemes[input]; + return { mode: theme.colorScheme ?? "light", theme }; + } + return { mode: "light", url: input }; + } + return { mode: input.colorScheme ?? "light", theme: input }; +} + +function camelToKebab(key: string): string { + return key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); +} + +function warnUnknown(group: string, key: string): void { + console.warn( + `[Claudius] Ignoring unknown theme token "${key}" in "${group}"`, + ); +} + +function collect( + out: Record, + group: string, + prefix: string, + validKeys: readonly string[], + values: Record | undefined, + suffix = "", +): void { + if (!values) return; + for (const [key, value] of Object.entries(values)) { + if (!validKeys.includes(key)) { + warnUnknown(group, key); + continue; + } + if (typeof value !== "string") continue; + out[`${prefix}${camelToKebab(key)}${suffix}`] = value; + } +} + +/** + * Converts a theme object into the --cl-* custom-property map applied to the + * widget root. Light colors mirror into the -dark variables so themed tokens + * survive dark mode; `colorsDark` overrides the mirror per token. + */ +export function themeToCssVars(theme: ClaudiusTheme): Record { + const vars: Record = {}; + + collect(vars, "colors", "--cl-color-", THEME_COLOR_TOKENS, theme.colors); + // Mirror light colors into dark before applying explicit dark overrides. + for (const [name, value] of Object.entries({ ...vars })) { + vars[`${name}-dark`] = value; + } + collect( + vars, + "colorsDark", + "--cl-color-", + THEME_COLOR_TOKENS, + theme.colorsDark, + "-dark", + ); + + collect(vars, "radii", "--cl-radius-", THEME_RADIUS_TOKENS, theme.radii); + collect(vars, "shadows", "--cl-shadow-", THEME_SHADOW_TOKENS, theme.shadows); + collect(vars, "fonts", "--cl-font-", THEME_FONT_TOKENS, theme.fonts); + + return vars; +} diff --git a/widget/src/theme/themes.ts b/widget/src/theme/themes.ts new file mode 100644 index 0000000..46d29ed --- /dev/null +++ b/widget/src/theme/themes.ts @@ -0,0 +1,99 @@ +import type { BuiltinThemeName, ClaudiusTheme } from "./types"; + +/** + * Built-in themes. `default` is intentionally empty: the baked-in token + * defaults (see tailwind.config.ts fallbacks and the dark block in + * styles.css) ARE the default theme. + */ +export const builtinThemes: Record = { + default: { + name: "default", + }, + + minimal: { + name: "minimal", + colors: { + accent: "#171717", + accentText: "#ffffff", + surface: "#ffffff", + surfaceMuted: "#f5f5f5", + text: "#171717", + textMuted: "#737373", + border: "#e5e5e5", + link: "#171717", + }, + colorsDark: { + accent: "#fafafa", + accentText: "#171717", + surface: "#0a0a0a", + surfaceMuted: "#1c1c1c", + text: "#fafafa", + textMuted: "#a3a3a3", + border: "#2e2e2e", + field: "#1c1c1c", + link: "#fafafa", + }, + radii: { sm: "4px", md: "6px", lg: "10px" }, + shadows: { + elevated: "0 4px 16px rgb(0 0 0 / 0.12)", + floating: "0 2px 8px rgb(0 0 0 / 0.12)", + floatingHover: "0 4px 12px rgb(0 0 0 / 0.16)", + }, + }, + + playful: { + name: "playful", + colors: { + accent: "#8b5cf6", + surfaceMuted: "#f5f3ff", + assistantBubbleText: "#4c1d95", + link: "#7c3aed", + }, + colorsDark: { + accent: "#a78bfa", + surfaceMuted: "#2e1065", + assistantBubbleText: "#ede9fe", + link: "#c4b5fd", + }, + radii: { sm: "12px", md: "16px", lg: "24px", tail: "8px" }, + shadows: { + elevated: "0 24px 48px -12px rgb(139 92 246 / 0.35)", + floating: "0 10px 20px -5px rgb(139 92 246 / 0.4)", + floatingHover: "0 16px 28px -6px rgb(139 92 246 / 0.5)", + }, + fonts: { + heading: "ui-rounded, 'Hiragino Maru Gothic ProN', system-ui", + body: "ui-rounded, 'Hiragino Maru Gothic ProN', system-ui", + }, + }, + + corporate: { + name: "corporate", + colors: { + accent: "#1e3a5f", + surfaceMuted: "#f3f4f6", + text: "#1f2937", + textMuted: "#6b7280", + border: "#d1d5db", + link: "#1d4ed8", + }, + colorsDark: { + accent: "#2c5282", + link: "#93c5fd", + }, + radii: { sm: "4px", md: "6px", lg: "8px", tail: "2px" }, + shadows: { + elevated: "0 8px 24px rgb(15 23 42 / 0.16)", + floating: "0 4px 12px rgb(15 23 42 / 0.18)", + floatingHover: "0 6px 16px rgb(15 23 42 / 0.24)", + }, + fonts: { + heading: "'Segoe UI', 'Helvetica Neue', Arial, system-ui", + body: "'Segoe UI', 'Helvetica Neue', Arial, system-ui", + }, + }, +}; + +export function isBuiltinThemeName(value: string): value is BuiltinThemeName { + return Object.prototype.hasOwnProperty.call(builtinThemes, value); +} diff --git a/widget/src/theme/types.ts b/widget/src/theme/types.ts new file mode 100644 index 0000000..2cf5878 --- /dev/null +++ b/widget/src/theme/types.ts @@ -0,0 +1,72 @@ +export const THEME_COLOR_TOKENS = [ + "accent", + "accentText", + "accentSoft", + "accentTextMuted", + "surface", + "surfaceMuted", + "text", + "textMuted", + "border", + "userBubble", + "userBubbleText", + "assistantBubble", + "assistantBubbleText", + "field", + "error", + "errorSurface", + "errorText", + "link", + "scrim", +] as const; + +export const THEME_RADIUS_TOKENS = ["sm", "md", "lg", "full", "tail"] as const; + +export const THEME_SHADOW_TOKENS = [ + "elevated", + "floating", + "floatingHover", +] as const; + +export const THEME_FONT_TOKENS = ["heading", "body"] as const; + +export type ThemeColorToken = (typeof THEME_COLOR_TOKENS)[number]; +export type ThemeRadiusToken = (typeof THEME_RADIUS_TOKENS)[number]; +export type ThemeShadowToken = (typeof THEME_SHADOW_TOKENS)[number]; +export type ThemeFontToken = (typeof THEME_FONT_TOKENS)[number]; + +/** + * A Claudius design-token theme. Every value is a CSS string applied via + * --cl-* custom properties. `colors` apply to both light and dark modes + * unless `colorsDark` overrides a token for dark mode specifically. + * + * JSON theme files validate against + * https://claudius-docs.pages.dev/schema/theme.v1.json + */ +export interface ClaudiusTheme { + $schema?: string; + name?: string; + /** Initial color scheme this theme is designed for. Defaults to "light". */ + colorScheme?: "light" | "dark" | "auto"; + colors?: Partial>; + colorsDark?: Partial>; + radii?: Partial>; + shadows?: Partial>; + fonts?: Partial>; +} + +export type BuiltinThemeName = "default" | "minimal" | "playful" | "corporate"; + +/** + * Everything the `theme` option accepts: the original color-scheme modes, a + * built-in theme name, an inline theme object, or a URL to a theme JSON file. + * `(string & {})` keeps literal autocomplete while allowing URLs. + */ +export type ClaudiusThemeInput = + | "light" + | "dark" + | "auto" + | BuiltinThemeName + | ClaudiusTheme + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + | (string & {}); From 1ce06d34e48ee2dc000a96a65802e119b133e3c1 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 11 Jun 2026 01:49:39 -0400 Subject: [PATCH 4/9] feat(widget): theme prop accepts built-in names, theme objects, and URLs useTheme resolves the union: mode strings keep their existing meaning, built-in names map to bundled themes, other strings are fetched as theme JSON (falling back to defaults on any failure), and objects apply directly. accentColor still wins over the theme accent. Exports builtinThemes and the ClaudiusTheme types. Co-Authored-By: Claude Fable 5 --- widget/src/components/ChatWidget.tsx | 37 +++- widget/src/embed.tsx | 9 +- widget/src/index.ts | 10 + .../src/theme/__tests__/integration.test.tsx | 177 ++++++++++++++++++ widget/src/theme/useTheme.ts | 68 +++++++ 5 files changed, 289 insertions(+), 12 deletions(-) create mode 100644 widget/src/theme/__tests__/integration.test.tsx create mode 100644 widget/src/theme/useTheme.ts diff --git a/widget/src/components/ChatWidget.tsx b/widget/src/components/ChatWidget.tsx index 59347de..6eca2fa 100644 --- a/widget/src/components/ChatWidget.tsx +++ b/widget/src/components/ChatWidget.tsx @@ -11,6 +11,8 @@ import { createTranslations, } from "../i18n"; import { resolveTranslations, type LocaleCode } from "../locales"; +import { useTheme } from "../theme/useTheme"; +import type { ClaudiusThemeInput } from "../theme/types"; export type WidgetPosition = | "bottom-right" @@ -29,7 +31,12 @@ export interface ChatWidgetProps { persistMessages?: boolean; storageKeyPrefix?: string; requestTimeoutMs?: number; - theme?: "light" | "dark" | "auto"; + /** + * Color-scheme mode ("light" | "dark" | "auto"), a built-in theme name + * ("default" | "minimal" | "playful" | "corporate"), an inline + * ClaudiusTheme object, or a URL to a theme JSON file. + */ + theme?: ClaudiusThemeInput; accentColor?: string; position?: WidgetPosition; locale?: LocaleCode; @@ -92,10 +99,12 @@ export function ChatWidget({ const toggleRef = useRef(null); const prevOpenRef = useRef(isOpen); + const { mode, cssVars } = useTheme(theme); + const [osDark, setOsDark] = useState(false); useEffect(() => { - if (theme !== "auto") return; + if (mode !== "auto") return; const mq = window.matchMedia("(prefers-color-scheme: dark)"); setOsDark(mq.matches); @@ -103,7 +112,7 @@ export function ChatWidget({ const handler = (e: MediaQueryListEvent) => setOsDark(e.matches); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); - }, [theme]); + }, [mode]); useEffect(() => { // Return focus to toggle button when chat closes @@ -157,11 +166,23 @@ export function ChatWidget({ onGreeting: handleTriggerGreeting, }); - const isDark = theme === "dark" || (theme === "auto" && osDark); - - const wrapperStyle: React.CSSProperties | undefined = accentColor - ? ({ "--cl-color-accent": accentColor } as React.CSSProperties) - : undefined; + const isDark = mode === "dark" || (mode === "auto" && osDark); + + // accentColor (the v1 API) wins over the theme's accent; it also overrides + // the dark mirror so the override holds in dark mode. + const tokenVars: Record = { + ...cssVars, + ...(accentColor + ? { + "--cl-color-accent": accentColor, + "--cl-color-accent-dark": accentColor, + } + : {}), + }; + const wrapperStyle: React.CSSProperties | undefined = + Object.keys(tokenVars).length > 0 + ? (tokenVars as React.CSSProperties) + : undefined; return ( // Outer div carries theme token vars; the inner div carries the dark-mode diff --git a/widget/src/embed.tsx b/widget/src/embed.tsx index 720817a..e427051 100644 --- a/widget/src/embed.tsx +++ b/widget/src/embed.tsx @@ -3,6 +3,7 @@ import { ChatWidget, WidgetPosition } from "./components/ChatWidget"; import type { Trigger } from "./hooks/useTriggers"; import type { LocaleCode } from "./locales"; import type { ClaudiusTranslations } from "./i18n"; +import type { ClaudiusThemeInput } from "./theme/types"; import "./styles.css"; // Injected at build time by vite.config.embed.ts; undefined under unit tests. @@ -20,7 +21,7 @@ interface ClaudiusConfig { persistMessages?: boolean; storageKeyPrefix?: string; requestTimeoutMs?: number; - theme?: "light" | "dark" | "auto"; + theme?: ClaudiusThemeInput; accentColor?: string; position?: WidgetPosition; locale?: LocaleCode; @@ -145,9 +146,9 @@ class ClaudiusChat extends HTMLElement { ? requestTimeoutMs : undefined } - theme={ - (this.getAttribute("theme") as "light" | "dark" | "auto") ?? undefined - } + // Mode strings, built-in theme names, and theme URLs all work as an + // attribute; inline theme objects need ClaudiusConfig or React. + theme={this.getAttribute("theme") ?? undefined} accentColor={this.getAttribute("accent-color") ?? undefined} position={ (this.getAttribute("position") as WidgetPosition) ?? undefined diff --git a/widget/src/index.ts b/widget/src/index.ts index a47c4cd..2d69cde 100644 --- a/widget/src/index.ts +++ b/widget/src/index.ts @@ -8,6 +8,16 @@ export { defaultTranslations, createTranslations } from "./i18n"; export { locales, detectLocale, resolveTranslations } from "./locales"; export type { LocaleCode } from "./locales"; export type { Trigger, TriggerAction, UrlPattern } from "./hooks/useTriggers"; +export { builtinThemes } from "./theme"; +export type { + ClaudiusTheme, + ClaudiusThemeInput, + BuiltinThemeName, + ThemeColorToken, + ThemeRadiusToken, + ThemeShadowToken, + ThemeFontToken, +} from "./theme"; export { ChatApiClient } from "./api/client"; export type { ChatApiClientOptions } from "./api/client"; export { ChatApiError, DebounceError } from "./api/errors"; diff --git a/widget/src/theme/__tests__/integration.test.tsx b/widget/src/theme/__tests__/integration.test.tsx new file mode 100644 index 0000000..faec751 --- /dev/null +++ b/widget/src/theme/__tests__/integration.test.tsx @@ -0,0 +1,177 @@ +import { render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChatWidget } from "../../components/ChatWidget"; + +function getRoot(container: HTMLElement): HTMLElement { + return container.querySelector(".claudius-root") as HTMLElement; +} + +function getDarkWrapper(container: HTMLElement): HTMLElement { + return container.querySelector("[data-claudius-dark]") as HTMLElement; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("ChatWidget theme prop", () => { + it("applies a built-in theme's tokens by name", () => { + const { container } = render( + , + ); + const root = getRoot(container); + expect(root.style.getPropertyValue("--cl-color-accent")).toBe("#1e3a5f"); + expect(root.style.getPropertyValue("--cl-radius-lg")).toBe("8px"); + }); + + it("applies an inline theme object", () => { + const { container } = render( + , + ); + const root = getRoot(container); + expect(root.style.getPropertyValue("--cl-color-accent")).toBe("#ff0066"); + expect(root.style.getPropertyValue("--cl-color-accent-dark")).toBe( + "#ff0066", + ); + expect(root.style.getPropertyValue("--cl-radius-lg")).toBe("0px"); + }); + + it("keeps mode-only strings working with no token vars (existing API)", () => { + const { container } = render( + , + ); + expect(getDarkWrapper(container).getAttribute("data-claudius-dark")).toBe( + "true", + ); + expect(getRoot(container).getAttribute("style")).toBeFalsy(); + }); + + it("honors a theme object's colorScheme for dark mode", () => { + const { container } = render( + , + ); + expect(getDarkWrapper(container).getAttribute("data-claudius-dark")).toBe( + "true", + ); + }); + + it("lets accentColor override the theme accent in both modes", () => { + const { container } = render( + , + ); + const root = getRoot(container); + expect(root.style.getPropertyValue("--cl-color-accent")).toBe("#bada55"); + expect(root.style.getPropertyValue("--cl-color-accent-dark")).toBe( + "#bada55", + ); + }); + + it("fetches a theme from a URL and applies it", async () => { + const theme = { colors: { accent: "#123456" } }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(theme), + }); + vi.stubGlobal("fetch", fetchMock); + + const { container } = render( + , + ); + + await waitFor(() => { + expect( + getRoot(container).style.getPropertyValue("--cl-color-accent"), + ).toBe("#123456"); + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://cdn.example/acme.theme.json", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("falls back to defaults and logs when the theme URL fails", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 404 }), + ); + + const { container } = render( + , + ); + + await waitFor(() => { + expect(error).toHaveBeenCalledWith( + expect.stringContaining("missing.json"), + expect.anything(), + ); + }); + expect(getRoot(container).style.getPropertyValue("--cl-color-accent")).toBe( + "", + ); + }); + + it("rejects non-object theme JSON and keeps defaults", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(["not", "a", "theme"]), + }), + ); + + const { container } = render( + , + ); + + await waitFor(() => { + expect(error).toHaveBeenCalled(); + }); + expect(getRoot(container).getAttribute("style")).toBeFalsy(); + }); + + it("uses the fetched theme's colorScheme", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ colorScheme: "dark", colors: { accent: "#000" } }), + }), + ); + + const { container } = render( + , + ); + + await waitFor(() => { + expect(getDarkWrapper(container).getAttribute("data-claudius-dark")).toBe( + "true", + ); + }); + }); +}); diff --git a/widget/src/theme/useTheme.ts b/widget/src/theme/useTheme.ts new file mode 100644 index 0000000..a3f2cd7 --- /dev/null +++ b/widget/src/theme/useTheme.ts @@ -0,0 +1,68 @@ +import { useEffect, useMemo, useState } from "react"; +import { + resolveThemeInput, + themeToCssVars, + type ColorSchemeMode, +} from "./resolve"; +import type { ClaudiusTheme, ClaudiusThemeInput } from "./types"; + +function isThemeShaped(value: unknown): value is ClaudiusTheme { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export interface UseThemeResult { + mode: ColorSchemeMode; + /** --cl-* custom properties to apply inline on the widget root. */ + cssVars: Record; +} + +/** + * Resolves the `theme` option into a color-scheme mode and a set of CSS + * custom properties. URL inputs are fetched; any failure (network, JSON, + * shape) logs one console.error and leaves the default theme active so the + * widget never breaks because a theme file is unreachable. + */ +export function useTheme( + input: ClaudiusThemeInput | undefined, +): UseThemeResult { + const resolved = useMemo(() => resolveThemeInput(input), [input]); + const [fetched, setFetched] = useState(null); + + const url = resolved.url; + + useEffect(() => { + setFetched(null); + if (!url) return; + + const controller = new AbortController(); + fetch(url, { signal: controller.signal }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + }) + .then((json: unknown) => { + if (!isThemeShaped(json)) { + throw new Error("theme JSON must be an object"); + } + setFetched(json); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + console.error(`[Claudius] Failed to load theme from ${url}:`, err); + }); + + return () => controller.abort(); + }, [url]); + + const activeTheme = resolved.theme ?? fetched ?? null; + const mode = fetched?.colorScheme ?? resolved.mode; + + const cssVars = useMemo( + () => (activeTheme ? themeToCssVars(activeTheme) : {}), + [activeTheme], + ); + + return { mode, cssVars }; +} From ce43d5bd4c1ff52d7af02fc821cf4aed0138cecc Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 11 Jun 2026 01:51:04 -0400 Subject: [PATCH 5/9] feat(widget): publish theme.v1 JSON schema with validation tests Source of truth lives next to the theme types; the byte-identical copy in docs/public/schema/ is served at https://claudius-docs.pages.dev/schema/theme.v1.json. AJV validates the built-ins and rejection cases in tests (dev-only dependency). Co-Authored-By: Claude Fable 5 --- docs/public/schema/theme.v1.json | 81 ++++++++++++++++++++ widget/package.json | 1 + widget/pnpm-lock.yaml | 29 +++++++ widget/src/theme/__tests__/schema.test.ts | 92 +++++++++++++++++++++++ widget/src/theme/theme.v1.schema.json | 81 ++++++++++++++++++++ 5 files changed, 284 insertions(+) create mode 100644 docs/public/schema/theme.v1.json create mode 100644 widget/src/theme/__tests__/schema.test.ts create mode 100644 widget/src/theme/theme.v1.schema.json diff --git a/docs/public/schema/theme.v1.json b/docs/public/schema/theme.v1.json new file mode 100644 index 0000000..99db592 --- /dev/null +++ b/docs/public/schema/theme.v1.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://claudius-docs.pages.dev/schema/theme.v1.json", + "title": "Claudius Theme", + "description": "Design-token theme for the Claudius chat widget. Every value is a CSS string applied through a --cl-* custom property. `colors` apply to both light and dark modes unless `colorsDark` overrides a token for dark mode.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "name": { + "type": "string", + "description": "Human-readable theme name", + "minLength": 1 + }, + "colorScheme": { + "description": "Initial color scheme this theme is designed for (default: light)", + "enum": ["light", "dark", "auto"] + }, + "colors": { "$ref": "#/definitions/colorTokens" }, + "colorsDark": { "$ref": "#/definitions/colorTokens" }, + "radii": { + "type": "object", + "additionalProperties": false, + "properties": { + "sm": { "$ref": "#/definitions/cssValue" }, + "md": { "$ref": "#/definitions/cssValue" }, + "lg": { "$ref": "#/definitions/cssValue" }, + "full": { "$ref": "#/definitions/cssValue" }, + "tail": { "$ref": "#/definitions/cssValue" } + } + }, + "shadows": { + "type": "object", + "additionalProperties": false, + "properties": { + "elevated": { "$ref": "#/definitions/cssValue" }, + "floating": { "$ref": "#/definitions/cssValue" }, + "floatingHover": { "$ref": "#/definitions/cssValue" } + } + }, + "fonts": { + "type": "object", + "additionalProperties": false, + "properties": { + "heading": { "$ref": "#/definitions/cssValue" }, + "body": { "$ref": "#/definitions/cssValue" } + } + } + }, + "definitions": { + "cssValue": { + "type": "string", + "minLength": 1 + }, + "colorTokens": { + "type": "object", + "additionalProperties": false, + "properties": { + "accent": { "$ref": "#/definitions/cssValue" }, + "accentText": { "$ref": "#/definitions/cssValue" }, + "accentSoft": { "$ref": "#/definitions/cssValue" }, + "accentTextMuted": { "$ref": "#/definitions/cssValue" }, + "surface": { "$ref": "#/definitions/cssValue" }, + "surfaceMuted": { "$ref": "#/definitions/cssValue" }, + "text": { "$ref": "#/definitions/cssValue" }, + "textMuted": { "$ref": "#/definitions/cssValue" }, + "border": { "$ref": "#/definitions/cssValue" }, + "userBubble": { "$ref": "#/definitions/cssValue" }, + "userBubbleText": { "$ref": "#/definitions/cssValue" }, + "assistantBubble": { "$ref": "#/definitions/cssValue" }, + "assistantBubbleText": { "$ref": "#/definitions/cssValue" }, + "field": { "$ref": "#/definitions/cssValue" }, + "error": { "$ref": "#/definitions/cssValue" }, + "errorSurface": { "$ref": "#/definitions/cssValue" }, + "errorText": { "$ref": "#/definitions/cssValue" }, + "link": { "$ref": "#/definitions/cssValue" }, + "scrim": { "$ref": "#/definitions/cssValue" } + } + } + } +} diff --git a/widget/package.json b/widget/package.json index 9fad446..1f29d5b 100644 --- a/widget/package.json +++ b/widget/package.json @@ -55,6 +55,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.4.0", "@vitest/coverage-v8": "^4.1.5", + "ajv": "^8.20.0", "autoprefixer": "^10.4.0", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.0", diff --git a/widget/pnpm-lock.yaml b/widget/pnpm-lock.yaml index ae6079f..76695b5 100644 --- a/widget/pnpm-lock.yaml +++ b/widget/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(vitest@4.1.0(jsdom@26.1.0)(vite@6.4.1(jiti@1.21.7))) + ajv: + specifier: ^8.20.0 + version: 8.20.0 autoprefixer: specifier: ^10.4.0 version: 10.4.27(postcss@8.5.8) @@ -1140,6 +1143,9 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1581,6 +1587,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1926,6 +1935,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2286,6 +2298,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3698,6 +3714,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4236,6 +4259,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.2: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4589,6 +4614,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -4978,6 +5005,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve@1.22.11: diff --git a/widget/src/theme/__tests__/schema.test.ts b/widget/src/theme/__tests__/schema.test.ts new file mode 100644 index 0000000..a0e783c --- /dev/null +++ b/widget/src/theme/__tests__/schema.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import Ajv from "ajv"; +import schema from "../theme.v1.schema.json"; +import { builtinThemes } from "../themes"; +import { + THEME_COLOR_TOKENS, + THEME_RADIUS_TOKENS, + THEME_SHADOW_TOKENS, + THEME_FONT_TOKENS, +} from "../types"; + +const ajv = new Ajv({ allErrors: true }); +const validate = ajv.compile(schema); + +describe("theme.v1 JSON schema", () => { + it("accepts every built-in theme", () => { + for (const [name, theme] of Object.entries(builtinThemes)) { + const valid = validate(theme); + expect(valid, `${name}: ${ajv.errorsText(validate.errors)}`).toBe(true); + } + }); + + it("accepts a kitchen-sink theme using every token", () => { + const theme = { + $schema: "https://claudius-docs.pages.dev/schema/theme.v1.json", + name: "kitchen-sink", + colorScheme: "auto", + colors: Object.fromEntries(THEME_COLOR_TOKENS.map((t) => [t, "#123456"])), + colorsDark: Object.fromEntries( + THEME_COLOR_TOKENS.map((t) => [t, "#654321"]), + ), + radii: Object.fromEntries(THEME_RADIUS_TOKENS.map((t) => [t, "4px"])), + shadows: Object.fromEntries( + THEME_SHADOW_TOKENS.map((t) => [t, "0 1px 2px black"]), + ), + fonts: Object.fromEntries( + THEME_FONT_TOKENS.map((t) => [t, "Georgia, serif"]), + ), + }; + expect(validate(theme), ajv.errorsText(validate.errors)).toBe(true); + }); + + it("rejects unknown color tokens", () => { + expect(validate({ colors: { banana: "#ffff00" } })).toBe(false); + }); + + it("rejects unknown top-level properties", () => { + expect(validate({ tokens: {} })).toBe(false); + }); + + it("rejects non-string and empty values", () => { + expect(validate({ colors: { accent: 42 } })).toBe(false); + expect(validate({ radii: { md: "" } })).toBe(false); + }); + + it("rejects invalid colorScheme values", () => { + expect(validate({ colorScheme: "sepia" })).toBe(false); + }); + + it("schema token lists stay in sync with the TypeScript types", () => { + const colorProps = Object.keys( + (schema as Record)["definitions"]["colorTokens"][ + "properties" + ], + ); + expect(colorProps.sort()).toEqual([...THEME_COLOR_TOKENS].sort()); + }); + + it("published copy in docs/public is byte-identical to the source", () => { + const source = readFileSync( + join(__dirname, "..", "theme.v1.schema.json"), + "utf8", + ); + const published = readFileSync( + join( + __dirname, + "..", + "..", + "..", + "..", + "docs", + "public", + "schema", + "theme.v1.json", + ), + "utf8", + ); + expect(published).toBe(source); + }); +}); diff --git a/widget/src/theme/theme.v1.schema.json b/widget/src/theme/theme.v1.schema.json new file mode 100644 index 0000000..99db592 --- /dev/null +++ b/widget/src/theme/theme.v1.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://claudius-docs.pages.dev/schema/theme.v1.json", + "title": "Claudius Theme", + "description": "Design-token theme for the Claudius chat widget. Every value is a CSS string applied through a --cl-* custom property. `colors` apply to both light and dark modes unless `colorsDark` overrides a token for dark mode.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "name": { + "type": "string", + "description": "Human-readable theme name", + "minLength": 1 + }, + "colorScheme": { + "description": "Initial color scheme this theme is designed for (default: light)", + "enum": ["light", "dark", "auto"] + }, + "colors": { "$ref": "#/definitions/colorTokens" }, + "colorsDark": { "$ref": "#/definitions/colorTokens" }, + "radii": { + "type": "object", + "additionalProperties": false, + "properties": { + "sm": { "$ref": "#/definitions/cssValue" }, + "md": { "$ref": "#/definitions/cssValue" }, + "lg": { "$ref": "#/definitions/cssValue" }, + "full": { "$ref": "#/definitions/cssValue" }, + "tail": { "$ref": "#/definitions/cssValue" } + } + }, + "shadows": { + "type": "object", + "additionalProperties": false, + "properties": { + "elevated": { "$ref": "#/definitions/cssValue" }, + "floating": { "$ref": "#/definitions/cssValue" }, + "floatingHover": { "$ref": "#/definitions/cssValue" } + } + }, + "fonts": { + "type": "object", + "additionalProperties": false, + "properties": { + "heading": { "$ref": "#/definitions/cssValue" }, + "body": { "$ref": "#/definitions/cssValue" } + } + } + }, + "definitions": { + "cssValue": { + "type": "string", + "minLength": 1 + }, + "colorTokens": { + "type": "object", + "additionalProperties": false, + "properties": { + "accent": { "$ref": "#/definitions/cssValue" }, + "accentText": { "$ref": "#/definitions/cssValue" }, + "accentSoft": { "$ref": "#/definitions/cssValue" }, + "accentTextMuted": { "$ref": "#/definitions/cssValue" }, + "surface": { "$ref": "#/definitions/cssValue" }, + "surfaceMuted": { "$ref": "#/definitions/cssValue" }, + "text": { "$ref": "#/definitions/cssValue" }, + "textMuted": { "$ref": "#/definitions/cssValue" }, + "border": { "$ref": "#/definitions/cssValue" }, + "userBubble": { "$ref": "#/definitions/cssValue" }, + "userBubbleText": { "$ref": "#/definitions/cssValue" }, + "assistantBubble": { "$ref": "#/definitions/cssValue" }, + "assistantBubbleText": { "$ref": "#/definitions/cssValue" }, + "field": { "$ref": "#/definitions/cssValue" }, + "error": { "$ref": "#/definitions/cssValue" }, + "errorSurface": { "$ref": "#/definitions/cssValue" }, + "errorText": { "$ref": "#/definitions/cssValue" }, + "link": { "$ref": "#/definitions/cssValue" }, + "scrim": { "$ref": "#/definitions/cssValue" } + } + } + } +} From c1f1b2afbc6e212409478883114e336bad231145 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 11 Jun 2026 01:54:16 -0400 Subject: [PATCH 6/9] docs: theming v2 reference, migration guide, and visual theme editor Co-Authored-By: Claude Fable 5 --- docs/astro.config.mjs | 5 +- docs/src/content/docs/api/widget.md | 26 +- .../src/content/docs/configuration/theming.md | 158 ++++++-- docs/src/content/docs/configuration/widget.md | 2 +- .../docs/migration/accent-color-to-themes.md | 67 ++++ docs/src/pages/theme-editor.astro | 354 ++++++++++++++++++ 6 files changed, 580 insertions(+), 32 deletions(-) create mode 100644 docs/src/content/docs/migration/accent-color-to-themes.md create mode 100644 docs/src/pages/theme-editor.astro diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 491e788..fa13a46 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -40,7 +40,10 @@ export default defineConfig({ }, { label: "Configuration", - items: [{ autogenerate: { directory: "configuration" } }], + items: [ + { autogenerate: { directory: "configuration" } }, + { label: "Theme editor", link: "/theme-editor/" }, + ], }, { label: "Deployment", diff --git a/docs/src/content/docs/api/widget.md b/docs/src/content/docs/api/widget.md index 017a5a0..8184950 100644 --- a/docs/src/content/docs/api/widget.md +++ b/docs/src/content/docs/api/widget.md @@ -24,7 +24,7 @@ interface ChatWidgetProps { persistMessages?: boolean; storageKeyPrefix?: string; requestTimeoutMs?: number; - theme?: "light" | "dark" | "auto"; + theme?: ClaudiusThemeInput; accentColor?: string; position?: "bottom-right" | "bottom-left" | "top-right" | "top-left"; locale?: "en" | "es" | "fr" | "de"; @@ -47,6 +47,30 @@ type Trigger = type TriggerAction = "open" | { greeting: string }; ``` +### `ClaudiusThemeInput` and `ClaudiusTheme` + +```ts +type ClaudiusThemeInput = + | "light" | "dark" | "auto" // color-scheme mode + | "default" | "minimal" | "playful" | "corporate" // built-in themes + | ClaudiusTheme // inline tokens + | (string & {}); // URL to theme JSON + +interface ClaudiusTheme { + $schema?: string; + name?: string; + colorScheme?: "light" | "dark" | "auto"; + colors?: Partial>; + colorsDark?: Partial>; + radii?: Partial>; + shadows?: Partial>; + fonts?: Partial>; +} +``` + +The four built-ins are exported as `builtinThemes`. Token semantics and +defaults: [theming reference](/configuration/theming/#token-reference). + ### `ClaudiusTranslations` All UI strings; see the [key table](/configuration/localization/#available-keys). diff --git a/docs/src/content/docs/configuration/theming.md b/docs/src/content/docs/configuration/theming.md index 7bf3e6f..069cb11 100644 --- a/docs/src/content/docs/configuration/theming.md +++ b/docs/src/content/docs/configuration/theming.md @@ -1,51 +1,151 @@ --- title: Theming -description: Light, dark, and auto themes; brand colors; deeper customization. +description: Design tokens, theme files, built-in themes, and dark mode. sidebar: order: 5 --- -## Theme modes +Every visual decision in the widget — colors, radii, shadows, fonts — resolves +through a `--cl-*` CSS custom property, so a theme can restyle all of it +without forking CSS. Try combinations live in the +[theme editor](/theme-editor/). -| Mode | Behavior | -|------|----------| -| `"light"` (default) | Light background, dark text | -| `"dark"` | Dark background, light text | -| `"auto"` | Follows the OS via `prefers-color-scheme`, live-updating | +## The `theme` option + +One option accepts four kinds of values (React prop, `window.ClaudiusConfig` +key, or `` attribute): + +| Value | Example | Effect | +|-------|---------|--------| +| Mode string | `"light"`, `"dark"`, `"auto"` | Color scheme only (the original API, unchanged) | +| Built-in theme name | `"minimal"`, `"playful"`, `"corporate"`, `"default"` | Applies a bundled theme | +| Theme object | `{ colors: { accent: "#0057a3" } }` | Inline design tokens (React / `ClaudiusConfig` only) | +| URL string | `"https://your-site.example/claudius-theme.json"` | Fetches a JSON theme file validated against [the schema](https://claudius-docs.pages.dev/schema/theme.v1.json) | + +If a theme URL is unreachable or invalid, the widget logs a console error and +keeps the default theme — it never breaks because a theme file is down. ```js -window.ClaudiusConfig = { apiUrl: "...", theme: "auto" }; +window.ClaudiusConfig = { + apiUrl: "...", + theme: { + colorScheme: "auto", + colors: { accent: "#0057a3", surfaceMuted: "#eef4fa" }, + radii: { lg: "12px" }, + }, +}; ``` -## Brand color +## Theme files -`accentColor` overrides the primary color (toggle bubble, header, send -button) at runtime via CSS custom properties — no rebuild needed: +A theme file is JSON validated against +`https://claudius-docs.pages.dev/schema/theme.v1.json` (reference it via +`$schema` for IDE autocomplete): -```js -window.ClaudiusConfig = { apiUrl: "...", accentColor: "#0057a3" }; +```json +{ + "$schema": "https://claudius-docs.pages.dev/schema/theme.v1.json", + "name": "acme", + "colorScheme": "light", + "colors": { "accent": "#aa0000", "userBubbleText": "#fff8f0" }, + "colorsDark": { "accent": "#ff6666" }, + "radii": { "sm": "4px", "md": "6px", "lg": "10px" }, + "shadows": { "elevated": "0 8px 24px rgb(0 0 0 / 0.18)" }, + "fonts": { "body": "'Inter', system-ui" } +} ``` -## Position +The [theme editor](/theme-editor/) builds and exports these files visually. + +## Token reference + +### Colors (`colors` / `colorsDark`) + +`colors` apply to **both** light and dark mode; `colorsDark` overrides +individual tokens in dark mode only. Untouched tokens use the built-in +palette of whichever mode is active. + +| Key | CSS property | Used for | Light default | Dark default | +|-----|--------------|----------|---------------|--------------| +| `accent` | `--cl-color-accent` | Header, toggle bubble, send button, focus rings | `#2563eb` | `#2563eb` | +| `accentText` | `--cl-color-accent-text` | Text/icons on accent surfaces | `#ffffff` | `#ffffff` | +| `accentSoft` | `--cl-color-accent-soft` | Avatar circle, hover overlay on the header | `rgb(255 255 255 / 0.2)` | same | +| `accentTextMuted` | `--cl-color-accent-text-muted` | Dimmed header icons | `rgb(255 255 255 / 0.7)` | same | +| `surface` | `--cl-color-surface` | Window, panels, greeting card | `#ffffff` | `#111827` | +| `surfaceMuted` | `--cl-color-surface-muted` | Assistant bubble, hovers, source cards | `#f1f5f9` | `#1f2937` | +| `text` | `--cl-color-text` | Primary text | `#1e293b` | `#f3f4f6` | +| `textMuted` | `--cl-color-text-muted` | Secondary text, placeholders | `#64748b` | `#9ca3af` | +| `border` | `--cl-color-border` | Borders, dividers, drag handle | `#e2e8f0` | `#374151` | +| `userBubble` | `--cl-color-user-bubble` | User message background | follows `accent` | follows `accent` | +| `userBubbleText` | `--cl-color-user-bubble-text` | User message text | `#ffffff` | `#ffffff` | +| `assistantBubble` | `--cl-color-assistant-bubble` | Assistant message background | follows `surfaceMuted` | follows `surfaceMuted` | +| `assistantBubbleText` | `--cl-color-assistant-bubble-text` | Assistant message text | `#1e293b` | `#e5e7eb` | +| `field` | `--cl-color-field` | Input field background | `#ffffff` | `#1f2937` | +| `error` | `--cl-color-error` | Error text, retry button | `#dc2626` | `#f87171` | +| `errorSurface` | `--cl-color-error-surface` | Error chip background | `#fef2f2` | `rgb(127 29 29 / 0.3)` | +| `errorText` | `--cl-color-error-text` | Text on the retry button | `#ffffff` | `#ffffff` | +| `link` | `--cl-color-link` | Links in messages | inherits text | `#60a5fa` | +| `scrim` | `--cl-color-scrim` | Mobile backdrop | `rgb(0 0 0 / 0.5)` | same | + +### Radii (`radii`) + +| Key | CSS property | Used for | Default | +|-----|--------------|----------|---------| +| `sm` | `--cl-radius-sm` | Input field, send button, error chip | `8px` | +| `md` | `--cl-radius-md` | Buttons, source cards | `12px` | +| `lg` | `--cl-radius-lg` | Window, message bubbles, greeting card | `16px` | +| `full` | `--cl-radius-full` | Toggle bubble, pills, avatars | `9999px` | +| `tail` | `--cl-radius-tail` | The "tail" corner of message bubbles | `2px` | -`position` anchors the widget to any corner: `bottom-right` (default), -`bottom-left`, `top-right`, `top-left`. +### Shadows (`shadows`) -## Deeper customization +| Key | CSS property | Used for | +|-----|--------------|----------| +| `elevated` | `--cl-shadow-elevated` | Chat window | +| `floating` | `--cl-shadow-floating` | Toggle bubble, greeting card | +| `floatingHover` | `--cl-shadow-floating-hover` | Greeting card hover | -For changes beyond the accent color — fonts, radii, the full palette — edit -`widget/tailwind.config.ts` (brand colors like `pmds-blue`, `pmds-dark`, -`pmds-light-green`) and rebuild the embed: +### Fonts (`fonts`) -```bash -cd widget -pnpm build:embed # dist/claudius.iife.js + dist/claudius.css +| Key | CSS property | Default | +|-----|--------------|---------| +| `heading` | `--cl-font-heading` | `system-ui` | +| `body` | `--cl-font-body` | `system-ui` | + +## Built-in themes + +| Name | Personality | +|------|-------------| +| `default` | The stock Claudius look (blue accent, 16px cards) | +| `minimal` | Monochrome, square-ish corners, hairline shadows | +| `playful` | Violet accent, pill-shaped bubbles, soft colored shadows, rounded type | +| `corporate` | Navy accent, tight radii, subdued grays | + +```js +window.ClaudiusConfig = { apiUrl: "...", theme: "corporate" }; +``` + +Built-ins are exported for composing — e.g. corporate in dark mode: + +```tsx +import { ChatWidget, builtinThemes } from "claudius-chat-widget"; + +; ``` -Then [self-host the bundle](/deployment/self-hosted/) instead of using the -shared CDN build. +## Dark mode + +`colorScheme` inside a theme (or the plain `"light"` / `"dark"` / `"auto"` +strings) controls the mode; `"auto"` follows `prefers-color-scheme` live. In +dark mode every color token swaps to its dark value: your `colors`, your +`colorsDark` overrides, or the built-in dark palette, in that order. + +## Still works: `accentColor` and `--claudius-*` -:::note[Planned] -Design tokens via CSS custom properties with a JSON theme schema are planned — -see [#73](https://github.com/PMDevSolutions/Claudius/issues/73). -::: +The v1 `accentColor` option still works and overrides the theme's accent — +see the [migration guide](/migration/accent-color-to-themes/). Pre-token +`--claudius-*` custom properties (e.g. `--claudius-primary`) also keep +working and win over theme tokens; treat them as deprecated. diff --git a/docs/src/content/docs/configuration/widget.md b/docs/src/content/docs/configuration/widget.md index 2ca177f..1076175 100644 --- a/docs/src/content/docs/configuration/widget.md +++ b/docs/src/content/docs/configuration/widget.md @@ -19,7 +19,7 @@ attributes on the `` web component. | `persistMessages` | `boolean` | `true` | Save history to `sessionStorage` (survives navigation, clears on tab close) | | `storageKeyPrefix` | `string` | `"claudius:messages"` | Storage key prefix; set a unique value per widget when embedding several on one page | | `requestTimeoutMs` | `number` | `30000` | Per-attempt request timeout; `0` disables. Timeouts surface a retryable error | -| `theme` | `"light" \| "dark" \| "auto"` | `"light"` | Color scheme; `auto` follows `prefers-color-scheme` | +| `theme` | `"light" \| "dark" \| "auto"` \| theme name \| `ClaudiusTheme` \| URL | `"light"` | Color scheme, a [built-in theme](/configuration/theming/#built-in-themes), an inline [theme object](/configuration/theming/), or a URL to a theme JSON file | | `accentColor` | `string` | `"#2563eb"` | Primary brand color override | | `position` | `"bottom-right" \| "bottom-left" \| "top-right" \| "top-left"` | `"bottom-right"` | Corner the bubble and window anchor to | | `locale` | `"en" \| "es" \| "fr" \| "de"` | auto-detected | UI language; see [Localization](/configuration/localization/) | diff --git a/docs/src/content/docs/migration/accent-color-to-themes.md b/docs/src/content/docs/migration/accent-color-to-themes.md new file mode 100644 index 0000000..33cc0c4 --- /dev/null +++ b/docs/src/content/docs/migration/accent-color-to-themes.md @@ -0,0 +1,67 @@ +--- +title: accentColor → design tokens +description: Moving from the v1 accent-only API to full theme files. +sidebar: + order: 5 +--- + +Theming v2 (1.5.0) introduces design-token themes. Nothing breaks: every +pre-existing option keeps its exact behavior, and this guide is only about +adopting the new capabilities. + +## What stays the same + +- `theme: "light" | "dark" | "auto"` — unchanged meaning (color scheme) +- `accentColor` — still supported. It overrides the active theme's accent in + both modes, so existing embeds render the same +- External `--claudius-*` custom-property overrides — still honored, and they + win over theme tokens. Consider them deprecated in favor of `--cl-*` tokens + +One intentional fix: user message bubbles now follow `accentColor` (and the +theme accent). Previously the header recolored but user bubbles stayed blue. +Set `colors.userBubble` explicitly if you relied on that. + +## Upgrading an accent-only embed + +Before: + +```js +window.ClaudiusConfig = { + apiUrl: "...", + theme: "auto", + accentColor: "#0057a3", +}; +``` + +After — same result, expressed as a theme (and room to grow): + +```js +window.ClaudiusConfig = { + apiUrl: "...", + theme: { + colorScheme: "auto", + colors: { accent: "#0057a3" }, + }, +}; +``` + +From there, add brand fonts, radii, and full palettes token by token — see +the [token reference](/configuration/theming/#token-reference) — or build the +file visually in the [theme editor](/theme-editor/) and host it: + +```js +window.ClaudiusConfig = { + apiUrl: "...", + theme: "https://your-site.example/claudius-theme.json", +}; +``` + +## Precedence summary + +From weakest to strongest: + +1. Built-in defaults (light or dark palette per mode) +2. Active theme's `colors` / other token groups +3. Theme's `colorsDark` (dark mode only) +4. `accentColor` (accent token only) +5. External `--claudius-*` overrides (deprecated) diff --git a/docs/src/pages/theme-editor.astro b/docs/src/pages/theme-editor.astro new file mode 100644 index 0000000..3fd3bcf --- /dev/null +++ b/docs/src/pages/theme-editor.astro @@ -0,0 +1,354 @@ +--- +import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; +--- + + +

+ Adjust tokens, watch the preview, then export a JSON file that validates + against + theme.v1.json. Host it anywhere and point the widget at it: + theme: "https://your-site.example/claudius-theme.json". See + the + theming reference for what each token + controls. The preview is a simplified replica driven by the exact same + tokens as the real widget. +

+ +
+
+
+ + + + + + +
+
+

Export

+

+      
+ + + +
+
+ +
+ +
+
+
+ S + + Support + Ask me anything + + × +
+
+
+ Hi! How can I help you today? +
+
What are your opening hours?
+
+ We're available Monday to Friday, 9am to 5pm. Details on our + contact + page. +
+
+ Something went wrong. Retry +
+
+
+ Type your message... + +
+
+
💬
+
+
+
+
+ + + + From 9c3c2cf45367a2aad44b97a54c2e635ff690d26f Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 11 Jun 2026 01:55:18 -0400 Subject: [PATCH 7/9] chore(widget): drop unused eslint-disable in theme types Co-Authored-By: Claude Fable 5 --- widget/src/theme/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/widget/src/theme/types.ts b/widget/src/theme/types.ts index 2cf5878..0e76196 100644 --- a/widget/src/theme/types.ts +++ b/widget/src/theme/types.ts @@ -68,5 +68,4 @@ export type ClaudiusThemeInput = | "auto" | BuiltinThemeName | ClaudiusTheme - // eslint-disable-next-line @typescript-eslint/no-empty-object-type | (string & {}); From 3053c35ec115c4f0aab5a4dda61c7f43bde4deaa Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 11 Jun 2026 01:57:50 -0400 Subject: [PATCH 8/9] style(widget): format resolve.test.ts with prettier Co-Authored-By: Claude Fable 5 --- widget/src/theme/__tests__/resolve.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/widget/src/theme/__tests__/resolve.test.ts b/widget/src/theme/__tests__/resolve.test.ts index 59a0b08..c7dbeee 100644 --- a/widget/src/theme/__tests__/resolve.test.ts +++ b/widget/src/theme/__tests__/resolve.test.ts @@ -107,9 +107,7 @@ describe("themeToCssVars", () => { colors: { accent: "#fff", banana: "#yellow" } as never, }); expect(vars["--cl-color-banana"]).toBeUndefined(); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("banana"), - ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("banana")); }); it("skips non-string values without crashing", () => { From 193e760221ebdcdcd5cf1ba3f3e0a9ae06196a3d Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 11 Jun 2026 02:01:12 -0400 Subject: [PATCH 9/9] test(widget): update e2e drag-handle selector for token class rename Co-Authored-By: Claude Fable 5 --- widget/e2e/mobile.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/widget/e2e/mobile.spec.ts b/widget/e2e/mobile.spec.ts index 36e6c2a..b2c981d 100644 --- a/widget/e2e/mobile.spec.ts +++ b/widget/e2e/mobile.spec.ts @@ -14,7 +14,9 @@ test.describe("mobile responsive layout", () => { await expect(dialog).toHaveAttribute("aria-modal", "true"); // Drag handle: the small rounded bar inside the aria-hidden wrapper. - const dragHandle = dialog.locator('[aria-hidden="true"] .rounded-full').first(); + const dragHandle = dialog + .locator('[aria-hidden="true"] .rounded-claudius-full') + .first(); await expect(dragHandle).toBeVisible(); // Scrim is rendered as a sibling of the dialog with the dedicated class.