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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions docs/plans/2026-06-11-theming-v2-design.md
Original file line number Diff line number Diff line change
@@ -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-<name>-dark, <current dark value>)`.
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<Record<ThemeColorToken, string>>; // camelCase keys
colorsDark?: Partial<Record<ThemeColorToken, string>>; // dark overrides
radii?: Partial<Record<"sm" | "md" | "lg" | "full" | "tail", string>>;
shadows?: Partial<Record<"elevated" | "floating" | "floatingHover", string>>;
fonts?: Partial<Record<"heading" | "body", string>>;
}
```

- 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 `<StarlightPage>` +
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`).
47 changes: 47 additions & 0 deletions docs/plans/2026-06-11-theming-v2-implementation.md
Original file line number Diff line number Diff line change
@@ -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, <dark default>)`.
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 (`<div className="claudius-root" style={vars}>` 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).
81 changes: 81 additions & 0 deletions docs/public/schema/theme.v1.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
}
26 changes: 25 additions & 1 deletion docs/src/content/docs/api/widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<Record<ThemeColorToken, string>>;
colorsDark?: Partial<Record<ThemeColorToken, string>>;
radii?: Partial<Record<"sm" | "md" | "lg" | "full" | "tail", string>>;
shadows?: Partial<Record<"elevated" | "floating" | "floatingHover", string>>;
fonts?: Partial<Record<"heading" | "body", string>>;
}
```

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).
Expand Down
Loading
Loading