@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
@@ -280,9 +280,10 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
280
280
  },
281
281
  },
282
282
  launcher: {
283
- background: 'palette.colors.primary.500',
284
- foreground: 'palette.colors.primary.50',
285
- border: 'palette.colors.gray.200',
283
+ // Launcher pill role: shared surface/primary tones until a host overrides.
284
+ background: 'semantic.colors.surface',
285
+ foreground: 'semantic.colors.primary',
286
+ border: 'semantic.colors.border',
286
287
  size: '60px',
287
288
  iconSize: '28px',
288
289
  borderRadius: 'palette.radius.full',
@@ -299,16 +300,14 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
299
300
  canvasBackground: DEFAULT_PANEL_CANVAS_BACKGROUND,
300
301
  },
301
302
  header: {
302
- // Header role: solid primary
303
+ // Header role: solid primary. Border, subtitle, and action icons stay
304
+ // unset so they derive from this background/foreground pair.
303
305
  background: 'palette.colors.primary.500',
304
- border: 'palette.colors.primary.600',
306
+ foreground: 'palette.colors.primary.50',
305
307
  borderRadius: 'palette.radius.xl palette.radius.xl 0 0',
306
308
  padding: 'semantic.spacing.md',
307
309
  iconBackground: 'palette.colors.primary.600',
308
310
  iconForeground: 'palette.colors.primary.50',
309
- titleForeground: 'palette.colors.primary.50',
310
- subtitleForeground: 'palette.colors.primary.200',
311
- actionIconForeground: 'palette.colors.primary.200',
312
311
  },
