@runtypelabs/persona 4.12.0 → 4.14.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 (68) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +98 -6
  4. package/dist/context-mentions.d.ts +98 -6
  5. package/dist/index.cjs +64 -64
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +183 -8
  8. package/dist/index.d.ts +183 -8
  9. package/dist/index.global.js +54 -54
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +67 -67
  12. package/dist/index.js.map +1 -1
  13. package/dist/launcher.global.js +1 -1
  14. package/dist/launcher.global.js.map +1 -1
  15. package/dist/smart-dom-reader.d.cts +98 -6
  16. package/dist/smart-dom-reader.d.ts +98 -6
  17. package/dist/theme-editor-preview.cjs +66 -66
  18. package/dist/theme-editor-preview.d.cts +100 -6
  19. package/dist/theme-editor-preview.d.ts +100 -6
  20. package/dist/theme-editor-preview.js +75 -75
  21. package/dist/theme-editor.cjs +10 -10
  22. package/dist/theme-editor.d.cts +134 -7
  23. package/dist/theme-editor.d.ts +134 -7
  24. package/dist/theme-editor.js +10 -10
  25. package/dist/theme-reference.cjs +1 -1
  26. package/dist/widget.css +1 -1
  27. package/package.json +1 -1
  28. package/src/artifacts-session.test.ts +106 -0
  29. package/src/client.test.ts +2 -2
  30. package/src/client.ts +4 -1
  31. package/src/components/artifact-card.ts +13 -2
  32. package/src/components/artifact-inline.ts +11 -2
  33. package/src/components/composer-builder.test.ts +2 -0
  34. package/src/components/composer-builder.ts +1 -1
  35. package/src/components/composer-parts.test.ts +2 -0
  36. package/src/components/composer-parts.ts +1 -1
  37. package/src/components/message-bubble.test.ts +7 -1
  38. package/src/components/message-bubble.ts +11 -8
  39. package/src/index-core.ts +34 -0
  40. package/src/session.ts +45 -4
  41. package/src/styles/widget.css +10 -1
  42. package/src/theme-editor/index.ts +5 -0
  43. package/src/theme-editor/presets.test.ts +43 -0
  44. package/src/theme-editor/presets.ts +101 -87
  45. package/src/theme-editor/sections.test.ts +39 -7
  46. package/src/theme-editor/sections.ts +60 -8
  47. package/src/theme-editor/state-preferences.test.ts +15 -0
  48. package/src/theme-editor/state.ts +46 -0
  49. package/src/theme-editor/types.ts +15 -1
  50. package/src/theme-editor/webmcp/tools.test.ts +13 -0
  51. package/src/theme-editor/webmcp/tools.ts +5 -0
  52. package/src/types.ts +139 -9
  53. package/src/ui.artifact-pane-gating.test.ts +5 -5
  54. package/src/ui.config-preferences.test.ts +166 -0
  55. package/src/ui.scroll-additive.test.ts +365 -0
  56. package/src/ui.scroll.test.ts +19 -6
  57. package/src/ui.ts +158 -53
  58. package/src/ui.typing-indicator.test.ts +135 -0
  59. package/src/utils/artifact-display.test.ts +180 -16
  60. package/src/utils/artifact-display.ts +155 -14
  61. package/src/utils/auto-follow.test.ts +38 -0
  62. package/src/utils/auto-follow.ts +35 -0
  63. package/src/utils/config-merge.test.ts +60 -0
  64. package/src/utils/config-merge.ts +6 -1
  65. package/src/utils/feature-preferences.test.ts +326 -0
  66. package/src/utils/feature-preferences.ts +498 -0
  67. package/src/utils/theme.test.ts +92 -0
  68. package/src/utils/tokens.ts +7 -7
