@runtypelabs/persona 4.17.1 → 4.18.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 (158) hide show
  1. package/dist/chunk-3I2S3I5P.js +1 -0
  2. package/dist/chunk-76IELU7F.js +1 -0
  3. package/dist/chunk-EFJBUH5F.js +1 -0
  4. package/dist/chunk-PDPVORUF.js +1 -0
  5. package/dist/chunk-Q735PSZL.js +1 -0
  6. package/dist/chunk-VK6JOIYJ.js +1 -0
  7. package/dist/codegen.cjs +4 -4
  8. package/dist/codegen.js +4 -4
  9. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  10. package/dist/context-mentions.cjs +9 -8
  11. package/dist/context-mentions.d.cts +1212 -14
  12. package/dist/context-mentions.d.ts +1212 -14
  13. package/dist/context-mentions.js +9 -8
  14. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  15. package/dist/event-stream-view.cjs +129 -0
  16. package/dist/event-stream-view.d.cts +8384 -0
  17. package/dist/event-stream-view.d.ts +8384 -0
  18. package/dist/event-stream-view.js +129 -0
  19. package/dist/history-view-BD6OXLNA.js +898 -0
  20. package/dist/history-view.cjs +898 -0
  21. package/dist/history-view.d.cts +1461 -0
  22. package/dist/history-view.d.ts +1461 -0
  23. package/dist/history-view.js +898 -0
  24. package/dist/index.cjs +77 -77
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +1987 -17
  27. package/dist/index.d.ts +1987 -17
  28. package/dist/index.global.js +67 -66
  29. package/dist/index.global.js.map +1 -1
  30. package/dist/index.js +77 -77
  31. package/dist/index.js.map +1 -1
  32. package/dist/launcher.global.js +5 -4
  33. package/dist/launcher.global.js.map +1 -1
  34. package/dist/plugin-kit.cjs +1 -1
  35. package/dist/plugin-kit.d.cts +0 -8
  36. package/dist/plugin-kit.d.ts +0 -8
  37. package/dist/plugin-kit.js +1 -1
  38. package/dist/smart-dom-reader.d.cts +1212 -14
  39. package/dist/smart-dom-reader.d.ts +1212 -14
  40. package/dist/theme-editor-preview.cjs +970 -73
  41. package/dist/theme-editor-preview.d.cts +1331 -15
  42. package/dist/theme-editor-preview.d.ts +1331 -15
  43. package/dist/theme-editor-preview.js +81 -81
  44. package/dist/theme-editor.cjs +7 -7
  45. package/dist/theme-editor.d.cts +1215 -15
  46. package/dist/theme-editor.d.ts +1215 -15
  47. package/dist/theme-editor.js +9 -9
  48. package/dist/theme-reference.cjs +1 -1
  49. package/dist/theme-reference.d.cts +226 -7
  50. package/dist/theme-reference.d.ts +226 -7
  51. package/dist/theme-reference.js +1 -1
  52. package/dist/widget.css +1 -1
  53. package/package.json +16 -4
  54. package/src/client-history-rest.test.ts +1034 -0
  55. package/src/client-visitor-history.test.ts +596 -0
  56. package/src/client.ts +1040 -24
  57. package/src/components/artifact-inline.ts +8 -1
  58. package/src/components/artifact-pane.test.ts +278 -1
  59. package/src/components/artifact-pane.ts +135 -67
  60. package/src/components/artifact-preview.test.ts +14 -1
  61. package/src/components/artifact-preview.ts +9 -0
  62. package/src/components/header-builder.ts +3 -0
  63. package/src/components/header-layouts.test.ts +171 -7
  64. package/src/components/header-layouts.ts +83 -53
  65. package/src/components/header-parts.test.ts +154 -1
  66. package/src/components/header-parts.ts +266 -138
  67. package/src/components/history-confirm-dialog.ts +177 -0
  68. package/src/components/history-shell-copy.ts +99 -0
  69. package/src/components/history-view/announcer.ts +40 -0
  70. package/src/components/history-view/copy.ts +105 -0
  71. package/src/components/history-view/css.ts +1078 -0
  72. package/src/components/history-view/grouping.test.ts +142 -0
  73. package/src/components/history-view/grouping.ts +142 -0
  74. package/src/components/history-view/icons.ts +73 -0
  75. package/src/components/history-view/rows.ts +341 -0
  76. package/src/components/history-view/state.ts +309 -0
  77. package/src/components/history-view.test.ts +2660 -0
  78. package/src/components/history-view.ts +1891 -0
  79. package/src/components/launcher-surface.test.ts +133 -0
  80. package/src/components/launcher.test.ts +18 -0
  81. package/src/components/launcher.ts +7 -7
  82. package/src/components/panel.ts +17 -7
  83. package/src/components/welcome.ts +72 -2
  84. package/src/defaults.ts +36 -3
  85. package/src/event-stream-view-bundle.test.ts +69 -0
  86. package/src/event-stream-view-entry.ts +19 -0
  87. package/src/event-stream-view-loader.ts +31 -0
  88. package/src/event-stream-view.ts +19 -0
  89. package/src/generated/runtype-openapi-contract.ts +1 -0
  90. package/src/history-render.ts +325 -0
  91. package/src/history-view-bundle.test.ts +78 -0
  92. package/src/history-view-entry.ts +41 -0
  93. package/src/history-view-loader.test.ts +30 -0
  94. package/src/history-view-loader.ts +32 -0
  95. package/src/history-view.ts +28 -0
  96. package/src/index-core.ts +50 -2
  97. package/src/index-global.ts +55 -0
  98. package/src/internal/demo-history-provider.test.ts +402 -0
  99. package/src/internal/demo-history-provider.ts +689 -0
  100. package/src/internal/history-provider-registry.ts +25 -0
  101. package/src/internal/history-provider.ts +150 -0
  102. package/src/internal/runtype-history-provider.test.ts +313 -0
  103. package/src/internal/runtype-history-provider.ts +240 -0
  104. package/src/plugin-kit.test.ts +16 -0
  105. package/src/plugin-kit.ts +13 -5
  106. package/src/plugins/types.ts +177 -1
  107. package/src/session-history.test.ts +1397 -0
  108. package/src/session.ts +1200 -26
  109. package/src/styles/widget-styles.test.ts +133 -0
  110. package/src/styles/widget.css +227 -8
  111. package/src/theme-editor/preview-utils.test.ts +19 -0
  112. package/src/theme-editor/preview-utils.ts +15 -1
  113. package/src/theme-editor/preview.ts +7 -1
  114. package/src/theme-editor/role-mappings.ts +4 -1
  115. package/src/theme-editor/sections.test.ts +168 -1
  116. package/src/theme-editor/sections.ts +136 -7
  117. package/src/theme-editor/webmcp/summary.ts +16 -2
  118. package/src/theme-reference.ts +20 -6
  119. package/src/types/theme.ts +213 -7
  120. package/src/types.ts +927 -7
  121. package/src/ui.artifact-rail-resize.test.ts +235 -0
  122. package/src/ui.attachments-drop.test.ts +1 -0
  123. package/src/ui.header-update-stability.test.ts +2 -2
  124. package/src/ui.history-chunk.test.ts +72 -0
  125. package/src/ui.history-config-provider.test.ts +318 -0
  126. package/src/ui.history-controller.test.ts +711 -0
  127. package/src/ui.history-lifecycle.test.ts +456 -0
  128. package/src/ui.history-render-hooks.test.ts +904 -0
  129. package/src/ui.history-shell.test.ts +1887 -0
  130. package/src/ui.rail-overlay.test.ts +573 -0
  131. package/src/ui.rail-resize.test.ts +284 -0
  132. package/src/ui.rebuild-composer.test.ts +21 -0
  133. package/src/ui.render-welcome.test.ts +42 -0
  134. package/src/ui.shortcuts.test.ts +166 -0
  135. package/src/ui.ts +3071 -240
  136. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  137. package/src/utils/artifact-file.ts +15 -0
  138. package/src/utils/artifact-gate.test.ts +4 -0
  139. package/src/utils/artifact-gate.ts +13 -17
  140. package/src/utils/artifact-resize.test.ts +28 -0
  141. package/src/utils/artifact-resize.ts +9 -2
  142. package/src/utils/buttons.ts +188 -1
  143. package/src/utils/config-merge.test.ts +91 -0
  144. package/src/utils/dropdown.ts +10 -1
  145. package/src/utils/history-messages.test.ts +284 -0
  146. package/src/utils/history-messages.ts +284 -0
  147. package/src/utils/icons.ts +3 -0
  148. package/src/utils/shortcuts.test.ts +288 -0
  149. package/src/utils/shortcuts.ts +221 -0
  150. package/src/utils/theme.test.ts +501 -4
  151. package/src/utils/theme.ts +42 -3
  152. package/src/utils/tokens.ts +153 -15
  153. package/src/utils/tooltip-timing.ts +4 -0
  154. package/src/utils/tooltip.test.ts +249 -7
  155. package/src/utils/tooltip.ts +146 -4
  156. package/src/utils/visitor-store.test.ts +425 -0
  157. package/src/utils/visitor-store.ts +337 -0
  158. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -1,7 +1,14 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { afterEach, describe, expect, it } from 'vitest';