313
312
  message: {
314
313
  user: {
@@ -729,6 +728,9 @@ export function createTheme(
729
728
  return theme;
730
729
  }
731
730
 
731
+ /** Coerce a unitless "0" to "0px" for vars consumed inside length calc()s. */
732
+ const zeroLength = (value: string): string => (value.trim() === '0' ? '0px' : value);
733
+
732
734
  export function themeToCssVariables(theme: PersonaTheme): Record<string, string> {
733
735
  const resolved = resolveTokens(theme);
734
736
  const cssVars: Record<string, string> = {};
@@ -787,10 +789,20 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
787
789
  '9999px';
788
790
  cssVars['--persona-launcher-bg'] =
789
791
  cssVars['--persona-components-launcher-background'] ??
790
- cssVars['--persona-primary'];
792
+ cssVars['--persona-surface'];
791
793
  cssVars['--persona-launcher-fg'] =
792
794
  cssVars['--persona-components-launcher-foreground'] ??
793
- cssVars['--persona-text-inverse'];
795
+ cssVars['--persona-primary'];
796
+ // Subtitle keeps the shared muted tone while the foreground is stock (it
797
+ // resolves to primary); a custom foreground washes to 70% so both pill
798
+ // text lines recolor together. Compared by resolved value, not key
799
+ // presence: DEFAULT_COMPONENTS materializes launcher.foreground into
800
+ // every theme.
801
+ const launcherFg = cssVars['--persona-components-launcher-foreground'];
802
+ cssVars['--persona-launcher-fg-muted'] =
803
+ launcherFg && launcherFg !== cssVars['--persona-primary']
804
+ ? `color-mix(in srgb, ${launcherFg} 70%, transparent)`
805
+ : cssVars['--persona-muted'];
794
806
  cssVars['--persona-launcher-border'] =
795
807
  cssVars['--persona-components-launcher-border'] ??
796
808
  cssVars['--persona-border'];
@@ -853,8 +865,15 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
853
865
  // dedicated CSS variables that the widget CSS reads for individual elements.
854
866
  cssVars['--persona-header-bg'] =
855
867
  cssVars['--persona-components-header-background'] ?? cssVars['--persona-surface'];
868
+ // The background/foreground pair anchors every other header color: unset
869
+ // text and border keys mix the two rather than falling back to page chrome.
870
+ const headerBg = cssVars['--persona-header-bg'];
871
+ const headerFg = cssVars['--persona-components-header-foreground'];
872
+ const headerMutedFg = headerFg ? `color-mix(in srgb, ${headerFg} 72%, ${headerBg})` : undefined;
856
873
  cssVars['--persona-header-border'] =
857
- cssVars['--persona-components-header-border'] ?? cssVars['--persona-divider'];
874
+ cssVars['--persona-components-header-border'] ??
875
+ (headerFg ? `color-mix(in srgb, ${headerFg} 14%, ${headerBg})` : undefined) ??
876
+ cssVars['--persona-divider'];
858
877
  cssVars['--persona-header-icon-bg'] =
859
878
  cssVars['--persona-components-header-iconBackground'] ?? cssVars['--persona-primary'];
860
879
  cssVars['--persona-header-icon-fg'] =
@@ -863,17 +882,133 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
863
882
  cssVars['--persona-header-title-fg'] =
864
883
  cssVars['--persona-components-header-title-color'] ??
865
884
  cssVars['--persona-components-header-titleForeground'] ??
885
+ headerFg ??
866
886
  cssVars['--persona-primary'];
867
887
  cssVars['--persona-header-subtitle-fg'] =
868
888
  cssVars['--persona-components-header-subtitle-color'] ??
869
889
  cssVars['--persona-components-header-subtitleForeground'] ??
890
+ headerMutedFg ??
870
891
  cssVars['--persona-text-muted'];
871
892
  cssVars['--persona-header-action-icon-fg'] =
872
- cssVars['--persona-components-header-actionIconForeground'] ?? cssVars['--persona-muted'];
893
+ cssVars['--persona-components-header-actionIconForeground'] ??
894
+ headerMutedFg ??
895
+ cssVars['--persona-muted'];
896
+ // Unified header control box. Every header icon button reads these two from
897
+ // the stylesheet; per-control config keys stay inline and win. The third
898
+ // control token, `header.controlStrokeWidth`, needs no alias: widget.css
899
+ // reads its auto-emitted `--persona-components-header-controlStrokeWidth`
900
+ // directly and carries the 1.5 default in the var() fallback.
901
+ cssVars['--persona-header-control-size'] =
902
+ cssVars['--persona-components-header-controlSize'] ?? '32px';
903
+ cssVars['--persona-header-control-icon-size'] =
904
+ cssVars['--persona-components-header-controlIconSize'] ?? '20px';
873
905
 
874
906
  const headerTokens = theme.components?.header;
875
907
  if (headerTokens?.shadow) cssVars['--persona-header-shadow'] = headerTokens.shadow;
876
908
  if (headerTokens?.borderBottom) cssVars['--persona-header-border-bottom'] = headerTokens.borderBottom;
909
+ // Conditional: the stylesheet carries `auto` / the rail's own fallback chain,
910
+ // so an unset token must leave the variable undefined.
911
+ if (headerTokens?.minHeight) cssVars['--persona-header-min-height'] = headerTokens.minHeight;
912
+
913
+ // Messages rail header strip. Same conditional rule: the chunk's CSS carries
914
+ // the surface/border/height fallback chain when a theme says nothing.
915
+ const railHeaderBg = cssVars['--persona-components-history-railHeader-background'];
916
+ if (railHeaderBg) cssVars['--persona-history-rail-header-bg'] = railHeaderBg;
917
+ const railHeaderBorder = cssVars['--persona-components-history-railHeader-border'];
918
+ if (railHeaderBorder) cssVars['--persona-history-rail-header-border'] = railHeaderBorder;
919
+ const railHeaderMinHeight = cssVars['--persona-components-history-railHeader-minHeight'];
920
+ if (railHeaderMinHeight)
921
+ cssVars['--persona-history-rail-header-min-height'] = railHeaderMinHeight;
922
+
923
+ // Floating (overlay-collapsed) rail. Same conditional rule: the host's
924
+ // inline styles carry every default in their var() fallback.
925
+ for (const [token, alias] of [
926
+ ['margin', 'margin'],
927
+ ['borderRadius', 'radius'],
928
+ ['shadow', 'shadow'],
929
+ ['background', 'bg'],
930
+ ] as const) {
931
+ const value = cssVars[`--persona-components-history-overlay-${token}`];
932
+ if (value) cssVars[`--persona-history-overlay-${alias}`] = value;
933
+ }
934
+
935
+ // Row overflow menu. Same conditional rule: the chunk's CSS carries the
936
+ // elevated color-mix default in its var() fallback.
937
+ for (const [token, alias] of [
938
+ ['background', 'bg'],
939
+ ['borderRadius', 'radius'],
940
+ ] as const) {
941
+ const value = cssVars[`--persona-components-history-menu-${token}`];
942
+ if (value) cssVars[`--persona-history-menu-${alias}`] = value;
943
+ }
944
+ // Messages enter/exit motion. Read straight off the theme: durations are
945
+ // authored as bare millisecond NUMBERS, and resolveTokens collects only
946
+ // string leaves, so these never reach the flattened vars. A unitless value
947
+ // gains "ms" for the CSS animation shorthand; 0 emits "0ms" (leg disabled).
948
+ const historyMotion = theme.components?.history?.motion;
949
+ if (historyMotion) {
950
+ const asTime = (value: number | string | undefined): string | undefined => {
951
+ if (value === undefined) return undefined;
952
+ const s = String(value).trim();
953
+ return /^\d+(\.\d+)?$/.test(s) ? `${s}ms` : s;
954
+ };
955
+ const enterMs = asTime(historyMotion.enterDurationMs);
956
+ if (enterMs !== undefined) cssVars['--persona-history-enter-ms'] = enterMs;
957
+ const exitMs = asTime(historyMotion.exitDurationMs);
958
+ if (exitMs !== undefined) cssVars['--persona-history-exit-ms'] = exitMs;
959
+ if (historyMotion.enterEasing)
960
+ cssVars['--persona-history-enter-easing'] = historyMotion.enterEasing;
961
+ if (historyMotion.exitEasing)
962
+ cssVars['--persona-history-exit-easing'] = historyMotion.exitEasing;
963
+ }
964
+ // Destructive action text. The chunk falls back to the light-surface
965
+ // error-600; the built-in dark theme sets this to a passing lighter red.
966
+ const historyDangerFg = cssVars['--persona-components-history-dangerForeground'];
967
+ if (historyDangerFg) cssVars['--persona-history-danger-fg'] = historyDangerFg;
968
+ // Confirmation dialog. The danger fill/label pair is always emitted so the
969
+ // dialog follows the error palette; scrim and shadow stay conditional with
970
+ // the dialog's inline fallbacks as the defaults.
971
+ cssVars['--persona-danger'] =
972
+ cssVars['--persona-components-history-confirm-dangerBackground'] ??
973
+ cssVars['--persona-palette-colors-error-700'] ??
974
+ '#b42318';
975
+ cssVars['--persona-danger-fg'] =
976
+ cssVars['--persona-components-history-confirm-dangerForeground'] ?? '#ffffff';
977
+ for (const token of ['scrim', 'shadow'] as const) {
978
+ const value = cssVars[`--persona-components-history-confirm-${token}`];
979
+ if (value) cssVars[`--persona-history-confirm-${token}`] = value;
980
+ }
981
+
982
+ // Portaled control tooltip. Same conditional rule: widget.css carries the
983
+ // built-in look in every var() fallback, so an unset token stays undefined.
984
+ for (const [token, alias] of [
985
+ ['background', 'background'],
986
+ ['foreground', 'foreground'],
987
+ ['hintForeground', 'hint-fg'],
988
+ ['borderRadius', 'radius'],
989
+ ['fontSize', 'font-size'],
990
+ ['padding', 'padding'],
991
+ ['maxWidth', 'max-width'],
992
+ ['shadow', 'shadow'],
993
+ ]) {
994
+ const value = cssVars[`--persona-components-tooltip-${token}`];
995
+ if (value) cssVars[`--persona-tooltip-${alias}`] = value;
996
+ }
997
+ // Booleans never resolve into a token, so the arrow reads off the theme.
998
+ if (theme.components?.tooltip?.arrow === false)
999
+ cssVars['--persona-tooltip-arrow-display'] = 'none';
1000
+
1001
+ // Per-message action row (copy, vote, read aloud). Same conditional rule:
1002
+ // widget.css falls back to the scheme-aware ghost wash and semantic text, so
1003
+ // an unset token must leave the variable undefined.
1004
+ for (const [token, alias] of [
1005
+ ['hoverBackground', 'hover-bg'],
1006
+ ['hoverForeground', 'hover-fg'],
1007
+ ['borderRadius', 'radius'],
1008
+ ] as const) {
1009
+ const value = cssVars[`--persona-components-messageActions-${token}`];
1010
+ if (value) cssVars[`--persona-message-action-${alias}`] = value;
1011
+ }
877
1012
 
878
1013
  // Intro card aliases: short names the panel inline-styles read directly.
879
1014
  // The full-path `--persona-components-introCard-*` variables auto-emit above.
@@ -1102,16 +1237,19 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
1102
1237
  if (t.iconPadding) cssVars['--persona-artifact-toolbar-icon-padding'] = t.iconPadding;
1103
1238
  if (t.iconBorderRadius) cssVars['--persona-artifact-toolbar-icon-radius'] = t.iconBorderRadius;
1104
1239
  if (t.iconBorder) cssVars['--persona-artifact-toolbar-icon-border'] = t.iconBorder;
1105
- if (t.toggleGroupGap) cssVars['--persona-artifact-toolbar-toggle-group-gap'] = t.toggleGroupGap;
1240
+ // A bare "0" is a <number> in calc(), not a <length>; gap/padding feed the
1241
+ // toggle thumb's width calc, so a unitless zero would zero out the thumb.
1242
+ if (t.toggleGroupGap) cssVars['--persona-artifact-toolbar-toggle-group-gap'] = zeroLength(t.toggleGroupGap);
1106
1243
  if (t.toggleBorderRadius) cssVars['--persona-artifact-toolbar-toggle-radius'] = t.toggleBorderRadius;
1107
- if (t.toggleGroupPadding) cssVars['--persona-artifact-toolbar-toggle-group-padding'] = t.toggleGroupPadding;
1244
+ if (t.toggleGroupPadding) cssVars['--persona-artifact-toolbar-toggle-group-padding'] = zeroLength(t.toggleGroupPadding);
1108
1245
  if (t.toggleGroupBorder) cssVars['--persona-artifact-toolbar-toggle-group-border'] = t.toggleGroupBorder;
1109
1246
  if (t.toggleGroupBorderRadius) cssVars['--persona-artifact-toolbar-toggle-group-radius'] = t.toggleGroupBorderRadius;
1110
1247
  if (t.toggleGroupBackground) cssVars['--persona-artifact-toolbar-toggle-group-bg'] = resolveTokenValue(theme, t.toggleGroupBackground) ?? t.toggleGroupBackground;
1111
1248
  if (t.copyBackground) cssVars['--persona-artifact-toolbar-copy-bg'] = t.copyBackground;
1112
1249
  if (t.copyBorder) cssVars['--persona-artifact-toolbar-copy-border'] = t.copyBorder;
1113
1250
  if (t.copyColor) cssVars['--persona-artifact-toolbar-copy-color'] = t.copyColor;
1114
- if (t.copyBorderRadius) cssVars['--persona-artifact-toolbar-copy-radius'] = t.copyBorderRadius;
1251
+ // Feeds the split-button halves' inner-radius calc; must stay a <length>.
1252
+ if (t.copyBorderRadius) cssVars['--persona-artifact-toolbar-copy-radius'] = zeroLength(t.copyBorderRadius);
1115
1253
  if (t.copyPadding) cssVars['--persona-artifact-toolbar-copy-padding'] = t.copyPadding;
1116
1254
  if (t.copyMenuBackground) {
1117
1255
  cssVars['--persona-artifact-toolbar-copy-menu-bg'] = t.copyMenuBackground;
@@ -0,0 +1,4 @@
1
+ /** First-hover wait. Matches ChatGPT and Claude. */
2
+ export const DEFAULT_TOOLTIP_DELAY_MS = 200;
3
+ /** After one tooltip, later ones in this window open immediately. */
4
+ export const DEFAULT_TOOLTIP_SKIP_DELAY_MS = 300;
@@ -1,7 +1,23 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
- import { attachTooltip } from "./tooltip";
4
+ import {
5
+ attachTooltip,
6
+ DEFAULT_TOOLTIP_DELAY_MS,
7
+ resetTooltipTiming,
8
+ } from "./tooltip";
9
+
10
+ /**
11
+ * Focus with a pinned keyboard-visible verdict. jsdom implements the spec's
12
+ * focus-visible propagation, so a bare focus() verdict depends on what ran
13
+ * before it; tests that open via focus must not be order-dependent.
14
+ */
15
+ const focusVisible = (button: HTMLElement): void => {
16
+ vi.spyOn(button, "matches").mockImplementation(
17
+ (selector: string) => selector === ":focus-visible"
18
+ );
19
+ button.focus();
20
+ };
5
21
 
6
22
  const rect = (
7
23
  left: number,
@@ -98,7 +114,7 @@ describe("attachTooltip", () => {
98
114
  );
99
115
 
100
116
  attachTooltip({ anchor: button, text: "Close chat" });
101
- button.focus();
117
+ focusVisible(button);
102
118
 
103
119
  const tooltip = document.body.querySelector<HTMLElement>(
104
120
  ".persona-control-tooltip"
@@ -110,12 +126,26 @@ describe("attachTooltip", () => {
110
126
  expect(document.body.querySelector(".persona-control-tooltip")).toBeNull();
111
127
  });
112
128
 
113
- it("dismisses an open tooltip with Escape", () => {
129
+ it("stays closed on focus that is not focus-visible", () => {
114
130
  const button = document.createElement("button");
115
131
  document.body.appendChild(button);
132
+ // jsdom reports every focus as :focus-visible; model a browser's
133
+ // programmatic-focus-after-mouse verdict instead.
134
+ vi.spyOn(button, "matches").mockImplementation(
135
+ (selector: string) => selector !== ":focus-visible"
136
+ );
116
137
 
117
138
  attachTooltip({ anchor: button, text: "Close chat" });
118
139
  button.focus();
140
+ expect(document.body.querySelector(".persona-control-tooltip")).toBeNull();
141
+ });
142
+
143
+ it("dismisses an open tooltip with Escape", () => {
144
+ const button = document.createElement("button");
145
+ document.body.appendChild(button);
146
+
147
+ attachTooltip({ anchor: button, text: "Close chat" });
148
+ focusVisible(button);
119
149
  expect(document.body.querySelector(".persona-control-tooltip")).not.toBeNull();
120
150
 
121
151
  button.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
@@ -123,6 +153,47 @@ describe("attachTooltip", () => {
123
153
  expect(document.body.querySelector(".persona-control-tooltip")).toBeNull();
124
154
  });
125
155
 
156
+ it("dismisses on activation and reopens with the label the control now has", () => {
157
+ const button = document.createElement("button");
158
+ button.setAttribute("aria-label", "Collapse conversation list");
159
+ document.body.appendChild(button);
160
+ const tip = () =>
161
+ document.body.querySelector<HTMLElement>(".persona-control-tooltip");
162
+
163
+ attachTooltip({
164
+ anchor: button,
165
+ text: () => button.getAttribute("aria-label") ?? "",
166
+ });
167
+ button.dispatchEvent(new MouseEvent("mouseenter"));
168
+ expect(tip()?.textContent).toContain("Collapse conversation list");
169
+
170
+ // The pointer never left, so nothing but the activation closes it.
171
+ button.click();
172
+ expect(tip()).toBeNull();
173
+
174
+ button.setAttribute("aria-label", "Expand conversation list");
175
+ button.dispatchEvent(new MouseEvent("mouseenter"));
176
+ expect(tip()?.textContent).toContain("Expand conversation list");
177
+ });
178
+
179
+ it("dismisses a keyboard-opened tooltip on activation until focus returns", () => {
180
+ const button = document.createElement("button");
181
+ document.body.appendChild(button);
182
+ const tip = () => document.body.querySelector(".persona-control-tooltip");
183
+
184
+ attachTooltip({ anchor: button, text: "Close chat" });
185
+ focusVisible(button);
186
+ expect(tip()).not.toBeNull();
187
+
188
+ // Space/Enter activates without moving focus off the control.
189
+ button.click();
190
+ expect(tip()).toBeNull();
191
+
192
+ button.blur();
193
+ focusVisible(button);
194
+ expect(tip()).not.toBeNull();
195
+ });
196
+
126
197
  it("suppresses hover tooltips when the device has no hover capability", () => {
127
198
  vi.stubGlobal(
128
199
  "matchMedia",
@@ -166,7 +237,7 @@ describe("attachTooltip", () => {
166
237
  );
167
238
 
168
239
  attachTooltip({ anchor: button, text: "Add context" });
169
- button.focus();
240
+ focusVisible(button);
170
241
  const tooltip = document.body.querySelector<HTMLElement>(
171
242
  ".persona-control-tooltip"
172
243
  );
@@ -186,7 +257,7 @@ describe("attachTooltip", () => {
186
257
  document.body.appendChild(button);
187
258
 
188
259
  attachTooltip({ anchor: button, text: "Add context" });
189
- button.focus();
260
+ focusVisible(button);
190
261
  expect(document.body.querySelector(".persona-control-tooltip")).not.toBeNull();
191
262
 
192
263
  button.remove();
@@ -203,11 +274,101 @@ describe("attachTooltip", () => {
203
274
  const next = attachTooltip({ anchor: button, text: "New", enabled: false });
204
275
 
205
276
  expect(old.isOpen).toBe(false);
206
- button.focus();
277
+ focusVisible(button);
207
278
  expect(next.isOpen).toBe(false);
208
279
  expect(document.body.querySelector(".persona-control-tooltip")).toBeNull();
209
280
  });
210
281
 
282
+ it("renders a shortcut hint chip and refreshes it alongside the label", () => {
283
+ const button = document.createElement("button");
284
+ button.setAttribute("aria-label", "New conversation");
285
+ document.body.appendChild(button);
286
+ let hint = "⌘K";
287
+
288
+ attachTooltip({
289
+ anchor: button,
290
+ text: () => button.getAttribute("aria-label") ?? "",
291
+ hint: () => hint,
292
+ });
293
+ focusVisible(button);
294
+
295
+ const chip = () =>
296
+ document.body.querySelector<HTMLElement>(
297
+ ".persona-control-tooltip__hint"
298
+ );
299
+ expect(chip()?.textContent).toBe("⌘K");
300
+
301
+ hint = "⌘⇧K";
302
+ window.dispatchEvent(new Event("resize"));
303
+ expect(chip()?.textContent).toBe("⌘⇧K");
304
+ });
305
+
306
+ it("renders no hint chip when the hint is absent or empty", () => {
307
+ const bare = document.createElement("button");
308
+ const blank = document.createElement("button");
309
+ document.body.append(bare, blank);
310
+
311
+ attachTooltip({ anchor: bare, text: "Close chat" });
312
+ focusVisible(bare);
313
+ expect(
314
+ document.body.querySelector(".persona-control-tooltip__hint")
315
+ ).toBeNull();
316
+ bare.blur();
317
+
318
+ attachTooltip({ anchor: blank, text: "Close chat", hint: () => " " });
319
+ focusVisible(blank);
320
+ expect(
321
+ document.body.querySelector(".persona-control-tooltip__hint")
322
+ ).toBeNull();
323
+ });
324
+
325
+ it("copies the anchor's resolved theme variables onto a body-portaled tooltip", () => {
326
+ const mount = document.createElement("div");
327
+ const button = document.createElement("button");
328
+ mount.appendChild(button);
329
+ document.body.appendChild(mount);
330
+
331
+ // The mount carries the theme; jsdom does not inherit custom properties,
332
+ // so the anchor's computed style is stubbed the way a browser resolves it.
333
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
334
+ (element: Element) =>
335
+ ({
336
+ fontFamily: element === button ? "Geist, sans-serif" : "serif",
337
+ getPropertyValue: (name: string) =>
338
+ element === button
339
+ ? ({
340
+ "--persona-tooltip-background": "#1d4ed8",
341
+ "--persona-tooltip-hint-fg": "rgba(255,255,255,0.6)",
342
+ "--persona-tooltip-arrow-display": "none",
343
+ "--persona-radius-sm": "0.5rem",
344
+ })[name] ?? ""
345
+ : "",
346
+ }) as CSSStyleDeclaration
347
+ );
348
+
349
+ attachTooltip({ anchor: button, text: "Close chat", hint: "Esc" });
350
+ focusVisible(button);
351
+
352
+ const tooltip = document.body.querySelector<HTMLElement>(
353
+ ".persona-control-tooltip"
354
+ );
355
+ expect(tooltip!.style.getPropertyValue("--persona-tooltip-background")).toBe(
356
+ "#1d4ed8"
357
+ );
358
+ expect(tooltip!.style.getPropertyValue("--persona-tooltip-hint-fg")).toBe(
359
+ "rgba(255,255,255,0.6)"
360
+ );
361
+ // arrow: false rides along, so a themed tooltip hides the caret off-mount.
362
+ expect(
363
+ tooltip!.style.getPropertyValue("--persona-tooltip-arrow-display")
364
+ ).toBe("none");
365
+ expect(tooltip!.style.getPropertyValue("--persona-radius-sm")).toBe("0.5rem");
366
+ // The body portal inherits the page font; the anchor's family wins instead.
367
+ expect(tooltip!.style.fontFamily).toBe("Geist, sans-serif");
368
+ // Unresolved variables are never written as empty declarations.
369
+ expect(tooltip!.style.getPropertyValue("--persona-tooltip-padding")).toBe("");
370
+ });
371
+
211
372
  it("portals into the anchor's shadow root so widget styles still apply", () => {
212
373
  const host = document.createElement("div");
213
374
  const shadow = host.attachShadow({ mode: "open" });
@@ -216,7 +377,7 @@ describe("attachTooltip", () => {
216
377
  document.body.appendChild(host);
217
378
 
218
379
  attachTooltip({ anchor: button, text: "Shadow tooltip" });
219
- button.focus();
380
+ focusVisible(button);
220
381
 
221
382
  expect(shadow.querySelector(".persona-control-tooltip")?.textContent).toContain(
222
383
  "Shadow tooltip"
@@ -224,3 +385,84 @@ describe("attachTooltip", () => {
224
385
  expect(document.body.querySelector(".persona-control-tooltip")).toBeNull();
225
386
  });
226
387
  });
388
+
389
+ describe("attachTooltip hover delay", () => {
390
+ afterEach(() => {
391
+ document.body.innerHTML = "";
392
+ vi.useRealTimers();
393
+ vi.restoreAllMocks();
394
+ resetTooltipTiming({ delayMs: 0, skipDelayMs: 0 });
395
+ });
396
+
397
+ const tip = () => document.body.querySelector(".persona-control-tooltip");
398
+
399
+ const hoverButton = (label: string): HTMLButtonElement => {
400
+ const button = document.createElement("button");
401
+ button.setAttribute("aria-label", label);
402
+ document.body.appendChild(button);
403
+ attachTooltip({
404
+ anchor: button,
405
+ text: () => button.getAttribute("aria-label") ?? "",
406
+ });
407
+ return button;
408
+ };
409
+
410
+ it("waits the product default before showing on hover", () => {
411
+ resetTooltipTiming();
412
+ vi.useFakeTimers();
413
+ const button = hoverButton("Close chat");
414
+
415
+ button.dispatchEvent(new MouseEvent("mouseenter"));
416
+ expect(tip()).toBeNull();
417
+
418
+ vi.advanceTimersByTime(DEFAULT_TOOLTIP_DELAY_MS - 1);
419
+ expect(tip()).toBeNull();
420
+
421
+ vi.advanceTimersByTime(1);
422
+ expect(tip()?.textContent).toContain("Close chat");
423
+ });
424
+
425
+ it("cancels a pending hover if the pointer leaves before the delay", () => {
426
+ resetTooltipTiming();
427
+ vi.useFakeTimers();
428
+ const button = hoverButton("Close chat");
429
+
430
+ button.dispatchEvent(new MouseEvent("mouseenter"));
431
+ vi.advanceTimersByTime(100);
432
+ button.dispatchEvent(new MouseEvent("mouseleave"));
433
+ vi.advanceTimersByTime(DEFAULT_TOOLTIP_DELAY_MS);
434
+ expect(tip()).toBeNull();
435
+ });
436
+
437
+ it("opens the next tooltip immediately inside the skip-delay window", () => {
438
+ resetTooltipTiming();
439
+ vi.useFakeTimers();
440
+ const first = hoverButton("New conversation");
441
+ const second = hoverButton("Close chat");
442
+
443
+ first.dispatchEvent(new MouseEvent("mouseenter"));
444
+ vi.advanceTimersByTime(DEFAULT_TOOLTIP_DELAY_MS);
445
+ expect(tip()?.textContent).toContain("New conversation");
446
+
447
+ first.dispatchEvent(new MouseEvent("mouseleave"));
448
+ expect(tip()).toBeNull();
449
+
450
+ second.dispatchEvent(new MouseEvent("mouseenter"));
451
+ expect(tip()?.textContent).toContain("Close chat");
452
+ });
453
+
454
+ it("opens immediately on keyboard-visible focus even when hover is delayed", () => {
455
+ resetTooltipTiming();
456
+ const button = hoverButton("Close chat");
457
+ focusVisible(button);
458
+ expect(tip()?.textContent).toContain("Close chat");
459
+ });
460
+
461
+ it("honors delayMs: 0 for instant hover", () => {
462
+ const button = document.createElement("button");
463
+ document.body.appendChild(button);
464
+ attachTooltip({ anchor: button, text: "Close chat", delayMs: 0 });
465
+ button.dispatchEvent(new MouseEvent("mouseenter"));
466
+ expect(tip()?.textContent).toContain("Close chat");
467
+ });
468
+ });