Skip to content

Commit 96dbdc0

Browse files
authored
Merge pull request #115 from PMDevSolutions/73-theming-v2-design-tokens
feat: theming v2 with design tokens, theme files, and editor
2 parents bb262ed + 193e760 commit 96dbdc0

36 files changed

Lines changed: 1944 additions & 147 deletions

docs/astro.config.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ export default defineConfig({
4040
},
4141
{
4242
label: "Configuration",
43-
items: [{ autogenerate: { directory: "configuration" } }],
43+
items: [
44+
{ autogenerate: { directory: "configuration" } },
45+
{ label: "Theme editor", link: "/theme-editor/" },
46+
],
4447
},
4548
{
4649
label: "Deployment",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Theming v2 Design (issue #73)
2+
3+
Design tokens via CSS custom properties, schema-validated theme files, four
4+
built-in themes, a theme editor, and a documented migration path from the
5+
`accentColor`-only API.
6+
7+
## Constraints
8+
9+
- **No breaking changes.** The CDN `@1` channel auto-updates production sites.
10+
`theme: "light" | "dark" | "auto"`, `accentColor`, and the existing
11+
`--claudius-*` custom properties must keep working unchanged.
12+
- Default appearance must be pixel-identical before/after the refactor; the
13+
existing 220 widget tests are the regression net.
14+
15+
## Token set (`--cl-*`)
16+
17+
| Group | Tokens | Notes |
18+
|-------|--------|-------|
19+
| 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) |
20+
| Radii | `sm` (8px), `md` (12px), `lg` (16px), `full` (9999px), `tail` (2px) | window/bubbles=lg, buttons/inputs=md, bubble tail=tail |
21+
| Shadows | `elevated` (window), `floating` (toggle), `floating-hover` | |
22+
| Fonts | `heading`, `body` | carried over from `--claudius-font-*` |
23+
24+
**Dark mode:** light values are token defaults; `[data-claudius-dark="true"]`
25+
reassigns each color token to `var(--cl-color-<name>-dark, <current dark value>)`.
26+
The wrapper splits into an outer div carrying inline theme vars and an inner
27+
div carrying `data-claudius-dark`, so the attribute rule beats inherited
28+
inline light values. Today's hard-coded `dark:` utilities become the `-dark`
29+
defaults, keeping default dark mode pixel-identical.
30+
31+
**Legacy aliases:** Tailwind palette entries become fallback chains, e.g.
32+
`var(--claudius-primary, var(--cl-color-accent, #2563eb))` — anyone setting
33+
`--claudius-*` externally keeps working and still wins.
34+
35+
## API
36+
37+
```ts
38+
type ClaudiusThemeInput =
39+
| "light" | "dark" | "auto" // existing: mode only
40+
| "default" | "minimal" | "playful" | "corporate" // built-in themes
41+
| ClaudiusTheme // inline token object
42+
| (string & {}); // URL to a theme JSON
43+
44+
interface ClaudiusTheme {
45+
$schema?: string;
46+
name?: string;
47+
colorScheme?: "light" | "dark" | "auto"; // defaults to "light"
48+
colors?: Partial<Record<ThemeColorToken, string>>; // camelCase keys
49+
colorsDark?: Partial<Record<ThemeColorToken, string>>; // dark overrides
50+
radii?: Partial<Record<"sm" | "md" | "lg" | "full" | "tail", string>>;
51+
shadows?: Partial<Record<"elevated" | "floating" | "floatingHover", string>>;
52+
fonts?: Partial<Record<"heading" | "body", string>>;
53+
}
54+
```
55+
56+
- Strings `light|dark|auto` keep their exact current meaning (mode only).
57+
- Built-in names resolve to exported theme objects (`builtinThemes`); each
58+
carries its own `colorScheme`. Combining a built-in with a different mode:
59+
`theme={{ ...builtinThemes.corporate, colorScheme: "dark" }}`.
60+
- Any other string is treated as a URL: fetched, JSON-parsed, structurally
61+
checked. On fetch/parse/shape failure: console.error and fall back to the
62+
default theme — the widget never breaks because a theme file is down.
63+
- `accentColor` still works and **wins over** the theme's accent (it is the
64+
older, more specific contract). Internally it now sets `--cl-color-accent`.
65+
- Web component: `theme` attribute accepts mode strings, built-in names, and
66+
URLs (attributes can't express objects; documented).
67+
- Package exports gain `ClaudiusTheme` and `builtinThemes`.
68+
69+
## Built-in themes
70+
71+
- `default` — empty overrides (the baked-in defaults)
72+
- `minimal` — monochrome: near-black accent, square-ish radii (4/6/10px), hairline shadows
73+
- `playful` — violet accent, pill radii (16/20/24px), soft colored shadows, rounded font stack
74+
- `corporate` — navy accent, tight radii (4/6/8px), subtle shadows, neutral grays
75+
76+
## Schema
77+
78+
JSON Schema draft-07 (same dialect as `clients/_schema.json`), source of truth
79+
at `widget/src/theme/theme.v1.schema.json`, committed copy served from
80+
`docs/public/schema/theme.v1.json` → published at
81+
`https://claudius-docs.pages.dev/schema/theme.v1.json` (the issue's
82+
`claudius.dev` host doesn't exist yet; same "or equivalent" precedent as #74
83+
when the domain is attached the path carries over). A widget test asserts the
84+
two files are byte-identical (drift guard, no cross-package build coupling).
85+
All leaf values: non-empty strings (CSS color syntax is too varied to regex);
86+
`additionalProperties: false` everywhere catches typos. Runtime widget
87+
validation is a lightweight structural check (no AJV in the bundle); AJV is a
88+
widget devDependency for tests, which validate all four built-ins against the
89+
schema.
90+
91+
## Theme editor
92+
93+
A custom docs-site page at `/theme-editor/` (Starlight `<StarlightPage>` +
94+
vanilla JS island): grouped controls for all tokens (light + dark), a live
95+
preview pane — a static widget replica styled exclusively by the same
96+
`--cl-*` tokens — and export as download/copy of a JSON file containing only
97+
non-default values plus `$schema`. Built-ins selectable as starting points.
98+
The issue places the editor on the playground site, which doesn't exist yet
99+
(#48/#83); the docs site is its interim home, and the editor moves when the
100+
playground lands.
101+
102+
## Out of scope
103+
104+
- Playground site itself (#48/#83); per-tenant theme storage; CLI validation
105+
of theme files (possible follow-up to `pnpm claudius validate`).
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Theming v2 Implementation Plan (issue #73)
2+
3+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4+
5+
**Goal:** Design-token theming per `2026-06-11-theming-v2-design.md`, fully backward compatible.
6+
7+
**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.
8+
9+
**Tech Stack:** Tailwind 3 custom properties, Vitest (+ AJV for schema tests), Astro/Starlight custom page.
10+
11+
---
12+
13+
### Task 1: Token foundation (no behavior change)
14+
15+
**Files:** `widget/src/styles.css`, `widget/tailwind.config.ts`, all `widget/src/components/*.tsx` (class refactor), `widget/src/components/ChatWidget.tsx` (wrapper split).
16+
17+
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>)`.
18+
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.
19+
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.
20+
4. ChatWidget: split wrapper (`<div className="claudius-root" style={vars}>` outer, inner keeps `data-claudius-dark`); `accentColor` now sets `--cl-color-accent`.
21+
5. Run `pnpm test` (220 green), `pnpm lint`, `pnpm typecheck`, `pnpm build`. Commit: `refactor(widget): move all visual styles onto --cl-* design tokens`.
22+
23+
### Task 2: Theme engine (TDD)
24+
25+
**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`.
26+
27+
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.
28+
29+
### Task 3: theme prop + URL fetch (TDD)
30+
31+
**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`.
32+
33+
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.
34+
35+
### Task 4: Schema + validation tests
36+
37+
**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).
38+
39+
### Task 5: Docs + theme editor
40+
41+
**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).
42+
43+
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.
44+
45+
### Task 6: Verify + PR
46+
47+
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).

docs/public/schema/theme.v1.json

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://claudius-docs.pages.dev/schema/theme.v1.json",
4+
"title": "Claudius Theme",
5+
"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.",
6+
"type": "object",
7+
"additionalProperties": false,
8+
"properties": {
9+
"$schema": { "type": "string" },
10+
"name": {
11+
"type": "string",
12+
"description": "Human-readable theme name",
13+
"minLength": 1
14+
},
15+
"colorScheme": {
16+
"description": "Initial color scheme this theme is designed for (default: light)",
17+
"enum": ["light", "dark", "auto"]
18+
},
19+
"colors": { "$ref": "#/definitions/colorTokens" },
20+
"colorsDark": { "$ref": "#/definitions/colorTokens" },
21+
"radii": {
22+
"type": "object",
23+
"additionalProperties": false,
24+
"properties": {
25+
"sm": { "$ref": "#/definitions/cssValue" },
26+
"md": { "$ref": "#/definitions/cssValue" },
27+
"lg": { "$ref": "#/definitions/cssValue" },
28+
"full": { "$ref": "#/definitions/cssValue" },
29+
"tail": { "$ref": "#/definitions/cssValue" }
30+
}
31+
},
32+
"shadows": {
33+
"type": "object",
34+
"additionalProperties": false,
35+
"properties": {
36+
"elevated": { "$ref": "#/definitions/cssValue" },
37+
"floating": { "$ref": "#/definitions/cssValue" },
38+
"floatingHover": { "$ref": "#/definitions/cssValue" }
39+
}
40+
},
41+
"fonts": {
42+
"type": "object",
43+
"additionalProperties": false,
44+
"properties": {
45+
"heading": { "$ref": "#/definitions/cssValue" },
46+
"body": { "$ref": "#/definitions/cssValue" }
47+
}
48+
}
49+
},
50+
"definitions": {
51+
"cssValue": {
52+
"type": "string",
53+
"minLength": 1
54+
},
55+
"colorTokens": {
56+
"type": "object",
57+
"additionalProperties": false,
58+
"properties": {
59+
"accent": { "$ref": "#/definitions/cssValue" },
60+
"accentText": { "$ref": "#/definitions/cssValue" },
61+
"accentSoft": { "$ref": "#/definitions/cssValue" },
62+
"accentTextMuted": { "$ref": "#/definitions/cssValue" },
63+
"surface": { "$ref": "#/definitions/cssValue" },
64+
"surfaceMuted": { "$ref": "#/definitions/cssValue" },
65+
"text": { "$ref": "#/definitions/cssValue" },
66+
"textMuted": { "$ref": "#/definitions/cssValue" },
67+
"border": { "$ref": "#/definitions/cssValue" },
68+
"userBubble": { "$ref": "#/definitions/cssValue" },
69+
"userBubbleText": { "$ref": "#/definitions/cssValue" },
70+
"assistantBubble": { "$ref": "#/definitions/cssValue" },
71+
"assistantBubbleText": { "$ref": "#/definitions/cssValue" },
72+
"field": { "$ref": "#/definitions/cssValue" },
73+
"error": { "$ref": "#/definitions/cssValue" },
74+
"errorSurface": { "$ref": "#/definitions/cssValue" },
75+
"errorText": { "$ref": "#/definitions/cssValue" },
76+
"link": { "$ref": "#/definitions/cssValue" },
77+
"scrim": { "$ref": "#/definitions/cssValue" }
78+
}
79+
}
80+
}
81+
}

docs/src/content/docs/api/widget.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ interface ChatWidgetProps {
2424
persistMessages?: boolean;
2525
storageKeyPrefix?: string;
2626
requestTimeoutMs?: number;
27-
theme?: "light" | "dark" | "auto";
27+
theme?: ClaudiusThemeInput;
2828
accentColor?: string;
2929
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
3030
locale?: "en" | "es" | "fr" | "de";
@@ -47,6 +47,30 @@ type Trigger =
4747
type TriggerAction = "open" | { greeting: string };
4848
```
4949

50+
### `ClaudiusThemeInput` and `ClaudiusTheme`
51+
52+
```ts
53+
type ClaudiusThemeInput =
54+
| "light" | "dark" | "auto" // color-scheme mode
55+
| "default" | "minimal" | "playful" | "corporate" // built-in themes
56+
| ClaudiusTheme // inline tokens
57+
| (string & {}); // URL to theme JSON
58+
59+
interface ClaudiusTheme {
60+
$schema?: string;
61+
name?: string;
62+
colorScheme?: "light" | "dark" | "auto";
63+
colors?: Partial<Record<ThemeColorToken, string>>;
64+
colorsDark?: Partial<Record<ThemeColorToken, string>>;
65+
radii?: Partial<Record<"sm" | "md" | "lg" | "full" | "tail", string>>;
66+
shadows?: Partial<Record<"elevated" | "floating" | "floatingHover", string>>;
67+
fonts?: Partial<Record<"heading" | "body", string>>;
68+
}
69+
```
70+
71+
The four built-ins are exported as `builtinThemes`. Token semantics and
72+
defaults: [theming reference](/configuration/theming/#token-reference).
73+
5074
### `ClaudiusTranslations`
5175

5276
All UI strings; see the [key table](/configuration/localization/#available-keys).

0 commit comments

Comments
 (0)