@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,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 norm = normalizeColorValue(hex);
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 normalized = normalizeColorValue(hex);
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
+ });