@runtypelabs/persona 4.15.0 → 4.17.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 (91) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +283 -9
  4. package/dist/context-mentions.d.ts +283 -9
  5. package/dist/index.cjs +68 -68
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +299 -10
  8. package/dist/index.d.ts +299 -10
  9. package/dist/index.global.js +57 -57
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +69 -69
  12. package/dist/index.js.map +1 -1
  13. package/dist/install.global.js +1 -1
  14. package/dist/install.global.js.map +1 -1
  15. package/dist/launcher.global.js +2 -2
  16. package/dist/launcher.global.js.map +1 -1
  17. package/dist/markdown-parsers.js +36 -36
  18. package/dist/smart-dom-reader.d.cts +283 -9
  19. package/dist/smart-dom-reader.d.ts +283 -9
  20. package/dist/testing.cjs +3 -3
  21. package/dist/testing.d.cts +7 -0
  22. package/dist/testing.d.ts +7 -0
  23. package/dist/testing.js +3 -3
  24. package/dist/theme-editor-preview.cjs +64 -64
  25. package/dist/theme-editor-preview.d.cts +283 -9
  26. package/dist/theme-editor-preview.d.ts +283 -9
  27. package/dist/theme-editor-preview.js +56 -56
  28. package/dist/theme-editor.cjs +10 -10
  29. package/dist/theme-editor.d.cts +283 -10
  30. package/dist/theme-editor.d.ts +283 -10
  31. package/dist/theme-editor.js +10 -10
  32. package/dist/theme-reference.cjs +1 -1
  33. package/dist/theme-reference.d.cts +66 -3
  34. package/dist/theme-reference.d.ts +66 -3
  35. package/dist/theme-reference.js +1 -1
  36. package/dist/widget.css +1 -1
  37. package/package.json +2 -2
  38. package/src/components/artifact-pane.test.ts +27 -0
  39. package/src/components/artifact-pane.ts +7 -3
  40. package/src/components/header-builder.ts +27 -2
  41. package/src/components/header-layouts.test.ts +56 -0
  42. package/src/components/header-layouts.ts +29 -9
  43. package/src/components/launcher-surface.test.ts +403 -0
  44. package/src/components/launcher.ts +297 -2
  45. package/src/components/panel.ts +69 -74
  46. package/src/components/suggestions.test.ts +40 -0
  47. package/src/components/suggestions.ts +213 -153
  48. package/src/components/welcome.test.ts +121 -0
  49. package/src/components/welcome.ts +305 -0
  50. package/src/components/widget-view.test.ts +5 -3
  51. package/src/components/widget-view.ts +14 -7
  52. package/src/defaults.ts +2 -2
  53. package/src/index-core.ts +12 -1
  54. package/src/install.test.ts +35 -1
  55. package/src/install.ts +47 -11
  56. package/src/launcher-global.ts +10 -7
  57. package/src/plugins/types.ts +66 -1
  58. package/src/styles/widget-styles.test.ts +220 -0
  59. package/src/styles/widget.css +353 -9
  60. package/src/testing/mock-stream.test.ts +19 -0
  61. package/src/testing/mock-stream.ts +20 -0
  62. package/src/theme-editor/sections.ts +30 -1
  63. package/src/theme-reference.ts +26 -4
  64. package/src/types/theme.ts +40 -3
  65. package/src/types.ts +164 -6
  66. package/src/ui.body-top-slot.test.ts +95 -0
  67. package/src/ui.content-width.test.ts +91 -0
  68. package/src/ui.docked.test.ts +7 -4
  69. package/src/ui.launcher-teaser.test.ts +162 -0
  70. package/src/ui.rebuild-composer.test.ts +491 -0
  71. package/src/ui.render-welcome.test.ts +505 -0
  72. package/src/ui.scroll-additive.test.ts +91 -21
  73. package/src/ui.scroll.test.ts +302 -14
  74. package/src/ui.suggest-replies.test.ts +46 -0
  75. package/src/ui.ts +747 -136
  76. package/src/ui.welcome.test.ts +504 -0
  77. package/src/utils/artifact-gate.test.ts +27 -0
  78. package/src/utils/artifact-gate.ts +9 -0
  79. package/src/utils/attachment-manager.test.ts +37 -0
  80. package/src/utils/attachment-manager.ts +15 -0
  81. package/src/utils/auto-follow.test.ts +23 -0
  82. package/src/utils/auto-follow.ts +11 -1
  83. package/src/utils/config-merge.ts +2 -0
  84. package/src/utils/content-width.test.ts +37 -0
  85. package/src/utils/content-width.ts +17 -0
  86. package/src/utils/plugin-storage.test.ts +94 -0
  87. package/src/utils/plugin-storage.ts +98 -0
  88. package/src/utils/theme.test.ts +137 -0
  89. package/src/utils/tokens.ts +49 -3
  90. package/src/welcome.test.ts +213 -0
  91. package/src/welcome.ts +130 -0
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveContentMaxWidth } from "./content-width";
4
+ import type { AgentWidgetConfig } from "../types";
5
+
6
+ const cfg = (value?: Partial<AgentWidgetConfig>) =>
7
+ value as AgentWidgetConfig | undefined;
8
+
9
+ describe("resolveContentMaxWidth", () => {
10
+ it("defaults panels to the surveyed 768px column", () => {
11
+ expect(resolveContentMaxWidth(cfg({}), false)).toBe("768px");
12
+ expect(resolveContentMaxWidth(undefined, false)).toBe("768px");
13
+ });
14
+
15
+ it("keeps the composer-bar's own 720px fallback", () => {
16
+ expect(resolveContentMaxWidth(cfg({}), true)).toBe("720px");
17
+ });
18
+
19
+ it("lets layout.contentMaxWidth win everywhere", () => {
20
+ const config = cfg({ layout: { contentMaxWidth: "90ch" } });
21
+ expect(resolveContentMaxWidth(config, false)).toBe("90ch");
22
+ expect(resolveContentMaxWidth(config, true)).toBe("90ch");
23
+ });
24
+
25
+ it("passes the \"none\" opt-out through untouched", () => {
26
+ expect(
27
+ resolveContentMaxWidth(cfg({ layout: { contentMaxWidth: "none" } }), false)
28
+ ).toBe("none");
29
+ });
30
+
31
+ it("prefers composerBar.contentMaxWidth over the 720px fallback", () => {
32
+ const config = cfg({
33
+ launcher: { composerBar: { contentMaxWidth: "600px" } },
34
+ });
35
+ expect(resolveContentMaxWidth(config, true)).toBe("600px");
36
+ });
37
+ });
@@ -0,0 +1,17 @@
1
+ import type { AgentWidgetConfig } from "../types";
2
+
3
+ /**
4
+ * Resolver-owned default: every surveyed product caps the conversation
5
+ * column (704 to 896px, modal value 768px,
6
+ * "Hero width versus transcript width"). Kept out of defaults.ts so a
7
+ * materialized value cannot override the composer-bar's own 720px fallback.
8
+ * `"none"` opts out (applied as `max-width: none`).
9
+ */
10
+ export const resolveContentMaxWidth = (
11
+ config: AgentWidgetConfig | undefined,
12
+ composerBar: boolean
13
+ ): string =>
14
+ config?.layout?.contentMaxWidth ??
15
+ (composerBar
16
+ ? config?.launcher?.composerBar?.contentMaxWidth ?? "720px"
17
+ : "768px");
@@ -0,0 +1,94 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createPluginStorageFactory, pluginStorageKey } from "./plugin-storage";
6
+ import type { AgentWidgetConfig } from "../types";
7
+
8
+ const config = (patch: Partial<AgentWidgetConfig> = {}): AgentWidgetConfig =>
9
+ ({ apiUrl: "https://api.example.com/chat", ...patch }) as AgentWidgetConfig;
10
+
11
+ describe("plugin storage facade", () => {
12
+ afterEach(() => {
13
+ window.localStorage.clear();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it("namespaces keys by keyPrefix and plugin id", () => {
18
+ expect(pluginStorageKey(config(), "home-screen", "view")).toBe(
19
+ "persona-plugin:home-screen:view"
20
+ );
21
+ expect(
22
+ pluginStorageKey(
23
+ config({ persistState: { keyPrefix: "myapp-" } }),
24
+ "home-screen",
25
+ "view"
26
+ )
27
+ ).toBe("myapp-plugin:home-screen:view");
28
+ });
29
+
30
+ it("reads and writes localStorage under the namespaced key", () => {
31
+ const storage = createPluginStorageFactory(() => config())("pre-chat");
32
+ storage.set("identity", "ada");
33
+
34
+ expect(window.localStorage.getItem("persona-plugin:pre-chat:identity")).toBe(
35
+ "ada"
36
+ );
37
+ expect(storage.get("identity")).toBe("ada");
38
+
39
+ storage.remove("identity");
40
+ expect(storage.get("identity")).toBeNull();
41
+ });
42
+
43
+ it("keeps two plugins from colliding on the same key", () => {
44
+ const factory = createPluginStorageFactory(() => config());
45
+ factory("a").set("view", "home");
46
+ factory("b").set("view", "chat");
47
+
48
+ expect(factory("a").get("view")).toBe("home");
49
+ expect(factory("b").get("view")).toBe("chat");
50
+ });
51
+
52
+ it("downgrades to an in-memory map under persistState: false", () => {
53
+ const storage = createPluginStorageFactory(() =>
54
+ config({ persistState: false })
55
+ )("pre-chat");
56
+ storage.set("identity", "ada");
57
+
58
+ expect(storage.get("identity")).toBe("ada");
59
+ expect(window.localStorage.getItem("persona-plugin:pre-chat:identity")).toBeNull();
60
+ });
61
+
62
+ it("falls back to memory when localStorage access throws (private mode)", () => {
63
+ const setItem = vi
64
+ .spyOn(Storage.prototype, "setItem")
65
+ .mockImplementation(() => {
66
+ throw new Error("blocked");
67
+ });
68
+ const getItem = vi
69
+ .spyOn(Storage.prototype, "getItem")
70
+ .mockImplementation(() => {
71
+ throw new Error("blocked");
72
+ });
73
+
74
+ const storage = createPluginStorageFactory(() => config())("home-screen");
75
+ expect(() => storage.set("view", "home")).not.toThrow();
76
+ expect(storage.get("view")).toBe("home");
77
+
78
+ setItem.mockRestore();
79
+ getItem.mockRestore();
80
+ });
81
+
82
+ it("follows a live keyPrefix change through controller.update()", () => {
83
+ let current = config();
84
+ const storage = createPluginStorageFactory(() => current)("home-screen");
85
+ storage.set("view", "home");
86
+
87
+ current = config({ persistState: { keyPrefix: "next-" } });
88
+ expect(storage.get("view")).toBeNull();
89
+ storage.set("view", "chat");
90
+ expect(window.localStorage.getItem("next-plugin:home-screen:view")).toBe(
91
+ "chat"
92
+ );
93
+ });
94
+ });
@@ -0,0 +1,98 @@
1
+ import type { AgentWidgetConfig } from "../types";
2
+
3
+ /**
4
+ * Synchronous key-value store handed to plugin render hooks. Backed by
5
+ * `localStorage`, namespaced per plugin id. The async `storageAdapter` cannot
6
+ * back a synchronous API and `persistState` is not a general key-value surface.
7
+ */
8
+ export interface AgentWidgetPluginStorage {
9
+ get: (key: string) => string | null;
10
+ set: (key: string, value: string) => void;
11
+ remove: (key: string) => void;
12
+ }
13
+
14
+ /** One factory per widget instance; the in-memory fallback is shared by it. */
15
+ export type PluginStorageFactory = (
16
+ pluginId: string
17
+ ) => AgentWidgetPluginStorage;
18
+
19
+ const resolveKeyPrefix = (config: AgentWidgetConfig | undefined): string => {
20
+ const persist = config?.persistState;
21
+ return (
22
+ (typeof persist === "object" ? persist?.keyPrefix : undefined) ?? "persona-"
23
+ );
24
+ };
25
+
26
+ export const pluginStorageKey = (
27
+ config: AgentWidgetConfig | undefined,
28
+ pluginId: string,
29
+ key: string
30
+ ): string => `${resolveKeyPrefix(config)}plugin:${pluginId}:${key}`;
31
+
32
+ /**
33
+ * `persistState: false` and blocked storage both downgrade to a per-instance
34
+ * Map. localStorage access can throw (Safari private mode, partitioned
35
+ * iframes), so the first throw switches this instance to the Map for good.
36
+ */
37
+ export const createPluginStorageFactory = (
38
+ getConfig: () => AgentWidgetConfig | undefined
39
+ ): PluginStorageFactory => {
40
+ const memory = new Map<string, string>();
41
+ let blocked = false;
42
+
43
+ const store = (): Storage | null => {
44
+ if (blocked) return null;
45
+ if (getConfig()?.persistState === false) return null;
46
+ try {
47
+ if (typeof window === "undefined" || !window.localStorage) return null;
48
+ return window.localStorage;
49
+ } catch {
50
+ blocked = true;
51
+ return null;
52
+ }
53
+ };
54
+
55
+ return (pluginId: string): AgentWidgetPluginStorage => {
56
+ const keyFor = (key: string) =>
57
+ pluginStorageKey(getConfig(), pluginId, key);
58
+
59
+ return {
60
+ get: (key) => {
61
+ const full = keyFor(key);
62
+ const local = store();
63
+ if (local) {
64
+ try {
65
+ return local.getItem(full);
66
+ } catch {
67
+ blocked = true;
68
+ }
69
+ }
70
+ return memory.get(full) ?? null;
71
+ },
72
+ set: (key, value) => {
73
+ const full = keyFor(key);
74
+ const local = store();
75
+ if (local) {
76
+ try {
77
+ local.setItem(full, value);
78
+ return;
79
+ } catch {
80
+ blocked = true;
81
+ }
82
+ }
83
+ memory.set(full, value);
84
+ },
85
+ remove: (key) => {
86
+ const full = keyFor(key);
87
+ memory.delete(full);
88
+ const local = store();
89
+ if (!local) return;
90
+ try {
91
+ local.removeItem(full);
92
+ } catch {
93
+ blocked = true;
94
+ }
95
+ },
96
+ };
97
+ };
98
+ };
@@ -137,6 +137,36 @@ describe('theme utils', () => {
137
137
  expect(cssVars['--persona-button-radius']).toBe('6px');
138
138
  });
139
139
 
140
+ it('zeroes horizontal intro-card padding when the card resolves flat', () => {
141
+ const cssVars = themeToCssVariables(createTheme({} as any));
142
+ expect(cssVars['--persona-intro-card-padding']).toBe('1.5rem 0');
143
+ });
144
+
145
+ it('keeps full intro-card padding when a background or shadow makes it a card', () => {
146
+ const withBackground = themeToCssVariables(
147
+ createTheme({
148
+ components: { introCard: { background: '#ffffff' } },
149
+ } as any)
150
+ );
151
+ expect(withBackground['--persona-intro-card-padding']).toBe('1.5rem');
152
+
153
+ const withShadow = themeToCssVariables(
154
+ createTheme({
155
+ components: { introCard: { shadow: '0 1px 2px rgba(0,0,0,0.1)' } },
156
+ } as any)
157
+ );
158
+ expect(withShadow['--persona-intro-card-padding']).toBe('1.5rem');
159
+ });
160
+
161
+ it('lets an explicit intro-card padding token win over the flat default', () => {
162
+ const cssVars = themeToCssVariables(
163
+ createTheme({
164
+ components: { introCard: { padding: '2rem' } },
165
+ } as any)
166
+ );
167
+ expect(cssVars['--persona-intro-card-padding']).toBe('2rem');
168
+ });
169
+
140
170
  it.each([
141
171
  {
142
172
  role: 'surface',
@@ -287,6 +317,61 @@ describe('theme utils', () => {
287
317
  expect(customVars['--persona-header-action-icon-fg']).toBe('#9ca3af');
288
318
  });
289
319
 
320
+ it('emits full-path CSS variables for header and welcome text style tokens', () => {
321
+ const theme = createTheme({
322
+ components: {
323
+ introCard: {
324
+ title: { fontFamily: 'Georgia, serif', fontSize: '1.5rem' },
325
+ },
326
+ header: {
327
+ title: {
328
+ fontFamily: 'Inter, sans-serif',
329
+ color: 'palette.colors.secondary.500',
330
+ },
331
+ },
332
+ },
333
+ } as any);
334
+ const cssVars = themeToCssVariables(theme);
335
+
336
+ expect(cssVars['--persona-components-introCard-title-fontFamily']).toBe(
337
+ 'Georgia, serif'
338
+ );
339
+ expect(cssVars['--persona-components-introCard-title-fontSize']).toBe('1.5rem');
340
+ expect(cssVars['--persona-components-header-title-fontFamily']).toBe(
341
+ 'Inter, sans-serif'
342
+ );
343
+ // title.color supersedes the legacy titleForeground alias.
344
+ expect(cssVars['--persona-header-title-fg']).toBe('#8b5cf6');
345
+ });
346
+
347
+ it('keeps titleForeground working when no header.title.color is set', () => {
348
+ const cssVars = themeToCssVariables(
349
+ createTheme({
350
+ components: {
351
+ header: { titleForeground: 'palette.colors.gray.500' },
352
+ },
353
+ } as any)
354
+ );
355
+
356
+ expect(cssVars['--persona-header-title-fg']).toBe('#6b7280');
357
+ });
358
+
359
+ it('emits suggestion itemGap for every variant, defaulting to 8px', () => {
360
+ const defaults = themeToCssVariables(createTheme());
361
+ expect(defaults['--persona-components-suggestion-chip-itemGap']).toBe('8px');
362
+ expect(defaults['--persona-components-suggestion-card-itemGap']).toBe('8px');
363
+ expect(defaults['--persona-components-suggestion-list-itemGap']).toBe('8px');
364
+
365
+ const custom = themeToCssVariables(
366
+ createTheme({
367
+ components: { suggestion: { list: { itemGap: '20px' } } },
368
+ } as any)
369
+ );
370
+ expect(custom['--persona-components-suggestion-list-itemGap']).toBe('20px');
371
+ // Sibling variants keep the default.
372
+ expect(custom['--persona-components-suggestion-chip-itemGap']).toBe('8px');
373
+ });
374
+
290
375
  it('maps button.ghost tokens to the composer ghost icon-button CSS variables', () => {
291
376
  const cssVars = themeToCssVariables(createTheme());
292
377
 
@@ -574,4 +659,56 @@ describe('theme utils', () => {
574
659
  const cssVars = themeToCssVariables(theme);
575
660
  expect(cssVars['--persona-code-bg']).toBe('#fafafa');
576
661
  });
662
+
663
+ it('derives the shared scrollbar variables from components.scrollbar with border fallback', () => {
664
+ const defaults = themeToCssVariables(createTheme({} as any));
665
+ expect(defaults['--persona-scrollbar-thumb']).toBe(defaults['--persona-border']);
666
+ expect(defaults['--persona-scrollbar-track']).toBe('transparent');
667
+
668
+ const themed = themeToCssVariables(
669
+ createTheme({
670
+ components: { scrollbar: { thumb: '#00dfc1', track: '#111111' } },
671
+ } as any)
672
+ );
673
+ expect(themed['--persona-scrollbar-thumb']).toBe('#00dfc1');
674
+ expect(themed['--persona-scrollbar-track']).toBe('#111111');
675
+ });
676
+
677
+ it('exposes intro card border and composer border color tokens', () => {
678
+ const defaults = themeToCssVariables(createTheme({} as any));
679
+ expect(defaults['--persona-intro-card-border']).toBe('none');
680
+ expect(defaults['--persona-composer-border-color']).toBe(defaults['--persona-border']);
681
+
682
+ const themed = themeToCssVariables(
683
+ createTheme({
684
+ components: {
685
+ introCard: { border: '1px solid rgba(0, 0, 0, 0.1)' },
686
+ composer: { borderColor: 'rgba(29, 28, 23, 0.25)' },
687
+ },
688
+ } as any)
689
+ );
690
+ expect(themed['--persona-intro-card-border']).toBe('1px solid rgba(0, 0, 0, 0.1)');
691
+ expect(themed['--persona-composer-border-color']).toBe('rgba(29, 28, 23, 0.25)');
692
+ });
693
+
694
+ it('follows the palette radius for markdown code blocks so square themes get square code', () => {
695
+ const defaults = themeToCssVariables(createTheme({} as any));
696
+ expect(defaults['--persona-md-code-block-border-radius']).toBe('0.375rem');
697
+
698
+ const square = themeToCssVariables(
699
+ createTheme({
700
+ palette: {
701
+ radius: { sm: '0px', md: '0px', lg: '0px', xl: '0px', '2xl': '0px' },
702
+ },
703
+ } as any)
704
+ );
705
+ expect(square['--persona-md-code-block-border-radius']).toBe('0px');
706
+
707
+ const explicit = themeToCssVariables(
708
+ createTheme({
709
+ components: { markdown: { codeBlock: { borderRadius: '2px' } } },
710
+ } as any)
711
+ );
712
+ expect(explicit['--persona-md-code-block-border-radius']).toBe('2px');
713
+ });
577
714
  });
@@ -344,6 +344,7 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
344
344
  borderRadius: 'palette.radius.full',
345
345
  padding: '0.5rem 0.875rem',
346
346
  gap: '0.5rem',
347
+ itemGap: '8px',
347
348
  minHeight: '36px',
348
349
  fontSize: '0.8125rem',
349
350
  lineHeight: '1.25',
@@ -362,6 +363,7 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
362
363
  padding: '1rem',
363
364
  shadow: 'palette.shadows.sm',
364
365
  gap: '0.625rem',
366
+ itemGap: '8px',
365
367
  minHeight: '72px',
366
368
  fontSize: '0.875rem',
367
369
  lineHeight: '1.35',
@@ -379,6 +381,7 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
379
381
  borderRadius: 'palette.radius.md',
380
382
  padding: '0.75rem',
381
383
  gap: '0.625rem',
384
+ itemGap: '8px',
382
385
  minHeight: '44px',
383
386
  fontSize: '0.875rem',
384
387
  lineHeight: '1.35',
@@ -399,6 +402,10 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
399
402
  composer: {
400
403
  shadow: 'palette.shadows.none',
401
404
  },
405
+ scrollbar: {
406
+ thumb: 'semantic.colors.border',
407
+ track: 'transparent',
408
+ },
402
409
  markdown: {
403
410
  inlineCode: {
404
411
  background: 'palette.colors.gray.50',
@@ -415,6 +422,7 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
415
422
  background: 'semantic.colors.container',
416
423
  borderColor: 'semantic.colors.border',
417
424
  textColor: 'inherit',
425
+ borderRadius: 'palette.radius.md',
418
426
  },
419
427
  table: {
420
428
  headerBackground: 'semantic.colors.container',
@@ -851,10 +859,15 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
851
859
  cssVars['--persona-components-header-iconBackground'] ?? cssVars['--persona-primary'];
852
860
  cssVars['--persona-header-icon-fg'] =
853
861
  cssVars['--persona-components-header-iconForeground'] ?? cssVars['--persona-text-inverse'];
862
+ // `title.color` / `subtitle.color` supersede the legacy *Foreground tokens.
854
863
  cssVars['--persona-header-title-fg'] =
855
- cssVars['--persona-components-header-titleForeground'] ?? cssVars['--persona-primary'];
864
+ cssVars['--persona-components-header-title-color'] ??
865
+ cssVars['--persona-components-header-titleForeground'] ??
866
+ cssVars['--persona-primary'];
856
867
  cssVars['--persona-header-subtitle-fg'] =
857
- cssVars['--persona-components-header-subtitleForeground'] ?? cssVars['--persona-text-muted'];
868
+ cssVars['--persona-components-header-subtitle-color'] ??
869
+ cssVars['--persona-components-header-subtitleForeground'] ??
870
+ cssVars['--persona-text-muted'];
858
871
  cssVars['--persona-header-action-icon-fg'] =
859
872
  cssVars['--persona-components-header-actionIconForeground'] ?? cssVars['--persona-muted'];
860
873
 
@@ -870,10 +883,25 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
870
883
  cssVars['--persona-components-introCard-background'] ?? 'transparent';
871
884
  cssVars['--persona-intro-card-radius'] =
872
885
  cssVars['--persona-components-introCard-borderRadius'] ?? '1rem';
873
- cssVars['--persona-intro-card-padding'] =
886
+ // Flat cards (transparent background, no shadow) drop the horizontal
887
+ // component of the stock padding so the welcome text shares the content
888
+ // column's left edge instead of carrying an invisible card inset. Compared
889
+ // by resolved value, not key presence: DEFAULT_COMPONENTS materializes the
890
+ // introCard tokens into every theme. A non-stock padding is emitted as-is;
891
+ // '1.5rem 1.5rem' forces the symmetric inset on a flat card.
892
+ const introBg = cssVars['--persona-components-introCard-background'];
893
+ const introShadow = cssVars['--persona-components-introCard-shadow'];
894
+ const introCardFlat =
895
+ (!introBg || introBg === 'transparent' || introBg === 'none') &&
896
+ (!introShadow || introShadow === 'none');
897
+ const introPadding =
874
898
  cssVars['--persona-components-introCard-padding'] ?? '1.5rem';
899
+ cssVars['--persona-intro-card-padding'] =
900
+ introCardFlat && introPadding === '1.5rem' ? '1.5rem 0' : introPadding;
875
901
  cssVars['--persona-intro-card-shadow'] =
876
902
  cssVars['--persona-components-introCard-shadow'] ?? 'none';
903
+ cssVars['--persona-intro-card-border'] =
904
+ cssVars['--persona-components-introCard-border'] ?? 'none';
877
905
 
878
906
  cssVars['--persona-input-background'] =
879
907
  cssVars['--persona-components-input-background'] ?? cssVars['--persona-surface'];
@@ -947,6 +975,18 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
947
975
  cssVars['--persona-components-composer-fontSize'] ?? '0.875rem';
948
976
  cssVars['--persona-composer-line-height'] =
949
977
  cssVars['--persona-components-composer-lineHeight'] ?? '1.25rem';
978
+ cssVars['--persona-composer-border-color'] =
979
+ cssVars['--persona-components-composer-borderColor'] ??
980
+ cssVars['--persona-border'] ??
981
+ '#e5e7eb';
982
+
983
+ // Scrollbars (every scroller in the widget consumes these two)
984
+ cssVars['--persona-scrollbar-thumb'] =
985
+ cssVars['--persona-components-scrollbar-thumb'] ??
986
+ cssVars['--persona-border'] ??
987
+ '#e5e7eb';
988
+ cssVars['--persona-scrollbar-track'] =
989
+ cssVars['--persona-components-scrollbar-track'] ?? 'transparent';
950
990
 
951
991
  cssVars['--persona-md-inline-code-bg'] =
952
992
  cssVars['--persona-components-markdown-inlineCode-background'] ?? cssVars['--persona-container'];
@@ -979,6 +1019,12 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
979
1019
  cssVars['--persona-components-markdown-codeBlock-borderColor'] ?? cssVars['--persona-border'];
980
1020
  cssVars['--persona-md-code-block-text-color'] =
981
1021
  cssVars['--persona-components-markdown-codeBlock-textColor'] ?? 'inherit';
1022
+ // Follows the palette radius so square-corner themes get square code blocks;
1023
+ // the 0.375rem fallback matches palette.radius.md's default.
1024
+ cssVars['--persona-md-code-block-border-radius'] =
1025
+ cssVars['--persona-components-markdown-codeBlock-borderRadius'] ??
1026
+ cssVars['--persona-radius-md'] ??
1027
+ '0.375rem';
982
1028
 
983
1029
  // Markdown table
984
1030
  cssVars['--persona-md-table-header-bg'] =