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: 5 additions & 0 deletions .changeset/theme-css-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

Add typed CSS token overrides to `theme` and support changing them with `update()`.
34 changes: 34 additions & 0 deletions api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export interface ThemeConfig {
bgColor?: string;
fontFamily?: string;
borderRadius?: string | number;
tokens?: WidgetTokens;
position?: 'bottom-right' | 'bottom-left';
}

Expand Down Expand Up @@ -225,6 +226,10 @@ export interface WidgetLocaleStrings {
privacy: string;
}

export type WidgetTokenName = (typeof TOKEN_NAMES)[number];

export type WidgetTokens = Partial<Record<WidgetTokenName, string>>;

export type WidgetUpdate = Pick<WidgetConfig, 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings' | 'privacyUrl'> & {
theme?: Omit<ThemeConfig, 'position'>;
};
Expand All @@ -238,3 +243,32 @@ The same for each of: `ar`, `de`, `es`, `fr`, `he`, `hi`, `it`, `ja`, `ko`, `nl`
declare const _default: Partial<WidgetLocaleStrings>;
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"
];
```
4 changes: 4 additions & 0 deletions packages/widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},

Expand Down
2 changes: 1 addition & 1 deletion packages/widget/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 6 additions & 1 deletion packages/widget/src/loader/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},

Expand Down
34 changes: 34 additions & 0 deletions packages/widget/src/theme.ts
Original file line number Diff line number Diff line change
@@ -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<Record<WidgetTokenName, string>>;

export interface ThemeConfig {
/**
* `auto` (the default) follows the visitor's system setting and switches
Expand All @@ -13,6 +39,7 @@ export interface ThemeConfig {
bgColor?: string;
fontFamily?: string;
borderRadius?: string | number;
tokens?: WidgetTokens;
position?: 'bottom-right' | 'bottom-left';
}

Expand Down Expand Up @@ -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);
}
}
}
}

/**
Expand Down
7 changes: 6 additions & 1 deletion packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/widget/tests/api.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ChatCops, {
type PreChatFormConfig,
type ThemeConfig,
type ThemeMode,
type WidgetTokens,
type UserToken,
type WidgetConfig,
type WidgetLocaleStrings,
Expand Down Expand Up @@ -61,6 +62,7 @@ describe('the widget API types', () => {
expectTypeOf(ChatCops.update).parameters.toEqualTypeOf<[WidgetUpdate]>();
expectTypeOf<ChatCopsLoader['update']>().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
Expand All @@ -70,6 +72,12 @@ describe('the widget API types', () => {
it('exports the types a config is made of', () => {
expectTypeOf<NonNullable<WidgetConfig['theme']>>().toEqualTypeOf<ThemeConfig>();
expectTypeOf<NonNullable<ThemeConfig['mode']>>().toEqualTypeOf<ThemeMode>();
expectTypeOf<NonNullable<ThemeConfig['tokens']>>().toEqualTypeOf<WidgetTokens>();
const tokens: WidgetTokens = { border: '#d0d4da', 'panel-width': '440px' };
expectTypeOf(tokens).toEqualTypeOf<WidgetTokens>();
// @ts-expect-error: token names must be supported by the widget
const typo: WidgetTokens = { 'panel-wdith': '440px' };
expectTypeOf(typo).toEqualTypeOf<WidgetTokens>();
expectTypeOf<NonNullable<WidgetConfig['preChatForm']>>().toEqualTypeOf<PreChatFormConfig>();
expectTypeOf<NonNullable<WidgetConfig['persistence']>>().toEqualTypeOf<Persistence>();
expectTypeOf<MessageStatus>().toEqualTypeOf<'streaming' | 'complete' | 'error'>();
Expand Down
30 changes: 28 additions & 2 deletions packages/widget/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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' } });
Expand Down
35 changes: 34 additions & 1 deletion packages/widget/tests/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -247,4 +281,3 @@ describe('focus ring', () => {
expect(host.style.getPropertyValue('--cc-focus-ring')).toBe('0 0 0 3px red');
});
});

12 changes: 8 additions & 4 deletions scripts/api-snapshot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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`,
};
}
Expand All @@ -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 {
Expand Down
Loading