@runtypelabs/persona 3.21.3 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -262
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -235
  6. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  7. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  8. package/dist/animations/wipe.cjs +2 -72
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/animations/wipe.js +2 -45
  12. package/dist/index.cjs +52 -45
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +474 -6
  15. package/dist/index.d.ts +474 -6
  16. package/dist/index.global.js +107 -97
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +52 -45
  19. package/dist/index.js.map +1 -1
  20. package/dist/smart-dom-reader.cjs +23 -0
  21. package/dist/smart-dom-reader.d.cts +4521 -0
  22. package/dist/smart-dom-reader.d.ts +4521 -0
  23. package/dist/smart-dom-reader.js +23 -0
  24. package/dist/testing.cjs +3 -84
  25. package/dist/testing.js +3 -55
  26. package/dist/theme-editor.cjs +57 -22501
  27. package/dist/theme-editor.d.cts +348 -1
  28. package/dist/theme-editor.d.ts +348 -1
  29. package/dist/theme-editor.js +57 -22503
  30. package/package.json +16 -6
  31. package/src/client.test.ts +165 -0
  32. package/src/client.ts +144 -23
  33. package/src/components/event-stream-view.ts +122 -1
  34. package/src/index.ts +26 -0
  35. package/src/session.test.ts +258 -0
  36. package/src/session.ts +886 -30
  37. package/src/session.webmcp.test.ts +815 -0
  38. package/src/smart-dom-reader.test.ts +135 -0
  39. package/src/smart-dom-reader.ts +135 -0
  40. package/src/theme-editor/color-utils.test.ts +59 -0
  41. package/src/theme-editor/color-utils.ts +38 -2
  42. package/src/theme-editor/index.ts +35 -0
  43. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  44. package/src/theme-editor/webmcp/coerce.ts +286 -0
  45. package/src/theme-editor/webmcp/index.ts +45 -0
  46. package/src/theme-editor/webmcp/summary.ts +324 -0
  47. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  48. package/src/theme-editor/webmcp/tools.ts +795 -0
  49. package/src/theme-editor/webmcp/types.ts +87 -0
  50. package/src/types.ts +186 -0
  51. package/src/ui.composer-keyboard.test.ts +229 -0
  52. package/src/ui.ts +151 -8
  53. package/src/utils/composer-history.test.ts +128 -0
  54. package/src/utils/composer-history.ts +113 -0
  55. package/src/utils/message-fingerprint.test.ts +20 -0
  56. package/src/utils/message-fingerprint.ts +2 -0
  57. package/src/utils/smart-dom-adapter.test.ts +257 -0
  58. package/src/utils/smart-dom-adapter.ts +217 -0
  59. package/src/utils/throughput-tracker.test.ts +366 -0
  60. package/src/utils/throughput-tracker.ts +427 -0
  61. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  62. package/src/vendor/smart-dom-reader/README.md +61 -0
  63. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  64. package/src/vendor/smart-dom-reader/index.js +1618 -0
  65. package/src/webmcp-bridge.test.ts +429 -0
  66. package/src/webmcp-bridge.ts +547 -0
