@runtypelabs/persona 3.21.2 → 3.22.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 +67 -0
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
- package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +50 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +474 -6
- package/dist/index.d.ts +474 -6
- package/dist/index.global.js +98 -88
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -41
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +1875 -0
- package/dist/smart-dom-reader.d.cts +4521 -0
- package/dist/smart-dom-reader.d.ts +4521 -0
- package/dist/smart-dom-reader.js +1848 -0
- package/dist/theme-editor.cjs +2282 -90
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +2267 -90
- package/package.json +9 -2
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- package/src/components/composer-parts.test.ts +34 -0
- package/src/components/composer-parts.ts +9 -6
- package/src/index.ts +26 -0
- package/src/session.test.ts +258 -0
- package/src/session.ts +886 -30
- package/src/session.webmcp.test.ts +815 -0
- package/src/smart-dom-reader.test.ts +135 -0
- package/src/smart-dom-reader.ts +135 -0
- package/src/theme-editor/color-utils.test.ts +59 -0
- package/src/theme-editor/color-utils.ts +38 -2
- package/src/theme-editor/index.ts +35 -0
- package/src/theme-editor/webmcp/coerce.test.ts +86 -0
- package/src/theme-editor/webmcp/coerce.ts +286 -0
- package/src/theme-editor/webmcp/index.ts +45 -0
- package/src/theme-editor/webmcp/summary.ts +324 -0
- package/src/theme-editor/webmcp/tools.test.ts +205 -0
- package/src/theme-editor/webmcp/tools.ts +795 -0
- package/src/theme-editor/webmcp/types.ts +87 -0
- package/src/types.ts +186 -0
- package/src/ui.composer-keyboard.test.ts +229 -0
- package/src/ui.ts +127 -5
- package/src/utils/composer-history.test.ts +128 -0
- package/src/utils/composer-history.ts +113 -0
- package/src/utils/message-fingerprint.test.ts +20 -0
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/smart-dom-adapter.test.ts +257 -0
- package/src/utils/smart-dom-adapter.ts +217 -0
- package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
- package/src/vendor/smart-dom-reader/README.md +61 -0
- package/src/vendor/smart-dom-reader/index.d.ts +476 -0
- package/src/vendor/smart-dom-reader/index.js +1618 -0
- package/src/webmcp-bridge.test.ts +429 -0
- package/src/webmcp-bridge.ts +547 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// End-to-end smoke test for the optional smart-dom-reader entry, exercising the
|
|
4
|
+
// vendored library under jsdom. The pure-mapper correctness guarantee lives in
|
|
5
|
+
// utils/smart-dom-adapter.test.ts (no DOM, no library); this test confirms the
|
|
6
|
+
// vendored runtime loads and the provider wires up against a real document.
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
collectSmartDomContext,
|
|
10
|
+
createSmartDomReaderContextProvider
|
|
11
|
+
} from "./smart-dom-reader";
|
|
12
|
+
|
|
13
|
+
// jsdom implements no layout, so getBoundingClientRect()/offsetParent report the
|
|
14
|
+
// element as zero-size and the library's visibility filter would drop everything.
|
|
15
|
+
// includeHidden bypasses that filter so we can exercise real extraction under jsdom.
|
|
16
|
+
const JSDOM_OPTS = { extractionOptions: { includeHidden: true } } as const;
|
|
17
|
+
|
|
18
|
+
// This vitest jsdom environment doesn't expose CSS.escape (real browsers do). The
|
|
19
|
+
// vendored library calls it unguarded during selector generation, so shim it with the
|
|
20
|
+
// same fallback dom-context.ts uses.
|
|
21
|
+
function ensureCssEscape(): void {
|
|
22
|
+
const g = globalThis as unknown as { CSS?: { escape?: (s: string) => string } };
|
|
23
|
+
if (!g.CSS) g.CSS = {};
|
|
24
|
+
if (typeof g.CSS.escape !== "function") {
|
|
25
|
+
g.CSS.escape = (str: string) => str.replace(/([^\w-])/g, "\\$1");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("smart-dom-reader entry (jsdom)", () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
ensureCssEscape();
|
|
32
|
+
document.body.innerHTML = "";
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
document.body.innerHTML = "";
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("collects interactive elements from the light DOM", () => {
|
|
39
|
+
document.body.innerHTML = `
|
|
40
|
+
<main>
|
|
41
|
+
<button id="checkout">Checkout</button>
|
|
42
|
+
<a href="/cart">View cart</a>
|
|
43
|
+
<input type="text" name="q" />
|
|
44
|
+
</main>
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const elements = collectSmartDomContext(JSDOM_OPTS);
|
|
48
|
+
const selectors = elements.map((e) => e.selector).join(" ");
|
|
49
|
+
// At minimum the button and link should be discovered and actionable.
|
|
50
|
+
expect(elements.length).toBeGreaterThan(0);
|
|
51
|
+
expect(selectors).toMatch(/checkout|Checkout/i);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("pierces shadow DOM (full mode), surfacing elements the default TreeWalker reader misses", () => {
|
|
55
|
+
// The library pierces shadow roots in full mode, attaching shadow descendants as
|
|
56
|
+
// children of a semantic-container host; the adapter flattens those children. The
|
|
57
|
+
// default dom-context.ts TreeWalker cannot reach into shadow trees at all.
|
|
58
|
+
const host = document.createElement("article");
|
|
59
|
+
host.id = "wc-host";
|
|
60
|
+
document.body.appendChild(host);
|
|
61
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
62
|
+
shadow.innerHTML = `<button id="shadow-btn">Shadow action</button>`;
|
|
63
|
+
|
|
64
|
+
const elements = collectSmartDomContext({
|
|
65
|
+
mode: "full",
|
|
66
|
+
extractionOptions: { includeHidden: true }
|
|
67
|
+
});
|
|
68
|
+
const texts = elements.map((e) => e.text).join(" | ");
|
|
69
|
+
expect(texts).toContain("Shadow action");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("excludes the widget host (.persona-host) from results", () => {
|
|
73
|
+
document.body.innerHTML = `
|
|
74
|
+
<div class="persona-host"><button id="widget-internal">Send</button></div>
|
|
75
|
+
<main><button id="page-cta">Buy now</button></main>
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const elements = collectSmartDomContext(JSDOM_OPTS);
|
|
79
|
+
const selectors = elements.map((e) => e.selector);
|
|
80
|
+
expect(selectors.some((s) => s.includes("persona-host"))).toBe(false);
|
|
81
|
+
expect(elements.some((e) => e.text === "Buy now")).toBe(true);
|
|
82
|
+
expect(elements.some((e) => e.text === "Send")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("scopes extraction to `root`, ignoring elements outside the subtree", () => {
|
|
86
|
+
document.body.innerHTML = `
|
|
87
|
+
<nav><button id="chrome-cta">Sign up</button></nav>
|
|
88
|
+
<main id="content"><button id="page-cta">Buy now</button></main>
|
|
89
|
+
`;
|
|
90
|
+
const root = document.getElementById("content")!;
|
|
91
|
+
|
|
92
|
+
const elements = collectSmartDomContext({
|
|
93
|
+
root,
|
|
94
|
+
extractionOptions: { includeHidden: true }
|
|
95
|
+
});
|
|
96
|
+
expect(elements.some((e) => e.text === "Buy now")).toBe(true);
|
|
97
|
+
expect(elements.some((e) => e.text === "Sign up")).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("pierces shadow DOM within a scoped `root`", () => {
|
|
101
|
+
const host = document.createElement("article");
|
|
102
|
+
host.id = "scoped-host";
|
|
103
|
+
const main = document.createElement("main");
|
|
104
|
+
main.id = "scoped-content";
|
|
105
|
+
main.appendChild(host);
|
|
106
|
+
document.body.appendChild(main);
|
|
107
|
+
host.attachShadow({ mode: "open" }).innerHTML =
|
|
108
|
+
`<button id="scoped-shadow-btn">Scoped shadow action</button>`;
|
|
109
|
+
|
|
110
|
+
const elements = collectSmartDomContext({
|
|
111
|
+
root: main,
|
|
112
|
+
mode: "full",
|
|
113
|
+
extractionOptions: { includeHidden: true }
|
|
114
|
+
});
|
|
115
|
+
const texts = elements.map((e) => e.text).join(" | ");
|
|
116
|
+
expect(texts).toContain("Scoped shadow action");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("provider returns formatted context under the configured key", async () => {
|
|
120
|
+
document.body.innerHTML = `<main><button id="go">Continue</button></main>`;
|
|
121
|
+
const provider = createSmartDomReaderContextProvider({
|
|
122
|
+
contextKey: "pageContext",
|
|
123
|
+
...JSDOM_OPTS
|
|
124
|
+
});
|
|
125
|
+
const result = await provider({ messages: [], config: {} as never });
|
|
126
|
+
expect(result).toBeTruthy();
|
|
127
|
+
expect(typeof (result as Record<string, unknown>).pageContext).toBe("string");
|
|
128
|
+
expect((result as Record<string, string>).pageContext).toContain("Continue");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns [] for an empty document", () => {
|
|
132
|
+
document.body.innerHTML = "";
|
|
133
|
+
expect(collectSmartDomContext(JSDOM_OPTS)).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional entry point: `@runtypelabs/persona/smart-dom-reader`.
|
|
3
|
+
*
|
|
4
|
+
* Adapts `@mcp-b/smart-dom-reader` into Persona's enriched page-context pipeline and
|
|
5
|
+
* exposes a ready-made {@link AgentWidgetContextProvider} you can drop into
|
|
6
|
+
* `config.contextProviders`. This is the ONLY module that imports the smart-dom-reader
|
|
7
|
+
* runtime value, so the library never reaches the main bundle or the IIFE/CDN build —
|
|
8
|
+
* importing this subpath is opt-in.
|
|
9
|
+
*
|
|
10
|
+
* The library is **vendored** under `src/vendor/smart-dom-reader/` (it is mis-published
|
|
11
|
+
* on npm and cannot be imported by name — see that directory's README). Vendoring it
|
|
12
|
+
* here means consumers need no extra install: the code is bundled into this entry, and
|
|
13
|
+
* consumers who never import this subpath pay nothing.
|
|
14
|
+
*
|
|
15
|
+
* What it adds over the default `collectEnrichedPageContext`: Shadow-DOM piercing
|
|
16
|
+
* (on by default), form grouping, and page landmarks/state.
|
|
17
|
+
*
|
|
18
|
+
* ## Actionability caveat
|
|
19
|
+
*
|
|
20
|
+
* Persona's click loop (`utils/actions.ts`) drives `document.querySelector`, which
|
|
21
|
+
* cannot pierce shadow roots or evaluate XPath. The adapter therefore prefers plain-CSS
|
|
22
|
+
* selectors; elements reachable only via shadow-piercing / XPath selectors are surfaced
|
|
23
|
+
* to the model as context but are not clickable through the current action handlers.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* import initAgentWidget from "@runtypelabs/persona";
|
|
28
|
+
* import { createSmartDomReaderContextProvider } from "@runtypelabs/persona/smart-dom-reader";
|
|
29
|
+
*
|
|
30
|
+
* initAgentWidget({
|
|
31
|
+
* // ...config
|
|
32
|
+
* contextProviders: [createSmartDomReaderContextProvider()]
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { SmartDOMReader } from "./vendor/smart-dom-reader";
|
|
38
|
+
import type { ExtractionOptions } from "./vendor/smart-dom-reader";
|
|
39
|
+
import {
|
|
40
|
+
smartDomResultToEnriched,
|
|
41
|
+
type SmartDomAdapterOptions
|
|
42
|
+
} from "./utils/smart-dom-adapter";
|
|
43
|
+
import { formatEnrichedContext, type EnrichedPageElement } from "./utils/dom-context";
|
|
44
|
+
import type { AgentWidgetContextProvider } from "./types";
|
|
45
|
+
|
|
46
|
+
export { smartDomResultToEnriched };
|
|
47
|
+
export type { SmartDomAdapterOptions } from "./utils/smart-dom-adapter";
|
|
48
|
+
|
|
49
|
+
/** Options for {@link collectSmartDomContext} and {@link createSmartDomReaderContextProvider}. */
|
|
50
|
+
export interface SmartDomContextOptions extends SmartDomAdapterOptions {
|
|
51
|
+
/**
|
|
52
|
+
* `interactive` (default) extracts UI elements only; `full` additionally extracts
|
|
53
|
+
* semantic content (headings, images, tables, lists, articles).
|
|
54
|
+
*/
|
|
55
|
+
mode?: "interactive" | "full";
|
|
56
|
+
/**
|
|
57
|
+
* Extraction options passed through to smart-dom-reader (e.g. `includeShadowDOM`,
|
|
58
|
+
* `maxDepth`, `viewportOnly`). `includeShadowDOM` defaults to true in the library;
|
|
59
|
+
* the `.persona-host` exclusion guards against the widget reading its own shadow UI.
|
|
60
|
+
*
|
|
61
|
+
* Note: the vendored library exposes an `includeIframes` flag but does not actually
|
|
62
|
+
* traverse iframe content, so iframe piercing is not supported.
|
|
63
|
+
*/
|
|
64
|
+
extractionOptions?: Partial<ExtractionOptions>;
|
|
65
|
+
/** Document to extract from. Default: the global `document`. Ignored when `root` is set. */
|
|
66
|
+
document?: Document;
|
|
67
|
+
/**
|
|
68
|
+
* Scope extraction to this element's subtree instead of the whole document — parity
|
|
69
|
+
* with `collectEnrichedPageContext`'s `root`. Useful to read only a main-content region
|
|
70
|
+
* and skip site chrome (nav, sidebars). Shadow DOM inside the subtree is still pierced.
|
|
71
|
+
* When set, `document` is ignored.
|
|
72
|
+
*/
|
|
73
|
+
root?: Element;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Collect enriched page context using smart-dom-reader, mapped into Persona's
|
|
78
|
+
* {@link EnrichedPageElement}[] shape (parity with `collectEnrichedPageContext`).
|
|
79
|
+
*
|
|
80
|
+
* Pass `root` to scope extraction to an element subtree (skipping site chrome);
|
|
81
|
+
* otherwise the whole `document` (or `opts.document`) is read. Returns an empty array
|
|
82
|
+
* when neither `root` nor a document is available (e.g. SSR).
|
|
83
|
+
*/
|
|
84
|
+
export function collectSmartDomContext(
|
|
85
|
+
opts: SmartDomContextOptions = {}
|
|
86
|
+
): EnrichedPageElement[] {
|
|
87
|
+
const mode = opts.mode ?? "interactive";
|
|
88
|
+
|
|
89
|
+
let result;
|
|
90
|
+
if (opts.root) {
|
|
91
|
+
result = SmartDOMReader.extractFromElement(
|
|
92
|
+
opts.root,
|
|
93
|
+
mode,
|
|
94
|
+
opts.extractionOptions
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
const doc =
|
|
98
|
+
opts.document ?? (typeof document !== "undefined" ? document : undefined);
|
|
99
|
+
if (!doc) return [];
|
|
100
|
+
result =
|
|
101
|
+
mode === "full"
|
|
102
|
+
? SmartDOMReader.extractFull(doc, opts.extractionOptions)
|
|
103
|
+
: SmartDOMReader.extractInteractive(doc, opts.extractionOptions);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return smartDomResultToEnriched(result, {
|
|
107
|
+
includeSemantic: opts.includeSemantic ?? mode === "full",
|
|
108
|
+
excludeSelector: opts.excludeSelector,
|
|
109
|
+
maxTextLength: opts.maxTextLength,
|
|
110
|
+
maxElements: opts.maxElements
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Options for {@link createSmartDomReaderContextProvider}. */
|
|
115
|
+
export interface SmartDomReaderProviderOptions extends SmartDomContextOptions {
|
|
116
|
+
/** Key under which the formatted context is placed in `payload.context`. Default: "pageContext". */
|
|
117
|
+
contextKey?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build an {@link AgentWidgetContextProvider} that collects page context with
|
|
122
|
+
* smart-dom-reader and returns it under `contextKey` (default `"pageContext"`).
|
|
123
|
+
* Drop into `config.contextProviders`; `buildAgentPayload` merges the result into
|
|
124
|
+
* `payload.context` on every agent request.
|
|
125
|
+
*/
|
|
126
|
+
export function createSmartDomReaderContextProvider(
|
|
127
|
+
opts: SmartDomReaderProviderOptions = {}
|
|
128
|
+
): AgentWidgetContextProvider {
|
|
129
|
+
const contextKey = opts.contextKey ?? "pageContext";
|
|
130
|
+
return () => {
|
|
131
|
+
const elements = collectSmartDomContext(opts);
|
|
132
|
+
if (elements.length === 0) return {};
|
|
133
|
+
return { [contextKey]: formatEnrichedContext(elements) };
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
rgbToHex,
|
|
4
|
+
hexToHsl,
|
|
5
|
+
generateColorScale,
|
|
6
|
+
wcagContrastRatio,
|
|
7
|
+
SHADE_KEYS,
|
|
8
|
+
} from './color-utils';
|
|
9
|
+
|
|
10
|
+
describe('rgbToHex', () => {
|
|
11
|
+
it('parses integer rgb()', () => {
|
|
12
|
+
expect(rgbToHex('rgb(255, 0, 0)')).toBe('#ff0000');
|
|
13
|
+
expect(rgbToHex('rgb(37, 99, 235)')).toBe('#2563eb');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('drops the alpha channel from rgba()', () => {
|
|
17
|
+
expect(rgbToHex('rgba(0, 128, 0, 0.5)')).toBe('#008000');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parses percentage channels and clamps out-of-range values', () => {
|
|
21
|
+
expect(rgbToHex('rgb(100%, 0%, 0%)')).toBe('#ff0000');
|
|
22
|
+
expect(rgbToHex('rgb(300, -20, 0)')).toBe('#ff0000');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns null for non-rgb input', () => {
|
|
26
|
+
expect(rgbToHex('#2563eb')).toBeNull();
|
|
27
|
+
expect(rgbToHex('blue')).toBeNull();
|
|
28
|
+
expect(rgbToHex('rgb(1, 2)')).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('hexToHsl with rgb() input', () => {
|
|
33
|
+
it('produces the same HSL as the equivalent hex', () => {
|
|
34
|
+
const fromRgb = hexToHsl('rgb(37, 99, 235)');
|
|
35
|
+
const fromHex = hexToHsl('#2563eb');
|
|
36
|
+
expect(fromRgb.h).toBeCloseTo(fromHex.h, 5);
|
|
37
|
+
expect(fromRgb.s).toBeCloseTo(fromHex.s, 5);
|
|
38
|
+
expect(fromRgb.l).toBeCloseTo(fromHex.l, 5);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('generateColorScale with rgb() input', () => {
|
|
43
|
+
it('does not emit NaN shades for rgb() base colors', () => {
|
|
44
|
+
const scale = generateColorScale('rgb(255, 0, 0)');
|
|
45
|
+
for (const shade of SHADE_KEYS) {
|
|
46
|
+
expect(scale[shade]).toMatch(/^#[0-9a-f]{6}$/);
|
|
47
|
+
expect(scale[shade]).not.toContain('NaN');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('wcagContrastRatio with rgb() input', () => {
|
|
53
|
+
it('matches the hex equivalent', () => {
|
|
54
|
+
expect(wcagContrastRatio('rgb(255, 255, 255)', 'rgb(0, 0, 0)')).toBeCloseTo(
|
|
55
|
+
wcagContrastRatio('#ffffff', '#000000'),
|
|
56
|
+
5
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -64,6 +64,36 @@ export function isValidHex(value: string): boolean {
|
|
|
64
64
|
return /^#[0-9A-Fa-f]{6}$/.test(value);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Parse an `rgb()` / `rgba()` string into `#rrggbb`. Accepts integer (0-255) or
|
|
69
|
+
* percentage channels; any alpha component is dropped. Returns `null` when the
|
|
70
|
+
* string is not a parseable rgb/rgba value, so callers can fall back. This lets
|
|
71
|
+
* the HSL/luminance paths — which otherwise `parseInt` hex digits — accept the
|
|
72
|
+
* `rgb()` inputs that `coerceColor` admits without producing `#NaNNaNNaN`.
|
|
73
|
+
*/
|
|
74
|
+
export function rgbToHex(value: string): string | null {
|
|
75
|
+
const match = value.trim().toLowerCase().match(/^rgba?\(([^)]+)\)$/);
|
|
76
|
+
if (!match) return null;
|
|
77
|
+
|
|
78
|
+
const parts = match[1].split(',').map((p) => p.trim());
|
|
79
|
+
if (parts.length < 3) return null;
|
|
80
|
+
|
|
81
|
+
const channel = (raw: string): number => {
|
|
82
|
+
const isPct = raw.endsWith('%');
|
|
83
|
+
const n = parseFloat(isPct ? raw.slice(0, -1) : raw);
|
|
84
|
+
if (!Number.isFinite(n)) return NaN;
|
|
85
|
+
return Math.max(0, Math.min(255, Math.round(isPct ? (n / 100) * 255 : n)));
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const r = channel(parts[0]);
|
|
89
|
+
const g = channel(parts[1]);
|
|
90
|
+
const b = channel(parts[2]);
|
|
91
|
+
if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) return null;
|
|
92
|
+
|
|
93
|
+
const toHex = (n: number) => n.toString(16).padStart(2, '0');
|
|
94
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
67
97
|
// ─── WCAG Contrast ──────────────────────────────────────────────
|
|
68
98
|
|
|
69
99
|
/**
|
|
@@ -72,7 +102,8 @@ export function isValidHex(value: string): boolean {
|
|
|
72
102
|
*/
|
|
73
103
|
export function wcagContrastRatio(hex1: string, hex2: string): number {
|
|
74
104
|
const luminance = (hex: string): number => {
|
|
75
|
-
const
|
|
105
|
+
const normalized = normalizeColorValue(hex);
|
|
106
|
+
const norm = normalized.startsWith('rgb') ? rgbToHex(normalized) ?? '#000000' : normalized;
|
|
76
107
|
const channels = [1, 3, 5].map((i) => {
|
|
77
108
|
const v = parseInt(norm.slice(i, i + 2), 16) / 255;
|
|
78
109
|
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
@@ -87,7 +118,12 @@ export function wcagContrastRatio(hex1: string, hex2: string): number {
|
|
|
87
118
|
// ─── HSL Conversion ─────────────────────────────────────────────
|
|
88
119
|
|
|
89
120
|
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
|
90
|
-
const
|
|
121
|
+
const normalizedValue = normalizeColorValue(hex);
|
|
122
|
+
// `rgb()`/`rgba()` is valid color input (see coerceColor) but can't be sliced
|
|
123
|
+
// as hex digits; convert it first so the scale isn't built from NaN channels.
|
|
124
|
+
const normalized = normalizedValue.startsWith('rgb')
|
|
125
|
+
? rgbToHex(normalizedValue) ?? '#000000'
|
|
126
|
+
: normalizedValue;
|
|
91
127
|
const r = parseInt(normalized.slice(1, 3), 16) / 255;
|
|
92
128
|
const g = parseInt(normalized.slice(3, 5), 16) / 255;
|
|
93
129
|
const b = parseInt(normalized.slice(5, 7), 16) / 255;
|
|
@@ -128,6 +128,7 @@ export {
|
|
|
128
128
|
convertFromPx,
|
|
129
129
|
normalizeColorValue,
|
|
130
130
|
isValidHex,
|
|
131
|
+
rgbToHex,
|
|
131
132
|
wcagContrastRatio,
|
|
132
133
|
hexToHsl,
|
|
133
134
|
hslToHex,
|
|
@@ -138,3 +139,37 @@ export {
|
|
|
138
139
|
resolveThemeColorPath,
|
|
139
140
|
tokenRefDisplayName,
|
|
140
141
|
} from './color-utils';
|
|
142
|
+
|
|
143
|
+
// WebMCP tools (transport-agnostic factory for agent-driven theming)
|
|
144
|
+
export {
|
|
145
|
+
createThemeEditorTools,
|
|
146
|
+
toolResult,
|
|
147
|
+
buildSummary,
|
|
148
|
+
runContrastChecks,
|
|
149
|
+
quickContrastWarnings,
|
|
150
|
+
CONTRAST_PAIRS,
|
|
151
|
+
RADIUS_PRESETS,
|
|
152
|
+
coerceColor,
|
|
153
|
+
coerceFamily,
|
|
154
|
+
coerceIntensity,
|
|
155
|
+
coerceScheme,
|
|
156
|
+
coerceRoundnessStyle,
|
|
157
|
+
coerceRadius,
|
|
158
|
+
CSS_NAMED_COLORS,
|
|
159
|
+
} from './webmcp';
|
|
160
|
+
export type {
|
|
161
|
+
WebMcpTool,
|
|
162
|
+
ToolResult,
|
|
163
|
+
ToolAnnotations,
|
|
164
|
+
ToolTextContent,
|
|
165
|
+
ToolExecute,
|
|
166
|
+
ThemeEditorLike,
|
|
167
|
+
EditTarget,
|
|
168
|
+
CreateThemeEditorToolsOptions,
|
|
169
|
+
ThemeSummary,
|
|
170
|
+
RoleState,
|
|
171
|
+
ContrastReport,
|
|
172
|
+
ContrastCheck,
|
|
173
|
+
ContrastWarning,
|
|
174
|
+
ContrastLevel,
|
|
175
|
+
} from './webmcp';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
coerceColor,
|
|
4
|
+
coerceFamily,
|
|
5
|
+
coerceIntensity,
|
|
6
|
+
coerceScheme,
|
|
7
|
+
coerceRoundnessStyle,
|
|
8
|
+
coerceRadius,
|
|
9
|
+
coerceTypographyRef,
|
|
10
|
+
FONT_WEIGHT_REFS,
|
|
11
|
+
} from './coerce';
|
|
12
|
+
|
|
13
|
+
describe('coerceColor', () => {
|
|
14
|
+
it('normalizes bare and short hex', () => {
|
|
15
|
+
expect(coerceColor('2563eb')).toBe('#2563eb');
|
|
16
|
+
expect(coerceColor('#18f')).toBe('#1188ff');
|
|
17
|
+
expect(coerceColor('#2563EB')).toBe('#2563eb');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('maps CSS color names', () => {
|
|
21
|
+
expect(coerceColor('blue')).toBe('#0000ff');
|
|
22
|
+
expect(coerceColor('SlateBlue')).toBe('#6a5acd');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('passes through valid rgb/rgba and transparent', () => {
|
|
26
|
+
expect(coerceColor('transparent')).toBe('transparent');
|
|
27
|
+
expect(coerceColor('rgb(1, 2, 3)')).toBe('rgb(1, 2, 3)');
|
|
28
|
+
expect(coerceColor('rgba(1,2,3,0.5)')).toBe('rgba(1,2,3,0.5)');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('rejects malformed rgb-prefixed garbage', () => {
|
|
32
|
+
expect(() => coerceColor('rgbfoo')).toThrow(/not a recognized color/);
|
|
33
|
+
expect(() => coerceColor('rgba(')).toThrow(/not a recognized color/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws with guidance on garbage', () => {
|
|
37
|
+
expect(() => coerceColor('notacolor')).toThrow(/not a recognized color/);
|
|
38
|
+
expect(() => coerceColor('')).toThrow();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('enum coercion', () => {
|
|
43
|
+
it('coerces families with neutral synonyms', () => {
|
|
44
|
+
expect(coerceFamily('gray')).toBe('neutral');
|
|
45
|
+
expect(coerceFamily('Primary')).toBe('primary');
|
|
46
|
+
expect(() => coerceFamily('neutral', false)).toThrow(/Valid families/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('coerces intensity defaulting to solid', () => {
|
|
50
|
+
expect(coerceIntensity(undefined)).toBe('solid');
|
|
51
|
+
expect(coerceIntensity('SOFT')).toBe('soft');
|
|
52
|
+
expect(() => coerceIntensity('bright')).toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('coerces scheme with system → auto', () => {
|
|
56
|
+
expect(coerceScheme('system')).toBe('auto');
|
|
57
|
+
expect(coerceScheme('Dark')).toBe('dark');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('coerces roundness synonyms', () => {
|
|
61
|
+
expect(coerceRoundnessStyle('square')).toBe('sharp');
|
|
62
|
+
expect(coerceRoundnessStyle('circle')).toBe('pill');
|
|
63
|
+
expect(coerceRoundnessStyle('round')).toBe('rounded');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('coerceRadius', () => {
|
|
68
|
+
it('turns numbers into px', () => {
|
|
69
|
+
expect(coerceRadius(8)).toBe('8px');
|
|
70
|
+
});
|
|
71
|
+
it('normalizes css strings', () => {
|
|
72
|
+
expect(coerceRadius('0.5rem')).toBe('0.5rem');
|
|
73
|
+
expect(coerceRadius('9999px')).toBe('9999px');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('coerceTypographyRef', () => {
|
|
78
|
+
it('maps numeric weights to token refs', () => {
|
|
79
|
+
expect(coerceTypographyRef(600, FONT_WEIGHT_REFS, 'fontWeight')).toBe(
|
|
80
|
+
'palette.typography.fontWeight.semibold'
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
it('throws on unknown', () => {
|
|
84
|
+
expect(() => coerceTypographyRef('chunky', FONT_WEIGHT_REFS, 'fontWeight')).toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|