@runtypelabs/persona 4.14.0 → 4.16.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 (119) hide show
  1. package/README.md +4 -4
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
  5. package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-QJ4LV2KW.js +1 -0
  9. package/dist/codegen.cjs +12 -11
  10. package/dist/codegen.js +14 -13
  11. package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
  12. package/dist/context-mentions.cjs +11 -8
  13. package/dist/context-mentions.d.cts +520 -21
  14. package/dist/context-mentions.d.ts +520 -21
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +69 -68
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +595 -46
  19. package/dist/index.d.ts +595 -46
  20. package/dist/index.global.js +60 -57
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +69 -68
  23. package/dist/index.js.map +1 -1
  24. package/dist/launcher.global.js +5 -2
  25. package/dist/launcher.global.js.map +1 -1
  26. package/dist/smart-dom-reader.d.cts +520 -21
  27. package/dist/smart-dom-reader.d.ts +520 -21
  28. package/dist/testing.cjs +3 -3
  29. package/dist/testing.d.cts +7 -0
  30. package/dist/testing.d.ts +7 -0
  31. package/dist/testing.js +3 -3
  32. package/dist/theme-editor-preview.cjs +67 -67
  33. package/dist/theme-editor-preview.d.cts +534 -22
  34. package/dist/theme-editor-preview.d.ts +534 -22
  35. package/dist/theme-editor-preview.js +57 -57
  36. package/dist/theme-editor.cjs +10 -10
  37. package/dist/theme-editor.d.cts +521 -23
  38. package/dist/theme-editor.d.ts +521 -23
  39. package/dist/theme-editor.js +10 -10
  40. package/dist/theme-reference.cjs +1 -1
  41. package/dist/theme-reference.d.cts +104 -3
  42. package/dist/theme-reference.d.ts +104 -3
  43. package/dist/theme-reference.js +1 -1
  44. package/dist/widget.css +1 -1
  45. package/package.json +1 -1
  46. package/src/ask-user-question-tool.ts +10 -7
  47. package/src/components/artifact-pane.test.ts +27 -0
  48. package/src/components/artifact-pane.ts +7 -3
  49. package/src/components/artifact-preview.ts +1 -1
  50. package/src/components/composer-parts.test.ts +103 -4
  51. package/src/components/composer-parts.ts +53 -15
  52. package/src/components/header-builder.ts +27 -2
  53. package/src/components/header-layouts.test.ts +56 -0
  54. package/src/components/header-layouts.ts +29 -9
  55. package/src/components/launcher-surface.test.ts +403 -0
  56. package/src/components/launcher.ts +297 -2
  57. package/src/components/messages.ts +5 -2
  58. package/src/components/panel.ts +92 -71
  59. package/src/components/pill-composer-builder.test.ts +18 -0
  60. package/src/components/suggestions.test.ts +273 -0
  61. package/src/components/suggestions.ts +528 -86
  62. package/src/components/welcome.test.ts +121 -0
  63. package/src/components/welcome.ts +305 -0
  64. package/src/components/widget-view.test.ts +5 -3
  65. package/src/components/widget-view.ts +18 -7
  66. package/src/defaults.ts +7 -5
  67. package/src/generated/runtype-openapi-contract.ts +17 -0
  68. package/src/index-core.ts +29 -2
  69. package/src/launcher-global.ts +11 -8
  70. package/src/plugins/types.ts +136 -3
  71. package/src/presets.ts +3 -3
  72. package/src/session.ts +10 -8
  73. package/src/styles/widget-styles.test.ts +382 -0
  74. package/src/styles/widget.css +778 -4
  75. package/src/suggest-replies-tool.test.ts +233 -2
  76. package/src/suggest-replies-tool.ts +115 -39
  77. package/src/testing/mock-stream.test.ts +19 -0
  78. package/src/testing/mock-stream.ts +20 -0
  79. package/src/theme-editor/preview-utils.test.ts +34 -0
  80. package/src/theme-editor/preview-utils.ts +77 -2
  81. package/src/theme-editor/sections.test.ts +33 -0
  82. package/src/theme-editor/sections.ts +83 -6
  83. package/src/theme-reference.ts +34 -5
  84. package/src/types/theme.ts +72 -3
  85. package/src/types.ts +312 -18
  86. package/src/ui.body-top-slot.test.ts +95 -0
  87. package/src/ui.content-width.test.ts +91 -0
  88. package/src/ui.detached-panel.test.ts +1 -1
  89. package/src/ui.docked.test.ts +7 -4
  90. package/src/ui.launcher-teaser.test.ts +162 -0
  91. package/src/ui.rebuild-composer.test.ts +491 -0
  92. package/src/ui.render-welcome.test.ts +505 -0
  93. package/src/ui.scroll-additive.test.ts +91 -21
  94. package/src/ui.scroll.test.ts +302 -14
  95. package/src/ui.suggest-replies.test.ts +1112 -1
  96. package/src/ui.ts +1008 -149
  97. package/src/ui.welcome.test.ts +504 -0
  98. package/src/utils/artifact-file.test.ts +1 -1
  99. package/src/utils/artifact-gate.test.ts +27 -0
  100. package/src/utils/artifact-gate.ts +9 -0
  101. package/src/utils/attachment-manager.test.ts +37 -0
  102. package/src/utils/attachment-manager.ts +15 -0
  103. package/src/utils/auto-follow.test.ts +23 -0
  104. package/src/utils/auto-follow.ts +11 -1
  105. package/src/utils/code-generators.test.ts +40 -0
  106. package/src/utils/code-generators.ts +22 -0
  107. package/src/utils/config-merge.test.ts +24 -0
  108. package/src/utils/config-merge.ts +2 -0
  109. package/src/utils/content-width.test.ts +37 -0
  110. package/src/utils/content-width.ts +17 -0
  111. package/src/utils/icons.test.ts +26 -0
  112. package/src/utils/icons.ts +8 -0
  113. package/src/utils/plugin-storage.test.ts +94 -0
  114. package/src/utils/plugin-storage.ts +98 -0
  115. package/src/utils/theme.test.ts +216 -0
  116. package/src/utils/tokens.ts +113 -3
  117. package/src/welcome.test.ts +213 -0
  118. package/src/welcome.ts +130 -0
  119. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -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
 