4
- import { applyThemeVariables, createTheme, getActiveTheme, themeToCssVariables } from './theme';
4
+ import {
5
+ applyThemeVariables,
6
+ createDarkTheme,
7
+ createLightTheme,
8
+ createTheme,
9
+ getActiveTheme,
10
+ themeToCssVariables,
11
+ } from './theme';
5
12
 
6
13
  describe('theme utils', () => {
7
14
  afterEach(() => {
@@ -137,6 +144,61 @@ describe('theme utils', () => {
137
144
  expect(cssVars['--persona-button-radius']).toBe('6px');
138
145
  });
139
146
 
147
+ it('emits history motion vars with ms appended to bare durations', () => {
148
+ const cssVars = themeToCssVariables(
149
+ createTheme({
150
+ components: {
151
+ history: {
152
+ motion: {
153
+ enterDurationMs: 320,
154
+ enterEasing: 'ease-out',
155
+ exitDurationMs: 0,
156
+ exitEasing: 'linear',
157
+ },
158
+ },
159
+ },
160
+ } as any)
161
+ );
162
+ expect(cssVars['--persona-history-enter-ms']).toBe('320ms');
163
+ expect(cssVars['--persona-history-enter-easing']).toBe('ease-out');
164
+ expect(cssVars['--persona-history-exit-ms']).toBe('0ms');
165
+ expect(cssVars['--persona-history-exit-easing']).toBe('linear');
166
+
167
+ // Unset motion emits nothing: the chunk's fallbacks stay in charge.
168
+ const bare = themeToCssVariables(createTheme({} as any));
169
+ expect(bare['--persona-history-enter-ms']).toBeUndefined();
170
+ expect(bare['--persona-history-exit-easing']).toBeUndefined();
171
+ });
172
+
173
+ it('keys launcher pill vars to shared tokens by default and launcher tokens when customized', () => {
174
+ const defaults = themeToCssVariables(createTheme());
175
+ expect(defaults['--persona-launcher-bg']).toBe(defaults['--persona-surface']);
176
+ expect(defaults['--persona-launcher-fg']).toBe(defaults['--persona-primary']);
177
+ expect(defaults['--persona-launcher-fg-muted']).toBe(defaults['--persona-muted']);
178
+ expect(defaults['--persona-launcher-border']).toBe(defaults['--persona-border']);
179
+
180
+ const custom = themeToCssVariables(
181
+ createTheme({
182
+ components: {
183
+ launcher: {
184
+ background: '#1e293b',
185
+ foreground: '#f8fafc',
186
+ border: '#334155',
187
+ },
188
+ },
189
+ } as any)
190
+ );
191
+ expect(custom['--persona-launcher-bg']).toBe('#1e293b');
192
+ expect(custom['--persona-launcher-fg']).toBe('#f8fafc');
193
+ expect(custom['--persona-launcher-fg-muted']).toBe(
194
+ 'color-mix(in srgb, #f8fafc 70%, transparent)'
195
+ );
196
+ expect(custom['--persona-launcher-border']).toBe('#334155');
197
+ // Launcher tokens must not leak into the shared tokens the panel reads.
198
+ expect(custom['--persona-surface']).toBe(defaults['--persona-surface']);
199
+ expect(custom['--persona-primary']).toBe(defaults['--persona-primary']);
200
+ });
201
+
140
202
  it('zeroes horizontal intro-card padding when the card resolves flat', () => {
141
203
  const cssVars = themeToCssVariables(createTheme({} as any));
142
204
  expect(cssVars['--persona-intro-card-padding']).toBe('1.5rem 0');
@@ -294,9 +356,15 @@ describe('theme utils', () => {
294
356
  // Default header uses solid primary role: icon-bg=primary.600, icon-fg=primary.50, etc.
295
357
  expect(cssVars['--persona-header-icon-bg']).toBe('#0f0f0f'); // primary.600
296
358
  expect(cssVars['--persona-header-icon-fg']).toBe('#ffffff'); // primary.50
297
- expect(cssVars['--persona-header-title-fg']).toBe('#ffffff'); // primary.50
298
- expect(cssVars['--persona-header-subtitle-fg']).toBe('#d4d4d4'); // primary.200
299
- expect(cssVars['--persona-header-action-icon-fg']).toBe('#d4d4d4'); // primary.200
359
+ expect(cssVars['--persona-header-title-fg']).toBe('#ffffff'); // primary.50, via header.foreground
360
+ // Subtitle and action icons now derive from the background/foreground
361
+ // pair; the mix replaces the primary.200 they used to name directly.
362
+ expect(cssVars['--persona-header-subtitle-fg']).toBe(
363
+ 'color-mix(in srgb, #ffffff 72%, #171717)'
364
+ );
365
+ expect(cssVars['--persona-header-action-icon-fg']).toBe(
366
+ 'color-mix(in srgb, #ffffff 72%, #171717)'
367
+ );
300
368
 
301
369
  const custom = createTheme({
302
370
  components: {
@@ -317,6 +385,278 @@ describe('theme utils', () => {
317
385
  expect(customVars['--persona-header-action-icon-fg']).toBe('#9ca3af');
318
386
  });
319
387
 
388
+ it('renders the same default header chrome in light and dark schemes', () => {
389
+ // Golden values for the shipped header band. The dark preset overrides no
390
+ // header token, so both schemes must resolve identically.
391
+ const expected = {
392
+ '--persona-header-bg': '#171717', // primary.500
393
+ '--persona-header-border': 'color-mix(in srgb, #ffffff 14%, #171717)',
394
+ '--persona-header-title-fg': '#ffffff', // primary.50
395
+ '--persona-header-subtitle-fg': 'color-mix(in srgb, #ffffff 72%, #171717)',
396
+ '--persona-header-action-icon-fg': 'color-mix(in srgb, #ffffff 72%, #171717)',
397
+ '--persona-header-icon-bg': '#0f0f0f', // primary.600
398
+ '--persona-header-icon-fg': '#ffffff', // primary.50
399
+ };
400
+
401
+ const light = themeToCssVariables(createLightTheme());
402
+ const dark = themeToCssVariables(createDarkTheme());
403
+
404
+ for (const [key, value] of Object.entries(expected)) {
405
+ expect(light[key]).toBe(value);
406
+ expect(dark[key]).toBe(value);
407
+ }
408
+ });
409
+
410
+ it('derives header text and border from the background/foreground pair', () => {
411
+ const cssVars = themeToCssVariables(
412
+ createTheme({
413
+ components: {
414
+ header: { background: '#0b3d2e', foreground: '#f4f3ee' },
415
+ },
416
+ } as any)
417
+ );
418
+
419
+ expect(cssVars['--persona-header-bg']).toBe('#0b3d2e');
420
+ expect(cssVars['--persona-header-title-fg']).toBe('#f4f3ee');
421
+ expect(cssVars['--persona-header-subtitle-fg']).toBe(
422
+ 'color-mix(in srgb, #f4f3ee 72%, #0b3d2e)'
423
+ );
424
+ expect(cssVars['--persona-header-action-icon-fg']).toBe(
425
+ 'color-mix(in srgb, #f4f3ee 72%, #0b3d2e)'
426
+ );
427
+ expect(cssVars['--persona-header-border']).toBe(
428
+ 'color-mix(in srgb, #f4f3ee 14%, #0b3d2e)'
429
+ );
430
+ });
431
+
432
+ it('lets explicit header keys win over the foreground derivation', () => {
433
+ const cssVars = themeToCssVariables(
434
+ createTheme({
435
+ components: {
436
+ header: {
437
+ background: '#0b3d2e',
438
+ foreground: '#f4f3ee',
439
+ border: '#1f5c48',
440
+ titleForeground: '#ffe066',
441
+ actionIconForeground: '#cfd8cc',
442
+ subtitle: { color: '#b9c8bc' },
443
+ },
444
+ },
445
+ } as any)
446
+ );
447
+
448
+ expect(cssVars['--persona-header-border']).toBe('#1f5c48');
449
+ expect(cssVars['--persona-header-title-fg']).toBe('#ffe066');
450
+ expect(cssVars['--persona-header-action-icon-fg']).toBe('#cfd8cc');
451
+ expect(cssVars['--persona-header-subtitle-fg']).toBe('#b9c8bc');
452
+ });
453
+
454
+ it('ranks header title color as title.color, then titleForeground, then foreground', () => {
455
+ const base = { background: '#0b3d2e', foreground: '#f4f3ee' };
456
+
457
+ const fromForeground = themeToCssVariables(
458
+ createTheme({ components: { header: base } } as any)
459
+ );
460
+ const fromAlias = themeToCssVariables(
461
+ createTheme({
462
+ components: { header: { ...base, titleForeground: '#ffe066' } },
463
+ } as any)
464
+ );
465
+ const fromTitleColor = themeToCssVariables(
466
+ createTheme({
467
+ components: {
468
+ header: { ...base, titleForeground: '#ffe066', title: { color: '#8b5cf6' } },
469
+ },
470
+ } as any)
471
+ );
472
+
473
+ expect(fromForeground['--persona-header-title-fg']).toBe('#f4f3ee');
474
+ expect(fromAlias['--persona-header-title-fg']).toBe('#ffe066');
475
+ expect(fromTitleColor['--persona-header-title-fg']).toBe('#8b5cf6');
476
+ });
477
+
478
+ it('emits the shared header control box, glyph, and stroke tokens', () => {
479
+ const defaults = themeToCssVariables(createTheme());
480
+
481
+ expect(defaults['--persona-header-control-size']).toBe('32px');
482
+ expect(defaults['--persona-header-control-icon-size']).toBe('20px');
483
+ // The stroke has no alias: widget.css reads the full-path token and owns
484
+ // the 1.5 default, which keeps the sparse close X on its 1.05.
485
+ expect(defaults['--persona-components-header-controlStrokeWidth']).toBeUndefined();
486
+
487
+ const custom = themeToCssVariables(
488
+ createTheme({
489
+ components: {
490
+ header: {
491
+ controlSize: '32px',
492
+ controlIconSize: '18px',
493
+ controlStrokeWidth: '2',
494
+ },
495
+ },
496
+ } as any)
497
+ );
498
+
499
+ expect(custom['--persona-header-control-size']).toBe('32px');
500
+ expect(custom['--persona-header-control-icon-size']).toBe('18px');
501
+ // Unitless, straight through: no px suffix, no token resolution.
502
+ expect(custom['--persona-components-header-controlStrokeWidth']).toBe('2');
503
+ });
504
+
505
+ it('emits the launcher icon stroke token only when set', () => {
506
+ const defaults = themeToCssVariables(createTheme());
507
+
508
+ // No alias and no default: widget.css owns the shipped 2 in its var()
509
+ // fallback, so an unset token must leave the variable undefined.
510
+ expect(defaults['--persona-components-launcher-iconStrokeWidth']).toBeUndefined();
511
+
512
+ const custom = themeToCssVariables(
513
+ createTheme({
514
+ components: {
515
+ launcher: {
516
+ iconStrokeWidth: '1.5',
517
+ },
518
+ },
519
+ } as any)
520
+ );
521
+
522
+ // Unitless, straight through: no px suffix, no token resolution.
523
+ expect(custom['--persona-components-launcher-iconStrokeWidth']).toBe('1.5');
524
+ });
525
+
526
+ it('emits the header and rail-header height/surface tokens only when set', () => {
527
+ const defaults = themeToCssVariables(createTheme());
528
+ expect(defaults['--persona-header-min-height']).toBeUndefined();
529
+ expect(defaults['--persona-history-rail-header-bg']).toBeUndefined();
530
+ expect(defaults['--persona-history-rail-header-border']).toBeUndefined();
531
+ expect(defaults['--persona-history-rail-header-min-height']).toBeUndefined();
532
+
533
+ const custom = themeToCssVariables(
534
+ createTheme({
535
+ components: {
536
+ header: { minHeight: '64px' },
537
+ history: {
538
+ railHeader: {
539
+ background: 'palette.colors.gray.100',
540
+ border: '1px solid #e5e7eb',
541
+ minHeight: '64px',
542
+ title: {
543
+ color: 'palette.colors.gray.900',
544
+ fontSize: '15px',
545
+ fontWeight: '700',
546
+ },
547
+ },
548
+ },
549
+ },
550
+ } as any)
551
+ );
552
+
553
+ expect(custom['--persona-header-min-height']).toBe('64px');
554
+ // The background goes through token resolution; the rest are raw CSS.
555
+ expect(custom['--persona-history-rail-header-bg']).toBe('#f3f4f6');
556
+ expect(custom['--persona-history-rail-header-border']).toBe('1px solid #e5e7eb');
557
+ expect(custom['--persona-history-rail-header-min-height']).toBe('64px');
558
+ // The rail title is styled straight off the auto-emitted component vars.
559
+ expect(custom['--persona-components-history-railHeader-title-color']).toBe('#111827');
560
+ expect(custom['--persona-components-history-railHeader-title-fontSize']).toBe('15px');
561
+ expect(custom['--persona-components-history-railHeader-title-fontWeight']).toBe('700');
562
+ });
563
+
564
+ it('emits the floating rail aliases only when set', () => {
565
+ const defaults = themeToCssVariables(createTheme());
566
+ expect(defaults['--persona-history-overlay-margin']).toBeUndefined();
567
+ expect(defaults['--persona-history-overlay-radius']).toBeUndefined();
568
+ expect(defaults['--persona-history-overlay-shadow']).toBeUndefined();
569
+ expect(defaults['--persona-history-overlay-bg']).toBeUndefined();
570
+
571
+ const custom = themeToCssVariables(
572
+ createTheme({
573
+ components: {
574
+ history: {
575
+ overlay: {
576
+ margin: '12px',
577
+ borderRadius: '20px',
578
+ shadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
579
+ background: 'palette.colors.gray.100',
580
+ },
581
+ },
582
+ },
583
+ } as any)
584
+ );
585
+
586
+ expect(custom['--persona-history-overlay-margin']).toBe('12px');
587
+ expect(custom['--persona-history-overlay-radius']).toBe('20px');
588
+ expect(custom['--persona-history-overlay-shadow']).toBe(
589
+ '0 8px 24px rgba(0, 0, 0, 0.4)'
590
+ );
591
+ // The background goes through token resolution; the rest are raw CSS.
592
+ expect(custom['--persona-history-overlay-bg']).toBe('#f3f4f6');
593
+ });
594
+
595
+ it('emits the history menu aliases only when set', () => {
596
+ const defaults = themeToCssVariables(createTheme());
597
+ expect(defaults['--persona-history-menu-bg']).toBeUndefined();
598
+ expect(defaults['--persona-history-menu-radius']).toBeUndefined();
599
+
600
+ const custom = themeToCssVariables(
601
+ createTheme({
602
+ components: {
603
+ history: {
604
+ menu: {
605
+ background: 'palette.colors.gray.100',
606
+ borderRadius: '16px',
607
+ },
608
+ },
609
+ },
610
+ } as any)
611
+ );
612
+
613
+ expect(custom['--persona-history-menu-bg']).toBe('#f3f4f6');
614
+ expect(custom['--persona-history-menu-radius']).toBe('16px');
615
+ });
616
+
617
+ it('emits the tooltip aliases only when set, including the arrow opt-out', () => {
618
+ const defaults = themeToCssVariables(createTheme());
619
+ expect(defaults['--persona-tooltip-background']).toBeUndefined();
620
+ expect(defaults['--persona-tooltip-hint-fg']).toBeUndefined();
621
+ expect(defaults['--persona-tooltip-radius']).toBeUndefined();
622
+ expect(defaults['--persona-tooltip-arrow-display']).toBeUndefined();
623
+
624
+ const custom = themeToCssVariables(
625
+ createTheme({
626
+ components: {
627
+ tooltip: {
628
+ background: 'palette.colors.gray.800',
629
+ foreground: 'palette.colors.gray.50',
630
+ hintForeground: 'palette.colors.gray.400',
631
+ borderRadius: 'palette.radius.md',
632
+ fontSize: '13px',
633
+ padding: '8px 14px',
634
+ maxWidth: '240px',
635
+ shadow: 'none',
636
+ arrow: false,
637
+ },
638
+ },
639
+ } as any)
640
+ );
641
+
642
+ // Colors and the radius resolve through the palette; the rest are raw CSS.
643
+ expect(custom['--persona-tooltip-background']).toBe('#1f2937');
644
+ expect(custom['--persona-tooltip-foreground']).toBe('#f9fafb');
645
+ expect(custom['--persona-tooltip-hint-fg']).toBe('#9ca3af');
646
+ expect(custom['--persona-tooltip-radius']).toBe('0.375rem');
647
+ expect(custom['--persona-tooltip-font-size']).toBe('13px');
648
+ expect(custom['--persona-tooltip-padding']).toBe('8px 14px');
649
+ expect(custom['--persona-tooltip-max-width']).toBe('240px');
650
+ expect(custom['--persona-tooltip-shadow']).toBe('none');
651
+ expect(custom['--persona-tooltip-arrow-display']).toBe('none');
652
+
653
+ // arrow: true is the built-in look, so it emits nothing.
654
+ const withArrow = themeToCssVariables(
655
+ createTheme({ components: { tooltip: { arrow: true } } } as any)
656
+ );
657
+ expect(withArrow['--persona-tooltip-arrow-display']).toBeUndefined();
658
+ });
659
+
320
660
  it('emits full-path CSS variables for header and welcome text style tokens', () => {
321
661
  const theme = createTheme({
322
662
  components: {
@@ -397,6 +737,135 @@ describe('theme utils', () => {
397
737
  expect(customVars['--persona-button-ghost-hover-bg']).toBe('#f3f4f6'); // gray.100
398
738
  });
399
739
 
740
+ it('inverts the ghost hover wash for dark color schemes', () => {
741
+ // 5% black is invisible on a near-black surface, so every dark scheme —
742
+ // including a host theme that never mentions button.ghost — gets a light
743
+ // alpha wash instead. This drives the history rail's collapse toggle, its
744
+ // un-themed rows, and the per-message action buttons.
745
+ const light = themeToCssVariables(getActiveTheme({ colorScheme: 'light' }));
746
+ expect(light['--persona-button-ghost-hover-bg']).toBe('rgba(0, 0, 0, 0.05)');
747
+
748
+ const dark = themeToCssVariables(getActiveTheme({ colorScheme: 'dark' }));
749
+ expect(dark['--persona-button-ghost-hover-bg']).toBe('rgba(255, 255, 255, 0.08)');
750
+
751
+ // A host dark theme that overrides unrelated component tokens keeps it:
752
+ // the dark default is deep-merged underneath, not spread over.
753
+ const hostDark = themeToCssVariables(
754
+ getActiveTheme({
755
+ colorScheme: 'dark',
756
+ darkTheme: { components: { button: { primary: { background: '#22c55e' } } } },
757
+ } as any)
758
+ );
759
+ expect(hostDark['--persona-button-ghost-hover-bg']).toBe('rgba(255, 255, 255, 0.08)');
760
+
761
+ // An explicit hoverBackground always wins, in either scheme.
762
+ const explicitDark = themeToCssVariables(
763
+ getActiveTheme({
764
+ colorScheme: 'dark',
765
+ darkTheme: {
766
+ components: { button: { ghost: { hoverBackground: 'palette.colors.gray.800' } } },
767
+ },
768
+ } as any)
769
+ );
770
+ expect(explicitDark['--persona-button-ghost-hover-bg']).toBe('#1f2937'); // gray.800
771
+
772
+ // Set on `theme` alone it still reaches dark mode, which merges theme
773
+ // under darkTheme before the dark rebuild.
774
+ const explicitViaLight = themeToCssVariables(
775
+ getActiveTheme({
776
+ colorScheme: 'dark',
777
+ theme: { components: { button: { ghost: { hoverBackground: '#123456' } } } },
778
+ } as any)
779
+ );
780
+ expect(explicitViaLight['--persona-button-ghost-hover-bg']).toBe('#123456');
781
+ });
782
+
783
+ it('lightens the history danger red for dark color schemes', () => {
784
+ // error-600 lands ~3:1 on dark surfaces, under the AA floor for the 14px
785
+ // destructive labels; the built-in dark defaults swap in error-400.
786
+ const light = themeToCssVariables(getActiveTheme({ colorScheme: 'light' }));
787
+ expect(light['--persona-history-danger-fg']).toBeUndefined();
788
+
789
+ const dark = themeToCssVariables(getActiveTheme({ colorScheme: 'dark' }));
790
+ expect(dark['--persona-history-danger-fg']).toBe('#f87171');
791
+
792
+ // An explicit dangerForeground wins in either scheme.
793
+ const explicit = themeToCssVariables(
794
+ getActiveTheme({
795
+ colorScheme: 'dark',
796
+ darkTheme: {
797
+ components: { history: { dangerForeground: '#fecaca' } },
798
+ },
799
+ } as any)
800
+ );
801
+ expect(explicit['--persona-history-danger-fg']).toBe('#fecaca');
802
+ });
803
+
804
+ it('emits the confirm dialog danger pair and forks the fill for dark', () => {
805
+ // Always emitted: the dialog's inline var() must resolve so the fill
806
+ // follows a customized error palette instead of its legacy fallback.
807
+ const light = themeToCssVariables(getActiveTheme({ colorScheme: 'light' }));
808
+ expect(light['--persona-danger']).toBe('#b91c1c'); // error.700
809
+ expect(light['--persona-danger-fg']).toBe('#ffffff');
810
+ expect(light['--persona-history-confirm-scrim']).toBeUndefined();
811
+
812
+ const dark = themeToCssVariables(getActiveTheme({ colorScheme: 'dark' }));
813
+ expect(dark['--persona-danger']).toBe('#dc2626'); // error.600
814
+ // The light scrim default barely dims near-black surfaces.
815
+ expect(dark['--persona-history-confirm-scrim']).toBe('rgba(0, 0, 0, 0.6)');
816
+
817
+ const explicit = themeToCssVariables(
818
+ getActiveTheme({
819
+ colorScheme: 'dark',
820
+ darkTheme: {
821
+ components: {
822
+ history: {
823
+ confirm: {
824
+ dangerBackground: '#7f1d1d',
825
+ dangerForeground: '#fee2e2',
826
+ scrim: 'rgba(0, 0, 0, 0.6)',
827
+ },
828
+ },
829
+ },
830
+ },
831
+ } as any)
832
+ );
833
+ expect(explicit['--persona-danger']).toBe('#7f1d1d');
834
+ expect(explicit['--persona-danger-fg']).toBe('#fee2e2');
835
+ expect(explicit['--persona-history-confirm-scrim']).toBe('rgba(0, 0, 0, 0.6)');
836
+ });
837
+
838
+ it('emits messageActions aliases only when the group is configured', () => {
839
+ // widget.css chains to the ghost wash and semantic text on its own, so an
840
+ // unset group must leave every alias undefined.
841
+ const defaults = themeToCssVariables(createTheme());
842
+ expect(defaults['--persona-message-action-hover-bg']).toBeUndefined();
843
+ expect(defaults['--persona-message-action-hover-fg']).toBeUndefined();
844
+ expect(defaults['--persona-message-action-radius']).toBeUndefined();
845
+
846
+ const themed = themeToCssVariables(
847
+ createTheme({
848
+ components: {
849
+ messageActions: {
850
+ hoverBackground: 'palette.colors.gray.100',
851
+ hoverForeground: 'semantic.colors.text',
852
+ borderRadius: 'palette.radius.full',
853
+ },
854
+ },
855
+ } as any)
856
+ );
857
+ expect(themed['--persona-message-action-hover-bg']).toBe('#f3f4f6'); // gray.100
858
+ expect(themed['--persona-message-action-hover-fg']).toBe('#111827'); // semantic text
859
+ expect(themed['--persona-message-action-radius']).toBe('9999px');
860
+
861
+ // Partial groups emit only what they set.
862
+ const partial = themeToCssVariables(
863
+ createTheme({ components: { messageActions: { borderRadius: '2px' } } } as any)
864
+ );
865
+ expect(partial['--persona-message-action-radius']).toBe('2px');
866
+ expect(partial['--persona-message-action-hover-bg']).toBeUndefined();
867
+ });
868
+
400
869
  it('defaults artifact pane fill from semantic container and resolves toolbar background token refs', () => {
401
870
  const theme = createTheme();
402
871
  const cssVars = themeToCssVariables(theme);
@@ -420,6 +889,34 @@ describe('theme utils', () => {
420
889
  expect(surfaceVars['--persona-artifact-toolbar-bg']).toBe('#f9fafb');
421
890
  });
422
891
 
892
+ it('coerces unitless zero toggle-group gap/padding to px (calc-safe)', () => {
893
+ const theme = createTheme({
894
+ components: {
895
+ artifact: {
896
+ toolbar: {
897
+ toggleGroupGap: '0',
898
+ toggleGroupPadding: '0',
899
+ },
900
+ },
901
+ },
902
+ } as any);
903
+ const cssVars = themeToCssVariables(theme);
904
+ // The selection thumb's width calc() mixes these with lengths; a bare
905
+ // "0" is a <number> there and would invalidate the whole expression.
906
+ expect(cssVars['--persona-artifact-toolbar-toggle-group-gap']).toBe('0px');
907
+ expect(cssVars['--persona-artifact-toolbar-toggle-group-padding']).toBe('0px');
908
+
909
+ const unitful = themeToCssVariables(
910
+ createTheme({
911
+ components: {
912
+ artifact: { toolbar: { toggleGroupGap: '4px', toggleGroupPadding: '0.25rem' } },
913
+ },
914
+ } as any)
915
+ );
916
+ expect(unitful['--persona-artifact-toolbar-toggle-group-gap']).toBe('4px');
917
+ expect(unitful['--persona-artifact-toolbar-toggle-group-padding']).toBe('0.25rem');
918
+ });
919
+
423
920
  it('maps artifact inline chrome tokens to dedicated CSS variables (with semantic refs)', () => {
424
921
  const theme = createTheme({
425
922
  components: {
@@ -106,6 +106,36 @@ const DARK_PALETTE = {
106
106
  },
107
107
  };
108
108
 
109
+ /**
110
+ * Component defaults that only hold in a dark color scheme. Layered UNDER the
111
+ * host's theme, so any explicit value still wins.
112
+ *
113
+ * The ghost hover wash must invert with the scheme: 5% black is invisible on a
114
+ * near-black surface, so every dark scheme gets a light alpha instead.
115
+ */
116
+ const DARK_COMPONENTS: DeepPartial<PersonaTheme> = {
117
+ components: {
118
+ button: {
119
+ ghost: {
120
+ hoverBackground: 'rgba(255, 255, 255, 0.08)',
121
+ },
122
+ },
123
+ history: {
124
+ // error-400: the light-surface error-600 red lands ~3:1 on dark
125
+ // surfaces, under the 4.5:1 AA floor for the 14px destructive labels.
126
+ dangerForeground: '#f87171',
127
+ confirm: {
128
+ // error-600: bright enough to read as a control against a dark
129
+ // surface (3:1 non-text) while the white label keeps 4.8:1.
130
+ dangerBackground: 'palette.colors.error.600',
131
+ // 45% slate darkens near-black surfaces by almost nothing; dark
132
+ // schemes need a heavier dimmer for the modal to read as modal.
133
+ scrim: 'rgba(0, 0, 0, 0.6)',
134
+ },
135
+ },
136
+ },
137
+ };
138
+
109
139
  /**
110
140
  * Normalize theme config for merging; rejects non-objects.
111
141
  */
@@ -151,14 +181,23 @@ export const createDarkTheme = (userConfig?: DeepPartial<PersonaTheme>): Persona
151
181
  // layered UNDER the user's colors here. Spreading a pre-built default
152
182
  // palette instead would clobber the user's non-color palette overrides
153
183
  // (radius/typography) in dark mode while light mode honored them.
184
+ //
185
+ // DARK_COMPONENTS goes underneath by deep merge, not spread: a shallow
186
+ // spread of `components` would drop the dark ghost hover the moment a host
187
+ // set any unrelated component token.
188
+ const config = (deepMerge(
189
+ DARK_COMPONENTS as Record<string, unknown>,
190
+ (userConfig ?? {}) as Record<string, unknown>
191
+ ) ?? {}) as DeepPartial<PersonaTheme>;
192
+
154
193
  return createTheme(
155
194
  {
156
- ...userConfig,
195
+ ...config,
157
196
  palette: {
158
- ...userConfig?.palette,
197
+ ...config.palette,
159
198
  colors: {
160
199
  ...DARK_PALETTE.colors,
161
- ...userConfig?.palette?.colors,
200
+ ...config.palette?.colors,
162
201
  },
163
202
  },
164
203
  },