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/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). 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/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... + +
+
+
💬
+
+
+
+
+ + + + 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. 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/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/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/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/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/__tests__/resolve.test.ts b/widget/src/theme/__tests__/resolve.test.ts new file mode 100644 index 0000000..c7dbeee --- /dev/null +++ b/widget/src/theme/__tests__/resolve.test.ts @@ -0,0 +1,146 @@ +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/__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/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/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" } + } + } + } +} 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..0e76196 --- /dev/null +++ b/widget/src/theme/types.ts @@ -0,0 +1,71 @@ +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 + | (string & {}); 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 }; +} 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))", }, }, },