@@ -393,6 +478,40 @@ describe('theme utils', () => {
393
478
  expect(cssVars['--persona-composer-shadow']).toBe('none');
394
479
  });
395
480
 
481
+ it('falls back composer spacing/type vars to the utility-class defaults', () => {
482
+ const cssVars = themeToCssVariables(createTheme());
483
+
484
+ expect(cssVars['--persona-composer-padding']).toBe('0.75rem 1rem');
485
+ expect(cssVars['--persona-composer-gap']).toBe('0.5rem');
486
+ expect(cssVars['--persona-composer-font-size']).toBe('0.875rem');
487
+ expect(cssVars['--persona-composer-line-height']).toBe('1.25rem');
488
+ });
489
+
490
+ it('maps composer spacing/type tokens to dedicated CSS variables', () => {
491
+ const theme = createTheme({
492
+ components: {
493
+ composer: {
494
+ padding: '1rem 1.25rem',
495
+ gap: '0.75rem',
496
+ fontSize: '1rem',
497
+ lineHeight: '1.5rem',
498
+ },
499
+ },
500
+ } as any);
501
+
502
+ const cssVars = themeToCssVariables(theme);
503
+
504
+ expect(cssVars['--persona-components-composer-padding']).toBe('1rem 1.25rem');
505
+ expect(cssVars['--persona-components-composer-gap']).toBe('0.75rem');
506
+ expect(cssVars['--persona-components-composer-fontSize']).toBe('1rem');
507
+ expect(cssVars['--persona-components-composer-lineHeight']).toBe('1.5rem');
508
+
509
+ expect(cssVars['--persona-composer-padding']).toBe('1rem 1.25rem');
510
+ expect(cssVars['--persona-composer-gap']).toBe('0.75rem');
511
+ expect(cssVars['--persona-composer-font-size']).toBe('1rem');
512
+ expect(cssVars['--persona-composer-line-height']).toBe('1.5rem');
513
+ });
514
+
396
515
  it('maps scroll-to-bottom component tokens to dedicated CSS variables', () => {
397
516
  const theme = createTheme({
398
517
  components: {
@@ -462,6 +581,51 @@ describe('theme utils', () => {
462
581
  expect(cssVars['--persona-intro-card-bg']).toBe('transparent');
463
582
  });
464
583
 
584
+ it('resolves semantic suggestion tokens for every variant and state', () => {
585
+ const theme = createTheme({
586
+ components: {
587
+ suggestion: {
588
+ chip: {
589
+ background: 'palette.colors.accent.50',
590
+ hoverBackground: 'palette.colors.accent.100',
591
+ focusRing: 'semantic.colors.interactive.focus',
592
+ },
593
+ card: {
594
+ borderRadius: 'palette.radius.lg',
595
+ shadow: 'palette.shadows.md',
596
+ },
597
+ list: {
598
+ minHeight: '48px',
599
+ disabledOpacity: '0.4',
600
+ },
601
+ },
602
+ },
603
+ });
604
+ const cssVars = themeToCssVariables(theme);
605
+
606
+ expect(cssVars['--persona-components-suggestion-chip-background']).toBe(
607
+ '#ecfeff'
608
+ );
609
+ expect(
610
+ cssVars['--persona-components-suggestion-chip-hoverBackground']
611
+ ).toBe('#cffafe');
612
+ expect(cssVars['--persona-components-suggestion-chip-focusRing']).toBe(
613
+ '#0f0f0f'
614
+ );
615
+ expect(
616
+ cssVars['--persona-components-suggestion-card-borderRadius']
617
+ ).toBe('0.5rem');
618
+ expect(cssVars['--persona-components-suggestion-card-shadow']).toContain(
619
+ '0 4px 6px'
620
+ );
621
+ expect(cssVars['--persona-components-suggestion-list-minHeight']).toBe(
622
+ '48px'
623
+ );
624
+ expect(
625
+ cssVars['--persona-components-suggestion-list-disabledOpacity']
626
+ ).toBe('0.4');
627
+ });
628
+
465
629
  it('drives --persona-tool-bubble-shadow from the theme token (config.toolCall.shadow is applied inline on the bubble, not the root var)', () => {
466
630
  const el = document.createElement('div');
467
631
  applyThemeVariables(el, {
@@ -495,4 +659,56 @@ describe('theme utils', () => {
495
659
  const cssVars = themeToCssVariables(theme);
496
660
  expect(cssVars['--persona-code-bg']).toBe('#fafafa');
497
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
+ });
498
714
  });
@@ -336,6 +336,63 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
336
336
  padding: 'semantic.spacing.lg',
337
337
  shadow: 'palette.shadows.none',
338
338
  },
339
+ suggestion: {
340
+ chip: {
341
+ background: 'semantic.colors.surface',
342
+ foreground: 'semantic.colors.text',
343
+ border: 'semantic.colors.border',
344
+ borderRadius: 'palette.radius.full',
345
+ padding: '0.5rem 0.875rem',
346
+ gap: '0.5rem',
347
+ itemGap: '8px',
348
+ minHeight: '36px',
349
+ fontSize: '0.8125rem',
350
+ lineHeight: '1.25',
351
+ iconSize: '16px',
352
+ hoverBackground: 'palette.colors.gray.100',
353
+ hoverBorder: 'palette.colors.gray.300',
354
+ pressedBackground: 'palette.colors.gray.200',
355
+ focusRing: 'semantic.colors.interactive.focus',
356
+ disabledOpacity: '0.5',
357
+ },
358
+ card: {
359
+ background: 'semantic.colors.surface',
360
+ foreground: 'semantic.colors.text',
361
+ border: 'semantic.colors.border',
362
+ borderRadius: 'palette.radius.xl',
363
+ padding: '1rem',
364
+ shadow: 'palette.shadows.sm',
365
+ gap: '0.625rem',
366
+ itemGap: '8px',
367
+ minHeight: '72px',
368
+ fontSize: '0.875rem',
369
+ lineHeight: '1.35',
370
+ iconSize: '18px',
371
+ hoverBackground: 'palette.colors.gray.100',
372
+ hoverBorder: 'palette.colors.gray.300',
373
+ pressedBackground: 'palette.colors.gray.200',
374
+ focusRing: 'semantic.colors.interactive.focus',
375
+ disabledOpacity: '0.5',
376
+ },
377
+ list: {
378
+ background: 'transparent',
379
+ foreground: 'semantic.colors.text',
380
+ border: 'semantic.colors.border',
381
+ borderRadius: 'palette.radius.md',
382
+ padding: '0.75rem',
383
+ gap: '0.625rem',
384
+ itemGap: '8px',
385
+ minHeight: '44px',
386
+ fontSize: '0.875rem',
387
+ lineHeight: '1.35',
388
+ iconSize: '17px',
389
+ hoverBackground: 'palette.colors.gray.100',
390
+ hoverBorder: 'palette.colors.gray.300',
391
+ pressedBackground: 'palette.colors.gray.200',
392
+ focusRing: 'semantic.colors.interactive.focus',
393
+ disabledOpacity: '0.5',
394
+ },
395
+ },
339
396
  toolBubble: {
340
397
  shadow: 'palette.shadows.sm',
341
398
  },
@@ -345,6 +402,10 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
345
402
  composer: {
346
403
  shadow: 'palette.shadows.none',
347
404
  },
405
+ scrollbar: {
406
+ thumb: 'semantic.colors.border',
407
+ track: 'transparent',
408
+ },
348
409
  markdown: {
349
410
  inlineCode: {
350
411
  background: 'palette.colors.gray.50',
@@ -361,6 +422,7 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
361
422
  background: 'semantic.colors.container',
362
423
  borderColor: 'semantic.colors.border',
363
424
  textColor: 'inherit',
425
+ borderRadius: 'palette.radius.md',
364
426
  },
365
427
  table: {
366
428
  headerBackground: 'semantic.colors.container',
@@ -797,10 +859,15 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
797
859
  cssVars['--persona-components-header-iconBackground'] ?? cssVars['--persona-primary'];
798
860
  cssVars['--persona-header-icon-fg'] =
799
861
  cssVars['--persona-components-header-iconForeground'] ?? cssVars['--persona-text-inverse'];
862
+ // `title.color` / `subtitle.color` supersede the legacy *Foreground tokens.
800
863
  cssVars['--persona-header-title-fg'] =
801
- 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'];
802
867
  cssVars['--persona-header-subtitle-fg'] =
803
- 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'];
804
871
  cssVars['--persona-header-action-icon-fg'] =
805
872
  cssVars['--persona-components-header-actionIconForeground'] ?? cssVars['--persona-muted'];
806
873
 
@@ -816,10 +883,25 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
816
883
  cssVars['--persona-components-introCard-background'] ?? 'transparent';
817
884
  cssVars['--persona-intro-card-radius'] =
818
885
  cssVars['--persona-components-introCard-borderRadius'] ?? '1rem';
819
- 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 =
820
898
  cssVars['--persona-components-introCard-padding'] ?? '1.5rem';
899
+ cssVars['--persona-intro-card-padding'] =
900
+ introCardFlat && introPadding === '1.5rem' ? '1.5rem 0' : introPadding;
821
901
  cssVars['--persona-intro-card-shadow'] =
822
902
  cssVars['--persona-components-introCard-shadow'] ?? 'none';
903
+ cssVars['--persona-intro-card-border'] =
904
+ cssVars['--persona-components-introCard-border'] ?? 'none';
823
905
 
824
906
  cssVars['--persona-input-background'] =
825
907
  cssVars['--persona-components-input-background'] ?? cssVars['--persona-surface'];
@@ -883,6 +965,28 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
883
965
  cssVars['--persona-components-reasoningBubble-shadow'] ?? '0 5px 15px rgba(15, 23, 42, 0.08)';
884
966
  cssVars['--persona-composer-shadow'] =
885
967
  cssVars['--persona-components-composer-shadow'] ?? 'none';
968
+ // Composer spacing/type. Fallbacks reproduce the utility classes the form
969
+ // used to carry (`px-4 py-3`, `gap-2`, `text-sm`) exactly.
970
+ cssVars['--persona-composer-padding'] =
971
+ cssVars['--persona-components-composer-padding'] ?? '0.75rem 1rem';
972
+ cssVars['--persona-composer-gap'] =
973
+ cssVars['--persona-components-composer-gap'] ?? '0.5rem';
974
+ cssVars['--persona-composer-font-size'] =
975
+ cssVars['--persona-components-composer-fontSize'] ?? '0.875rem';
976
+ cssVars['--persona-composer-line-height'] =
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';
886
990
 
887
991
  cssVars['--persona-md-inline-code-bg'] =
888
992
  cssVars['--persona-components-markdown-inlineCode-background'] ?? cssVars['--persona-container'];
@@ -915,6 +1019,12 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
915
1019
  cssVars['--persona-components-markdown-codeBlock-borderColor'] ?? cssVars['--persona-border'];
916
1020
  cssVars['--persona-md-code-block-text-color'] =
917
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';
918
1028
 
919
1029
  // Markdown table
920
1030
  cssVars['--persona-md-table-header-bg'] =
@@ -0,0 +1,213 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { DEFAULT_WIDGET_CONFIG, mergeWithDefaults } from "./defaults";
4
+ import { mergeConfigUpdate } from "./utils/config-merge";
5
+ import {
6
+ DEFAULT_WELCOME_SUBTITLE,
7
+ DEFAULT_WELCOME_TITLE,
8
+ isWelcomeVisible,
9
+ resolveWelcomeConfig,
10
+ } from "./welcome";
11
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
12
+
13
+ const config = (partial: Partial<AgentWidgetConfig>): AgentWidgetConfig =>
14
+ partial as AgentWidgetConfig;
15
+
16
+ const userMessage: AgentWidgetMessage = {
17
+ id: "u1",
18
+ role: "user",
19
+ content: "hi",
20
+ createdAt: "2026-08-01T00:00:00.000Z",
21
+ streaming: false,
22
+ };
23
+
24
+ const assistantMessage: AgentWidgetMessage = {
25
+ ...userMessage,
26
+ id: "a1",
27
+ role: "assistant",
28
+ };
29
+
30
+ describe("resolveWelcomeConfig", () => {
31
+ it("falls back to resolver-owned defaults", () => {
32
+ const resolved = resolveWelcomeConfig(config({}));
33
+ expect(resolved.title).toBe(DEFAULT_WELCOME_TITLE);
34
+ expect(resolved.subtitle).toBe(DEFAULT_WELCOME_SUBTITLE);
35
+ expect(resolved.variant).toBe("card");
36
+ expect(resolved.dismiss).toBe("never");
37
+ expect(resolved.message).toBeUndefined();
38
+ expect(resolved.icon).toBeUndefined();
39
+ });
40
+
41
+ it("prefers welcome.* over the legacy copy aliases, per field", () => {
42
+ const resolved = resolveWelcomeConfig(
43
+ config({
44
+ welcome: { title: "New title" },
45
+ copy: { welcomeTitle: "Legacy title", welcomeSubtitle: "Legacy subtitle" },
46
+ })
47
+ );
48
+ expect(resolved.title).toBe("New title");
49
+ expect(resolved.subtitle).toBe("Legacy subtitle");
50
+ });
51
+
52
+ it("uses the legacy aliases when welcome does not set the field", () => {
53
+ const resolved = resolveWelcomeConfig(
54
+ config({ copy: { welcomeTitle: "Legacy", welcomeSubtitle: "Scope" } })
55
+ );
56
+ expect(resolved.title).toBe("Legacy");
57
+ expect(resolved.subtitle).toBe("Scope");
58
+ });
59
+
60
+ it("honors an explicitly empty title or subtitle", () => {
61
+ const resolved = resolveWelcomeConfig(
62
+ config({ welcome: { title: "", subtitle: "" } })
63
+ );
64
+ expect(resolved.title).toBe("");
65
+ expect(resolved.subtitle).toBe("");
66
+ });
67
+
68
+ it("maps showWelcomeCard: false onto variant none", () => {
69
+ expect(
70
+ resolveWelcomeConfig(config({ copy: { showWelcomeCard: false } })).variant
71
+ ).toBe("none");
72
+ expect(
73
+ resolveWelcomeConfig(config({ copy: { showWelcomeCard: true } })).variant
74
+ ).toBe("card");
75
+ });
76
+
77
+ it("lets welcome.variant win over showWelcomeCard", () => {
78
+ expect(
79
+ resolveWelcomeConfig(
80
+ config({ welcome: { variant: "hero" }, copy: { showWelcomeCard: false } })
81
+ ).variant
82
+ ).toBe("hero");
83
+ });
84
+
85
+ it("forces on-first-message dismissal for hero and suppresses the greeting", () => {
86
+ const resolved = resolveWelcomeConfig(
87
+ config({
88
+ welcome: { variant: "hero", dismiss: "never", message: "Hi there" },
89
+ })
90
+ );
91
+ expect(resolved.dismiss).toBe("on-first-message");
92
+ expect(resolved.message).toBeUndefined();
93
+ });
94
+
95
+ it("keeps the greeting for card and none variants", () => {
96
+ expect(
97
+ resolveWelcomeConfig(config({ welcome: { message: "Hi there" } })).message
98
+ ).toBe("Hi there");
99
+ expect(
100
+ resolveWelcomeConfig(
101
+ config({ welcome: { variant: "none", message: "Hi there" } })
102
+ ).message
103
+ ).toBe("Hi there");
104
+ });
105
+
106
+ it("warns once in debug mode when a field is set through both namespaces", () => {
107
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
108
+ const withBoth = config({
109
+ debug: true,
110
+ welcome: { title: "New" },
111
+ copy: { welcomeTitle: "Legacy" },
112
+ });
113
+ resolveWelcomeConfig(withBoth);
114
+ resolveWelcomeConfig(withBoth);
115
+ expect(warn).toHaveBeenCalledTimes(1);
116
+ expect(warn.mock.calls[0][0]).toContain("copy.welcomeTitle");
117
+ warn.mockRestore();
118
+ });
119
+
120
+ it("warns in debug mode when message is set with variant hero", () => {
121
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
122
+ resolveWelcomeConfig(
123
+ config({ debug: true, welcome: { variant: "hero", message: "Hi" } })
124
+ );
125
+ expect(warn).toHaveBeenCalledTimes(1);
126
+ expect(warn.mock.calls[0][0]).toContain("welcome.message is ignored");
127
+ warn.mockRestore();
128
+ });
129
+
130
+ it("stays silent outside debug mode", () => {
131
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
132
+ resolveWelcomeConfig(
133
+ config({ welcome: { title: "New" }, copy: { welcomeTitle: "Legacy" } })
134
+ );
135
+ expect(warn).not.toHaveBeenCalled();
136
+ warn.mockRestore();
137
+ });
138
+ });
139
+
140
+ describe("welcome defaults are never materialized into config", () => {
141
+ it("keeps welcome copy out of DEFAULT_WIDGET_CONFIG", () => {
142
+ expect(DEFAULT_WIDGET_CONFIG.copy).not.toHaveProperty("welcomeTitle");
143
+ expect(DEFAULT_WIDGET_CONFIG.copy).not.toHaveProperty("welcomeSubtitle");
144
+ expect(DEFAULT_WIDGET_CONFIG).not.toHaveProperty("welcome");
145
+ });
146
+
147
+ it("keeps welcome copy out of a defaults-merged config", () => {
148
+ const merged = mergeWithDefaults({ apiUrl: "https://example.com" });
149
+ expect(merged.copy).not.toHaveProperty("welcomeTitle");
150
+ expect(merged.copy).not.toHaveProperty("welcomeSubtitle");
151
+ expect(merged.welcome).toBeUndefined();
152
+ });
153
+
154
+ it("resets to the resolver default on an explicit-undefined patch", () => {
155
+ const initial = mergeWithDefaults({
156
+ apiUrl: "https://example.com",
157
+ welcome: { title: "Set by host" },
158
+ }) as AgentWidgetConfig;
159
+ expect(resolveWelcomeConfig(initial).title).toBe("Set by host");
160
+
161
+ const cleared = mergeConfigUpdate(initial, {
162
+ welcome: { title: undefined },
163
+ });
164
+ expect(cleared.welcome).not.toHaveProperty("title");
165
+ expect(resolveWelcomeConfig(cleared).title).toBe(DEFAULT_WELCOME_TITLE);
166
+ });
167
+
168
+ it("falls back to the legacy alias after clearing the welcome field", () => {
169
+ const initial = mergeWithDefaults({
170
+ apiUrl: "https://example.com",
171
+ welcome: { title: "New" },
172
+ copy: { welcomeTitle: "Legacy" },
173
+ }) as AgentWidgetConfig;
174
+ const cleared = mergeConfigUpdate(initial, { welcome: { title: undefined } });
175
+ expect(resolveWelcomeConfig(cleared).title).toBe("Legacy");
176
+ });
177
+
178
+ it("replaces the icon wholesale instead of splicing union variants", () => {
179
+ const initial = mergeWithDefaults({
180
+ apiUrl: "https://example.com",
181
+ welcome: { icon: { type: "image", url: "/logo.png", alt: "Logo" } },
182
+ }) as AgentWidgetConfig;
183
+ const updated = mergeConfigUpdate(initial, {
184
+ welcome: { icon: { type: "lucide", name: "sparkles" } },
185
+ });
186
+ expect(updated.welcome?.icon).toEqual({ type: "lucide", name: "sparkles" });
187
+ });
188
+ });
189
+
190
+ describe("isWelcomeVisible", () => {
191
+ const card = resolveWelcomeConfig(config({}));
192
+ const hero = resolveWelcomeConfig(config({ welcome: { variant: "hero" } }));
193
+ const none = resolveWelcomeConfig(config({ welcome: { variant: "none" } }));
194
+
195
+ it("shows a never-dismissing card regardless of transcript content", () => {
196
+ expect(isWelcomeVisible(card, [])).toBe(true);
197
+ expect(isWelcomeVisible(card, [userMessage])).toBe(true);
198
+ });
199
+
200
+ it("hides an on-first-message surface once a user message exists", () => {
201
+ expect(isWelcomeVisible(hero, [])).toBe(true);
202
+ expect(isWelcomeVisible(hero, [assistantMessage])).toBe(true);
203
+ expect(isWelcomeVisible(hero, [userMessage, assistantMessage])).toBe(false);
204
+ });
205
+
206
+ it("never shows variant none", () => {
207
+ expect(isWelcomeVisible(none, [])).toBe(false);
208
+ });
209
+
210
+ it("treats a missing message list as empty", () => {
211
+ expect(isWelcomeVisible(hero, undefined)).toBe(true);
212
+ });
213
+ });