@runtypelabs/persona 3.21.3 → 3.23.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 (66) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -262
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -235
  6. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  7. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  8. package/dist/animations/wipe.cjs +2 -72
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/animations/wipe.js +2 -45
  12. package/dist/index.cjs +52 -45
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +474 -6
  15. package/dist/index.d.ts +474 -6
  16. package/dist/index.global.js +107 -97
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +52 -45
  19. package/dist/index.js.map +1 -1
  20. package/dist/smart-dom-reader.cjs +23 -0
  21. package/dist/smart-dom-reader.d.cts +4521 -0
  22. package/dist/smart-dom-reader.d.ts +4521 -0
  23. package/dist/smart-dom-reader.js +23 -0
  24. package/dist/testing.cjs +3 -84
  25. package/dist/testing.js +3 -55
  26. package/dist/theme-editor.cjs +57 -22501
  27. package/dist/theme-editor.d.cts +348 -1
  28. package/dist/theme-editor.d.ts +348 -1
  29. package/dist/theme-editor.js +57 -22503
  30. package/package.json +16 -6
  31. package/src/client.test.ts +165 -0
  32. package/src/client.ts +144 -23
  33. package/src/components/event-stream-view.ts +122 -1
  34. package/src/index.ts +26 -0
  35. package/src/session.test.ts +258 -0
  36. package/src/session.ts +886 -30
  37. package/src/session.webmcp.test.ts +815 -0
  38. package/src/smart-dom-reader.test.ts +135 -0
  39. package/src/smart-dom-reader.ts +135 -0
  40. package/src/theme-editor/color-utils.test.ts +59 -0
  41. package/src/theme-editor/color-utils.ts +38 -2
  42. package/src/theme-editor/index.ts +35 -0
  43. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  44. package/src/theme-editor/webmcp/coerce.ts +286 -0
  45. package/src/theme-editor/webmcp/index.ts +45 -0
  46. package/src/theme-editor/webmcp/summary.ts +324 -0
  47. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  48. package/src/theme-editor/webmcp/tools.ts +795 -0
  49. package/src/theme-editor/webmcp/types.ts +87 -0
  50. package/src/types.ts +186 -0
  51. package/src/ui.composer-keyboard.test.ts +229 -0
  52. package/src/ui.ts +151 -8
  53. package/src/utils/composer-history.test.ts +128 -0
  54. package/src/utils/composer-history.ts +113 -0
  55. package/src/utils/message-fingerprint.test.ts +20 -0
  56. package/src/utils/message-fingerprint.ts +2 -0
  57. package/src/utils/smart-dom-adapter.test.ts +257 -0
  58. package/src/utils/smart-dom-adapter.ts +217 -0
  59. package/src/utils/throughput-tracker.test.ts +366 -0
  60. package/src/utils/throughput-tracker.ts +427 -0
  61. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  62. package/src/vendor/smart-dom-reader/README.md +61 -0
  63. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  64. package/src/vendor/smart-dom-reader/index.js +1618 -0
  65. package/src/webmcp-bridge.test.ts +429 -0
  66. package/src/webmcp-bridge.ts +547 -0
