@runtypelabs/persona 4.7.0 → 4.8.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 (117) hide show
  1. package/dist/animations/glyph-cycle.cjs +2 -2
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/glyph-cycle.js +2 -2
  5. package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
  6. package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
  7. package/dist/animations/wipe.cjs +2 -2
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/chunk-5EIIHQLQ.js +1 -0
  11. package/dist/codegen.cjs +12 -12
  12. package/dist/codegen.js +14 -14
  13. package/dist/index.cjs +91 -68
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +877 -172
  16. package/dist/index.d.ts +877 -172
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +91 -68
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.global.js +1 -1
  22. package/dist/install.global.js.map +1 -1
  23. package/dist/launcher.global.js +3 -2
  24. package/dist/launcher.global.js.map +1 -1
  25. package/dist/markdown-parsers.js +24 -24
  26. package/dist/plugin-kit.cjs +1 -1
  27. package/dist/plugin-kit.js +1 -1
  28. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  29. package/dist/runtype-tts.js +1 -1
  30. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  31. package/dist/smart-dom-reader.cjs +17 -17
  32. package/dist/smart-dom-reader.d.cts +753 -15
  33. package/dist/smart-dom-reader.d.ts +753 -15
  34. package/dist/smart-dom-reader.js +17 -17
  35. package/dist/testing.cjs +3 -3
  36. package/dist/testing.js +3 -3
  37. package/dist/theme-editor-preview.cjs +81 -58
  38. package/dist/theme-editor-preview.d.cts +761 -15
  39. package/dist/theme-editor-preview.d.ts +761 -15
  40. package/dist/theme-editor-preview.js +81 -58
  41. package/dist/theme-editor.cjs +6 -6
  42. package/dist/theme-editor.d.cts +753 -15
  43. package/dist/theme-editor.d.ts +753 -15
  44. package/dist/theme-editor.js +10 -10
  45. package/dist/theme-reference.cjs +1 -1
  46. package/dist/theme-reference.d.cts +74 -0
  47. package/dist/theme-reference.d.ts +74 -0
  48. package/dist/theme-reference.js +1 -1
  49. package/dist/voice-worklet-player.cjs +2 -2
  50. package/dist/voice-worklet-player.js +2 -2
  51. package/dist/webmcp-polyfill.js +2 -2
  52. package/dist/widget.css +1 -1
  53. package/package.json +2 -3
  54. package/src/artifacts-session.test.ts +178 -0
  55. package/src/client.test.ts +186 -1
  56. package/src/client.ts +92 -9
  57. package/src/components/artifact-card.test.ts +333 -0
  58. package/src/components/artifact-card.ts +75 -28
  59. package/src/components/artifact-inline.test.ts +1328 -0
  60. package/src/components/artifact-inline.ts +920 -0
  61. package/src/components/artifact-pane.test.ts +1042 -0
  62. package/src/components/artifact-pane.ts +440 -131
  63. package/src/components/artifact-preview.test.ts +1155 -0
  64. package/src/components/artifact-preview.ts +994 -0
  65. package/src/components/pill-composer-builder.test.ts +6 -2
  66. package/src/components/pill-composer-builder.ts +6 -6
  67. package/src/components/reasoning-bubble.ts +1 -13
  68. package/src/components/registry.ts +38 -3
  69. package/src/components/tool-bubble.ts +1 -13
  70. package/src/defaults.ts +1 -0
  71. package/src/generated/runtype-openapi-contract.ts +55 -3
  72. package/src/index-core.ts +20 -1
  73. package/src/index.ts +8 -0
  74. package/src/markdown-parsers-loader.test.ts +158 -0
  75. package/src/markdown-parsers-loader.ts +74 -9
  76. package/src/runtime/host-layout.test.ts +163 -0
  77. package/src/runtime/host-layout.ts +110 -7
  78. package/src/runtime/init.ts +18 -61
  79. package/src/session.ts +76 -22
  80. package/src/styles/widget.css +773 -26
  81. package/src/theme-editor/preview.ts +2 -0
  82. package/src/theme-editor/sections.test.ts +26 -1
  83. package/src/theme-editor/sections.ts +10 -2
  84. package/src/theme-reference.ts +2 -2
  85. package/src/tool-call-display-defaults.test.ts +1 -0
  86. package/src/types/theme.ts +77 -0
  87. package/src/types.ts +516 -17
  88. package/src/ui.artifact-pane-gating.test.ts +636 -0
  89. package/src/ui.component-directive.test.ts +104 -0
  90. package/src/ui.composer-bar.test.ts +60 -2
  91. package/src/ui.detached-panel.test.ts +1049 -0
  92. package/src/ui.tool-display.test.ts +51 -0
  93. package/src/ui.ts +698 -109
  94. package/src/utils/artifact-custom-actions.ts +128 -0
  95. package/src/utils/artifact-display.test.ts +42 -0
  96. package/src/utils/artifact-display.ts +84 -0
  97. package/src/utils/artifact-file.test.ts +116 -0
  98. package/src/utils/artifact-file.ts +117 -0
  99. package/src/utils/artifact-gate.test.ts +112 -5
  100. package/src/utils/artifact-gate.ts +39 -14
  101. package/src/utils/artifact-loading-status.ts +55 -0
  102. package/src/utils/artifact-status-label.ts +190 -0
  103. package/src/utils/buttons.ts +7 -1
  104. package/src/utils/code-highlight.test.ts +186 -0
  105. package/src/utils/code-highlight.ts +400 -0
  106. package/src/utils/icons.ts +2 -0
  107. package/src/utils/roving-tablist.test.ts +152 -0
  108. package/src/utils/roving-tablist.ts +111 -0
  109. package/src/utils/spinner.ts +45 -0
  110. package/src/utils/theme.test.ts +48 -0
  111. package/src/utils/theme.ts +7 -0
  112. package/src/utils/tokens.ts +91 -0
  113. package/src/utils/tool-loading-animation.test.ts +32 -0
  114. package/src/utils/tool-loading-animation.ts +24 -0
  115. package/dist/chunk-DFBSCFYN.js +0 -1
  116. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  117. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -39,6 +39,26 @@ function clearDocumentToolbarLayoutVars(mount: HTMLElement): void {
39
39
  mount.style.removeProperty("--persona-artifact-doc-toggle-active-border");
40
40
  }