@@ -0,0 +1,795 @@
1
+ /**
2
+ * WebMCP tool factory for the Persona theme editor.
3
+ *
4
+ * `createThemeEditorTools(state)` returns transport-agnostic tool definitions
5
+ * designed for Agent Experience: intent-level operations (set brand colors,
6
+ * assign a color role, set roundness…) rather than a 1:1 mapping of the ~150
7
+ * editor fields. Two altitudes — high-level semantic tools plus a low-level
8
+ * escape hatch (`set_theme_fields`) — keep the catalog small without losing
9
+ * coverage. Every mutation returns a compact summary + contrast warnings.
10
+ */
11
+
12
+ import { createTheme } from '../../utils/theme';
13
+ import type { AgentWidgetConfig } from '../../types';
14
+ import type { PersonaTheme } from '../../types/theme';
15
+ import { generateColorScale, SHADE_KEYS } from '../color-utils';
16
+ import { ALL_ROLES, resolveRoleAssignment } from '../role-mappings';
17
+ import type { RoleAssignmentOptions, FieldDef } from '../types';
18
+ import { THEME_EDITOR_PRESETS, getThemeEditorPreset } from '../presets';
19
+ import { ALL_TABS, CONFIGURE_SUB_GROUPS } from '../sections';
20
+ import {
21
+ toolResult,
22
+ type WebMcpTool,
23
+ type ThemeEditorLike,
24
+ type EditTarget,
25
+ type CreateThemeEditorToolsOptions,
26
+ } from './types';
27
+ import {
28
+ coerceColor,
29
+ coerceFamily,
30
+ coerceIntensity,
31
+ coerceScheme,
32
+ coerceRoundnessStyle,
33
+ coerceRadius,
34
+ coerceTypographyRef,
35
+ ROLE_FAMILY_NAMES,
36
+ FONT_FAMILY_REFS,
37
+ FONT_SIZE_REFS,
38
+ FONT_WEIGHT_REFS,
39
+ LINE_HEIGHT_REFS,
40
+ } from './coerce';
41
+ import {
42
+ buildSummary,
43
+ quickContrastWarnings,
44
+ runContrastChecks,
45
+ roleContrastPairKeys,
46
+ RADIUS_PRESETS,
47
+ roleKey,
48
+ type ContrastWarning,
49
+ type ContrastLevel,
50
+ } from './summary';
51
+
52
+ // ─── Role lookup ────────────────────────────────────────────────
53
+
54
+ const ROLE_ALIASES: Record<string, RoleAssignmentOptions> = {};
55
+ for (const role of ALL_ROLES) {
56
+ const key = roleKey(role.roleId);
57
+ ROLE_ALIASES[key] = role; // e.g. "user-messages"
58
+ ROLE_ALIASES[role.roleId] = role; // e.g. "role-user-messages"
59
+ }
60
+ Object.assign(ROLE_ALIASES, {
61
+ surface: ROLE_ALIASES['surfaces'],
62
+ background: ROLE_ALIASES['surfaces'],
63
+ backgrounds: ROLE_ALIASES['surfaces'],
64
+ user: ROLE_ALIASES['user-messages'],
65
+ 'user-message': ROLE_ALIASES['user-messages'],
66
+ assistant: ROLE_ALIASES['assistant-messages'],
67
+ 'assistant-message': ROLE_ALIASES['assistant-messages'],
68
+ actions: ROLE_ALIASES['primary-actions'],
69
+ buttons: ROLE_ALIASES['primary-actions'],
70
+ composer: ROLE_ALIASES['input'],
71
+ links: ROLE_ALIASES['links-focus'],
72
+ focus: ROLE_ALIASES['links-focus'],
73
+ border: ROLE_ALIASES['borders'],
74
+ dividers: ROLE_ALIASES['borders'],
75
+ scroll: ROLE_ALIASES['scroll-to-bottom'],
76
+ });
77
+
78
+ function coerceRole(input: unknown): RoleAssignmentOptions {
79
+ const key = String(input ?? '').trim().toLowerCase();
80
+ const role = ROLE_ALIASES[key];
81
+ if (!role) {
82
+ const valid = ALL_ROLES.map((r) => roleKey(r.roleId)).join(', ');
83
+ throw new Error(`Unknown role "${input}". Valid roles: ${valid}.`);
84
+ }
85
+ return role;
86
+ }
87
+
88
+ // ─── Field index (escape hatch) ─────────────────────────────────
89
+
90
+ function buildFieldIndex(): Map<string, FieldDef> {
91
+ const index = new Map<string, FieldDef>();
92
+ const addSections = (sections: { fields: FieldDef[] }[]) => {
93
+ for (const section of sections) {
94
+ for (const field of section.fields) {
95
+ if (!index.has(field.id)) index.set(field.id, field);
96
+ }
97
+ }
98
+ };
99
+ for (const tab of ALL_TABS) addSections(tab.sections);
100
+ for (const group of CONFIGURE_SUB_GROUPS) addSections(group.sections);
101
+ return index;
102
+ }
103
+
104
+ // ─── Config-tool path maps ──────────────────────────────────────
105
+
106
+ const FEATURE_PATHS: Record<string, string> = {
107
+ voice: 'voiceRecognition.enabled',
108
+ artifacts: 'features.artifacts.enabled',
109
+ attachments: 'attachments.enabled',
110
+ toolCalls: 'features.showToolCalls',
111
+ reasoning: 'features.showReasoning',
112
+ feedback: 'messageActions.enabled',
113
+ };
114
+
115
+ const LAYOUT_PATHS: Record<string, string> = {
116
+ avatars: 'layout.messages.avatar.show',
117
+ timestamps: 'layout.messages.timestamp.show',
118
+ showHeader: 'layout.showHeader',
119
+ messageStyle: 'layout.messages.layout',
120
+ };
121
+
122
+ const LAUNCHER_POSITIONS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
123
+ const MESSAGE_STYLES = ['bubble', 'flat', 'minimal'];
124
+
125
+ const COPY_PATHS: Record<string, string> = {
126
+ title: 'copy.welcomeTitle',
127
+ subtitle: 'copy.welcomeSubtitle',
128
+ placeholder: 'copy.inputPlaceholder',
129
+ sendLabel: 'copy.sendButtonLabel',
130
+ };
131
+
132
+ // ─── Factory ────────────────────────────────────────────────────
133
+
134
+ export function createThemeEditorTools(
135
+ state: ThemeEditorLike,
136
+ options?: CreateThemeEditorToolsOptions
137
+ ): WebMcpTool[] {
138
+ let editTarget: EditTarget = options?.editTarget ?? 'both';
139
+ let fieldIndex: Map<string, FieldDef> | null = null;
140
+
141
+ const rec = (input: unknown): Record<string, unknown> =>
142
+ input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
143
+
144
+ /** Expand a theme-relative path to light/dark writes per editTarget. */
145
+ const expandScoped = (
146
+ themeRelPath: string,
147
+ value: unknown,
148
+ target: EditTarget = editTarget
149
+ ): Record<string, unknown> => {
150
+ const out: Record<string, unknown> = {};
151
+ if (target === 'light' || target === 'both') out[`theme.${themeRelPath}`] = value;
152
+ if (target === 'dark' || target === 'both') out[`darkTheme.${themeRelPath}`] = value;
153
+ return out;
154
+ };
155
+
156
+ /** Drop light/dark writes that fall outside the current editTarget. */
157
+ const filterByEditTarget = (writes: Record<string, string>): Record<string, string> => {
158
+ if (editTarget === 'both') return writes;
159
+ const out: Record<string, string> = {};
160
+ for (const [k, v] of Object.entries(writes)) {
161
+ if (editTarget === 'light' && k.startsWith('theme.')) out[k] = v;
162
+ else if (editTarget === 'dark' && k.startsWith('darkTheme.')) out[k] = v;
163
+ }
164
+ return out;
165
+ };
166
+
167
+ /** Which variant a color mutation should be contrast-checked against. */
168
+ const warnVariant = (): 'light' | 'dark' => (editTarget === 'dark' ? 'dark' : 'light');
169
+
170
+ const result = (applied: unknown, warnings: ContrastWarning[] = []) =>
171
+ toolResult({ ok: true, summary: buildSummary(state), warnings, applied });
172
+
173
+ // ── Tools ──────────────────────────────────────────────────
174
+
175
+ const getThemeOverview: WebMcpTool = {
176
+ name: 'get_theme_overview',
177
+ title: 'Get current theme & what is editable',
178
+ description:
179
+ 'Read the current widget theme (brand colors, per-role color assignments, typography, roundness, color scheme, undo/redo state), the available presets, and the high-level levers you can change. Call this FIRST before editing.',
180
+ annotations: { readOnlyHint: true },
181
+ inputSchema: {
182
+ type: 'object',
183
+ properties: {
184
+ verbosity: {
185
+ type: 'string',
186
+ enum: ['summary', 'full'],
187
+ description: "Use 'full' to also include the field-id index for set_theme_fields.",
188
+ },
189
+ },
190
+ additionalProperties: false,
191
+ },
192
+ execute(input) {
193
+ const { verbosity } = rec(input);
194
+ const payload: Record<string, unknown> = {
195
+ summary: buildSummary(state),
196
+ availableRoles: ALL_ROLES.map((r) => ({
197
+ role: roleKey(r.roleId),
198
+ helper: r.helper,
199
+ })),
200
+ availableFamilies: ROLE_FAMILY_NAMES,
201
+ presets: THEME_EDITOR_PRESETS.map((p) => ({
202
+ id: p.id,
203
+ name: p.name,
204
+ description: p.description,
205
+ tags: p.tags ?? [],
206
+ })),
207
+ tools: [
208
+ { tool: 'set_brand_colors', hint: 'Recolor the palette (primary/secondary/accent) — auto-generates shade scales.' },
209
+ { tool: 'assign_color_role', hint: 'Recolor a region (header, user/assistant messages, actions, input, links, borders, surfaces, scroll) with a family + intensity.' },
210
+ { tool: 'set_typography', hint: 'Set font family, size, weight, line height.' },
211
+ { tool: 'set_roundness', hint: 'Set corner roundness (sharp/default/rounded/pill) or granular radii.' },
212
+ { tool: 'set_color_scheme', hint: 'Set light/dark/auto and which variant edits target.' },
213
+ { tool: 'apply_preset', hint: 'Apply a complete built-in preset.' },
214
+ { tool: 'configure_widget', hint: 'Toggle launcher position, features, and layout.' },
215
+ { tool: 'set_copy_and_suggestions', hint: 'Set welcome copy, placeholder, and suggestion chips.' },
216
+ { tool: 'set_theme_fields', hint: 'Advanced escape hatch: set any field by id or dot-path.' },
217
+ { tool: 'check_contrast', hint: 'Audit WCAG contrast across key text/background pairs.' },
218
+ { tool: 'manage_session', hint: 'Undo, redo, reset, or export the theme.' },
219
+ ],
220
+ };
221
+ if (verbosity === 'full') {
222
+ fieldIndex ??= buildFieldIndex();
223
+ payload.fieldIndex = Array.from(fieldIndex.values()).map((f) => ({
224
+ id: f.id,
225
+ path: f.path,
226
+ type: f.type,
227
+ label: f.label,
228
+ options: f.options?.map((o) => o.value),
229
+ }));
230
+ }
231
+ return toolResult(payload);
232
+ },
233
+ };
234
+
235
+ const setBrandColors: WebMcpTool = {
236
+ name: 'set_brand_colors',
237
+ title: 'Set brand colors',
238
+ description:
239
+ 'Set one or more brand colors (primary, secondary, accent). Each color auto-generates a full 50–950 shade scale and applies to the light and dark themes (per the current edit target). Accepts hex ("#2563eb", "2563eb", "#18f"), rgb()/rgba() ("rgb(37, 99, 235)"), or CSS color names ("blue", "slateblue").',
240
+ inputSchema: {
241
+ type: 'object',
242
+ properties: {
243
+ primary: { type: 'string', description: 'Hex, rgb()/rgba(), or CSS color name.' },
244
+ secondary: { type: 'string', description: 'Hex, rgb()/rgba(), or CSS color name.' },
245
+ accent: { type: 'string', description: 'Hex, rgb()/rgba(), or CSS color name.' },
246
+ },
247
+ additionalProperties: false,
248
+ },
249
+ execute(input) {
250
+ const args = rec(input);
251
+ const families: Array<'primary' | 'secondary' | 'accent'> = ['primary', 'secondary', 'accent'];
252
+ const writes: Record<string, unknown> = {};
253
+ const applied: Record<string, string> = {};
254
+
255
+ for (const family of families) {
256
+ if (args[family] === undefined) continue;
257
+ const base = coerceColor(args[family]);
258
+ applied[family] = base;
259
+ const scale = generateColorScale(base);
260
+ for (const shade of SHADE_KEYS) {
261
+ const value = scale[shade];
262
+ if (value === undefined) continue;
263
+ Object.assign(writes, expandScoped(`palette.colors.${family}.${shade}`, value));
264
+ }
265
+ }
266
+
267
+ if (Object.keys(applied).length === 0) {
268
+ throw new Error('Provide at least one of: primary, secondary, accent.');
269
+ }
270
+
271
+ state.setBatch(writes);
272
+ const warnings = quickContrastWarnings(
273
+ state,
274
+ ['primary-button', 'user-message'],
275
+ warnVariant()
276
+ );
277
+ return result(applied, warnings);
278
+ },
279
+ };
280
+
281
+ const assignColorRole: WebMcpTool = {
282
+ name: 'assign_color_role',
283
+ title: 'Assign a color family to an interface role',
284
+ description:
285
+ 'Recolor a semantic region of the widget by choosing a palette family and intensity. One call writes all related tokens (background, text, border, icon) consistently. Roles: header, user-messages, assistant-messages, primary-actions, input, links, borders, surfaces, scroll-to-bottom. Families: primary, secondary, accent, neutral. Intensity: solid (bold) or soft (tinted).',
286
+ inputSchema: {
287
+ type: 'object',
288
+ properties: {
289
+ role: { type: 'string', description: 'Interface role, e.g. "header" or "user-messages".' },
290
+ family: { type: 'string', enum: ROLE_FAMILY_NAMES },
291
+ intensity: { type: 'string', enum: ['solid', 'soft'], description: "Defaults to 'solid'." },
292
+ },
293
+ required: ['role', 'family'],
294
+ additionalProperties: false,
295
+ },
296
+ execute(input) {
297
+ const args = rec(input);
298
+ const role = coerceRole(args.role);
299
+ const family = coerceFamily(args.family, true);
300
+ const intensity = coerceIntensity(args.intensity);
301
+
302
+ const writes = filterByEditTarget(resolveRoleAssignment(family, intensity, role));
303
+ const tokensWritten = Object.keys(writes).length;
304
+ state.setBatch(writes);
305
+
306
+ const warnings = quickContrastWarnings(state, roleContrastPairKeys(role), warnVariant());
307
+ return result(
308
+ { role: roleKey(role.roleId), family, intensity, tokensWritten },
309
+ warnings
310
+ );
311
+ },
312
+ };
313
+
314
+ const setTypography: WebMcpTool = {
315
+ name: 'set_typography',
316
+ title: 'Set typography',
317
+ description:
318
+ 'Set font family, base size, weight, and line height in one call. fontFamily: sans|serif|mono. fontSize: xs|sm|base|lg|xl. fontWeight: normal|medium|semibold|bold (or 400–700). lineHeight: tight|normal|relaxed (or 1.25/1.5/1.625).',
319
+ inputSchema: {
320
+ type: 'object',
321
+ properties: {
322
+ fontFamily: { type: 'string' },
323
+ fontSize: { type: 'string' },
324
+ fontWeight: { type: ['string', 'number'] },
325
+ lineHeight: { type: ['string', 'number'] },
326
+ },
327
+ additionalProperties: false,
328
+ },
329
+ execute(input) {
330
+ const args = rec(input);
331
+ const writes: Record<string, unknown> = {};
332
+ const applied: Record<string, string> = {};
333
+
334
+ const apply = (
335
+ key: 'fontFamily' | 'fontSize' | 'fontWeight' | 'lineHeight',
336
+ refs: Record<string, string>
337
+ ) => {
338
+ if (args[key] === undefined) return;
339
+ const ref = coerceTypographyRef(args[key], refs, key);
340
+ applied[key] = ref.split('.').pop() ?? ref;
341
+ Object.assign(writes, expandScoped(`semantic.typography.${key}`, ref));
342
+ };
343
+
344
+ apply('fontFamily', FONT_FAMILY_REFS);
345
+ apply('fontSize', FONT_SIZE_REFS);
346
+ apply('fontWeight', FONT_WEIGHT_REFS);
347
+ apply('lineHeight', LINE_HEIGHT_REFS);
348
+
349
+ if (Object.keys(applied).length === 0) {
350
+ throw new Error('Provide at least one of: fontFamily, fontSize, fontWeight, lineHeight.');
351
+ }
352
+ state.setBatch(writes);
353
+ return result(applied);
354
+ },
355
+ };
356
+
357
+ const setRoundness: WebMcpTool = {
358
+ name: 'set_roundness',
359
+ title: 'Set corner roundness',
360
+ description:
361
+ 'Set overall corner roundness with a keyword (sharp, default, rounded, pill) which maps the full radius scale, OR pass granular radius values. Provide at least one of `style` or `radius`.',
362
+ inputSchema: {
363
+ type: 'object',
364
+ properties: {
365
+ style: { type: 'string', enum: ['sharp', 'default', 'rounded', 'pill'] },
366
+ radius: {
367
+ type: 'object',
368
+ description: 'Granular overrides (px number or CSS length).',
369
+ properties: {
370
+ sm: { type: ['string', 'number'] },
371
+ md: { type: ['string', 'number'] },
372
+ lg: { type: ['string', 'number'] },
373
+ xl: { type: ['string', 'number'] },
374
+ full: { type: ['string', 'number'] },
375
+ },
376
+ additionalProperties: false,
377
+ },
378
+ },
379
+ additionalProperties: false,
380
+ },
381
+ execute(input) {
382
+ const args = rec(input);
383
+ const writes: Record<string, unknown> = {};
384
+ const applied: Record<string, unknown> = {};
385
+
386
+ if (args.style !== undefined) {
387
+ const style = coerceRoundnessStyle(args.style);
388
+ applied.style = style;
389
+ for (const [key, value] of Object.entries(RADIUS_PRESETS[style])) {
390
+ Object.assign(writes, expandScoped(`palette.radius.${key}`, value));
391
+ }
392
+ }
393
+
394
+ if (args.radius !== undefined) {
395
+ const radius = rec(args.radius);
396
+ const overrides: Record<string, string> = {};
397
+ for (const key of ['sm', 'md', 'lg', 'xl', 'full']) {
398
+ if (radius[key] === undefined) continue;
399
+ const value = coerceRadius(radius[key]);
400
+ overrides[key] = value;
401
+ Object.assign(writes, expandScoped(`palette.radius.${key}`, value));
402
+ }
403
+ applied.radius = overrides;
404
+ }
405
+
406
+ if (Object.keys(writes).length === 0) {
407
+ throw new Error('Provide `style` (sharp|default|rounded|pill) or a `radius` object.');
408
+ }
409
+ state.setBatch(writes);
410
+ return result(applied);
411
+ },
412
+ };
413
+
414
+ const setColorScheme: WebMcpTool = {
415
+ name: 'set_color_scheme',
416
+ title: 'Set color scheme',
417
+ description:
418
+ 'Set the shipped widget color scheme (light, dark, or auto/follow-system). Optionally set `editTarget` to choose which theme variant subsequent styling edits write to (light, dark, or both — default both).',
419
+ inputSchema: {
420
+ type: 'object',
421
+ properties: {
422
+ scheme: { type: 'string', enum: ['light', 'dark', 'auto'] },
423
+ editTarget: { type: 'string', enum: ['light', 'dark', 'both'] },
424
+ },
425
+ additionalProperties: false,
426
+ },
427
+ execute(input) {
428
+ const args = rec(input);
429
+ const applied: Record<string, unknown> = {};
430
+ if (args.scheme !== undefined) {
431
+ const scheme = coerceScheme(args.scheme);
432
+ state.set('colorScheme', scheme);
433
+ applied.scheme = scheme;
434
+ }
435
+ if (args.editTarget !== undefined) {
436
+ const t = String(args.editTarget).trim().toLowerCase();
437
+ if (t !== 'light' && t !== 'dark' && t !== 'both') {
438
+ throw new Error(`Unknown editTarget "${args.editTarget}". Valid: light, dark, both.`);
439
+ }
440
+ editTarget = t;
441
+ applied.editTarget = t;
442
+ }
443
+ if (Object.keys(applied).length === 0) {
444
+ throw new Error('Provide `scheme` and/or `editTarget`.');
445
+ }
446
+ return toolResult({ ok: true, summary: buildSummary(state), warnings: [], applied, editTarget });
447
+ },
448
+ };
449
+
450
+ const applyPreset: WebMcpTool = {
451
+ name: 'apply_preset',
452
+ title: 'Apply a built-in theme preset',
453
+ description:
454
+ 'Apply a complete built-in preset, replacing theme tokens. Call get_theme_overview to list preset ids.',
455
+ inputSchema: {
456
+ type: 'object',
457
+ properties: { presetId: { type: 'string' } },
458
+ required: ['presetId'],
459
+ additionalProperties: false,
460
+ },
461
+ execute(input) {
462
+ const { presetId } = rec(input);
463
+ const preset = getThemeEditorPreset(String(presetId ?? ''));
464
+ if (!preset) {
465
+ const valid = THEME_EDITOR_PRESETS.map((p) => p.id).join(', ');
466
+ throw new Error(`Unknown preset "${presetId}". Valid presets: ${valid}.`);
467
+ }
468
+ const theme = createTheme(preset.theme, { validate: false });
469
+ const config: AgentWidgetConfig = { ...state.getConfig() };
470
+ if (preset.darkTheme) {
471
+ config.darkTheme = createTheme(preset.darkTheme, { validate: false }) as PersonaTheme;
472
+ }
473
+ if (preset.toolCall) config.toolCall = preset.toolCall;
474
+ state.setFullConfig(config, theme);
475
+ const warnings = quickContrastWarnings(state, ['body', 'assistant-message'], 'light');
476
+ return result({ appliedPreset: { id: preset.id, name: preset.name } }, warnings);
477
+ },
478
+ };
479
+
480
+ const configureWidget: WebMcpTool = {
481
+ name: 'configure_widget',
482
+ title: 'Configure launcher, features, and layout',
483
+ description:
484
+ 'Toggle non-theme widget configuration. launcherPosition: bottom-right|bottom-left|top-right|top-left. features: { voice, artifacts, attachments, toolCalls, reasoning, feedback } booleans. layout: { avatars, timestamps, showHeader } booleans and messageStyle: bubble|flat|minimal.',
485
+ inputSchema: {
486
+ type: 'object',
487
+ properties: {
488
+ launcherPosition: { type: 'string', enum: LAUNCHER_POSITIONS },
489
+ features: {
490
+ type: 'object',
491
+ properties: {
492
+ voice: { type: 'boolean' },
493
+ artifacts: { type: 'boolean' },
494
+ attachments: { type: 'boolean' },
495
+ toolCalls: { type: 'boolean' },
496
+ reasoning: { type: 'boolean' },
497
+ feedback: { type: 'boolean' },
498
+ },
499
+ additionalProperties: false,
500
+ },
501
+ layout: {
502
+ type: 'object',
503
+ properties: {
504
+ avatars: { type: 'boolean' },
505
+ timestamps: { type: 'boolean' },
506
+ showHeader: { type: 'boolean' },
507
+ messageStyle: { type: 'string', enum: MESSAGE_STYLES },
508
+ },
509
+ additionalProperties: false,
510
+ },
511
+ },
512
+ additionalProperties: false,
513
+ },
514
+ execute(input) {
515
+ const args = rec(input);
516
+ const writes: Record<string, unknown> = {};
517
+ const applied: Record<string, unknown> = {};
518
+
519
+ if (args.launcherPosition !== undefined) {
520
+ const pos = String(args.launcherPosition);
521
+ if (!LAUNCHER_POSITIONS.includes(pos)) {
522
+ throw new Error(`Unknown launcherPosition "${pos}". Valid: ${LAUNCHER_POSITIONS.join(', ')}.`);
523
+ }
524
+ writes['launcher.position'] = pos;
525
+ applied.launcherPosition = pos;
526
+ }
527
+
528
+ const features = rec(args.features);
529
+ for (const [key, path] of Object.entries(FEATURE_PATHS)) {
530
+ if (features[key] === undefined) continue;
531
+ writes[path] = Boolean(features[key]);
532
+ (applied.features ??= {} as Record<string, boolean>);
533
+ (applied.features as Record<string, boolean>)[key] = Boolean(features[key]);
534
+ }
535
+
536
+ const layout = rec(args.layout);
537
+ for (const [key, path] of Object.entries(LAYOUT_PATHS)) {
538
+ if (layout[key] === undefined) continue;
539
+ if (key === 'messageStyle') {
540
+ const style = String(layout[key]);
541
+ if (!MESSAGE_STYLES.includes(style)) {
542
+ throw new Error(`Unknown messageStyle "${style}". Valid: ${MESSAGE_STYLES.join(', ')}.`);
543
+ }
544
+ writes[path] = style;
545
+ (applied.layout ??= {} as Record<string, unknown>);
546
+ (applied.layout as Record<string, unknown>)[key] = style;
547
+ } else {
548
+ writes[path] = Boolean(layout[key]);
549
+ (applied.layout ??= {} as Record<string, unknown>);
550
+ (applied.layout as Record<string, unknown>)[key] = Boolean(layout[key]);
551
+ }
552
+ }
553
+
554
+ if (Object.keys(writes).length === 0) {
555
+ throw new Error('Provide launcherPosition, features, and/or layout.');
556
+ }
557
+ state.setBatch(writes);
558
+ return result(applied);
559
+ },
560
+ };
561
+
562
+ const setCopyAndSuggestions: WebMcpTool = {
563
+ name: 'set_copy_and_suggestions',
564
+ title: 'Set welcome copy and suggestion chips',
565
+ description:
566
+ 'Set the widget welcome copy and suggestion chips. title/subtitle are the welcome card text; placeholder is the input placeholder; sendLabel is the send button label; suggestions is an array of suggestion-chip strings (replaces the existing list).',
567
+ inputSchema: {
568
+ type: 'object',
569
+ properties: {
570
+ title: { type: 'string' },
571
+ subtitle: { type: 'string' },
572
+ placeholder: { type: 'string' },
573
+ sendLabel: { type: 'string' },
574
+ suggestions: { type: 'array', items: { type: 'string' } },
575
+ },
576
+ additionalProperties: false,
577
+ },
578
+ execute(input) {
579
+ const args = rec(input);
580
+ const writes: Record<string, unknown> = {};
581
+ const applied: Record<string, unknown> = {};
582
+
583
+ for (const [key, path] of Object.entries(COPY_PATHS)) {
584
+ if (args[key] === undefined) continue;
585
+ writes[path] = String(args[key]);
586
+ applied[key] = String(args[key]);
587
+ }
588
+
589
+ if (args.suggestions !== undefined) {
590
+ if (!Array.isArray(args.suggestions)) {
591
+ throw new Error('`suggestions` must be an array of strings.');
592
+ }
593
+ const chips = args.suggestions.filter((s): s is string => typeof s === 'string');
594
+ writes['suggestionChips'] = chips;
595
+ applied.suggestions = chips;
596
+ }
597
+
598
+ if (Object.keys(writes).length === 0) {
599
+ throw new Error('Provide at least one of: title, subtitle, placeholder, sendLabel, suggestions.');
600
+ }
601
+ state.setBatch(writes);
602
+ return result(applied);
603
+ },
604
+ };
605
+
606
+ const setThemeFields: WebMcpTool = {
607
+ name: 'set_theme_fields',
608
+ title: 'Set theme fields by id or path (advanced)',
609
+ description:
610
+ 'Advanced escape hatch: set individual editor fields by field id (see get_theme_overview verbosity:"full") — theme field ids follow the current edit target (light/dark/both) — or by raw dot-path (theme.* / darkTheme.* / a config path), which is written as-is. Use only when a higher-level tool does not cover the need. Values are validated against the field metadata.',
611
+ inputSchema: {
612
+ type: 'object',
613
+ properties: {
614
+ updates: {
615
+ type: 'array',
616
+ items: {
617
+ type: 'object',
618
+ properties: {
619
+ field: { type: 'string', description: 'Field id or dot-path.' },
620
+ value: { type: ['string', 'number', 'boolean'] },
621
+ },
622
+ required: ['field', 'value'],
623
+ additionalProperties: false,
624
+ },
625
+ },
626
+ },
627
+ required: ['updates'],
628
+ additionalProperties: false,
629
+ },
630
+ execute(input) {
631
+ const { updates } = rec(input);
632
+ if (!Array.isArray(updates) || updates.length === 0) {
633
+ throw new Error('`updates` must be a non-empty array of { field, value }.');
634
+ }
635
+ fieldIndex ??= buildFieldIndex();
636
+
637
+ const writes: Record<string, unknown> = {};
638
+ const report: Array<{
639
+ field: string;
640
+ resolvedPath?: string | string[];
641
+ ok: boolean;
642
+ error?: string;
643
+ }> = [];
644
+
645
+ for (const raw of updates) {
646
+ const entry = rec(raw);
647
+ const fieldKey = String(entry.field ?? '');
648
+ try {
649
+ const def = fieldIndex.get(fieldKey);
650
+ const path = def ? def.path : fieldKey;
651
+ if (!def && !/^(theme|darkTheme)\.|\./.test(path)) {
652
+ // A bare token with no field def and no dotted path is ambiguous.
653
+ throw new Error(
654
+ `Unknown field "${fieldKey}". Pass a known field id or a dot-path (e.g. theme.palette.radius.md).`
655
+ );
656
+ }
657
+ const value = coerceFieldValue(def, entry.value);
658
+ if (def && path.startsWith('theme.')) {
659
+ // Field ids resolve to light-theme paths; honor the active edit
660
+ // target so dark-only / both edits are reachable by id (not only by
661
+ // raw darkTheme.* dot-path).
662
+ const scoped = expandScoped(path.slice('theme.'.length), value);
663
+ Object.assign(writes, scoped);
664
+ report.push({ field: fieldKey, resolvedPath: Object.keys(scoped), ok: true });
665
+ } else {
666
+ writes[path] = value;
667
+ report.push({ field: fieldKey, resolvedPath: path, ok: true });
668
+ }
669
+ } catch (err) {
670
+ report.push({ field: fieldKey, ok: false, error: (err as Error).message });
671
+ }
672
+ }
673
+
674
+ const okWrites = report.filter((r) => r.ok);
675
+ if (Object.keys(writes).length > 0) state.setBatch(writes);
676
+ return toolResult({
677
+ ok: okWrites.length > 0,
678
+ summary: buildSummary(state),
679
+ warnings: [],
680
+ applied: { updates: report },
681
+ });
682
+ },
683
+ };
684
+
685
+ const checkContrast: WebMcpTool = {
686
+ name: 'check_contrast',
687
+ title: 'Check accessibility contrast',
688
+ description:
689
+ 'Run WCAG contrast checks over the key text/background pairs (message text, header title, input/body text, primary button). Returns each ratio, whether it passes, and a suggested foreground shade for failures.',
690
+ annotations: { readOnlyHint: true },
691
+ inputSchema: {
692
+ type: 'object',
693
+ properties: {
694
+ level: { type: 'string', enum: ['AA', 'AAA'], description: "Defaults to 'AA'." },
695
+ variant: { type: 'string', enum: ['light', 'dark', 'both'], description: "Defaults to 'both'." },
696
+ },
697
+ additionalProperties: false,
698
+ },
699
+ execute(input) {
700
+ const args = rec(input);
701
+ const level = (args.level === 'AAA' ? 'AAA' : 'AA') as ContrastLevel;
702
+ const variant =
703
+ args.variant === 'light' || args.variant === 'dark' ? args.variant : 'both';
704
+ const report = runContrastChecks(state, level, variant);
705
+ return toolResult({
706
+ level: report.level,
707
+ passing: report.checks.length - report.failures.length,
708
+ total: report.checks.length,
709
+ checks: report.checks,
710
+ failures: report.failures,
711
+ });
712
+ },
713
+ };
714
+
715
+ const manageSession: WebMcpTool = {
716
+ name: 'manage_session',
717
+ title: 'Undo, redo, reset, or export the theme',
718
+ description:
719
+ 'Session action. "undo"/"redo" step through edit history; "reset" restores defaults; "export" returns the embeddable theme snapshot (config + theme JSON) with no side effects.',
720
+ inputSchema: {
721
+ type: 'object',
722
+ properties: { action: { type: 'string', enum: ['undo', 'redo', 'reset', 'export'] } },
723
+ required: ['action'],
724
+ additionalProperties: false,
725
+ },
726
+ execute(input) {
727
+ const { action } = rec(input);
728
+ switch (action) {
729
+ case 'undo':
730
+ state.undo();
731
+ return result({ action: 'undo' });
732
+ case 'redo':
733
+ state.redo();
734
+ return result({ action: 'redo' });
735
+ case 'reset':
736
+ state.resetToDefaults();
737
+ return result({ action: 'reset' });
738
+ case 'export':
739
+ return toolResult({ ok: true, snapshot: state.exportSnapshot() });
740
+ default:
741
+ throw new Error(`Unknown action "${action}". Valid: undo, redo, reset, export.`);
742
+ }
743
+ },
744
+ };
745
+
746
+ return [
747
+ getThemeOverview,
748
+ setBrandColors,
749
+ assignColorRole,
750
+ setTypography,
751
+ setRoundness,
752
+ setColorScheme,
753
+ applyPreset,
754
+ configureWidget,
755
+ setCopyAndSuggestions,
756
+ setThemeFields,
757
+ checkContrast,
758
+ manageSession,
759
+ ];
760
+ }
761
+
762
+ // ─── Field value validation (escape hatch) ──────────────────────
763
+
764
+ function coerceFieldValue(def: FieldDef | undefined, value: unknown): unknown {
765
+ if (!def) return value;
766
+
767
+ switch (def.type) {
768
+ case 'color':
769
+ return def.parseValue ? def.parseValue(coerceColor(value)) : coerceColor(value);
770
+ case 'toggle':
771
+ return typeof value === 'boolean' ? value : value === 'true' || value === 1;
772
+ case 'slider': {
773
+ const num = Number(value);
774
+ if (!Number.isFinite(num)) throw new Error(`"${def.id}" expects a number.`);
775
+ if (def.slider) {
776
+ const { min, max } = def.slider;
777
+ if (num < min || num > max) {
778
+ throw new Error(`"${def.id}" must be between ${min} and ${max}.`);
779
+ }
780
+ }
781
+ return def.parseValue ? def.parseValue(num) : num;
782
+ }
783
+ case 'select': {
784
+ const str = String(value);
785
+ if (def.options && !def.options.some((o) => o.value === str)) {
786
+ throw new Error(
787
+ `"${def.id}" must be one of: ${def.options.map((o) => o.value).join(', ')}.`
788
+ );
789
+ }
790
+ return def.parseValue ? def.parseValue(str) : str;
791
+ }
792
+ default:
793
+ return def.parseValue ? def.parseValue(value) : value;
794
+ }
795
+ }