@@ -0,0 +1,257 @@
1
+ // Pure mapper test — no DOM, no installed/vendored library at runtime (types only).
2
+ import { describe, it, expect } from "vitest";
3
+ import { smartDomResultToEnriched } from "./smart-dom-adapter";
4
+ import { formatEnrichedContext } from "./dom-context";
5
+ import type {
6
+ SmartDOMResult,
7
+ ExtractedElement,
8
+ ElementSelector
9
+ } from "../vendor/smart-dom-reader";
10
+
11
+ function makeSelector(partial: Partial<ElementSelector> = {}): ElementSelector {
12
+ return { css: "div", xpath: "/html/body/div", ...partial };
13
+ }
14
+
15
+ function makeEl(partial: Partial<ExtractedElement> = {}): ExtractedElement {
16
+ return {
17
+ tag: partial.tag ?? "div",
18
+ text: partial.text ?? "",
19
+ selector: partial.selector ?? makeSelector(),
20
+ attributes: partial.attributes ?? {},
21
+ context: {
22
+ parentChain: [],
23
+ ...(partial.context ?? {})
24
+ },
25
+ interaction: partial.interaction ?? {},
26
+ children: partial.children
27
+ };
28
+ }
29
+
30
+ function makeResult(
31
+ interactive: Partial<SmartDOMResult["interactive"]> = {},
32
+ semantic?: SmartDOMResult["semantic"]
33
+ ): SmartDOMResult {
34
+ return {
35
+ mode: semantic ? "full" : "interactive",
36
+ timestamp: 0,
37
+ page: {
38
+ url: "https://example.com",
39
+ title: "Example",
40
+ hasErrors: false,
41
+ isLoading: false,
42
+ hasModals: false
43
+ },
44
+ landmarks: {
45
+ navigation: [],
46
+ main: [],
47
+ forms: [],
48
+ headers: [],
49
+ footers: [],
50
+ articles: [],
51
+ sections: []
52
+ },
53
+ interactive: {
54
+ buttons: [],
55
+ links: [],
56
+ inputs: [],
57
+ forms: [],
58
+ clickable: [],
59
+ ...interactive
60
+ },
61
+ semantic
62
+ };
63
+ }
64
+
65
+ describe("smartDomResultToEnriched", () => {
66
+ it("prefers the highest-scoring plain-CSS candidate and skips XPath/text", () => {
67
+ const result = makeResult({
68
+ buttons: [
69
+ makeEl({
70
+ tag: "button",
71
+ text: "Add to cart",
72
+ interaction: { click: true },
73
+ selector: makeSelector({
74
+ css: "button.add",
75
+ xpath: "/html/body/button[1]",
76
+ candidates: [
77
+ { type: "xpath", value: "/html/body/button[1]", score: 100 },
78
+ { type: "text", value: "text=Add to cart", score: 90 },
79
+ { type: "data-testid", value: '[data-testid="add"]', score: 80 },
80
+ { type: "class-path", value: "button.add", score: 50 }
81
+ ]
82
+ })
83
+ })
84
+ ]
85
+ });
86
+
87
+ const enriched = smartDomResultToEnriched(result);
88
+ expect(enriched).toHaveLength(1);
89
+ // best plain-CSS candidate by score (data-testid:80 > class-path:50; xpath/text skipped)
90
+ expect(enriched[0].selector).toBe('[data-testid="add"]');
91
+ expect(enriched[0].interactivity).toBe("clickable");
92
+ });
93
+
94
+ it("falls back to selector.css when no candidates qualify", () => {
95
+ const result = makeResult({
96
+ buttons: [
97
+ makeEl({
98
+ tag: "button",
99
+ text: "Go",
100
+ interaction: { click: true },
101
+ selector: makeSelector({ css: "button.go", candidates: [] })
102
+ })
103
+ ]
104
+ });
105
+ const enriched = smartDomResultToEnriched(result);
106
+ expect(enriched[0].selector).toBe("button.go");
107
+ });
108
+
109
+ it("classifies interactivity from tag/role/interaction", () => {
110
+ const result = makeResult({
111
+ buttons: [
112
+ makeEl({
113
+ tag: "button",
114
+ text: "Buy",
115
+ interaction: { click: true },
116
+ selector: makeSelector({ css: "button.buy" })
117
+ })
118
+ ],
119
+ links: [
120
+ makeEl({
121
+ tag: "a",
122
+ text: "Home",
123
+ attributes: { href: "/home" },
124
+ interaction: { nav: true },
125
+ selector: makeSelector({ css: "a.home" })
126
+ })
127
+ ],
128
+ inputs: [
129
+ makeEl({
130
+ tag: "input",
131
+ attributes: { type: "text", name: "q" },
132
+ interaction: { change: true },
133
+ selector: makeSelector({ css: "input.q" })
134
+ })
135
+ ]
136
+ });
137
+
138
+ const enriched = smartDomResultToEnriched(result);
139
+ const bySel = Object.fromEntries(enriched.map((e) => [e.selector, e]));
140
+ expect(bySel["button.buy"].interactivity).toBe("clickable");
141
+ expect(bySel["a.home"].interactivity).toBe("navigable");
142
+ expect(bySel["input.q"].interactivity).toBe("input");
143
+ });
144
+
145
+ it("excludes elements under the host (.persona-host) via parentChain", () => {
146
+ const result = makeResult({
147
+ buttons: [
148
+ makeEl({
149
+ tag: "button",
150
+ text: "Widget send",
151
+ interaction: { click: true },
152
+ selector: makeSelector({ css: "button.send" }),
153
+ context: { parentChain: ["div.persona-host", "div.panel"] }
154
+ }),
155
+ makeEl({
156
+ tag: "button",
157
+ text: "Page button",
158
+ interaction: { click: true },
159
+ selector: makeSelector({ css: "button.page" }),
160
+ context: { parentChain: ["main", "section"] }
161
+ })
162
+ ]
163
+ });
164
+
165
+ const enriched = smartDomResultToEnriched(result);
166
+ expect(enriched.map((e) => e.selector)).toEqual(["button.page"]);
167
+ });
168
+
169
+ it("includes semantic elements only when present and not disabled", () => {
170
+ const semantic: SmartDOMResult["semantic"] = {
171
+ headings: [
172
+ makeEl({
173
+ tag: "h1",
174
+ text: "Title",
175
+ selector: makeSelector({ css: "h1.title" })
176
+ })
177
+ ],
178
+ images: [],
179
+ tables: [],
180
+ lists: [],
181
+ articles: []
182
+ };
183
+ const result = makeResult(
184
+ {
185
+ buttons: [
186
+ makeEl({
187
+ tag: "button",
188
+ text: "Go",
189
+ interaction: { click: true },
190
+ selector: makeSelector({ css: "button.go" })
191
+ })
192
+ ]
193
+ },
194
+ semantic
195
+ );
196
+
197
+ const withSemantic = smartDomResultToEnriched(result);
198
+ expect(withSemantic.map((e) => e.selector)).toContain("h1.title");
199
+ const heading = withSemantic.find((e) => e.selector === "h1.title");
200
+ expect(heading?.interactivity).toBe("static");
201
+
202
+ const withoutSemantic = smartDomResultToEnriched(result, {
203
+ includeSemantic: false
204
+ });
205
+ expect(withoutSemantic.map((e) => e.selector)).not.toContain("h1.title");
206
+ });
207
+
208
+ it("deduplicates by selector and honors maxElements", () => {
209
+ const dup = makeEl({
210
+ tag: "button",
211
+ text: "Dup",
212
+ interaction: { click: true },
213
+ selector: makeSelector({ css: "button.dup" })
214
+ });
215
+ const result = makeResult({
216
+ buttons: [dup, dup],
217
+ clickable: [dup]
218
+ });
219
+ expect(smartDomResultToEnriched(result)).toHaveLength(1);
220
+
221
+ const many = makeResult({
222
+ buttons: [
223
+ makeEl({ tag: "button", text: "1", interaction: { click: true }, selector: makeSelector({ css: "button.a" }) }),
224
+ makeEl({ tag: "button", text: "2", interaction: { click: true }, selector: makeSelector({ css: "button.b" }) }),
225
+ makeEl({ tag: "button", text: "3", interaction: { click: true }, selector: makeSelector({ css: "button.c" }) })
226
+ ]
227
+ });
228
+ expect(smartDomResultToEnriched(many, { maxElements: 2 })).toHaveLength(2);
229
+ });
230
+
231
+ it("produces output that feeds formatEnrichedContext", () => {
232
+ const result = makeResult({
233
+ buttons: [
234
+ makeEl({
235
+ tag: "button",
236
+ text: "Checkout",
237
+ interaction: { click: true },
238
+ selector: makeSelector({ css: "button.checkout" })
239
+ })
240
+ ],
241
+ links: [
242
+ makeEl({
243
+ tag: "a",
244
+ text: "Cart",
245
+ attributes: { href: "/cart" },
246
+ interaction: { nav: true },
247
+ selector: makeSelector({ css: "a.cart" })
248
+ })
249
+ ]
250
+ });
251
+
252
+ const formatted = formatEnrichedContext(smartDomResultToEnriched(result));
253
+ expect(formatted).toContain("button.checkout");
254
+ expect(formatted).toContain("a.cart");
255
+ expect(formatted).toContain("Checkout");
256
+ });
257
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Pure mapper: `@mcp-b/smart-dom-reader` output → Persona's {@link EnrichedPageElement}[].
3
+ *
4
+ * This module imports the smart-dom-reader types with `import type` ONLY, so the
5
+ * library's runtime value is never pulled in here. That keeps the mapper — and its
6
+ * unit test — free of any DOM and free of the (vendored) library at runtime; the
7
+ * types are erased during compilation.
8
+ *
9
+ * The smart-dom-reader returns a structured {@link SmartDOMResult} JSON object with
10
+ * rich selectors (CSS + XPath + ranked candidates). Persona's collect → reason → act
11
+ * loop drives clicks through `document.querySelector` (see `utils/actions.ts`), which
12
+ * cannot pierce shadow roots or evaluate XPath. So this mapper deliberately prefers
13
+ * the **best plain-CSS candidate selector** for each element and skips XPath / text
14
+ * pseudo-selectors, keeping results actionable.
15
+ */
16
+
17
+ import type {
18
+ SmartDOMResult,
19
+ ExtractedElement,
20
+ ElementSelectorCandidate
21
+ } from "../vendor/smart-dom-reader";
22
+ import type { EnrichedPageElement } from "./dom-context";
23
+
24
+ /** Options for {@link smartDomResultToEnriched}. */
25
+ export interface SmartDomAdapterOptions {
26
+ /**
27
+ * Include non-interactive `semantic` groups (headings, images, tables, lists,
28
+ * articles) when the result has them (i.e. full-mode extraction). Default: true.
29
+ */
30
+ includeSemantic?: boolean;
31
+ /**
32
+ * Skip elements whose own selector / ancestor chain contains this selector string,
33
+ * so the widget never reports its own shadow-DOM UI. Matched as a substring against
34
+ * the element's candidate selectors and `context.parentChain`. Default: ".persona-host".
35
+ * Pass "" to disable.
36
+ */
37
+ excludeSelector?: string;
38
+ /** Truncate each element's text to this many characters. Default: 200. */
39
+ maxTextLength?: number;
40
+ /** Optional cap on the number of mapped elements returned. */
41
+ maxElements?: number;
42
+ }
43
+
44
+ /**
45
+ * Candidate selector types that resolve through `document.querySelector` (plain CSS).
46
+ * Everything else smart-dom-reader can emit — `xpath` and text pseudo-selectors —
47
+ * is not actionable via the current click loop, so it is skipped here.
48
+ */
49
+ const PLAIN_CSS_CANDIDATE_TYPES: ReadonlySet<ElementSelectorCandidate["type"]> =
50
+ new Set(["id", "data-testid", "role-aria", "name", "class-path", "css-path"]);
51
+
52
+ /** Looks like an XPath expression rather than a CSS selector. */
53
+ function looksLikeXPath(value: string): boolean {
54
+ const v = value.trim();
55
+ return v.startsWith("/") || v.startsWith("(") || v.startsWith("./");
56
+ }
57
+
58
+ /**
59
+ * Pick the best `document.querySelector`-able selector for an extracted element:
60
+ * the highest-scoring plain-CSS candidate, falling back to the primary `css` field
61
+ * when no candidate qualifies. Returns null when only XPath-like selectors exist.
62
+ */
63
+ function bestPlainCssSelector(el: ExtractedElement): string | null {
64
+ const candidates = el.selector.candidates;
65
+ if (candidates && candidates.length > 0) {
66
+ let best: ElementSelectorCandidate | null = null;
67
+ for (const c of candidates) {
68
+ if (!PLAIN_CSS_CANDIDATE_TYPES.has(c.type)) continue;
69
+ if (!c.value || looksLikeXPath(c.value)) continue;
70
+ if (!best || c.score > best.score) best = c;
71
+ }
72
+ if (best) return best.value;
73
+ }
74
+ const css = el.selector.css;
75
+ if (css && !looksLikeXPath(css)) return css;
76
+ return null;
77
+ }
78
+
79
+ /** Classify an extracted element into Persona's interactivity buckets. */
80
+ function classifyInteractivity(
81
+ el: ExtractedElement
82
+ ): EnrichedPageElement["interactivity"] {
83
+ const tag = el.tag.toLowerCase();
84
+ const role = el.interaction.role ?? el.attributes.role;
85
+
86
+ if (tag === "a" && (el.interaction.nav || el.attributes.href != null)) {
87
+ return "navigable";
88
+ }
89
+ if (tag === "input" || tag === "select" || tag === "textarea") return "input";
90
+ if (
91
+ role === "textbox" ||
92
+ role === "combobox" ||
93
+ role === "listbox" ||
94
+ role === "spinbutton"
95
+ ) {
96
+ return "input";
97
+ }
98
+ if (el.interaction.change && !el.interaction.click) return "input";
99
+ if (
100
+ tag === "button" ||
101
+ role === "button" ||
102
+ el.interaction.click ||
103
+ el.interaction.submit
104
+ ) {
105
+ return "clickable";
106
+ }
107
+ return "static";
108
+ }
109
+
110
+ /** Build the relevant-attribute bag, mirroring `dom-context.collectAttributes`. */
111
+ function collectAttributes(el: ExtractedElement): Record<string, string> {
112
+ const attrs: Record<string, string> = { ...el.attributes };
113
+ const role = el.interaction.role;
114
+ if (role && !attrs.role) attrs.role = role;
115
+ return attrs;
116
+ }
117
+
118
+ /**
119
+ * Returns true when the element sits under `excludeSelector` (e.g. the widget host).
120
+ * smart-dom-reader has no exclude option and pierces shadow DOM by default, so this
121
+ * guards against the widget reading its own UI. The check is a substring match across
122
+ * the element's candidate selectors and ancestor chain — robust for the default
123
+ * `.persona-host` class guard.
124
+ */
125
+ function isExcluded(el: ExtractedElement, excludeSelector: string): boolean {
126
+ if (!excludeSelector) return false;
127
+ const haystacks: Array<string | undefined> = [
128
+ el.selector.css,
129
+ el.selector.xpath,
130
+ el.context.nearestForm,
131
+ el.context.nearestSection,
132
+ el.context.nearestMain,
133
+ el.context.nearestNav,
134
+ ...(el.selector.candidates?.map((c) => c.value) ?? []),
135
+ ...el.context.parentChain
136
+ ];
137
+ return haystacks.some((h) => !!h && h.includes(excludeSelector));
138
+ }
139
+
140
+ /**
141
+ * Map a {@link SmartDOMResult} into Persona's {@link EnrichedPageElement}[] shape so it
142
+ * can be formatted by `formatEnrichedContext` and consumed wherever the default
143
+ * `collectEnrichedPageContext` output is.
144
+ *
145
+ * - Maps `interactive.{buttons, links, inputs, clickable}` and, when present and
146
+ * `includeSemantic` is not false, `semantic.{headings, images, tables, lists, articles}`.
147
+ * - Chooses the best plain-CSS selector per element (skipping XPath / shadow-piercing
148
+ * selectors) so results stay actionable via `document.querySelector`.
149
+ * - Drops elements under `excludeSelector` (default `.persona-host`).
150
+ * - Deduplicates by selector and preserves discovery order (interactive before semantic).
151
+ */
152
+ export function smartDomResultToEnriched(
153
+ result: SmartDOMResult,
154
+ opts: SmartDomAdapterOptions = {}
155
+ ): EnrichedPageElement[] {
156
+ const includeSemantic = opts.includeSemantic ?? true;
157
+ const excludeSelector = opts.excludeSelector ?? ".persona-host";
158
+ const maxTextLength = opts.maxTextLength ?? 200;
159
+
160
+ const groups: ExtractedElement[][] = [
161
+ result.interactive.buttons,
162
+ result.interactive.links,
163
+ result.interactive.inputs,
164
+ result.interactive.clickable
165
+ ];
166
+
167
+ if (includeSemantic && result.semantic) {
168
+ groups.push(
169
+ result.semantic.headings,
170
+ result.semantic.images,
171
+ result.semantic.tables,
172
+ result.semantic.lists,
173
+ result.semantic.articles
174
+ );
175
+ }
176
+
177
+ const out: EnrichedPageElement[] = [];
178
+ const seen = new Set<string>();
179
+
180
+ // Walk an element and any nested children. In full mode the library attaches
181
+ // shadow-DOM descendants as `children` of semantic containers, so recursing here
182
+ // is how those pierced elements surface. Returns false once the optional
183
+ // maxElements cap is reached.
184
+ const visit = (el: ExtractedElement): boolean => {
185
+ if (isExcluded(el, excludeSelector)) return true;
186
+
187
+ const selector = bestPlainCssSelector(el);
188
+ if (selector && !seen.has(selector)) {
189
+ seen.add(selector);
190
+ out.push({
191
+ selector,
192
+ tagName: el.tag.toLowerCase(),
193
+ text: (el.text ?? "").trim().substring(0, maxTextLength),
194
+ role: el.interaction.role ?? el.attributes.role ?? null,
195
+ interactivity: classifyInteractivity(el),
196
+ attributes: collectAttributes(el)
197
+ });
198
+ if (opts.maxElements && out.length >= opts.maxElements) return false;
199
+ }
200
+
201
+ if (el.children) {
202
+ for (const child of el.children) {
203
+ if (!visit(child)) return false;
204
+ }
205
+ }
206
+ return true;
207
+ };
208
+
209
+ for (const group of groups) {
210
+ if (!group) continue;
211
+ for (const el of group) {
212
+ if (!visit(el)) return out;
213
+ }
214
+ }
215
+
216
+ return out;
217
+ }