41
41
 
42
+ const ARTIFACT_APPEARANCE_MODES = ["panel", "seamless", "detached"] as const;
43
+ type ArtifactAppearance = (typeof ARTIFACT_APPEARANCE_MODES)[number];
44
+
45
+ /** Resolve the effective pane appearance, applying the detached-panel coordinated default. */
46
+ function resolveArtifactPaneAppearance(config: AgentWidgetConfig): ArtifactAppearance {
47
+ const raw = config.features?.artifacts?.layout?.paneAppearance;
48
+ if (raw && (ARTIFACT_APPEARANCE_MODES as readonly string[]).includes(raw)) {
49
+ return raw as ArtifactAppearance;
50
+ }
51
+ if (raw) return "panel";
52
+ // Coordinated default: a detached panel gets a detached pane unless overridden.
53
+ return config.launcher?.detachedPanel ? "detached" : "panel";
54
+ }
55
+
56
+ /** True when the effective pane appearance resolves to "detached" (explicit or coordinated default). */
57
+ export function isArtifactPaneAppearanceDetached(config: AgentWidgetConfig | undefined): boolean {
58
+ if (!config || !artifactsSidebarEnabled(config)) return false;
59
+ return resolveArtifactPaneAppearance(config) === "detached";
60
+ }
61
+
42
62
  export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidgetConfig): void {
43
63
  if (!artifactsSidebarEnabled(config)) {
44
64
  mount.style.removeProperty("--persona-artifact-split-gap");
@@ -52,7 +72,12 @@ export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidg
52
72
  return;
53
73
  }
54
74
  const l = config.features?.artifacts?.layout;
55
- mount.style.setProperty("--persona-artifact-split-gap", l?.splitGap ?? "0.5rem");
75
+ // Detached shows the canvas between columns; panel/seamless weld at gap 0.
76
+ const gapDefault =
77
+ resolveArtifactPaneAppearance(config) === "detached"
78
+ ? "var(--persona-panel-inset)"
79
+ : "0";
80
+ mount.style.setProperty("--persona-artifact-split-gap", l?.splitGap ?? gapDefault);
56
81
  mount.style.setProperty("--persona-artifact-pane-width", l?.paneWidth ?? "40%");
57
82
  mount.style.setProperty("--persona-artifact-pane-max-width", l?.paneMaxWidth ?? "28rem");
58
83
  if (l?.paneMinWidth) {
@@ -95,22 +120,18 @@ export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidg
95
120
  applyArtifactPaneBorderTheme(mount, config);
96
121
  }
97
122
 
98
- const ARTIFACT_APPEARANCE_MODES = ["panel", "seamless"] as const;
99
-
100
- /** Toggle root classes for artifact pane appearance, radius, shadow, and unified chrome. */
123
+ /** Toggle root classes for artifact pane appearance, radius, and shadow vars. */
101
124
  export function applyArtifactPaneAppearance(mount: HTMLElement, config: AgentWidgetConfig): void {
102
125
  for (const m of ARTIFACT_APPEARANCE_MODES) {
103
126
  mount.classList.remove(`persona-artifact-appearance-${m}`);
104
127
  }
105
- mount.classList.remove("persona-artifact-unified-split");
106
128
  mount.style.removeProperty("--persona-artifact-pane-radius");
107
129
  mount.style.removeProperty("--persona-artifact-pane-shadow");
108
- mount.style.removeProperty("--persona-artifact-unified-outer-radius");
130
+ mount.style.removeProperty("--persona-artifact-chat-shadow");
109
131
  if (!artifactsSidebarEnabled(config)) return;
110
132
 
111
133
  const layout = config.features?.artifacts?.layout;
112
- const raw = layout?.paneAppearance ?? "panel";
113
- const mode = (ARTIFACT_APPEARANCE_MODES as readonly string[]).includes(raw) ? raw : "panel";
134
+ const mode = resolveArtifactPaneAppearance(config);
114
135
  mount.classList.add(`persona-artifact-appearance-${mode}`);
115
136
 
116
137
  const radius = layout?.paneBorderRadius?.trim();
@@ -123,13 +144,17 @@ export function applyArtifactPaneAppearance(mount: HTMLElement, config: AgentWid
123
144
  mount.style.setProperty("--persona-artifact-pane-shadow", shadow);
124
145
  }
125
146
 
126
- if (layout?.unifiedSplitChrome === true) {
127
- mount.classList.add("persona-artifact-unified-split");
128
- const outer = layout.unifiedSplitOuterRadius?.trim() || radius;
129
- if (outer) {
130
- mount.style.setProperty("--persona-artifact-unified-outer-radius", outer);
131
- }
147
+ // Chat card gains its own front shadow lookup so a detached split can flatten
148
+ // the chat column while the pane stays raised. Unset falls back to the pane chain.
149
+ const chatShadow = layout?.chatShadow?.trim();
150
+ if (chatShadow) {
151
+ mount.style.setProperty("--persona-artifact-chat-shadow", chatShadow);
132
152
  }
153
+
154
+ // Panel/seamless splits weld by default; `unifiedSplitChrome` is a deprecated
155
+ // no-op. The welded outer-right radius (`--persona-artifact-welded-outer-radius`)
156
+ // is owned by ui.ts syncPanelChrome so it derives from the same resolved panel
157
+ // radius as the chat card; `unifiedSplitOuterRadius` / `paneBorderRadius` win there.
133
158
  }
134
159
 
135
160
  /** Widen floating panel when artifacts show (default true); `false` opts out. */
@@ -0,0 +1,55 @@
1
+ import type { AgentWidgetArtifactsFeature } from "../types";
2
+ import { appendCharSpans } from "./tool-loading-animation";
3
+
4
+ /**
5
+ * Apply the artifact "Generating …" streaming animation to a status element.
6
+ *
7
+ * Shared by the artifact reference card and the inline chrome so both surfaces
8
+ * honor `features.artifacts.loadingAnimation` (and the color / duration knobs)
9
+ * identically. Character-by-character modes (`shimmer`, `shimmer-color`,
10
+ * `rainbow`) wrap each character in a `persona-tool-char` span; `pulse` fades
11
+ * the whole element; `none` renders plain text.
12
+ *
13
+ * The caller owns the element and its reset — pass a fresh (or cleared) span so
14
+ * stale animation classes from a previous streaming render do not linger.
15
+ */
16
+ export function applyArtifactLoadingStatus(
17
+ statusText: HTMLElement,
18
+ text: string,
19
+ artifactsCfg: AgentWidgetArtifactsFeature | undefined
20
+ ): void {
21
+ const loadingAnimation = artifactsCfg?.loadingAnimation ?? "shimmer";
22
+ const duration = artifactsCfg?.loadingAnimationDuration ?? 2000;
23
+
24
+ if (loadingAnimation === "none") {
25
+ statusText.textContent = text;
26
+ return;
27
+ }
28
+
29
+ if (loadingAnimation === "pulse") {
30
+ statusText.setAttribute("data-preserve-animation", "true");
31
+ statusText.classList.add("persona-tool-loading-pulse");
32
+ statusText.style.setProperty("--persona-tool-anim-duration", `${duration}ms`);
33
+ statusText.textContent = text;
34
+ return;
35
+ }
36
+
37
+ statusText.setAttribute("data-preserve-animation", "true");
38
+ statusText.classList.add(`persona-tool-loading-${loadingAnimation}`);
39
+ statusText.style.setProperty("--persona-tool-anim-duration", `${duration}ms`);
40
+ if (loadingAnimation === "shimmer-color") {
41
+ if (artifactsCfg?.loadingAnimationColor) {
42
+ statusText.style.setProperty(
43
+ "--persona-tool-anim-color",
44
+ artifactsCfg.loadingAnimationColor
45
+ );
46
+ }
47
+ if (artifactsCfg?.loadingAnimationSecondaryColor) {
48
+ statusText.style.setProperty(
49
+ "--persona-tool-anim-secondary-color",
50
+ artifactsCfg.loadingAnimationSecondaryColor
51
+ );
52
+ }
53
+ }
54
+ appendCharSpans(statusText, text, 0);
55
+ }
@@ -0,0 +1,190 @@
1
+ import type {
2
+ AgentWidgetArtifactsFeature,
3
+ PersonaArtifactRecord,
4
+ PersonaArtifactStatusLabelContext
5
+ } from "../types";
6
+ import { createElement } from "./dom";
7
+ import { fileTypeLabel } from "./artifact-file";
8
+ import { applyArtifactLoadingStatus } from "./artifact-loading-status";
9
+
10
+ /**
11
+ * Shared resolver + applier for the artifact streaming status label
12
+ * (`features.artifacts.statusLabel`).
13
+ *
14
+ * Three surfaces render the "Generating …" status while an artifact streams:
15
+ * the reference card status line, the inline chrome meta span, and the inline
16
+ * streaming status body. All three resolve their text through
17
+ * {@link resolveArtifactStatusLabel} and paint it through
18
+ * {@link applyArtifactStatus}, so a host `statusLabel` string / function reads
19
+ * identically everywhere.
20
+ *
21
+ * Animation-stability contract: the animated label span is (re)built only when
22
+ * its text changes, so a per-delta detail update (live counters) never restarts
23
+ * the shimmer/pulse/typewriter animation. `applyArtifactStatus` enforces this by
24
+ * tracking the last-applied label text on the label span and by keeping the
25
+ * plain detail span as a separate sibling the animation never touches.
26
+ */
27
+
28
+ export type ArtifactStatusSurface = "card" | "inline-chrome" | "status-body";
29
+
30
+ export type ResolvedArtifactStatus = { label: string; detail?: string };
31
+
32
+ /** Class on the animated label span managed inside a status container. */
33
+ export const STATUS_LABEL_CLASS = "persona-artifact-status-label";
34
+ /** Class on the plain, freely-updating detail span. */
35
+ export const STATUS_DETAIL_CLASS = "persona-artifact-status-detail";
36
+ /** Attribute recording the last label text applied to the label span. */
37
+ const APPLIED_LABEL_ATTR = "data-artifact-status-label";
38
+
39
+ /**
40
+ * First-seen streaming timestamp per artifact id, so `elapsedMs` starts near 0
41
+ * and a completed → re-streamed artifact restarts cleanly. Set on the first
42
+ * streaming resolve; cleared on complete (see {@link clearArtifactStatusTracking})
43
+ * so the map cannot grow without bound.
44
+ */
45
+ const firstSeenAt = new Map<string, number>();
46
+
47
+ const now = (): number =>
48
+ typeof performance !== "undefined" && typeof performance.now === "function"
49
+ ? performance.now()
50
+ : Date.now();
51
+
52
+ /** Drop the elapsed-time tracking for an artifact. Call on complete. */
53
+ export function clearArtifactStatusTracking(id: string): void {
54
+ if (id) firstSeenAt.delete(id);
55
+ }
56
+
57
+ /** Type subject used to build the default label; mirrors the card/chrome logic. */
58
+ function typeLabelFor(record: PersonaArtifactRecord): string {
59
+ const file = record.artifactType === "markdown" ? record.file : undefined;
60
+ if (file) return fileTypeLabel(file);
61
+ return record.artifactType === "component" ? "Component" : "Document";
62
+ }
63
+
64
+ /**
65
+ * Resolve the streaming status label + optional detail for one surface.
66
+ *
67
+ * - `statusLabel` unset → the default `Generating <type>...`.
68
+ * - string → that string as the label (no detail).
69
+ * - function → called with a per-surface context; a string return is the label,
70
+ * an object return is `{ label, detail }`. A throw falls back to the default.
71
+ */
72
+ export function resolveArtifactStatusLabel(
73
+ record: PersonaArtifactRecord,
74
+ cfg: AgentWidgetArtifactsFeature | undefined,
75
+ surface: ArtifactStatusSurface
76
+ ): ResolvedArtifactStatus {
77
+ const typeLabel = typeLabelFor(record);
78
+ const defaultLabel = `Generating ${typeLabel.toLowerCase()}...`;
79
+
80
+ const statusLabel = cfg?.statusLabel;
81
+ if (typeof statusLabel === "string") {
82
+ return { label: statusLabel };
83
+ }
84
+ if (typeof statusLabel !== "function") {
85
+ return { label: defaultLabel };
86
+ }
87
+
88
+ // Track first-seen only while streaming (the surfaces only resolve while
89
+ // streaming); elapsedMs measures from that instant.
90
+ const id = record.id;
91
+ let firstSeen = firstSeenAt.get(id);
92
+ if (record.status !== "complete" && firstSeen === undefined) {
93
+ firstSeen = now();
94
+ firstSeenAt.set(id, firstSeen);
95
+ }
96
+ const elapsedMs = firstSeen === undefined ? 0 : Math.max(0, now() - firstSeen);
97
+
98
+ const isComponent = record.artifactType === "component";
99
+ const markdown =
100
+ !isComponent && typeof record.markdown === "string" ? record.markdown : "";
101
+ const chars = isComponent ? 0 : markdown.length;
102
+ const lines = isComponent || markdown === "" ? 0 : markdown.split("\n").length;
103
+ const file = record.artifactType === "markdown" ? record.file : undefined;
104
+
105
+ const ctx: PersonaArtifactStatusLabelContext = {
106
+ artifactId: id,
107
+ artifactType: record.artifactType,
108
+ title: record.title,
109
+ typeLabel,
110
+ file,
111
+ chars,
112
+ lines,
113
+ elapsedMs,
114
+ // Lazy: only materialize the accumulated source if the host asks for it.
115
+ content: () => (isComponent ? "" : markdown),
116
+ surface
117
+ };
118
+
119
+ try {
120
+ const result = statusLabel(ctx);
121
+ if (typeof result === "string") return { label: result };
122
+ if (
123
+ result &&
124
+ typeof result === "object" &&
125
+ typeof result.label === "string"
126
+ ) {
127
+ return {
128
+ label: result.label,
129
+ detail: typeof result.detail === "string" ? result.detail : undefined
130
+ };
131
+ }
132
+ // A malformed return (null/number/etc.) falls back like a throw.
133
+ return { label: defaultLabel };
134
+ } catch {
135
+ // A bad host callback must never break rendering.
136
+ return { label: defaultLabel };
137
+ }
138
+ }
139
+
140
+ /** Restore the label span to its base state before re-applying the animation. */
141
+ function resetLabelSpan(labelEl: HTMLElement): void {
142
+ labelEl.className = STATUS_LABEL_CLASS;
143
+ labelEl.removeAttribute("data-preserve-animation");
144
+ labelEl.style.removeProperty("--persona-tool-anim-duration");
145
+ labelEl.style.removeProperty("--persona-tool-anim-color");
146
+ labelEl.style.removeProperty("--persona-tool-anim-secondary-color");
147
+ labelEl.replaceChildren();
148
+ }
149
+
150
+ /**
151
+ * Paint a resolved status into `container`, which owns exactly two children: an
152
+ * animated label span and an optional plain detail span.
153
+ *
154
+ * The label span is rebuilt (and its animation re-applied) ONLY when its text
155
+ * differs from the last application, so a detail-only update never restarts the
156
+ * loading animation. The detail span updates its `textContent` freely and is
157
+ * removed when the resolved detail is empty.
158
+ */
159
+ export function applyArtifactStatus(
160
+ container: HTMLElement,
161
+ resolved: ResolvedArtifactStatus,
162
+ cfg: AgentWidgetArtifactsFeature | undefined
163
+ ): void {
164
+ let labelEl = container.querySelector<HTMLElement>(
165
+ `:scope > .${STATUS_LABEL_CLASS}`
166
+ );
167
+ if (!labelEl) {
168
+ labelEl = createElement("span", STATUS_LABEL_CLASS);
169
+ container.appendChild(labelEl);
170
+ }
171
+ if (labelEl.getAttribute(APPLIED_LABEL_ATTR) !== resolved.label) {
172
+ resetLabelSpan(labelEl);
173
+ applyArtifactLoadingStatus(labelEl, resolved.label, cfg);
174
+ labelEl.setAttribute(APPLIED_LABEL_ATTR, resolved.label);
175
+ }
176
+
177
+ let detailEl = container.querySelector<HTMLElement>(
178
+ `:scope > .${STATUS_DETAIL_CLASS}`
179
+ );
180
+ const detail = resolved.detail;
181
+ if (detail) {
182
+ if (!detailEl) {
183
+ detailEl = createElement("span", STATUS_DETAIL_CLASS);
184
+ container.appendChild(detailEl);
185
+ }
186
+ if (detailEl.textContent !== detail) detailEl.textContent = detail;
187
+ } else if (detailEl) {
188
+ detailEl.remove();
189
+ }
190
+ }
@@ -149,6 +149,8 @@ export interface ToggleGroupItem {
149
149
  icon?: string;
150
150
  /** Accessible label for the button. */
151
151
  label: string;
152
+ /** Extra CSS class(es) appended after "persona-icon-btn". */
153
+ className?: string;
152
154
  }
153
155
 
154
156
  /** Options for {@link createToggleGroup}. */
@@ -202,6 +204,7 @@ export function createToggleGroup(options: CreateToggleGroupOptions): ToggleGrou
202
204
  btn = createIconButton({
203
205
  icon: item.icon,
204
206
  label: item.label,
207
+ className: item.className,
205
208
  onClick: () => {
206
209
  currentId = item.id;
207
210
  updatePressed();
@@ -209,7 +212,10 @@ export function createToggleGroup(options: CreateToggleGroupOptions): ToggleGrou
209
212
  },
210
213
  });
211
214
  } else {
212
- btn = createElement("button", "persona-icon-btn");
215
+ btn = createElement(
216
+ "button",
217
+ "persona-icon-btn" + (item.className ? " " + item.className : ""),
218
+ );
213
219
  btn.type = "button";
214
220
  btn.setAttribute("aria-label", item.label);
215
221
  btn.title = item.label;
@@ -0,0 +1,186 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ highlightCode,
6
+ resolveHighlightLanguage,
7
+ MAX_HIGHLIGHT_LENGTH,
8
+ } from "./code-highlight";
9
+
10
+ /** Reconstruct the source from a highlighted fragment (invariant helper). */
11
+ const textOf = (frag: DocumentFragment): string => {
12
+ const holder = document.createElement("div");
13
+ holder.appendChild(frag.cloneNode(true));
14
+ return holder.textContent ?? "";
15
+ };
16
+
17
+ /** Collect token classes present in a highlighted fragment. */
18
+ const tokenClasses = (frag: DocumentFragment): string[] => {
19
+ const holder = document.createElement("div");
20
+ holder.appendChild(frag.cloneNode(true));
21
+ return Array.from(holder.querySelectorAll("span[class^='persona-code-token-']"))
22
+ .map((el) => el.className);
23
+ };
24
+
25
+ const classFor = (source: string, lang: string, snippet: string): string | null => {
26
+ const holder = document.createElement("div");
27
+ holder.appendChild(highlightCode(source, lang));
28
+ const spans = Array.from(
29
+ holder.querySelectorAll("span[class^='persona-code-token-']")
30
+ );
31
+ const hit = spans.find((el) => el.textContent === snippet);
32
+ return hit ? hit.className : null;
33
+ };
34
+
35
+ describe("resolveHighlightLanguage", () => {
36
+ it("prefers explicit language and maps aliases", () => {
37
+ expect(resolveHighlightLanguage("html")).toBe("html");
38
+ expect(resolveHighlightLanguage("htm")).toBe("html");
39
+ expect(resolveHighlightLanguage("mjs")).toBe("js");
40
+ expect(resolveHighlightLanguage("tsx")).toBe("js");
41
+ expect(resolveHighlightLanguage("typescript")).toBe("js");
42
+ expect(resolveHighlightLanguage("jsonc")).toBe("json");
43
+ });
44
+
45
+ it("falls back to the file extension", () => {
46
+ expect(resolveHighlightLanguage(undefined, "src/app.css")).toBe("css");
47
+ expect(resolveHighlightLanguage(undefined, "a/b/main.ts")).toBe("js");
48
+ expect(resolveHighlightLanguage(undefined, "index.html")).toBe("html");
49
+ });
50
+
51
+ it("returns null for unknown languages and extensionless paths", () => {
52
+ expect(resolveHighlightLanguage("rust")).toBeNull();
53
+ expect(resolveHighlightLanguage(undefined, "Makefile")).toBeNull();
54
+ expect(resolveHighlightLanguage(undefined)).toBeNull();
55
+ });
56
+ });
57
+
58
+ describe("highlightCode token classification", () => {
59
+ it("classifies js keywords, strings, numbers, and comments", () => {
60
+ expect(classFor("const x = 1;", "js", "const")).toBe(
61
+ "persona-code-token-keyword"
62
+ );
63
+ expect(classFor('const s = "hi";', "js", '"hi"')).toBe(
64
+ "persona-code-token-string"
65
+ );
66
+ expect(classFor("const n = 42;", "js", "42")).toBe(
67
+ "persona-code-token-number"
68
+ );
69
+ expect(classFor("// note\nx", "js", "// note")).toBe(
70
+ "persona-code-token-comment"
71
+ );
72
+ expect(classFor("const b = true;", "js", "true")).toBe(
73
+ "persona-code-token-keyword"
74
+ );
75
+ });
76
+
77
+ it("classifies json keys as property and values as string", () => {
78
+ const src = '{"name": "cat", "n": 3, "ok": true}';
79
+ expect(classFor(src, "json", '"name"')).toBe("persona-code-token-property");
80
+ expect(classFor(src, "json", '"cat"')).toBe("persona-code-token-string");
81
+ expect(classFor(src, "json", "3")).toBe("persona-code-token-number");
82
+ expect(classFor(src, "json", "true")).toBe("persona-code-token-keyword");
83
+ });
84
+
85
+ it("classifies css properties, values, at-rules, and comments", () => {
86
+ const src = "@media all { .a { color: #fff; margin: 4px; } } /* c */";
87
+ expect(classFor(src, "css", "color")).toBe("persona-code-token-property");
88
+ expect(classFor(src, "css", "#fff")).toBe("persona-code-token-number");
89
+ expect(classFor(src, "css", "@media")).toBe("persona-code-token-keyword");
90
+ expect(classFor(src, "css", "/* c */")).toBe("persona-code-token-comment");
91
+ });
92
+
93
+ it("classifies html tags, attributes, attribute values, and comments", () => {
94
+ const src = '<a href="x">hi</a><!-- c -->';
95
+ expect(classFor(src, "html", "href")).toBe("persona-code-token-attr");
96
+ expect(classFor(src, "html", '"x"')).toBe("persona-code-token-string");
97
+ expect(classFor(src, "html", "<!-- c -->")).toBe(
98
+ "persona-code-token-comment"
99
+ );
100
+ // Tag bracket + name.
101
+ expect(classFor(src, "html", "<a")).toBe("persona-code-token-tag");
102
+ });
103
+
104
+ it("highlights embedded <script> bodies with the js tokenizer", () => {
105
+ const src = "<script>const y = 2;</script>";
106
+ expect(classFor(src, "html", "const")).toBe("persona-code-token-keyword");
107
+ });
108
+ });
109
+
110
+ describe("highlightCode line + newline invariants", () => {
111
+ it("splits a multi-line block comment across lines without newlines in spans", () => {
112
+ const src = "/* line one\nline two */\nvar x;";
113
+ const frag = highlightCode(src, "js");
114
+ const holder = document.createElement("div");
115
+ holder.appendChild(frag.cloneNode(true));
116
+ const commentSpans = holder.querySelectorAll(".persona-code-token-comment");
117
+ // The block comment spans two lines → two comment spans, neither with "\n".
118
+ expect(commentSpans.length).toBe(2);
119
+ commentSpans.forEach((el) => expect(el.textContent).not.toContain("\n"));
120
+ // No token span anywhere contains a newline.
121
+ holder
122
+ .querySelectorAll("span[class^='persona-code-token-']")
123
+ .forEach((el) => expect(el.textContent).not.toContain("\n"));
124
+ });
125
+
126
+ it("reconstructs the source verbatim (textContent === source)", () => {
127
+ for (const [src, lang] of [
128
+ ["const x = 1;\nconst y = 2;\n", "js"],
129
+ ["line1\nline2\nline3", "js"],
130
+ ["a\n\nb\n", "js"],
131
+ ['{"k": "v"}\n', "json"],
132
+ ["<div>\n <span>hi</span>\n</div>\n", "html"],
133
+ ["", "js"],
134
+ ] as const) {
135
+ expect(textOf(highlightCode(src, lang))).toBe(src);
136
+ }
137
+ });
138
+
139
+ it("does not emit a phantom numbered line for a trailing newline", () => {
140
+ const holder = document.createElement("div");
141
+ holder.appendChild(highlightCode("a\nb\n", "js"));
142
+ // "a\nb\n" → 2 numbered lines, trailing newline preserved in textContent.
143
+ expect(holder.querySelectorAll(".persona-code-line").length).toBe(2);
144
+ expect(holder.textContent).toBe("a\nb\n");
145
+ });
146
+
147
+ it("keeps one line for wholly empty source", () => {
148
+ const holder = document.createElement("div");
149
+ holder.appendChild(highlightCode("", "js"));
150
+ expect(holder.querySelectorAll(".persona-code-line").length).toBe(1);
151
+ expect(holder.textContent).toBe("");
152
+ });
153
+ });
154
+
155
+ describe("highlightCode fallbacks and safety", () => {
156
+ it("returns plain line-numbered text for an unknown language", () => {
157
+ const frag = highlightCode("const x = 1;", "rust");
158
+ expect(tokenClasses(frag)).toEqual([]);
159
+ });
160
+
161
+ it("skips tokenization for oversized sources", () => {
162
+ const big = "const x = 1;\n".repeat(
163
+ Math.ceil((MAX_HIGHLIGHT_LENGTH + 100) / 13)
164
+ );
165
+ expect(big.length).toBeGreaterThan(MAX_HIGHLIGHT_LENGTH);
166
+ const frag = highlightCode(big, "js");
167
+ expect(tokenClasses(frag)).toEqual([]);
168
+ // Still line-numbered (line spans present) and verbatim.
169
+ const holder = document.createElement("div");
170
+ holder.appendChild(frag);
171
+ expect(holder.querySelector(".persona-code-line")).toBeTruthy();
172
+ });
173
+
174
+ it("never emits any element other than spans for hostile input", () => {
175
+ // XSS-shaped content embedded as a JS string literal.
176
+ const src = 'const html = "<img src=x onerror=alert(1)>";';
177
+ const holder = document.createElement("div");
178
+ holder.appendChild(highlightCode(src, "js"));
179
+ // Only <span> elements exist; no <img> (or anything else) was created.
180
+ const all = Array.from(holder.querySelectorAll("*"));
181
+ expect(all.every((el) => el.tagName === "SPAN")).toBe(true);
182
+ expect(holder.querySelector("img")).toBeNull();
183
+ // The dangerous text survives as inert text content.
184
+ expect(holder.textContent).toContain("<img src=x onerror=alert(1)>");
185
+ });
186
+ });