@@ -0,0 +1,498 @@
1
+ import type {
2
+ AgentWidgetArtifactsFeature,
3
+ AgentWidgetConfig,
4
+ AgentWidgetFeatureFlags,
5
+ PersonaArtifactDisplayMode,
6
+ PersonaArtifactDisplayModeInput,
7
+ PersonaArtifactDisplayRules,
8
+ PersonaArtifactFilesDisplayRules,
9
+ PersonaArtifactKind,
10
+ WidgetArtifactLayoutPreference,
11
+ WidgetPreferenceSlice,
12
+ } from "../types";
13
+ import {
14
+ canonicalArtifactDisplayMode,
15
+ normalizeMediaType,
16
+ } from "./artifact-display";
17
+
18
+ export { normalizeMediaType };
19
+
20
+ export type WidgetPreferenceParseIssueCode =
21
+ | "invalid_type"
22
+ | "invalid_value"
23
+ | "unknown_key"
24
+ | "deprecated_key";
25
+
26
+ export type WidgetPreferenceParseIssue = {
27
+ path: string;
28
+ code: WidgetPreferenceParseIssueCode;
29
+ message: string;
30
+ };
31
+
32
+ export type WidgetPreferenceParseResult = {
33
+ preferences: WidgetPreferenceSlice;
34
+ issues: WidgetPreferenceParseIssue[];
35
+ };
36
+
37
+ const BLOCKED_KEYS = new Set(["__proto__", "prototype", "constructor"]);
38
+ // Accepted input values; "card" is the deprecated alias of "collapsed" and
39
+ // parsing canonicalizes it.
40
+ const DISPLAY_MODES = new Set<PersonaArtifactDisplayModeInput>([
41
+ "collapsed",
42
+ "card",
43
+ "panel",
44
+ "inline",
45
+ ]);
46
+ const ARTIFACT_KINDS = new Set<PersonaArtifactKind>([
47
+ "markdown",
48
+ "component",
49
+ ]);
50
+ // Exact "type/subtype" or major-type wildcard "type/*"; "*/*" is rejected
51
+ // because it duplicates files.default.
52
+ const MEDIA_TYPE_SELECTOR_PATTERN =
53
+ /^[a-z0-9][a-z0-9!#$&^_.+-]*\/(\*|[a-z0-9!#$&^_.+-]+)$/;
54
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
55
+ typeof value === "object" && value !== null && !Array.isArray(value);
56
+
57
+ const isSafeKey = (key: string): boolean => !BLOCKED_KEYS.has(key);
58
+
59
+ const isDisplayMode = (
60
+ value: unknown
61
+ ): value is PersonaArtifactDisplayModeInput =>
62
+ typeof value === "string" &&
63
+ DISPLAY_MODES.has(value as PersonaArtifactDisplayModeInput);
64
+
65
+ const hasKeys = (value: object): boolean => Object.keys(value).length > 0;
66
+
67
+ function issue(
68
+ issues: WidgetPreferenceParseIssue[],
69
+ path: string,
70
+ code: WidgetPreferenceParseIssueCode,
71
+ message: string
72
+ ): undefined {
73
+ issues.push({ path, code, message });
74
+ return undefined;
75
+ }
76
+
77
+ const UNKNOWN_KEY_MESSAGE =
78
+ "Ignored because this key is not part of the persisted preference schema.";
79
+ const UNSUPPORTED_VALUE_MESSAGE = "Ignored an unsupported value.";
80
+ const MODE_VALUE_MESSAGE = "Expected collapsed, panel, or inline.";
81
+ const MEDIA_TYPE_MESSAGE =
82
+ 'Expected a MIME type such as "text/html" or a wildcard such as "image/*".';
83
+
84
+ type PreferenceNode = {
85
+ /** Fold this key's parsed value into another output key, that key winning. */
86
+ alias?: string;
87
+ /** Reported whenever the input carries this key, valid or not. */
88
+ deprecated?: string;
89
+ } & (
90
+ | { kind: "boolean" }
91
+ | { kind: "string"; message: string }
92
+ | { kind: "enum"; values: ReadonlySet<string>; message: string }
93
+ /** Display mode; canonicalizes the deprecated "card" alias to "collapsed". */
94
+ | { kind: "mode"; message: string }
95
+ /** Mode values keyed by a closed selector set, or by MIME selector. */
96
+ | { kind: "modeMap"; keys: ReadonlySet<string> | "mediaType" }
97
+ | {
98
+ kind: "object";
99
+ children: Record<string, PreferenceNode>;
100
+ message?: string;
101
+ /** Also accept a bare mode string in place of the rules object. */
102
+ bareMode?: true;
103
+ }
104
+ );
105
+
106
+ const DEFAULT_MODE: PreferenceNode = {
107
+ kind: "mode",
108
+ message: UNSUPPORTED_VALUE_MESSAGE,
109
+ };
110
+ const CSS_LENGTH: PreferenceNode = {
111
+ kind: "string",
112
+ message: "Expected a CSS length string.",
113
+ };
114
+ const BOOLEAN: PreferenceNode = { kind: "boolean" };
115
+
116
+ // Keyed by the preference type so the table stays exhaustive; string keys are
117
+ // declared before boolean keys because issue order follows declaration order.
118
+ const ARTIFACT_LAYOUT_CHILDREN: Record<
119
+ keyof WidgetArtifactLayoutPreference,
120
+ PreferenceNode
121
+ > = {
122
+ paneWidth: CSS_LENGTH,
123
+ paneMaxWidth: CSS_LENGTH,
124
+ paneMinWidth: CSS_LENGTH,
125
+ expandedPanelWidth: CSS_LENGTH,
126
+ resizableMinWidth: CSS_LENGTH,
127
+ resizableMaxWidth: CSS_LENGTH,
128
+ expandLauncherPanelWhenOpen: BOOLEAN,
129
+ resizable: BOOLEAN,
130
+ };
131
+
132
+ const ROOT_NODE: PreferenceNode = {
133
+ kind: "object",
134
+ message: "Expected a preference object.",
135
+ children: {
136
+ showToolCalls: BOOLEAN,
137
+ showReasoning: BOOLEAN,
138
+ toolCallDisplay: {
139
+ kind: "object",
140
+ children: {
141
+ collapsedMode: {
142
+ kind: "enum",
143
+ values: new Set(["tool-call", "tool-name", "tool-preview"]),
144
+ message: UNSUPPORTED_VALUE_MESSAGE,
145
+ },
146
+ groupedMode: {
147
+ kind: "enum",
148
+ values: new Set(["stack", "summary"]),
149
+ message: UNSUPPORTED_VALUE_MESSAGE,
150
+ },
151
+ loadingAnimation: {
152
+ kind: "enum",
153
+ values: new Set([
154
+ "none",
155
+ "pulse",
156
+ "shimmer",
157
+ "shimmer-color",
158
+ "rainbow",
159
+ ]),
160
+ message: UNSUPPORTED_VALUE_MESSAGE,
161
+ },
162
+ expandable: BOOLEAN,
163
+ grouped: BOOLEAN,
164
+ },
165
+ },
166
+ reasoningDisplay: {
167
+ kind: "object",
168
+ children: { expandable: BOOLEAN, activePreview: BOOLEAN },
169
+ },
170
+ artifacts: {
171
+ kind: "object",
172
+ children: {
173
+ display: {
174
+ kind: "object",
175
+ bareMode: true,
176
+ message: "Expected an artifact display rules object.",
177
+ children: {
178
+ default: DEFAULT_MODE,
179
+ files: {
180
+ kind: "object",
181
+ bareMode: true,
182
+ message:
183
+ "Expected collapsed, panel, inline, or a file display rules object.",
184
+ children: {
185
+ default: DEFAULT_MODE,
186
+ byMediaType: { kind: "modeMap", keys: "mediaType" },
187
+ },
188
+ },
189
+ // Declared before byKind: fold order decides alias precedence.
190
+ byType: {
191
+ kind: "modeMap",
192
+ keys: ARTIFACT_KINDS,
193
+ alias: "byKind",
194
+ deprecated:
195
+ "Use byKind. The legacy selector was normalized automatically.",
196
+ },
197
+ byKind: { kind: "modeMap", keys: ARTIFACT_KINDS },
198
+ },
199
+ },
200
+ filePreview: { kind: "object", children: { enabled: BOOLEAN } },
201
+ layout: { kind: "object", children: ARTIFACT_LAYOUT_CHILDREN },
202
+ },
203
+ },
204
+ },
205
+ };
206
+
207
+ /**
208
+ * Walk one value against its schema node. Returns the parsed value, or
209
+ * `undefined` when the value is absent, rejected, or parsed to nothing.
210
+ */
211
+ function parseNode(
212
+ value: unknown,
213
+ node: PreferenceNode,
214
+ path: string,
215
+ issues: WidgetPreferenceParseIssue[]
216
+ ): unknown {
217
+ if (value === undefined) return undefined;
218
+ switch (node.kind) {
219
+ case "boolean":
220
+ return typeof value === "boolean"
221
+ ? value
222
+ : issue(issues, path, "invalid_type", "Expected a boolean.");
223
+ case "string":
224
+ return typeof value === "string"
225
+ ? value
226
+ : issue(issues, path, "invalid_type", node.message);
227
+ case "enum":
228
+ return typeof value === "string" && node.values.has(value)
229
+ ? value
230
+ : issue(issues, path, "invalid_value", node.message);
231
+ case "mode":
232
+ return isDisplayMode(value)
233
+ ? canonicalArtifactDisplayMode(value)
234
+ : issue(issues, path, "invalid_value", node.message);
235
+ case "modeMap": {
236
+ if (!isRecord(value)) {
237
+ return issue(issues, path, "invalid_type", "Expected an object.");
238
+ }
239
+ const keys = node.keys === "mediaType" ? undefined : node.keys;
240
+ const map: Record<string, PersonaArtifactDisplayMode> = {};
241
+ for (const [key, mode] of Object.entries(value)) {
242
+ const selector = keys ? key : normalizeMediaType(key);
243
+ const known =
244
+ isSafeKey(key) &&
245
+ (keys ? keys.has(key) : MEDIA_TYPE_SELECTOR_PATTERN.test(selector));
246
+ if (!known) {
247
+ issue(
248
+ issues,
249
+ `${path}.${key}`,
250
+ keys ? "unknown_key" : "invalid_value",
251
+ keys ? "Ignored an unsupported selector." : MEDIA_TYPE_MESSAGE
252
+ );
253
+ } else if (!isDisplayMode(mode)) {
254
+ issue(issues, `${path}.${key}`, "invalid_value", MODE_VALUE_MESSAGE);
255
+ } else {
256
+ map[selector] = canonicalArtifactDisplayMode(mode);
257
+ }
258
+ }
259
+ return hasKeys(map) ? map : undefined;
260
+ }
261
+ case "object": {
262
+ // A bare mode is a meaningful stored value: when layers merge it replaces
263
+ // every lower-layer display rule instead of refining them.
264
+ if (node.bareMode && isDisplayMode(value)) {
265
+ return canonicalArtifactDisplayMode(value);
266
+ }
267
+ if (!isRecord(value)) {
268
+ return issue(
269
+ issues,
270
+ path,
271
+ "invalid_type",
272
+ node.message ?? "Expected an object."
273
+ );
274
+ }
275
+ const childKeys = Object.keys(node.children);
276
+ const childPath = (key: string) => (path ? `${path}.${key}` : key);
277
+ for (const key of Object.keys(value)) {
278
+ if (!isSafeKey(key) || !childKeys.includes(key)) {
279
+ issue(issues, childPath(key), "unknown_key", UNKNOWN_KEY_MESSAGE);
280
+ }
281
+ }
282
+ const result: Record<string, unknown> = {};
283
+ for (const key of childKeys) {
284
+ const child = node.children[key];
285
+ const parsed = parseNode(value[key], child, childPath(key), issues);
286
+ if (child.deprecated && value[key] !== undefined) {
287
+ issue(issues, childPath(key), "deprecated_key", child.deprecated);
288
+ }
289
+ if (parsed === undefined) continue;
290
+ const target = child.alias ?? key;
291
+ const previous = result[target];
292
+ result[target] =
293
+ isRecord(previous) && isRecord(parsed)
294
+ ? { ...previous, ...parsed }
295
+ : parsed;
296
+ }
297
+ return hasKeys(result) ? result : undefined;
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Parse untrusted JSON into Persona's curated preference schema.
304
+ *
305
+ * Unknown, invalid, capability, callback, renderer, and security fields are
306
+ * omitted. The returned issues are suitable for logging or a settings UI.
307
+ */
308
+ export function parseWidgetPreferenceSlice(
309
+ input: unknown
310
+ ): WidgetPreferenceParseResult {
311
+ const issues: WidgetPreferenceParseIssue[] = [];
312
+ // null is an absent slice, not a malformed one.
313
+ const parsed =
314
+ input === null ? undefined : parseNode(input, ROOT_NODE, "", issues);
315
+ return {
316
+ preferences: (parsed as WidgetPreferenceSlice | undefined) ?? {},
317
+ issues,
318
+ };
319
+ }
320
+
321
+ function cloneValue<T>(value: T): T {
322
+ if (Array.isArray(value)) return value.map(cloneValue) as T;
323
+ if (!isRecord(value)) return value;
324
+ const result: Record<string, unknown> = {};
325
+ for (const [key, child] of Object.entries(value)) {
326
+ if (isSafeKey(key) && child !== undefined) result[key] = cloneValue(child);
327
+ }
328
+ return result as T;
329
+ }
330
+
331
+ function mergeRecords(
332
+ lower: Record<string, unknown>,
333
+ upper: Record<string, unknown>
334
+ ): Record<string, unknown> {
335
+ const result = cloneValue(lower);
336
+ for (const [key, value] of Object.entries(upper)) {
337
+ if (!isSafeKey(key) || value === undefined) continue;
338
+ const previous = result[key];
339
+ result[key] =
340
+ isRecord(previous) && isRecord(value)
341
+ ? mergeRecords(previous, value)
342
+ : cloneValue(value);
343
+ }
344
+ return result;
345
+ }
346
+
347
+ /**
348
+ * Merge lowest-to-highest preference slices after parsing each as untrusted
349
+ * data. Objects merge per key, so later slices win only for keys they
350
+ * explicitly contain; a string `display` or `display.files` value in a later
351
+ * slice replaces that whole subtree instead.
352
+ */
353
+ export function mergeFeaturePreferences(
354
+ layers: readonly WidgetPreferenceSlice[]
355
+ ): WidgetPreferenceSlice {
356
+ let merged: Record<string, unknown> = {};
357
+ let display: ArtifactDisplayValue | undefined;
358
+ for (const layer of layers) {
359
+ const parsed = parseWidgetPreferenceSlice(layer).preferences;
360
+ display = mergeDisplayValue(display, parsed.artifacts?.display);
361
+ merged = mergeRecords(merged, parsed as Record<string, unknown>);
362
+ }
363
+ const result = merged as WidgetPreferenceSlice;
364
+ // The generic record merge cannot express string-replaces-object, so the
365
+ // display subtree is recomputed with the display-aware fold.
366
+ if (result.artifacts) {
367
+ if (display !== undefined) result.artifacts.display = display;
368
+ else delete result.artifacts.display;
369
+ }
370
+ return result;
371
+ }
372
+
373
+ type ArtifactDisplayValue =
374
+ | PersonaArtifactDisplayModeInput
375
+ | PersonaArtifactDisplayRules;
376
+
377
+ type ArtifactFilesValue = PersonaArtifactDisplayRules["files"];
378
+
379
+ /**
380
+ * One merge rule at every level: an upper string replaces the whole subtree it
381
+ * names; an upper object refines the lower value per key (a lower string is
382
+ * refined as that subtree's `default`).
383
+ */
384
+ function mergeFilesValue(
385
+ lower: ArtifactFilesValue,
386
+ upper: ArtifactFilesValue
387
+ ): ArtifactFilesValue {
388
+ if (upper === undefined) {
389
+ return lower === undefined ? undefined : cloneValue(lower);
390
+ }
391
+ if (typeof upper === "string") return upper;
392
+ const low: PersonaArtifactFilesDisplayRules =
393
+ typeof lower === "string" ? { default: lower } : lower ?? {};
394
+ const defaultMode = upper.default ?? low.default;
395
+ const byMediaType = { ...low.byMediaType, ...upper.byMediaType };
396
+ const result: PersonaArtifactFilesDisplayRules = {
397
+ ...(defaultMode ? { default: defaultMode } : {}),
398
+ ...(hasKeys(byMediaType) ? { byMediaType } : {}),
399
+ };
400
+ return hasKeys(result) ? result : undefined;
401
+ }
402
+
403
+ function mergeDisplayValue(
404
+ lower: AgentWidgetArtifactsFeature["display"],
405
+ upper: AgentWidgetArtifactsFeature["display"]
406
+ ): ArtifactDisplayValue | undefined {
407
+ if (upper === undefined) {
408
+ return lower === undefined ? undefined : cloneValue(lower);
409
+ }
410
+ if (typeof upper === "string") return upper;
411
+ const low: PersonaArtifactDisplayRules =
412
+ typeof lower === "string" ? { default: lower } : lower ?? {};
413
+ const defaultMode = upper.default ?? low.default;
414
+ const byKind = {
415
+ ...low.byType,
416
+ ...low.byKind,
417
+ ...upper.byType,
418
+ ...upper.byKind,
419
+ };
420
+ const files = mergeFilesValue(low.files, upper.files);
421
+ const result: PersonaArtifactDisplayRules = {
422
+ ...(defaultMode ? { default: defaultMode } : {}),
423
+ ...(hasKeys(byKind) ? { byKind } : {}),
424
+ ...(files !== undefined ? { files } : {}),
425
+ };
426
+ return hasKeys(result) ? result : undefined;
427
+ }
428
+
429
+ /**
430
+ * Overlay persisted display preferences onto code-owned feature config.
431
+ * Only fields in `WidgetPreferenceSlice` are copied.
432
+ */
433
+ export function applyFeaturePreferences(
434
+ baseFeatures: AgentWidgetFeatureFlags = {},
435
+ layers: readonly WidgetPreferenceSlice[]
436
+ ): AgentWidgetFeatureFlags {
437
+ const preferences = mergeFeaturePreferences(layers);
438
+ const result: AgentWidgetFeatureFlags = { ...baseFeatures };
439
+ if (preferences.showToolCalls !== undefined) {
440
+ result.showToolCalls = preferences.showToolCalls;
441
+ }
442
+ if (preferences.showReasoning !== undefined) {
443
+ result.showReasoning = preferences.showReasoning;
444
+ }
445
+ if (preferences.toolCallDisplay) {
446
+ result.toolCallDisplay = {
447
+ ...baseFeatures.toolCallDisplay,
448
+ ...preferences.toolCallDisplay,
449
+ };
450
+ }
451
+ if (preferences.reasoningDisplay) {
452
+ result.reasoningDisplay = {
453
+ ...baseFeatures.reasoningDisplay,
454
+ ...preferences.reasoningDisplay,
455
+ };
456
+ }
457
+ if (preferences.artifacts) {
458
+ const baseArtifacts = baseFeatures.artifacts ?? {};
459
+ const artifacts: AgentWidgetArtifactsFeature = { ...baseArtifacts };
460
+ if (preferences.artifacts.display !== undefined) {
461
+ const display = mergeDisplayValue(
462
+ baseArtifacts.display,
463
+ preferences.artifacts.display
464
+ );
465
+ if (display !== undefined) artifacts.display = display;
466
+ }
467
+ if (preferences.artifacts.filePreview) {
468
+ artifacts.filePreview = {
469
+ ...baseArtifacts.filePreview,
470
+ enabled: preferences.artifacts.filePreview.enabled,
471
+ };
472
+ }
473
+ if (preferences.artifacts.layout) {
474
+ artifacts.layout = {
475
+ ...baseArtifacts.layout,
476
+ ...preferences.artifacts.layout,
477
+ };
478
+ }
479
+ result.artifacts = artifacts;
480
+ }
481
+ return result;
482
+ }
483
+
484
+ /**
485
+ * Bake `config.preferences` into `config.features` for one widget instance.
486
+ * Callers supporting live preference updates must re-resolve from the
487
+ * pre-preference base features, not from a previously resolved result.
488
+ */
489
+ export function resolveConfigPreferences(
490
+ config: AgentWidgetConfig
491
+ ): AgentWidgetConfig {
492
+ const preferences = config.preferences;
493
+ if (!preferences || !hasKeys(preferences)) return config;
494
+ return {
495
+ ...config,
496
+ features: applyFeaturePreferences(config.features ?? {}, [preferences]),
497
+ };
498
+ }
@@ -137,6 +137,98 @@ describe('theme utils', () => {
137
137
  expect(cssVars['--persona-button-radius']).toBe('6px');
138
138
  });
139
139
 
140
+ it.each([
141
+ {
142
+ role: 'surface',
143
+ sentinel: '#010203',
144
+ consumers: [
145
+ '--persona-components-input-background',
146
+ '--persona-input-background',
147
+ ],
148
+ },
149
+ {
150
+ role: 'container',
151
+ sentinel: '#0a0b0c',
152
+ consumers: [
153
+ '--persona-components-message-assistant-background',
154
+ '--persona-message-assistant-bg',
155
+ ],
156
+ },
157
+ {
158
+ role: 'textMuted',
159
+ sentinel: '#0d0e0f',
160
+ consumers: [
161
+ '--persona-components-input-placeholder',
162
+ '--persona-input-placeholder',
163
+ ],
164
+ },
165
+ {
166
+ role: 'text',
167
+ sentinel: '#121314',
168
+ consumers: [
169
+ '--persona-components-message-assistant-text',
170
+ '--persona-message-assistant-text',
171
+ ],
172
+ },
173
+ {
174
+ role: 'border',
175
+ sentinel: '#232425',
176
+ consumers: [
177
+ '--persona-components-message-assistant-border',
178
+ '--persona-message-assistant-border',
179
+ ],
180
+ },
181
+ ])(
182
+ 'cascades semantic.colors.$role to its default input and assistant consumers',
183
+ ({ role, sentinel, consumers }) => {
184
+ const theme = createTheme({
185
+ semantic: {
186
+ colors: {
187
+ [role]: sentinel,
188
+ },
189
+ },
190
+ } as any);
191
+ const cssVars = themeToCssVariables(theme);
192
+
193
+ for (const consumer of consumers) {
194
+ expect(cssVars[consumer]).toBe(sentinel);
195
+ }
196
+ }
197
+ );
198
+
199
+ it('keeps explicit input and assistant component colors above semantic defaults', () => {
200
+ const theme = createTheme({
201
+ semantic: {
202
+ colors: {
203
+ surface: '#010203',
204
+ container: '#0a0b0c',
205
+ text: '#121314',
206
+ border: '#232425',
207
+ },
208
+ },
209
+ components: {
210
+ input: {
211
+ background: '#343536',
212
+ placeholder: '#3a3b3c',
213
+ },
214
+ message: {
215
+ assistant: {
216
+ background: '#454647',
217
+ text: '#565758',
218
+ border: '#676869',
219
+ },
220
+ },
221
+ },
222
+ } as any);
223
+ const cssVars = themeToCssVariables(theme);
224
+
225
+ expect(cssVars['--persona-input-background']).toBe('#343536');
226
+ expect(cssVars['--persona-input-placeholder']).toBe('#3a3b3c');
227
+ expect(cssVars['--persona-message-assistant-bg']).toBe('#454647');
228
+ expect(cssVars['--persona-message-assistant-text']).toBe('#565758');
229
+ expect(cssVars['--persona-message-assistant-border']).toBe('#676869');
230
+ });
231
+
140
232
  it('maps markdown link and optional heading tokens to consumer CSS vars', () => {
141
233
  const theme = createTheme({
142
234
  components: {
@@ -269,9 +269,9 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
269
269
  },
270
270
  },
271
271
  input: {
272
- // Input role: soft gray
273
- background: 'palette.colors.gray.50',
274
- placeholder: 'palette.colors.gray.400',
272
+ // Input role: semantic surface so host light/dark schemes cascade.
273
+ background: 'semantic.colors.surface',
274
+ placeholder: 'semantic.colors.textMuted',
275
275
  borderRadius: 'palette.radius.lg',
276
276
  padding: 'semantic.spacing.md',
277
277
  focus: {
@@ -319,11 +319,11 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
319
319
  shadow: 'palette.shadows.sm',
320
320
  },
321
321
  assistant: {
322
- // Assistant Messages role: soft gray
323
- background: 'palette.colors.gray.50',
324
- text: 'palette.colors.gray.900',
322
+ // Assistant Messages role: semantic neutral container.
323
+ background: 'semantic.colors.container',
324
+ text: 'semantic.colors.text',
325
325
  borderRadius: 'palette.radius.lg',
326
- border: 'palette.colors.gray.200',
326
+ border: 'semantic.colors.border',
327
327
  shadow: 'palette.shadows.sm',
328
328
  },
329
329
  border: 'semantic.colors.border',