@runtypelabs/persona 4.14.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
- package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-QJ4LV2KW.js +1 -0
- package/dist/codegen.cjs +12 -11
- package/dist/codegen.js +14 -13
- package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
- package/dist/context-mentions.cjs +11 -8
- package/dist/context-mentions.d.cts +520 -21
- package/dist/context-mentions.d.ts +520 -21
- package/dist/context-mentions.js +11 -8
- package/dist/index.cjs +69 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +595 -46
- package/dist/index.d.ts +595 -46
- package/dist/index.global.js +60 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +69 -68
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +520 -21
- package/dist/smart-dom-reader.d.ts +520 -21
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +7 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +67 -67
- package/dist/theme-editor-preview.d.cts +534 -22
- package/dist/theme-editor-preview.d.ts +534 -22
- package/dist/theme-editor-preview.js +57 -57
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +521 -23
- package/dist/theme-editor.d.ts +521 -23
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +104 -3
- package/dist/theme-reference.d.ts +104 -3
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/ask-user-question-tool.ts +10 -7
- package/src/components/artifact-pane.test.ts +27 -0
- package/src/components/artifact-pane.ts +7 -3
- package/src/components/artifact-preview.ts +1 -1
- package/src/components/composer-parts.test.ts +103 -4
- package/src/components/composer-parts.ts +53 -15
- package/src/components/header-builder.ts +27 -2
- package/src/components/header-layouts.test.ts +56 -0
- package/src/components/header-layouts.ts +29 -9
- package/src/components/launcher-surface.test.ts +403 -0
- package/src/components/launcher.ts +297 -2
- package/src/components/messages.ts +5 -2
- package/src/components/panel.ts +92 -71
- package/src/components/pill-composer-builder.test.ts +18 -0
- package/src/components/suggestions.test.ts +273 -0
- package/src/components/suggestions.ts +528 -86
- package/src/components/welcome.test.ts +121 -0
- package/src/components/welcome.ts +305 -0
- package/src/components/widget-view.test.ts +5 -3
- package/src/components/widget-view.ts +18 -7
- package/src/defaults.ts +7 -5
- package/src/generated/runtype-openapi-contract.ts +17 -0
- package/src/index-core.ts +29 -2
- package/src/launcher-global.ts +11 -8
- package/src/plugins/types.ts +136 -3
- package/src/presets.ts +3 -3
- package/src/session.ts +10 -8
- package/src/styles/widget-styles.test.ts +382 -0
- package/src/styles/widget.css +778 -4
- package/src/suggest-replies-tool.test.ts +233 -2
- package/src/suggest-replies-tool.ts +115 -39
- package/src/testing/mock-stream.test.ts +19 -0
- package/src/testing/mock-stream.ts +20 -0
- package/src/theme-editor/preview-utils.test.ts +34 -0
- package/src/theme-editor/preview-utils.ts +77 -2
- package/src/theme-editor/sections.test.ts +33 -0
- package/src/theme-editor/sections.ts +83 -6
- package/src/theme-reference.ts +34 -5
- package/src/types/theme.ts +72 -3
- package/src/types.ts +312 -18
- package/src/ui.body-top-slot.test.ts +95 -0
- package/src/ui.content-width.test.ts +91 -0
- package/src/ui.detached-panel.test.ts +1 -1
- package/src/ui.docked.test.ts +7 -4
- package/src/ui.launcher-teaser.test.ts +162 -0
- package/src/ui.rebuild-composer.test.ts +491 -0
- package/src/ui.render-welcome.test.ts +505 -0
- package/src/ui.scroll-additive.test.ts +91 -21
- package/src/ui.scroll.test.ts +302 -14
- package/src/ui.suggest-replies.test.ts +1112 -1
- package/src/ui.ts +1008 -149
- package/src/ui.welcome.test.ts +504 -0
- package/src/utils/artifact-file.test.ts +1 -1
- package/src/utils/artifact-gate.test.ts +27 -0
- package/src/utils/artifact-gate.ts +9 -0
- package/src/utils/attachment-manager.test.ts +37 -0
- package/src/utils/attachment-manager.ts +15 -0
- package/src/utils/auto-follow.test.ts +23 -0
- package/src/utils/auto-follow.ts +11 -1
- package/src/utils/code-generators.test.ts +40 -0
- package/src/utils/code-generators.ts +22 -0
- package/src/utils/config-merge.test.ts +24 -0
- package/src/utils/config-merge.ts +2 -0
- package/src/utils/content-width.test.ts +37 -0
- package/src/utils/content-width.ts +17 -0
- package/src/utils/icons.test.ts +26 -0
- package/src/utils/icons.ts +8 -0
- package/src/utils/plugin-storage.test.ts +94 -0
- package/src/utils/plugin-storage.ts +98 -0
- package/src/utils/theme.test.ts +216 -0
- package/src/utils/tokens.ts +113 -3
- package/src/welcome.test.ts +213 -0
- package/src/welcome.ts +130 -0
- package/dist/chunk-MMUPR2JW.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.16.0",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -5,15 +5,18 @@
|
|
|
5
5
|
* `clientTools[]` (set `features.askUserQuestion.expose: true`): the same
|
|
6
6
|
* wire surface WebMCP page tools ride. The server registers it as a LOCAL
|
|
7
7
|
* tool under its bare name (`origin: 'sdk'` tools are not `webmcp:`-prefixed),
|
|
8
|
-
* so when the model calls it the execution pauses with a `step_await
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* so when the model calls it the execution pauses with a `step_await`, the
|
|
9
|
+
* widget's answer-pill sheet renders, and `session.resolveAskUserQuestion()`
|
|
10
|
+
* resumes the execution with the structured answers. The public await frame
|
|
11
|
+
* for a local-tool pause carries no `awaitReason`; that discriminator is
|
|
12
|
+
* internal, and its absence is what marks a client-resolvable pause.
|
|
12
13
|
*
|
|
13
14
|
* This replaces the previous integrator burden of hand-declaring the tool in
|
|
14
15
|
* a flow's `runtimeTools` and keeping that schema in sync with the renderer.
|
|
15
16
|
* Flows that already declare `ask_user_question` server-side should leave
|
|
16
|
-
* `expose` off: the
|
|
17
|
+
* `expose` off: the API dedupes a double declaration by name with
|
|
18
|
+
* `clientTools[]` winning, so the model still sees exactly one tool, but the
|
|
19
|
+
* widget's description and schema silently replace the flow's.
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
22
|
import {
|
|
@@ -22,7 +25,7 @@ import {
|
|
|
22
25
|
} from "./components/ask-user-question-bubble";
|
|
23
26
|
import {
|
|
24
27
|
SUGGEST_REPLIES_CLIENT_TOOL,
|
|
25
|
-
|
|
28
|
+
resolveFollowUpsFeature,
|
|
26
29
|
} from "./suggest-replies-tool";
|
|
27
30
|
import type { AgentWidgetConfig, ClientToolDefinition } from "./types";
|
|
28
31
|
|
|
@@ -129,7 +132,7 @@ export const builtInClientToolsForDispatch = (
|
|
|
129
132
|
if (ask?.expose === true && ask.enabled !== false) {
|
|
130
133
|
tools.push(ASK_USER_QUESTION_CLIENT_TOOL);
|
|
131
134
|
}
|
|
132
|
-
if (
|
|
135
|
+
if (resolveFollowUpsFeature(config).expose) {
|
|
133
136
|
tools.push(SUGGEST_REPLIES_CLIENT_TOOL);
|
|
134
137
|
}
|
|
135
138
|
return tools;
|
|
@@ -345,6 +345,33 @@ describe("artifact-pane view/source toggle", () => {
|
|
|
345
345
|
expect(pre?.textContent).toBe(HTML_RAW);
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
+
it("hides the toggle for non-previewable file kinds, where both views are the source", () => {
|
|
349
|
+
const jsonRaw = '{ "a": 1 }\n';
|
|
350
|
+
const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
|
|
351
|
+
pane.update({
|
|
352
|
+
artifacts: [
|
|
353
|
+
fileRecord({
|
|
354
|
+
markdown: wireFor(jsonRaw, "json"),
|
|
355
|
+
file: { path: "persona.config.json", mimeType: "application/json", language: "json" },
|
|
356
|
+
}),
|
|
357
|
+
],
|
|
358
|
+
selectedId: "a1",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(toggleBtn(pane, "Rendered view")).toBeNull();
|
|
362
|
+
expect(toggleBtn(pane, "Source")).toBeNull();
|
|
363
|
+
// The content itself is the source view.
|
|
364
|
+
expect(contentEl(pane).querySelector("pre")?.textContent).toBe(jsonRaw);
|
|
365
|
+
|
|
366
|
+
// Selection moving to a previewable file mounts the toggle again.
|
|
367
|
+
pane.update({
|
|
368
|
+
artifacts: [fileRecord()],
|
|
369
|
+
selectedId: "a1",
|
|
370
|
+
});
|
|
371
|
+
expect(toggleBtn(pane, "Rendered view")).toBeTruthy();
|
|
372
|
+
expect(toggleBtn(pane, "Source")).toBeTruthy();
|
|
373
|
+
});
|
|
374
|
+
|
|
348
375
|
it("drops the content padding (flush class) in source view and restores it when rendered", () => {
|
|
349
376
|
const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
|
|
350
377
|
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
PersonaArtifactRecord,
|
|
5
5
|
PersonaArtifactCustomAction,
|
|
6
6
|
} from "../types";
|
|
7
|
-
import { fileTypeLabel, basenameOf } from "../utils/artifact-file";
|
|
7
|
+
import { fileKindOf, fileTypeLabel, basenameOf } from "../utils/artifact-file";
|
|
8
8
|
import {
|
|
9
9
|
buildArtifactActionButton,
|
|
10
10
|
artifactRecordActionContext,
|
|
@@ -621,8 +621,12 @@ export function createArtifactPane(
|
|
|
621
621
|
const selFile = sel.artifactType === "markdown" ? sel.file : undefined;
|
|
622
622
|
|
|
623
623
|
// Expose the rendered/source toggle for previewable file artifacts even
|
|
624
|
-
// outside the document toolbar preset.
|
|
625
|
-
|
|
624
|
+
// outside the document toolbar preset. Kind "other" (json, css, ts, …)
|
|
625
|
+
// has no rendered form — preview falls back to the source view — so the
|
|
626
|
+
// toggle would be a visible no-op there.
|
|
627
|
+
updateFileToggleVisibility(
|
|
628
|
+
Boolean(selFile) && fileKindOf(selFile!) !== "other"
|
|
629
|
+
);
|
|
626
630
|
|
|
627
631
|
if (documentChrome) {
|
|
628
632
|
const kind = selFile
|
|
@@ -670,7 +670,7 @@ export function renderArtifactPreviewBody(
|
|
|
670
670
|
|
|
671
671
|
// Render the syntax-highlighted source. On the inline path the <pre> lives in
|
|
672
672
|
// a scroll window (`fixed` → reserved height + internal scroll + follow/fade);
|
|
673
|
-
// on the pane path it is a bare child of `el`,
|
|
673
|
+
// on the pane path it is a bare child of `el`, unchanged from before.
|
|
674
674
|
const renderSource = (
|
|
675
675
|
text: string,
|
|
676
676
|
opts: { language?: string; path?: string } | undefined,
|
|
@@ -44,8 +44,59 @@ describe("createComposerTextarea", () => {
|
|
|
44
44
|
Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
45
45
|
textarea.dispatchEvent(new Event("input"));
|
|
46
46
|
expect(textarea.style.height).toBe("200px");
|
|
47
|
+
// The override keeps winning on later input events (pill composer path).
|
|
48
|
+
textarea.dispatchEvent(new Event("input"));
|
|
49
|
+
expect(textarea.style.height).toBe("200px");
|
|
50
|
+
expect(textarea.style.maxHeight).toBe("200px");
|
|
51
|
+
document.body.removeChild(textarea);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("caps at 3 lines of the rendered line height when themed", () => {
|
|
55
|
+
const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
|
|
56
|
+
textarea.style.lineHeight = "24px";
|
|
57
|
+
document.body.appendChild(textarea);
|
|
58
|
+
attachAutoResize();
|
|
59
|
+
Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
60
|
+
textarea.dispatchEvent(new Event("input"));
|
|
61
|
+
expect(textarea.style.height).toBe("72px");
|
|
62
|
+
expect(textarea.style.maxHeight).toBe("72px");
|
|
47
63
|
document.body.removeChild(textarea);
|
|
48
64
|
});
|
|
65
|
+
|
|
66
|
+
it("defaults to 60px (3 × 20px) before any resize", () => {
|
|
67
|
+
const { textarea } = createComposerTextarea(baseConfig);
|
|
68
|
+
expect(textarea.style.maxHeight).toBe("60px");
|
|
69
|
+
expect(textarea.style.overflowY).toBe("auto");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("falls back to 60px when the line height is `normal`", () => {
|
|
73
|
+
const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
|
|
74
|
+
textarea.style.lineHeight = "normal";
|
|
75
|
+
document.body.appendChild(textarea);
|
|
76
|
+
attachAutoResize();
|
|
77
|
+
Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
78
|
+
textarea.dispatchEvent(new Event("input"));
|
|
79
|
+
expect(textarea.style.height).toBe("60px");
|
|
80
|
+
document.body.removeChild(textarea);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("falls back to 60px for unitless line heights and detached nodes", () => {
|
|
84
|
+
const unitless = createComposerTextarea(baseConfig);
|
|
85
|
+
unitless.textarea.style.lineHeight = "1.5";
|
|
86
|
+
document.body.appendChild(unitless.textarea);
|
|
87
|
+
unitless.attachAutoResize();
|
|
88
|
+
Object.defineProperty(unitless.textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
89
|
+
unitless.textarea.dispatchEvent(new Event("input"));
|
|
90
|
+
expect(unitless.textarea.style.height).toBe("60px");
|
|
91
|
+
document.body.removeChild(unitless.textarea);
|
|
92
|
+
|
|
93
|
+
// Never appended: getComputedStyle still must not throw or produce NaN.
|
|
94
|
+
const detached = createComposerTextarea(baseConfig);
|
|
95
|
+
detached.attachAutoResize();
|
|
96
|
+
Object.defineProperty(detached.textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
97
|
+
detached.textarea.dispatchEvent(new Event("input"));
|
|
98
|
+
expect(detached.textarea.style.height).toBe("60px");
|
|
99
|
+
});
|
|
49
100
|
});
|
|
50
101
|
|
|
51
102
|
describe("createSendButton", () => {
|
|
@@ -97,10 +148,12 @@ describe("createSendButton", () => {
|
|
|
97
148
|
expect(s.borderWidth).toBe("2px");
|
|
98
149
|
expect(s.borderStyle).toBe("solid");
|
|
99
150
|
expect(s.borderColor).toBe("rgb(7, 8, 9)");
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
expect(s.
|
|
103
|
-
expect(s.
|
|
151
|
+
// Icon mode zeroes padding even when configured: the glyph is drawn at
|
|
152
|
+
// the button size, so padding would crush it.
|
|
153
|
+
expect(s.paddingLeft).toBe("0px");
|
|
154
|
+
expect(s.paddingRight).toBe("0px");
|
|
155
|
+
expect(s.paddingTop).toBe("0px");
|
|
156
|
+
expect(s.paddingBottom).toBe("0px");
|
|
104
157
|
// An explicit backgroundColor means the fallback class is not added.
|
|
105
158
|
expect(send.button.classList.contains("persona-bg-persona-primary")).toBe(false);
|
|
106
159
|
});
|
|
@@ -142,6 +195,52 @@ describe("createSendButton", () => {
|
|
|
142
195
|
expect(iconCount(send.button)).toBe(1);
|
|
143
196
|
});
|
|
144
197
|
|
|
198
|
+
it("zeroes padding so configured padding cannot crush the glyph", () => {
|
|
199
|
+
const send = createSendButton({
|
|
200
|
+
...baseConfig,
|
|
201
|
+
sendButton: {
|
|
202
|
+
useIcon: true,
|
|
203
|
+
iconName: "send",
|
|
204
|
+
size: "40px",
|
|
205
|
+
paddingX: "12px",
|
|
206
|
+
paddingY: "10px",
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
expect(send.button.style.paddingLeft).toBe("0px");
|
|
210
|
+
expect(send.button.style.paddingTop).toBe("0px");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("draws the glyph at half the button by default and honors iconSize", () => {
|
|
214
|
+
const send = createSendButton({
|
|
215
|
+
...baseConfig,
|
|
216
|
+
sendButton: { useIcon: true, iconName: "send", size: "32px" },
|
|
217
|
+
});
|
|
218
|
+
expect(send.button.querySelector("svg")?.getAttribute("width")).toBe("16");
|
|
219
|
+
|
|
220
|
+
const sized = createSendButton({
|
|
221
|
+
...baseConfig,
|
|
222
|
+
sendButton: {
|
|
223
|
+
useIcon: true,
|
|
224
|
+
iconName: "send",
|
|
225
|
+
size: "32px",
|
|
226
|
+
iconSize: "26px",
|
|
227
|
+
iconStrokeWidth: 2.5,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const svg = sized.button.querySelector("svg");
|
|
231
|
+
expect(svg?.getAttribute("width")).toBe("26");
|
|
232
|
+
expect(svg?.getAttribute("stroke-width")).toBe("2.5");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("keeps configured padding in text mode", () => {
|
|
236
|
+
const send = createSendButton({
|
|
237
|
+
...baseConfig,
|
|
238
|
+
sendButton: { useIcon: false, paddingX: "12px", paddingY: "10px" },
|
|
239
|
+
});
|
|
240
|
+
expect(send.button.style.paddingLeft).toBe("12px");
|
|
241
|
+
expect(send.button.style.paddingTop).toBe("10px");
|
|
242
|
+
});
|
|
243
|
+
|
|
145
244
|
it("does not stack a stale icon when an external re-render swapped the live icon node", () => {
|
|
146
245
|
const send = createSendButton(iconConfig);
|
|
147
246
|
// Simulate a DOM morph/re-render (e.g. a host calling controller.update())
|
|
@@ -39,19 +39,47 @@ export const createComposerTextarea = (config?: AgentWidgetConfig): ComposerText
|
|
|
39
39
|
'var(--persona-input-font-family, var(--persona-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif))';
|
|
40
40
|
textarea.style.fontWeight = "var(--persona-input-font-weight, var(--persona-font-weight, 400))";
|
|
41
41
|
|
|
42
|
-
// Auto-resize: expand up to 3 lines for the full composer
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
42
|
+
// Auto-resize: expand up to 3 lines for the full composer. The pill composer
|
|
43
|
+
// overrides this maxHeight after construction (allowing more growth in
|
|
44
|
+
// expanded mode), and the closure below honors whatever maxHeight is set at
|
|
45
|
+
// the time of the input event.
|
|
46
46
|
const defaultMaxLines = 3;
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// Used only when the rendered line height can't be resolved (detached node,
|
|
48
|
+
// jsdom, `normal`); matches the `persona-text-sm` / composer default.
|
|
49
|
+
const fallbackLineHeight = 20;
|
|
50
|
+
|
|
51
|
+
// `theme.components.composer.lineHeight` (--persona-composer-line-height) can
|
|
52
|
+
// change the rendered line height, so 3 lines is only correct when measured
|
|
53
|
+
// from the live element. Read lazily: at construction the textarea is usually
|
|
54
|
+
// still detached and getComputedStyle would report nothing useful.
|
|
55
|
+
const readLineHeight = (): number => {
|
|
56
|
+
const view = textarea.ownerDocument?.defaultView;
|
|
57
|
+
if (!view?.getComputedStyle) return fallbackLineHeight;
|
|
58
|
+
// Only px values are meaningful here: `normal` and unitless ratios aren't
|
|
59
|
+
// line box heights we can multiply.
|
|
60
|
+
const computed = view.getComputedStyle(textarea).lineHeight;
|
|
61
|
+
if (!computed || !computed.trim().endsWith("px")) return fallbackLineHeight;
|
|
62
|
+
const parsed = parseFloat(computed);
|
|
63
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackLineHeight;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Tracks the last maxHeight this module wrote, so a caller's post-construction
|
|
67
|
+
// override is distinguishable from our own default and always wins.
|
|
68
|
+
let managedMaxHeight = `${defaultMaxLines * fallbackLineHeight}px`;
|
|
69
|
+
textarea.style.maxHeight = managedMaxHeight;
|
|
49
70
|
textarea.style.overflowY = "auto";
|
|
50
71
|
|
|
51
72
|
// Read maxHeight at event time so callers can change it after construction.
|
|
52
73
|
const readMaxHeight = (): number => {
|
|
53
|
-
const
|
|
54
|
-
|
|
74
|
+
const current = textarea.style.maxHeight;
|
|
75
|
+
if (current !== managedMaxHeight) {
|
|
76
|
+
const overridden = parseFloat(current);
|
|
77
|
+
if (Number.isFinite(overridden) && overridden > 0) return overridden;
|
|
78
|
+
}
|
|
79
|
+
const derived = defaultMaxLines * readLineHeight();
|
|
80
|
+
managedMaxHeight = `${derived}px`;
|
|
81
|
+
textarea.style.maxHeight = managedMaxHeight;
|
|
82
|
+
return derived;
|
|
55
83
|
};
|
|
56
84
|
|
|
57
85
|
const attachAutoResize = () => {
|
|
@@ -142,10 +170,13 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
|
|
|
142
170
|
borderWidth: sendButtonConfig.borderWidth || undefined,
|
|
143
171
|
borderStyle: sendButtonConfig.borderWidth ? "solid" : undefined,
|
|
144
172
|
borderColor: sendButtonConfig.borderColor || undefined,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
173
|
+
// Padding is text-mode-only: icon mode is a fixed `size` box with the
|
|
174
|
+
// glyph drawn at that size, so padding just crushes the SVG (the
|
|
175
|
+
// always-present defaults squeezed a 40px glyph into 16x20).
|
|
176
|
+
paddingLeft: useIcon ? "0" : sendButtonConfig.paddingX || undefined,
|
|
177
|
+
paddingRight: useIcon ? "0" : sendButtonConfig.paddingX || undefined,
|
|
178
|
+
paddingTop: useIcon ? "0" : sendButtonConfig.paddingY || undefined,
|
|
179
|
+
paddingBottom: useIcon ? "0" : sendButtonConfig.paddingY || undefined,
|
|
149
180
|
},
|
|
150
181
|
});
|
|
151
182
|
|
|
@@ -154,11 +185,18 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
|
|
|
154
185
|
let stopIcon: SVGElement | null = null;
|
|
155
186
|
|
|
156
187
|
if (useIcon) {
|
|
157
|
-
|
|
188
|
+
// Default glyph box is half the button, the closest clean ratio to the
|
|
189
|
+
// pre-fix live rendering (a 40px glyph crushed to a ~16px content box).
|
|
190
|
+
// Dense glyphs like the default "send" plane fill most of their viewBox;
|
|
191
|
+
// sparse ones (arrow-up) may want a larger explicit `iconSize`.
|
|
192
|
+
const iconSize =
|
|
193
|
+
parseFloat(sendButtonConfig.iconSize ?? "") ||
|
|
194
|
+
Math.round((parseFloat(buttonSize) || 24) * 0.5);
|
|
195
|
+
const iconStroke = sendButtonConfig.iconStrokeWidth ?? 2;
|
|
158
196
|
const iconColor = textColor?.trim() || "currentColor";
|
|
159
197
|
|
|
160
198
|
if (iconName) {
|
|
161
|
-
sendIcon = renderLucideIcon(iconName, iconSize, iconColor,
|
|
199
|
+
sendIcon = renderLucideIcon(iconName, iconSize, iconColor, iconStroke);
|
|
162
200
|
if (sendIcon) {
|
|
163
201
|
button.appendChild(sendIcon);
|
|
164
202
|
} else {
|
|
@@ -168,7 +206,7 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
|
|
|
168
206
|
button.textContent = iconText;
|
|
169
207
|
}
|
|
170
208
|
|
|
171
|
-
stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor,
|
|
209
|
+
stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, iconStroke);
|
|
172
210
|
} else {
|
|
173
211
|
button.textContent = sendLabel;
|
|
174
212
|
}
|
|
@@ -13,6 +13,28 @@ export const HEADER_THEME_CSS = {
|
|
|
13
13
|
"var(--persona-header-action-icon-fg, var(--persona-muted, #9ca3af))",
|
|
14
14
|
} as const;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Inline typography for the header title/subtitle, from
|
|
18
|
+
* `theme.components.header.title` / `.subtitle`. Fallbacks reproduce the
|
|
19
|
+
* `persona-text-base persona-font-semibold` / `persona-text-xs` utilities.
|
|
20
|
+
*/
|
|
21
|
+
export const HEADER_TITLE_TYPOGRAPHY = {
|
|
22
|
+
fontFamily: "var(--persona-components-header-title-fontFamily, inherit)",
|
|
23
|
+
fontSize: "var(--persona-components-header-title-fontSize, 1rem)",
|
|
24
|
+
fontWeight: "var(--persona-components-header-title-fontWeight, 600)",
|
|
25
|
+
lineHeight: "var(--persona-components-header-title-lineHeight, 1.5rem)",
|
|
26
|
+
letterSpacing: "var(--persona-components-header-title-letterSpacing, normal)",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export const HEADER_SUBTITLE_TYPOGRAPHY = {
|
|
30
|
+
fontFamily: "var(--persona-components-header-subtitle-fontFamily, inherit)",
|
|
31
|
+
fontSize: "var(--persona-components-header-subtitle-fontSize, 0.75rem)",
|
|
32
|
+
fontWeight: "var(--persona-components-header-subtitle-fontWeight, inherit)",
|
|
33
|
+
lineHeight: "var(--persona-components-header-subtitle-lineHeight, 1rem)",
|
|
34
|
+
letterSpacing:
|
|
35
|
+
"var(--persona-components-header-subtitle-letterSpacing, normal)",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
16
38
|
export interface HeaderElements {
|
|
17
39
|
header: HTMLElement;
|
|
18
40
|
iconHolder: HTMLElement;
|
|
@@ -99,12 +121,15 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
|
|
|
99
121
|
const title = createNode("span", {
|
|
100
122
|
className: "persona-text-base persona-font-semibold",
|
|
101
123
|
text: config?.launcher?.title ?? "Chat Assistant",
|
|
102
|
-
style: { color: HEADER_THEME_CSS.titleColor },
|
|
124
|
+
style: { color: HEADER_THEME_CSS.titleColor, ...HEADER_TITLE_TYPOGRAPHY },
|
|
103
125
|
});
|
|
104
126
|
const subtitle = createNode("span", {
|
|
105
127
|
className: "persona-text-xs",
|
|
106
128
|
text: config?.launcher?.subtitle ?? "Here to help you get answers fast",
|
|
107
|
-
style: {
|
|
129
|
+
style: {
|
|
130
|
+
color: HEADER_THEME_CSS.subtitleColor,
|
|
131
|
+
...HEADER_SUBTITLE_TYPOGRAPHY,
|
|
132
|
+
},
|
|
108
133
|
});
|
|
109
134
|
|
|
110
135
|
headerCopy.append(title, subtitle);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { buildMinimalHeader } from "./header-layouts";
|
|
6
|
+
import { HEADER_THEME_CSS } from "./header-builder";
|
|
7
|
+
|
|
8
|
+
const build = (layoutHeaderConfig: Parameters<typeof buildMinimalHeader>[0]["layoutHeaderConfig"]) =>
|
|
9
|
+
buildMinimalHeader({
|
|
10
|
+
config: { apiUrl: "https://example.com/api" },
|
|
11
|
+
showClose: true,
|
|
12
|
+
onClose: () => {},
|
|
13
|
+
layoutHeaderConfig,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("minimal header trailing actions", () => {
|
|
17
|
+
it("clusters actions at the trailing edge with the close button's chrome", () => {
|
|
18
|
+
const onAction = vi.fn();
|
|
19
|
+
const elements = build({
|
|
20
|
+
layout: "minimal",
|
|
21
|
+
trailingActions: [{ id: "home", icon: "house", ariaLabel: "Back to home" }],
|
|
22
|
+
onAction,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const button = elements.header.querySelector<HTMLButtonElement>(
|
|
26
|
+
'button[aria-label="Back to home"]'
|
|
27
|
+
);
|
|
28
|
+
expect(button).not.toBeNull();
|
|
29
|
+
|
|
30
|
+
// Trailing cluster, not the title row: the button shares a parent with
|
|
31
|
+
// the close button wrapper and does not precede the title.
|
|
32
|
+
expect(button!.parentElement).toBe(elements.closeButtonWrapper.parentElement);
|
|
33
|
+
expect(elements.headerTitle.parentElement!.contains(button!)).toBe(false);
|
|
34
|
+
|
|
35
|
+
// Close-button chrome: 32px round hit area, header action-icon color.
|
|
36
|
+
expect(button!.style.height).toBe("32px");
|
|
37
|
+
expect(button!.style.width).toBe("32px");
|
|
38
|
+
expect(button!.style.color).toBe(HEADER_THEME_CSS.actionIconColor);
|
|
39
|
+
expect(button!.className).toContain("persona-rounded-full");
|
|
40
|
+
|
|
41
|
+
button!.click();
|
|
42
|
+
expect(onAction).toHaveBeenCalledWith("home");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("still ignores trailingActions when titleMenu is configured", () => {
|
|
46
|
+
const elements = build({
|
|
47
|
+
layout: "minimal",
|
|
48
|
+
titleMenu: { menuItems: [{ id: "a", label: "A" }], onSelect: () => {} },
|
|
49
|
+
trailingActions: [{ id: "home", icon: "house", ariaLabel: "Back to home" }],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
elements.header.querySelector('button[aria-label="Back to home"]')
|
|
54
|
+
).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
buildHeader,
|
|
12
12
|
HEADER_THEME_CSS,
|
|
13
|
+
HEADER_TITLE_TYPOGRAPHY,
|
|
13
14
|
HeaderElements,
|
|
14
15
|
attachHeaderToContainer as _attachHeaderToContainer,
|
|
15
16
|
} from "./header-builder";
|
|
@@ -70,14 +71,20 @@ function appendTrailingHeaderActions(
|
|
|
70
71
|
): void {
|
|
71
72
|
if (!actions?.length) return;
|
|
72
73
|
for (const a of actions) {
|
|
74
|
+
// Same chrome as the close button beside them: 32px round hit area,
|
|
75
|
+
// hover fill, and the header zone's action-icon color (the muted body
|
|
76
|
+
// token disappears on themed headers).
|
|
73
77
|
const btn = createElement(
|
|
74
78
|
"button",
|
|
75
|
-
"persona-inline-flex persona-items-center persona-justify-center persona-rounded-
|
|
79
|
+
"persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-0"
|
|
76
80
|
) as HTMLButtonElement;
|
|
77
81
|
btn.type = "button";
|
|
82
|
+
btn.style.height = "32px";
|
|
83
|
+
btn.style.width = "32px";
|
|
84
|
+
btn.style.color = HEADER_THEME_CSS.actionIconColor;
|
|
78
85
|
btn.setAttribute("aria-label", a.ariaLabel ?? a.label ?? a.id);
|
|
79
86
|
if (a.icon) {
|
|
80
|
-
const ic = renderLucideIcon(a.icon,
|
|
87
|
+
const ic = renderLucideIcon(a.icon, 16, "currentColor", 2);
|
|
81
88
|
if (ic) btn.appendChild(ic);
|
|
82
89
|
} else if (a.label) {
|
|
83
90
|
btn.textContent = a.label;
|
|
@@ -91,7 +98,9 @@ function appendTrailingHeaderActions(
|
|
|
91
98
|
items: a.menuItems,
|
|
92
99
|
onSelect: (itemId) => onAction?.(itemId),
|
|
93
100
|
anchor: wrapper,
|
|
94
|
-
|
|
101
|
+
// The cluster sits at the trailing edge; a left-aligned menu would
|
|
102
|
+
// extend past it.
|
|
103
|
+
position: 'bottom-right',
|
|
95
104
|
});
|
|
96
105
|
wrapper.appendChild(dropdown.element);
|
|
97
106
|
btn.addEventListener("click", (e) => {
|
|
@@ -148,14 +157,10 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
|
|
|
148
157
|
// Title only (no icon, no subtitle)
|
|
149
158
|
headerTitle = createElement("span", "persona-text-base persona-font-semibold persona-truncate");
|
|
150
159
|
headerTitle.style.color = HEADER_THEME_CSS.titleColor;
|
|
160
|
+
Object.assign(headerTitle.style, HEADER_TITLE_TYPOGRAPHY);
|
|
151
161
|
headerTitle.textContent = launcher.title ?? "Chat Assistant";
|
|
152
162
|
|
|
153
163
|
titleRow.appendChild(headerTitle);
|
|
154
|
-
appendTrailingHeaderActions(
|
|
155
|
-
titleRow,
|
|
156
|
-
layoutHeaderConfig?.trailingActions,
|
|
157
|
-
layoutHeaderConfig?.onAction ?? onHeaderAction
|
|
158
|
-
);
|
|
159
164
|
|
|
160
165
|
// Make title row clickable when onTitleClick is provided
|
|
161
166
|
if (layoutHeaderConfig?.onTitleClick) {
|
|
@@ -229,7 +234,22 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
|
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
closeButtonWrapper.appendChild(closeButton);
|
|
232
|
-
|
|
237
|
+
|
|
238
|
+
// Trailing edge: action buttons cluster with the close button, matching
|
|
239
|
+
// its chrome. `titleMenu` still ignores `trailingActions` (documented).
|
|
240
|
+
const trailingCluster = createElement(
|
|
241
|
+
"div",
|
|
242
|
+
"persona-flex persona-items-center persona-gap-1"
|
|
243
|
+
);
|
|
244
|
+
if (!titleMenuConfig) {
|
|
245
|
+
appendTrailingHeaderActions(
|
|
246
|
+
trailingCluster,
|
|
247
|
+
layoutHeaderConfig?.trailingActions,
|
|
248
|
+
layoutHeaderConfig?.onAction ?? onHeaderAction
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
trailingCluster.appendChild(closeButtonWrapper);
|
|
252
|
+
header.appendChild(trailingCluster);
|
|
233
253
|
|
|
234
254
|
// title was moved into titleRow; keep headerTitle ref pointing at title for updateController
|
|
235
255
|
|