diff --git a/.changeset/theme-css-tokens.md b/.changeset/theme-css-tokens.md new file mode 100644 index 00000000..d2235cb3 --- /dev/null +++ b/.changeset/theme-css-tokens.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": minor +--- + +Add typed CSS token overrides to `theme` and support changing them with `update()`. diff --git a/api/widget.api.md b/api/widget.api.md index d9568103..c45de799 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -79,6 +79,7 @@ export interface ThemeConfig { bgColor?: string; fontFamily?: string; borderRadius?: string | number; + tokens?: WidgetTokens; position?: 'bottom-right' | 'bottom-left'; } @@ -225,6 +226,10 @@ export interface WidgetLocaleStrings { privacy: string; } +export type WidgetTokenName = (typeof TOKEN_NAMES)[number]; + +export type WidgetTokens = Partial>; + export type WidgetUpdate = Pick & { theme?: Omit; }; @@ -238,3 +243,32 @@ The same for each of: `ar`, `de`, `es`, `fr`, `he`, `hi`, `it`, `ja`, `ko`, `nl` declare const _default: Partial; export { _default as default }; ``` + +## Types no entry exports + +They're part of the API above all the same: a change to one is a change to the API. + +```ts +declare const TOKEN_NAMES: readonly [ + "accent", + "accent-hover", + "on-accent", + "accent-contrast", + "text", + "bg", + "bg-surface", + "bg-input", + "border", + "text-secondary", + "danger", + "code-bg", + "pre-bg", + "shadow", + "font", + "radius", + "fab-size", + "panel-width", + "panel-height", + "focus-ring" +]; +``` diff --git a/packages/widget/README.md b/packages/widget/README.md index 5ba253b6..a3bd1853 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -75,6 +75,10 @@ ChatCops.init({ bgColor: '#0a0a0a', fontFamily: 'system-ui, sans-serif', borderRadius: '12px', + tokens: { + border: '#d0d4da', + 'panel-width': '440px', + }, position: 'bottom-right', // 'bottom-right' | 'bottom-left' }, diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index a84f778d..d9a353ee 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -22,7 +22,7 @@ export { type WidgetEventHandler, } from './widget.js'; export type { MessageData, MessageErrorType, MessageStatus } from './dom/messages.js'; -export type { ThemeConfig, ThemeMode } from './theme.js'; +export type { ThemeConfig, ThemeMode, WidgetTokenName, WidgetTokens } from './theme.js'; export type { Persistence } from './storage.js'; export type { UserToken } from './api/client.js'; export type { WidgetLocaleStrings } from './i18n.js'; diff --git a/packages/widget/src/loader/loader.ts b/packages/widget/src/loader/loader.ts index d8b921ff..3e19074f 100644 --- a/packages/widget/src/loader/loader.ts +++ b/packages/widget/src/loader/loader.ts @@ -293,7 +293,12 @@ export function createLoader( updates.push(changes); const shadow = launcher?.shadowRoot; // The whole theme so far: a partial one would drop the mode it doesn't name. - if (changes.theme && shadow) applyTheme(shadow, Object.assign({}, config?.theme, ...updates.map((u) => u.theme))); + if (changes.theme && shadow) { + const themes = [config?.theme, ...updates.map((u) => u.theme)]; + applyTheme(shadow, Object.assign({}, ...themes, { + tokens: Object.assign({}, ...themes.map((theme) => theme?.tokens)), + })); + } if (changes.strings?.openChat) button?.setAttribute('aria-label', changes.strings.openChat); }, diff --git a/packages/widget/src/theme.ts b/packages/widget/src/theme.ts index ac620b70..8a1b80ff 100644 --- a/packages/widget/src/theme.ts +++ b/packages/widget/src/theme.ts @@ -1,5 +1,31 @@ export type ThemeMode = 'light' | 'dark' | 'auto'; +const TOKEN_NAMES = [ + 'accent', + 'accent-hover', + 'on-accent', + 'accent-contrast', + 'text', + 'bg', + 'bg-surface', + 'bg-input', + 'border', + 'text-secondary', + 'danger', + 'code-bg', + 'pre-bg', + 'shadow', + 'font', + 'radius', + 'fab-size', + 'panel-width', + 'panel-height', + 'focus-ring', +] as const; + +export type WidgetTokenName = (typeof TOKEN_NAMES)[number]; +export type WidgetTokens = Partial>; + export interface ThemeConfig { /** * `auto` (the default) follows the visitor's system setting and switches @@ -13,6 +39,7 @@ export interface ThemeConfig { bgColor?: string; fontFamily?: string; borderRadius?: string | number; + tokens?: WidgetTokens; position?: 'bottom-right' | 'bottom-left'; } @@ -41,6 +68,13 @@ export function applyTheme(root: ShadowRoot, theme: ThemeConfig): void { const radius = toCssLength(theme.borderRadius); if (radius) host.style.setProperty('--cc-radius', radius); } + if (theme.tokens) { + for (const [name, value] of Object.entries(theme.tokens)) { + if (TOKEN_NAMES.includes(name as WidgetTokenName) && typeof value === 'string' && value.trim()) { + host.style.setProperty(`--cc-${name}`, value); + } + } + } } /** diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index ca7571c5..ee91327b 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -559,7 +559,12 @@ export class Widget { */ update(changes: WidgetUpdate): void { const current = this.config; - const theme = { ...current.theme, ...changes.theme, position: current.theme?.position }; + const theme = { + ...current.theme, + ...changes.theme, + tokens: { ...current.theme?.tokens, ...changes.theme?.tokens }, + position: current.theme?.position, + }; this.config = { ...current, ...changes, diff --git a/packages/widget/tests/api.test-d.ts b/packages/widget/tests/api.test-d.ts index 63c78651..efdd35b6 100644 --- a/packages/widget/tests/api.test-d.ts +++ b/packages/widget/tests/api.test-d.ts @@ -6,6 +6,7 @@ import ChatCops, { type PreChatFormConfig, type ThemeConfig, type ThemeMode, + type WidgetTokens, type UserToken, type WidgetConfig, type WidgetLocaleStrings, @@ -61,6 +62,7 @@ describe('the widget API types', () => { expectTypeOf(ChatCops.update).parameters.toEqualTypeOf<[WidgetUpdate]>(); expectTypeOf().parameters.toEqualTypeOf<[WidgetUpdate]>(); ChatCops.update({ theme: { mode: 'dark', accent: '#000' }, branding: { name: 'Sales' }, strings: { sendButton: 'Go' } }); + ChatCops.update({ theme: { tokens: { border: '#d0d4da', 'panel-width': '440px' } } }); // @ts-expect-error: moving the chat needs init() ChatCops.update({ theme: { position: 'bottom-left' } }); // @ts-expect-error: the server can't change on a running chat @@ -70,6 +72,12 @@ describe('the widget API types', () => { it('exports the types a config is made of', () => { expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + const tokens: WidgetTokens = { border: '#d0d4da', 'panel-width': '440px' }; + expectTypeOf(tokens).toEqualTypeOf(); + // @ts-expect-error: token names must be supported by the widget + const typo: WidgetTokens = { 'panel-wdith': '440px' }; + expectTypeOf(typo).toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf().toEqualTypeOf<'streaming' | 'complete' | 'error'>(); diff --git a/packages/widget/tests/api.test.ts b/packages/widget/tests/api.test.ts index 31f9f655..e591a8b5 100644 --- a/packages/widget/tests/api.test.ts +++ b/packages/widget/tests/api.test.ts @@ -203,12 +203,17 @@ describe('update', () => { const host = (widget: Widget) => root(widget).host as HTMLElement; it('changes the theme in place and keeps what it leaves out', () => { - const widget = new Widget({ apiUrl: '/chat', theme: { mode: 'dark', accent: '#2563eb', fontFamily: 'Inter' } }); + const widget = new Widget({ + apiUrl: '/chat', + theme: { mode: 'dark', accent: '#2563eb', fontFamily: 'Inter', tokens: { border: '#d0d4da', 'panel-width': '440px' } }, + }); widget.init(); const panel = root(widget).querySelector('.cc-panel'); - widget.update({ theme: { accent: '#16a34a' } }); + widget.update({ theme: { accent: '#16a34a', tokens: { border: '#ccddee' } } }); expect(host(widget).style.getPropertyValue('--cc-theme-accent')).toBe('#16a34a'); expect(host(widget).style.getPropertyValue('--cc-font')).toBe('Inter'); + expect(host(widget).style.getPropertyValue('--cc-border')).toBe('#ccddee'); + expect(host(widget).style.getPropertyValue('--cc-panel-width')).toBe('440px'); expect(host(widget).dataset.ccMode).toBe('dark'); widget.update({ theme: { mode: 'light' } }); expect(host(widget).dataset.ccMode).toBe('light'); @@ -500,6 +505,27 @@ describe('the script tag loader API', () => { loader.destroy(); }); + it('preserves token overrides across partial updates before and after loading', async () => { + const loader = createLoader(panel()); + loader.init({ apiUrl: '/chat', theme: { fontFamily: 'Arial', tokens: { font: 'Inter' } } }); + loader.update({ theme: { tokens: { border: '#ddd' } } }); + loader.update({ theme: { tokens: { 'panel-width': '440px' } } }); + const checkTokens = () => { + const host = document.querySelector('.chatcops-root') as HTMLElement; + expect(host.style.getPropertyValue('--cc-font')).toBe('Inter'); + expect(host.style.getPropertyValue('--cc-border')).toBe('#ddd'); + expect(host.style.getPropertyValue('--cc-panel-width')).toBe('440px'); + }; + checkTokens(); + loader.open(); + await tick(); + await tick(); + checkTokens(); + loader.update({ theme: { tokens: { 'panel-height': '600px' } } }); + checkTokens(); + loader.destroy(); + }); + it('drops updates made before init() again', async () => { const loader = createLoader(panel()); loader.init({ apiUrl: '/chat', branding: { name: 'Help' } }); diff --git a/packages/widget/tests/theme.test.ts b/packages/widget/tests/theme.test.ts index 196b618a..17c8189b 100644 --- a/packages/widget/tests/theme.test.ts +++ b/packages/widget/tests/theme.test.ts @@ -94,6 +94,40 @@ describe('applyTheme', () => { expect(get('--cc-bg-surface')).toBe('#1e1e1e'); expect(get('--cc-bg-input')).toBe('#2c2c2c'); }); + + it('sets custom tokens after the existing options', () => { + applyTheme(root, { + accent: '#4f46e5', + bgColor: '#0a0a0a', + tokens: { accent: '#e11d48', bg: '#18181b', border: '#d0d4da', 'panel-width': '440px' }, + }); + expect(get('--cc-accent')).toBe('#e11d48'); + expect(get('--cc-bg')).toBe('#18181b'); + expect(get('--cc-border')).toBe('#d0d4da'); + expect(get('--cc-panel-width')).toBe('440px'); + }); + + it('ignores unknown token names and empty values', () => { + applyTheme(root, { tokens: { border: '', unknown: '#fff' } as never }); + expect(get('--cc-border')).toBe(''); + expect(get('--cc-unknown')).toBe(''); + }); + + it('ignores undefined and whitespace-only token values', () => { + applyTheme(root, { tokens: { border: undefined, font: ' ', 'panel-width': '440px' } }); + expect(get('--cc-border')).toBe(''); + expect(get('--cc-font')).toBe(''); + expect(get('--cc-panel-width')).toBe('440px'); + }); + + it('keeps token overrides on the host in each selected mode', () => { + applyTheme(root, { mode: 'light', tokens: { border: '#d0d4da' } }); + expect(host.dataset.ccMode).toBe('light'); + expect(get('--cc-border')).toBe('#d0d4da'); + applyTheme(root, { mode: 'dark' }); + expect(host.dataset.ccMode).toBe('dark'); + expect(get('--cc-border')).toBe('#d0d4da'); + }); }); describe('accent text contrast', () => { @@ -247,4 +281,3 @@ describe('focus ring', () => { expect(host.style.getPropertyValue('--cc-focus-ring')).toBe('0 0 0 3px red'); }); }); - diff --git a/scripts/api-snapshot.mjs b/scripts/api-snapshot.mjs index 262d53ca..3ebf5902 100644 --- a/scripts/api-snapshot.mjs +++ b/scripts/api-snapshot.mjs @@ -7,7 +7,7 @@ // node scripts/api-snapshot.mjs --check # fail if they're out of date import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; +import { basename, dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; @@ -125,7 +125,9 @@ function referencedTypes(checker, declarations, ownDir) { if (name) { const symbol = checker.getSymbolAtLocation(ts.isQualifiedName(name) ? name.right : name); const target = symbol && resolve(checker, symbol); - const own = target?.declarations?.some((d) => d.getSourceFile().fileName.startsWith(ownDir)); + const own = target?.declarations?.some((d) => + d.getSourceFile().fileName.replaceAll('\\', '/').startsWith(`${ownDir.replaceAll('\\', '/')}/`) + ); if (own && !(target.flags & ts.SymbolFlags.TypeParameter)) found.add(target); } ts.forEachChild(node, visit); @@ -238,7 +240,7 @@ function snapshot(packageDir) { if (internal) body += `\n\n## Types no entry exports\n\nThey're part of the API above all the same: a change to one is a change to the API.\n\n\`\`\`ts\n${internal}\n\`\`\``; return { name: pkg.name, - file: join(apiDir, `${packageDir.split('/').pop()}.api.md`), + file: join(apiDir, `${basename(packageDir)}.api.md`), text: `# ${pkg.name} public API\n\nMade by \`node scripts/api-snapshot.mjs\` from the built types. Don't edit it by hand: change the code, build, and run the script again.\n\n${body}\n`, }; } @@ -254,7 +256,9 @@ if (!check) { for (const result of results) writeFileSync(result.file, result.text); console.log(`Wrote ${results.length} API snapshots to api/.`); } else { - const stale = results.filter((result) => !existsSync(result.file) || readFileSync(result.file, 'utf8') !== result.text); + const stale = results.filter((result) => + !existsSync(result.file) || readFileSync(result.file, 'utf8').replaceAll('\r\n', '\n') !== result.text + ); if (stale.length === 0) { console.log(`The API snapshots of ${results.length} packages are up to date.`); } else { diff --git a/website/src/content/docs/widget/theming.mdx b/website/src/content/docs/widget/theming.mdx index 9defd79d..bd90592e 100644 --- a/website/src/content/docs/widget/theming.mdx +++ b/website/src/content/docs/widget/theming.mdx @@ -18,6 +18,10 @@ ChatCops.init({ accentDark: '#a5b4fc', // the accent in dark mode; defaults to accent fontFamily: 'Inter, sans-serif', borderRadius: 12, // px if a number, or any CSS length like '1rem' + tokens: { + border: '#d0d4da', + 'panel-width': '440px', + }, position: 'bottom-right', // or 'bottom-left' }, }); @@ -38,6 +42,35 @@ On the script tag, these are `data-theme-mode`, `data-accent` and `data-accent-d The dark look is the one the widget had before light mode existed. Text on the accent is picked for contrast (white or near-black, with the accent darkened a little when neither reaches 4.5:1), in each mode for its own accent. +## CSS tokens + +Use `theme.tokens` to set any widget CSS token from JavaScript. Token names omit the `--cc-` prefix, and TypeScript checks them against the supported names. Tokens apply in every mode and take precedence over the corresponding options such as `accent` and `bgColor`. They can also be changed on a running widget with `update({ theme: { tokens: ... } })`. + +| Token | What it colors | Light default | Dark default | +|-------|----------------|---------------|--------------| +| `accent` | Primary accents, launcher and visitor messages | `#4f46e5` | `#6366f1` | +| `accent-hover` | Hovered accent backgrounds | `#4338ca` | `#4f46e5` | +| `on-accent` | Text and icons on accent backgrounds | `#ffffff` | `#ffffff` | +| `accent-contrast` | Accent background chosen for readable text | `#4f46e5` | `#6164ec` | +| `text` | Main text | `#1a1d23` | `#ffffff` | +| `text-secondary` | Secondary and subdued text | `#5b616e` | `#a0a0a0` | +| `bg` | Main widget background | `#ffffff` | `#0a0a0a` | +| `bg-surface` | Raised surfaces, such as bot messages and header | `#f4f5f7` | `#1a1a1a` | +| `bg-input` | Input backgrounds | `#ffffff` | `#2a2a2a` | +| `border` | Widget and input borders | `#d9dce1` | `#333333` | +| `danger` | Error and destructive states | `#c62828` | `#ef4444` | +| `code-bg` | Inline code backgrounds | `rgb(0 0 0 / 0.07)` | `rgba(0, 0, 0, 0.2)` | +| `pre-bg` | Code block backgrounds | `rgb(0 0 0 / 0.05)` | `rgba(0, 0, 0, 0.3)` | +| `shadow` | Shared panel shadow | `0 12px 40px rgb(16 24 40 / 0.18)` | `0 8px 32px rgba(0,0,0,.4)` | +| `font` | Widget font family | `system-ui, -apple-system, sans-serif` | `system-ui, -apple-system, sans-serif` | +| `radius` | Shared corner radius | `12px` | `12px` | +| `fab-size` | Launcher button width and height | `56px` | `56px` | +| `panel-width` | Chat panel width | `400px` | `400px` | +| `panel-height` | Chat panel height | `560px` | `560px` | +| `focus-ring` | Keyboard focus indicator | `0 0 0 2px var(--cc-bg), 0 0 0 4px var(--cc-accent)` | `0 0 0 2px var(--cc-bg), 0 0 0 4px var(--cc-accent)` | + +`accent`, `accent-hover`, `on-accent`, and `accent-contrast` defaults may be adjusted by `accent` and `accentDark`. `focus-ring` references the active background and accent colors. + ### Upgrading from a dark-only version Earlier versions of the widget were always dark. Now it's light on light systems, which suits most sites better. To keep the old look everywhere, set `mode: 'dark'` (or `data-theme-mode="dark"`). @@ -65,19 +98,17 @@ ChatCops.init({ ## CSS Override (Advanced) -You can override widget styles by targeting the host element. The first widget on a page has the id `chatcops-root`, and every widget host has the class `chatcops-root`, so use `.chatcops-root` to style all of them: +You can override widget styles by targeting the host element. The first widget on a page has the id `chatcops-root`, and every widget host has the class `chatcops-root`, so use `.chatcops-root` to style all of them. For a CSS-only override in one pinned mode, target its `data-cc-mode` value: ```css -#chatcops-root { - --cc-accent: #e11d48; - --cc-bg: #ffffff; - --cc-text: #111111; - --cc-panel-width: 450px; - --cc-panel-height: 600px; - --cc-fab-size: 64px; +.chatcops-root[data-cc-mode="dark"] { + --cc-border: #d0d4da; + --cc-panel-width: 440px; } ``` +The host has `data-cc-mode="light"` or `"dark"` when that mode is selected explicitly. + ## AI-generated replies Replies written by the model carry `data-ai-generated="true"`. User messages and the welcome message don't. The messages are inside the widget's shadow root, so the page's CSS can't select them, but scripts can: @@ -89,6 +120,8 @@ document.getElementById('chatcops-root').shadowRoot.querySelectorAll('[data-ai-g ## ThemeConfig Interface ```typescript +import type { WidgetTokens } from '@chatcops/widget'; + interface ThemeConfig { mode?: 'light' | 'dark' | 'auto'; // Default 'auto': follows the system setting accent?: string; // Hex color @@ -97,6 +130,7 @@ interface ThemeConfig { bgColor?: string; // Hex color fontFamily?: string; // CSS font-family string borderRadius?: string | number; // px if a number or numeric string (12, '12'), or any CSS length ('1rem') + tokens?: WidgetTokens; // Partial CSS tokens, named without the '--cc-' prefix; applied in every mode position?: 'bottom-right' | 'bottom-left'; } ```