@xapp/stentor-chat-widget 1.95.2 → 1.96.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const buildChatWidgetChannel_1 = require("../buildChatWidgetChannel");
4
+ /**
5
+ * The chat widget a new app starts with.
6
+ *
7
+ * Built here rather than in stentor-client so Studio can create the same widget
8
+ * server-side for a get.xapp.ai signup. Two builders would mean onboarding and
9
+ * the funnel producing subtly different widgets from the same inputs.
10
+ */
11
+ const BASE = {
12
+ appId: "buehler-air",
13
+ apiUrl: "https://api.xapp.ai",
14
+ mcpUrl: "https://agent.xapp.ai"
15
+ };
16
+ describe("buildChatWidgetChannel", () => {
17
+ it("connects over MCP by default", () => {
18
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(BASE);
19
+ expect(channel.connection.type).toEqual("mcp");
20
+ expect(channel.connection.serverUrl).toEqual("https://agent.xapp.ai/buehler-air");
21
+ });
22
+ it("uses homeowner mode, which needs no auth token", () => {
23
+ var _a;
24
+ // The widget runs on a public page. business_owner mode requires a
25
+ // token, and a token on a public page is not a token.
26
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(BASE);
27
+ expect((_a = channel.connection.mcp) === null || _a === void 0 ? void 0 : _a.mode).toEqual("homeowner");
28
+ expect(JSON.stringify(channel)).not.toContain("authToken");
29
+ });
30
+ it("turns the action bar on", () => {
31
+ var _a;
32
+ // Chat is not on the default tier, but the action bar is how people
33
+ // reach booking and the phone, so it ships enabled.
34
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(BASE);
35
+ expect((_a = channel.actionBar) === null || _a === void 0 ? void 0 : _a.enabled).toEqual(true);
36
+ });
37
+ it("offers booking first, ahead of calling and chat", () => {
38
+ // Booking is what the business is paying for. Calling is the fallback
39
+ // for people who will not use a form, and chat is last because it is
40
+ // the least likely to be enabled on their tier.
41
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(Object.assign(Object.assign({}, BASE), { formWidgetKey: "fw-1", phoneNumber: "+19042338831" }));
42
+ expect(channel.actionBar.buttons.map((b) => b.type)).toEqual(["schedule", "phone", "chat"]);
43
+ expect(channel.actionBar.buttons[0].label).toBeDefined();
44
+ });
45
+ it("carries the form widget's key so booking opens our form", () => {
46
+ var _a, _b, _c, _d;
47
+ // The button opens the form widget, which is a separate channel with
48
+ // its own key. Without it the button has nothing to open.
49
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(Object.assign(Object.assign({}, BASE), { formWidgetKey: "fw-key-123" }));
50
+ const book = channel.actionBar.buttons.find((b) => b.type === "schedule");
51
+ expect((_b = (_a = book.formConfig) === null || _a === void 0 ? void 0 : _a.api) === null || _b === void 0 ? void 0 : _b.key).toEqual("fw-key-123");
52
+ expect((_d = (_c = book.formConfig) === null || _c === void 0 ? void 0 : _c.api) === null || _d === void 0 ? void 0 : _d.url).toEqual("https://api.xapp.ai");
53
+ });
54
+ it("omits the booking button until the form widget exists", () => {
55
+ // The form widget is created first and its key read back. A booking
56
+ // button with no form behind it is a button that does nothing.
57
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(BASE);
58
+ expect(channel.actionBar.buttons.some((b) => b.type === "schedule")).toEqual(false);
59
+ });
60
+ it("adds click-to-call when the crawl found a number", () => {
61
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(Object.assign(Object.assign({}, BASE), { phoneNumber: "+19042338831" }));
62
+ const phone = channel.actionBar.buttons.find((b) => b.type === "phone");
63
+ expect(phone.phoneNumber).toEqual("+19042338831");
64
+ });
65
+ it("leaves the phone button out when there is no number", () => {
66
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(BASE);
67
+ expect(channel.actionBar.buttons.some((b) => b.type === "phone")).toEqual(false);
68
+ });
69
+ it("always offers chat itself", () => {
70
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(BASE);
71
+ expect(channel.actionBar.buttons.some((b) => b.type === "chat")).toEqual(true);
72
+ });
73
+ it("themes from the business's own colour", () => {
74
+ var _a, _b;
75
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(Object.assign(Object.assign({}, BASE), { primaryColor: "#f47c4c" }));
76
+ expect((_b = (_a = channel.theme) === null || _a === void 0 ? void 0 : _a.chatButton) === null || _b === void 0 ? void 0 : _b.background).toEqual("#f47c4c");
77
+ });
78
+ it("picks header text that reads on that colour rather than assuming dark", () => {
79
+ var _a, _b, _c;
80
+ // #f47c4c scores 154 for perceived brightness against a threshold of
81
+ // 186, so it takes white text. Assuming dark here is how a widget ships
82
+ // unreadable. The chat button carries no text of its own — it is an
83
+ // icon on a background — so the header is where this shows.
84
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(Object.assign(Object.assign({}, BASE), { primaryColor: "#f47c4c" }));
85
+ expect((_c = (_b = (_a = channel.theme) === null || _a === void 0 ? void 0 : _a.header) === null || _b === void 0 ? void 0 : _b.text) === null || _c === void 0 ? void 0 : _c.color).toEqual("#FFFFFF");
86
+ });
87
+ it("ignores a colour whose contrast cannot be computed", () => {
88
+ var _a, _b, _c, _d, _e;
89
+ // A scraped `orange` is valid CSS and unusable here: we cannot work out
90
+ // what text reads on it. Theming the header with it and guessing the
91
+ // text colour is how you ship invisible text, so the neutral default is
92
+ // used for both instead — background and foreground always a computed
93
+ // pair.
94
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(Object.assign(Object.assign({}, BASE), { primaryColor: "orange" }));
95
+ expect((_b = (_a = channel.theme) === null || _a === void 0 ? void 0 : _a.header) === null || _b === void 0 ? void 0 : _b.background).not.toEqual("orange");
96
+ expect(["#000000", "#FFFFFF"]).toContain((_e = (_d = (_c = channel.theme) === null || _c === void 0 ? void 0 : _c.header) === null || _d === void 0 ? void 0 : _d.text) === null || _e === void 0 ? void 0 : _e.color);
97
+ });
98
+ it("keeps a colour it can compute against", () => {
99
+ var _a, _b, _c, _d, _e;
100
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(Object.assign(Object.assign({}, BASE), { primaryColor: "rgb(20, 52, 204)" }));
101
+ expect((_b = (_a = channel.theme) === null || _a === void 0 ? void 0 : _a.header) === null || _b === void 0 ? void 0 : _b.background).toEqual("rgb(20, 52, 204)");
102
+ expect((_e = (_d = (_c = channel.theme) === null || _c === void 0 ? void 0 : _c.header) === null || _d === void 0 ? void 0 : _d.text) === null || _e === void 0 ? void 0 : _e.color).toEqual("#FFFFFF");
103
+ });
104
+ it("still builds a widget when the crawl found no colour", () => {
105
+ const channel = (0, buildChatWidgetChannel_1.buildChatWidgetChannel)(BASE);
106
+ expect(channel.theme).toBeDefined();
107
+ expect(channel.type).toEqual("chat-widget");
108
+ });
109
+ it("does not invent an id", () => {
110
+ // The id is assigned when the channel is written. A builder that made
111
+ // one up would be creating a second source of truth for it.
112
+ expect((0, buildChatWidgetChannel_1.buildChatWidgetChannel)(BASE).id).toBeUndefined();
113
+ });
114
+ });
115
+ //# sourceMappingURL=buildChatWidgetChannel.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buildChatWidgetChannel.test.js","sourceRoot":"","sources":["../../src/__tests__/buildChatWidgetChannel.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const color_1 = require("../color");
4
+ /**
5
+ * These decide whether a widget's text is legible on the colour we scraped off
6
+ * a customer's website, so the contrast rule matters more than the inversion.
7
+ */
8
+ describe("invertColorHEX", () => {
9
+ it("inverts a colour", () => {
10
+ expect((0, color_1.invertColorHEX)("#ffffff")).toEqual("#000000");
11
+ expect((0, color_1.invertColorHEX)("#000000")).toEqual("#ffffff");
12
+ });
13
+ it("expands three-digit hex", () => {
14
+ expect((0, color_1.invertColorHEX)("#fff")).toEqual((0, color_1.invertColorHEX)("#ffffff"));
15
+ });
16
+ it("accepts a colour with no leading hash", () => {
17
+ expect((0, color_1.invertColorHEX)("ffffff")).toEqual("#000000");
18
+ });
19
+ it("returns black or white when asked for contrast rather than inversion", () => {
20
+ // A mid-tone inverts to another mid-tone, which is unreadable behind
21
+ // text. bw picks whichever of black or white actually reads.
22
+ //
23
+ // Buehler's orange looks light and is not: weighted for perceived
24
+ // brightness it scores 154 against a threshold of 186, so it takes
25
+ // white text. Eyeballing this is how you ship an unreadable widget.
26
+ expect((0, color_1.invertColorHEX)("#f47c4c", true)).toEqual("#FFFFFF");
27
+ expect((0, color_1.invertColorHEX)("#0b83b3", true)).toEqual("#FFFFFF");
28
+ expect((0, color_1.invertColorHEX)("#c1bdb2", true)).toEqual("#000000");
29
+ });
30
+ it("has no answer for a missing colour", () => {
31
+ expect((0, color_1.invertColorHEX)(undefined)).toBeUndefined();
32
+ expect((0, color_1.invertColorHEX)("")).toBeUndefined();
33
+ });
34
+ it("refuses a value that is not a colour", () => {
35
+ expect(() => (0, color_1.invertColorHEX)("#12345")).toThrow(/Invalid HEX/);
36
+ });
37
+ });
38
+ describe("readableTextOn", () => {
39
+ it("picks the text colour that reads on the background", () => {
40
+ // From real crawls: Buehler's orange (154), Affordable Pest's grey
41
+ // (189) and XAPP's blue (60), against the 186 threshold.
42
+ expect((0, color_1.readableTextOn)("#f47c4c")).toEqual("#FFFFFF");
43
+ expect((0, color_1.readableTextOn)("#c1bdb2")).toEqual("#000000");
44
+ expect((0, color_1.readableTextOn)("#1434cc")).toEqual("#FFFFFF");
45
+ });
46
+ it("falls back to dark text when there is no background colour", () => {
47
+ // Better a readable default than a widget with white-on-white text
48
+ // because a crawl came back without a colour.
49
+ expect((0, color_1.readableTextOn)(undefined)).toEqual("#000000");
50
+ });
51
+ });
52
+ describe("invertColor", () => {
53
+ it("handles rgb as well as hex", () => {
54
+ // rgb input answers in rgb notation, as it always has. readableTextOn
55
+ // is the one that normalises, because a theme wants one shape.
56
+ expect((0, color_1.invertColor)("#ffffff", true)).toEqual("#000000");
57
+ expect((0, color_1.invertColor)("rgb(255, 255, 255)", true)).toEqual("rgba(0, 0, 0, 1)");
58
+ });
59
+ it("normalises rgb through readableTextOn", () => {
60
+ expect((0, color_1.readableTextOn)("rgb(255, 255, 255)")).toEqual("#000000");
61
+ expect((0, color_1.readableTextOn)("rgb(20, 52, 204)")).toEqual("#FFFFFF");
62
+ });
63
+ });
64
+ describe("readableTextOn with colours we cannot parse", () => {
65
+ it("never answers with the background it was given", () => {
66
+ // A CSS named colour is entirely plausible from a scrape — hand-written
67
+ // stylesheets are full of `orange`, `tomato`, `steelblue`. Returning it
68
+ // unchanged put the header text at exactly the header background.
69
+ expect((0, color_1.readableTextOn)("orange")).not.toEqual("orange");
70
+ expect((0, color_1.readableTextOn)("navy")).not.toEqual("navy");
71
+ });
72
+ it("only ever answers black or white", () => {
73
+ const answers = ["orange", "navy", "rgb( 255, 0, 0)", "RGB(255,0,0)", "hsl(9, 100%, 64%)", "#f47c4c", "not a colour"]
74
+ .map(color_1.readableTextOn);
75
+ answers.forEach((answer) => {
76
+ expect(["#000000", "#FFFFFF"]).toContain(answer);
77
+ });
78
+ });
79
+ });
80
+ describe("isSupportedColor", () => {
81
+ it("recognises the notations we can compute contrast for", () => {
82
+ expect((0, color_1.isSupportedColor)("#f47c4c")).toEqual(true);
83
+ expect((0, color_1.isSupportedColor)("#fff")).toEqual(true);
84
+ expect((0, color_1.isSupportedColor)("rgb(255, 0, 0)")).toEqual(true);
85
+ expect((0, color_1.isSupportedColor)("rgba(255, 0, 0, 0.5)")).toEqual(true);
86
+ });
87
+ it("rejects everything else, including named colours", () => {
88
+ // Not "these are invalid" — they are perfectly good CSS. We just
89
+ // cannot work out what text reads on them, and a background whose
90
+ // contrast we cannot compute is one we must not use.
91
+ expect((0, color_1.isSupportedColor)("orange")).toEqual(false);
92
+ expect((0, color_1.isSupportedColor)("hsl(9, 100%, 64%)")).toEqual(false);
93
+ expect((0, color_1.isSupportedColor)("#12345")).toEqual(false);
94
+ expect((0, color_1.isSupportedColor)(undefined)).toEqual(false);
95
+ expect((0, color_1.isSupportedColor)("")).toEqual(false);
96
+ });
97
+ });
98
+ //# sourceMappingURL=color.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"color.test.js","sourceRoot":"","sources":["../../src/__tests__/color.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ import { ChatWidgetChannelData } from "./ChannelData";
2
+ import { WidgetTheme } from "./WidgetTheme";
3
+ /**
4
+ * The chat widget a newly created app starts with.
5
+ *
6
+ * This lived in stentor-client, which meant a widget could only be built by a
7
+ * browser. Studio creates apps server-side too — a get.xapp.ai signup is
8
+ * confirmed by an email click, with no browser anywhere — and two builders fed
9
+ * the same business would drift into two different-looking widgets.
10
+ *
11
+ * The urls are parameters rather than read from configuration, because the two
12
+ * callers know their own environment and this package should not.
13
+ */
14
+ /**
15
+ * The channel as it is created, before it has one.
16
+ *
17
+ * `id` is assigned when the channel is written — it is not ours to invent —
18
+ * so what a builder can produce is everything except that.
19
+ */
20
+ export type NewChatWidgetChannel = Omit<ChatWidgetChannelData, "id">;
21
+ export interface BuildChatWidgetChannelProps {
22
+ appId: string;
23
+ apiUrl: string;
24
+ mcpUrl: string;
25
+ /**
26
+ * The business's own colour, as scraped. Absent is normal.
27
+ *
28
+ * Ignored unless we can compute what text reads on it — a named colour
29
+ * like `orange` is valid CSS and unusable here, and pairing it with a
30
+ * guessed foreground is how a widget ships with invisible text.
31
+ */
32
+ primaryColor?: string;
33
+ /**
34
+ * The form widget channel's key, which the booking button opens.
35
+ *
36
+ * The form widget has to be created first for this to exist. Without it the
37
+ * booking button is left out entirely rather than shipped pointing at
38
+ * nothing.
39
+ */
40
+ formWidgetKey?: string;
41
+ /** Click-to-call number, if the crawl found one. */
42
+ phoneNumber?: string;
43
+ }
44
+ export declare function buildChatWidgetChannel(props: BuildChatWidgetChannelProps): NewChatWidgetChannel;
45
+ /**
46
+ * A theme derived from the business's own colour.
47
+ *
48
+ * Only the text colour is computed rather than chosen: which of black or white
49
+ * reads on a given background is arithmetic, and getting it wrong ships a
50
+ * widget nobody can read.
51
+ */
52
+ export declare function buildChatTheme(primaryColor: string): WidgetTheme;
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildChatWidgetChannel = buildChatWidgetChannel;
4
+ exports.buildChatTheme = buildChatTheme;
5
+ const color_1 = require("./color");
6
+ /** Fallback when a crawl found no branding. Deliberately neutral. */
7
+ const DEFAULT_PRIMARY = "#1AA7DC";
8
+ function buildChatWidgetChannel(props) {
9
+ const { appId, apiUrl, mcpUrl, formWidgetKey, phoneNumber } = props;
10
+ // Only a colour whose contrast can actually be computed, so the background
11
+ // and the text on it are always a matched pair rather than one scraped
12
+ // value and one guess.
13
+ const primaryColor = (0, color_1.isSupportedColor)(props.primaryColor) ? props.primaryColor : DEFAULT_PRIMARY;
14
+ return {
15
+ type: "chat-widget",
16
+ // MCP by default. The widget runs on a public page, so homeowner mode
17
+ // — which is unauthenticated and carries the appId in the url — is the
18
+ // only one that can work there; business_owner needs a token, and a
19
+ // token on a public page is not a token.
20
+ connection: {
21
+ type: "mcp",
22
+ serverUrl: `${mcpUrl}/${appId}`,
23
+ mcp: {
24
+ mode: "homeowner"
25
+ }
26
+ },
27
+ theme: buildChatTheme(primaryColor),
28
+ actionBar: buildActionBar({ apiUrl, primaryColor, formWidgetKey, phoneNumber })
29
+ };
30
+ }
31
+ /**
32
+ * The dock of buttons that replaces the plain chat bubble.
33
+ *
34
+ * On by default, which is the part worth explaining: chat is not on the default
35
+ * tier, but the action bar is how someone reaches booking and the phone, and
36
+ * those are what the business is paying for.
37
+ *
38
+ * Order is deliberate. Booking is the thing we are selling, so it comes first;
39
+ * calling is the fallback for people who will not use a form; chat is last
40
+ * because it is the least likely to be enabled.
41
+ */
42
+ function buildActionBar(props) {
43
+ const { apiUrl, formWidgetKey, phoneNumber } = props;
44
+ const buttons = [];
45
+ if (formWidgetKey) {
46
+ buttons.push({
47
+ id: "book",
48
+ type: "schedule",
49
+ label: "Book",
50
+ formConfig: {
51
+ api: {
52
+ url: apiUrl,
53
+ key: formWidgetKey
54
+ }
55
+ }
56
+ });
57
+ }
58
+ if (phoneNumber) {
59
+ buttons.push({
60
+ id: "call",
61
+ type: "phone",
62
+ label: "Call",
63
+ phoneNumber
64
+ });
65
+ }
66
+ buttons.push({
67
+ id: "chat",
68
+ type: "chat",
69
+ label: "Chat"
70
+ });
71
+ return {
72
+ enabled: true,
73
+ position: "right",
74
+ buttons
75
+ };
76
+ }
77
+ /**
78
+ * A theme derived from the business's own colour.
79
+ *
80
+ * Only the text colour is computed rather than chosen: which of black or white
81
+ * reads on a given background is arithmetic, and getting it wrong ships a
82
+ * widget nobody can read.
83
+ */
84
+ function buildChatTheme(primaryColor) {
85
+ const onPrimary = (0, color_1.readableTextOn)(primaryColor);
86
+ return {
87
+ zIndex: "10000",
88
+ border: {
89
+ color: primaryColor,
90
+ radius: "10px"
91
+ },
92
+ chatButton: {
93
+ background: primaryColor,
94
+ margin: {
95
+ bottom: "10px",
96
+ left: "10px",
97
+ right: "10px",
98
+ top: "10px"
99
+ }
100
+ },
101
+ content: {
102
+ background: "#ffffff"
103
+ },
104
+ header: {
105
+ background: primaryColor,
106
+ text: {
107
+ color: onPrimary
108
+ }
109
+ }
110
+ };
111
+ }
112
+ //# sourceMappingURL=buildChatWidgetChannel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buildChatWidgetChannel.js","sourceRoot":"","sources":["../src/buildChatWidgetChannel.ts"],"names":[],"mappings":";;AAqDA,wDAuBC;AAiED,wCA4BC;AAtKD,mCAA2D;AA+C3D,qEAAqE;AACrE,MAAM,eAAe,GAAG,SAAS,CAAC;AAElC,SAAgB,sBAAsB,CAAC,KAAkC;IACrE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,GAAG,KAAK,CAAC;IACpE,2EAA2E;IAC3E,uEAAuE;IACvE,uBAAuB;IACvB,MAAM,YAAY,GAAG,IAAA,wBAAgB,EAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,eAAe,CAAC;IAEjG,OAAO;QACH,IAAI,EAAE,aAAa;QACnB,sEAAsE;QACtE,uEAAuE;QACvE,oEAAoE;QACpE,yCAAyC;QACzC,UAAU,EAAE;YACR,IAAI,EAAE,KAAK;YACX,SAAS,EAAE,GAAG,MAAM,IAAI,KAAK,EAAE;YAC/B,GAAG,EAAE;gBACD,IAAI,EAAE,WAAW;aACpB;SACJ;QACD,KAAK,EAAE,cAAc,CAAC,YAAY,CAAC;QACnC,SAAS,EAAE,cAAc,CAAC,EAAE,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,CAAC;KAC1D,CAAC;AAC9B,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAS,cAAc,CAAC,KAKvB;IACG,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,GAAG,KAAK,CAAC;IACrD,MAAM,OAAO,GAAsB,EAAE,CAAC;IAEtC,IAAI,aAAa,EAAE,CAAC;QAChB,OAAO,CAAC,IAAI,CAAC;YACT,EAAE,EAAE,MAAM;YACV,IAAI,EAAE,UAAU;YAChB,KAAK,EAAE,MAAM;YACb,UAAU,EAAE;gBACR,GAAG,EAAE;oBACD,GAAG,EAAE,MAAM;oBACX,GAAG,EAAE,aAAa;iBACrB;aACJ;SACJ,CAAC,CAAC;IACP,CAAC;IAED,IAAI,WAAW,EAAE,CAAC;QACd,OAAO,CAAC,IAAI,CAAC;YACT,EAAE,EAAE,MAAM;YACV,IAAI,EAAE,OAAO;YACb,KAAK,EAAE,MAAM;YACb,WAAW;SACd,CAAC,CAAC;IACP,CAAC;IAED,OAAO,CAAC,IAAI,CAAC;QACT,EAAE,EAAE,MAAM;QACV,IAAI,EAAE,MAAM;QACZ,KAAK,EAAE,MAAM;KAChB,CAAC,CAAC;IAEH,OAAO;QACH,OAAO,EAAE,IAAI;QACb,QAAQ,EAAE,OAAO;QACjB,OAAO;KACV,CAAC;AACN,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,cAAc,CAAC,YAAoB;IAC/C,MAAM,SAAS,GAAG,IAAA,sBAAc,EAAC,YAAY,CAAC,CAAC;IAE/C,OAAO;QACH,MAAM,EAAE,OAAO;QACf,MAAM,EAAE;YACJ,KAAK,EAAE,YAAY;YACnB,MAAM,EAAE,MAAM;SACjB;QACD,UAAU,EAAE;YACR,UAAU,EAAE,YAAY;YACxB,MAAM,EAAE;gBACJ,MAAM,EAAE,MAAM;gBACd,IAAI,EAAE,MAAM;gBACZ,KAAK,EAAE,MAAM;gBACb,GAAG,EAAE,MAAM;aACd;SACJ;QACD,OAAO,EAAE;YACL,UAAU,EAAE,SAAS;SACxB;QACD,MAAM,EAAE;YACJ,UAAU,EAAE,YAAY;YACxB,IAAI,EAAE;gBACF,KAAK,EAAE,SAAS;aACnB;SACJ;KACW,CAAC;AACrB,CAAC"}
package/lib/color.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ /*! Copyright (c) 2026, XAPP AI */
2
+ export interface Rgb {
3
+ r: number;
4
+ g: number;
5
+ b: number;
6
+ a?: number;
7
+ }
8
+ export declare function parseRgb(colorString: string): Rgb | null;
9
+ /**
10
+ * Inverts a hex colour, or returns black/white for contrast when `bw` is set.
11
+ *
12
+ * Inverting is rarely what you want behind text — a mid-tone inverts to another
13
+ * mid-tone and neither reads. `bw` is the contrast answer.
14
+ */
15
+ export declare function invertColorHEX(hex: string, bw?: boolean): string | undefined;
16
+ /** As invertColorHEX, for rgb/rgba input. */
17
+ export declare function invertColorRgb(rgb: string, bw?: boolean): string;
18
+ /** Inverts whichever colour notation was given. */
19
+ export declare function invertColor(color: string, bw?: boolean): string | undefined;
20
+ /**
21
+ * Whether we can work out what text reads on this colour.
22
+ *
23
+ * Hex and rgb/rgba only. A CSS named colour like `orange`, or `hsl(...)`, is
24
+ * perfectly valid CSS — we simply cannot compute its brightness, and a
25
+ * background whose contrast we cannot compute is one we must not use.
26
+ */
27
+ export declare function isSupportedColor(color: string | undefined): boolean;
28
+ /**
29
+ * The text colour that will read on this background: black or white, never
30
+ * anything else.
31
+ *
32
+ * That guarantee is the whole point. An earlier version leaned on invertColor,
33
+ * whose rgb path returns its input unchanged when it cannot parse it — so
34
+ * `readableTextOn("orange")` answered `"orange"`, and a header themed from a
35
+ * scraped named colour got text in exactly the background colour. Invisible,
36
+ * and by the one function meant to prevent it.
37
+ *
38
+ * Unparseable input falls back to black. That is a guess, so callers who
39
+ * control the background should check `isSupportedColor` first and use a
40
+ * colour whose contrast can actually be computed, rather than pairing a
41
+ * guessed foreground with an unknown background.
42
+ */
43
+ export declare function readableTextOn(background: string | undefined): string;
package/lib/color.js ADDED
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ /*! Copyright (c) 2026, XAPP AI */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.parseRgb = parseRgb;
5
+ exports.invertColorHEX = invertColorHEX;
6
+ exports.invertColorRgb = invertColorRgb;
7
+ exports.invertColor = invertColor;
8
+ exports.isSupportedColor = isSupportedColor;
9
+ exports.readableTextOn = readableTextOn;
10
+ /**
11
+ * Colour maths for building a widget theme from a business's own branding.
12
+ *
13
+ * These lived in stentor-client, which meant the only way to work out a
14
+ * readable text colour was to be running in a browser. Studio needs the same
15
+ * answers when it builds an app server-side for a get.xapp.ai signup, and two
16
+ * implementations of "which colour reads on this background" would drift into
17
+ * two different-looking widgets.
18
+ *
19
+ * The inputs are colours scraped off a customer's website, so they are whatever
20
+ * their designer chose — any hex length, rgb or rgba, or nothing at all.
21
+ */
22
+ /** Left-pads a hex component so "f" becomes "0f". */
23
+ function padZero(value, length = 2) {
24
+ const zeros = new Array(length).join("0");
25
+ return (zeros + value).slice(-length);
26
+ }
27
+ function parseRgb(colorString) {
28
+ const rgba = /^rgba?\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/;
29
+ const rgb = /^rgb?\((\d+),\s*(\d+),\s*(\d+)\)$/;
30
+ const withAlpha = colorString === null || colorString === void 0 ? void 0 : colorString.match(rgba);
31
+ if (withAlpha) {
32
+ return {
33
+ r: parseInt(withAlpha[1], 10),
34
+ g: parseInt(withAlpha[2], 10),
35
+ b: parseInt(withAlpha[3], 10),
36
+ a: parseFloat(withAlpha[4])
37
+ };
38
+ }
39
+ const plain = colorString === null || colorString === void 0 ? void 0 : colorString.match(rgb);
40
+ if (plain) {
41
+ return { r: parseInt(plain[1], 10), g: parseInt(plain[2], 10), b: parseInt(plain[3], 10) };
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * Whether text should be black or white to read on this background.
47
+ *
48
+ * The weighting is perceived brightness, not an average: the eye is far more
49
+ * sensitive to green than to blue, so a pure blue is dark to us even though its
50
+ * numeric average is mid-range.
51
+ *
52
+ * @see http://stackoverflow.com/a/3943023/112731
53
+ */
54
+ function isLight(r, g, b) {
55
+ return r * 0.299 + g * 0.587 + b * 0.114 > 186;
56
+ }
57
+ /**
58
+ * Inverts a hex colour, or returns black/white for contrast when `bw` is set.
59
+ *
60
+ * Inverting is rarely what you want behind text — a mid-tone inverts to another
61
+ * mid-tone and neither reads. `bw` is the contrast answer.
62
+ */
63
+ function invertColorHEX(hex, bw) {
64
+ if (!hex) {
65
+ return undefined;
66
+ }
67
+ let value = hex.indexOf("#") === 0 ? hex.slice(1) : hex;
68
+ if (value.length === 3) {
69
+ value = value[0] + value[0] + value[1] + value[1] + value[2] + value[2];
70
+ }
71
+ if (value.length !== 6) {
72
+ throw new Error("Invalid HEX color.");
73
+ }
74
+ const r = parseInt(value.slice(0, 2), 16);
75
+ const g = parseInt(value.slice(2, 4), 16);
76
+ const b = parseInt(value.slice(4, 6), 16);
77
+ if (bw) {
78
+ return isLight(r, g, b) ? "#000000" : "#FFFFFF";
79
+ }
80
+ return `#${padZero((255 - r).toString(16))}${padZero((255 - g).toString(16))}${padZero((255 - b).toString(16))}`;
81
+ }
82
+ /** As invertColorHEX, for rgb/rgba input. */
83
+ function invertColorRgb(rgb, bw) {
84
+ const parsed = parseRgb(rgb);
85
+ if (!parsed) {
86
+ return rgb;
87
+ }
88
+ const { r, g, b } = parsed;
89
+ if (bw) {
90
+ return isLight(r, g, b) ? "rgba(0, 0, 0, 1)" : "rgba(255, 255, 255, 1)";
91
+ }
92
+ return `rgba(${255 - r}, ${255 - g}, ${255 - b}, 1)`;
93
+ }
94
+ /** Inverts whichever colour notation was given. */
95
+ function invertColor(color, bw) {
96
+ if (!color) {
97
+ return undefined;
98
+ }
99
+ return color.startsWith("#") ? invertColorHEX(color, bw) : invertColorRgb(color, bw);
100
+ }
101
+ /**
102
+ * Whether we can work out what text reads on this colour.
103
+ *
104
+ * Hex and rgb/rgba only. A CSS named colour like `orange`, or `hsl(...)`, is
105
+ * perfectly valid CSS — we simply cannot compute its brightness, and a
106
+ * background whose contrast we cannot compute is one we must not use.
107
+ */
108
+ function isSupportedColor(color) {
109
+ if (!color) {
110
+ return false;
111
+ }
112
+ if (color.startsWith("#")) {
113
+ const value = color.slice(1);
114
+ return (value.length === 3 || value.length === 6) && /^[0-9a-f]+$/i.test(value);
115
+ }
116
+ return parseRgb(color) !== null;
117
+ }
118
+ /**
119
+ * The text colour that will read on this background: black or white, never
120
+ * anything else.
121
+ *
122
+ * That guarantee is the whole point. An earlier version leaned on invertColor,
123
+ * whose rgb path returns its input unchanged when it cannot parse it — so
124
+ * `readableTextOn("orange")` answered `"orange"`, and a header themed from a
125
+ * scraped named colour got text in exactly the background colour. Invisible,
126
+ * and by the one function meant to prevent it.
127
+ *
128
+ * Unparseable input falls back to black. That is a guess, so callers who
129
+ * control the background should check `isSupportedColor` first and use a
130
+ * colour whose contrast can actually be computed, rather than pairing a
131
+ * guessed foreground with an unknown background.
132
+ */
133
+ function readableTextOn(background) {
134
+ if (!isSupportedColor(background)) {
135
+ return "#000000";
136
+ }
137
+ const rgb = background.startsWith("#") ? hexToRgb(background) : parseRgb(background);
138
+ if (!rgb) {
139
+ return "#000000";
140
+ }
141
+ return isLight(rgb.r, rgb.g, rgb.b) ? "#000000" : "#FFFFFF";
142
+ }
143
+ function hexToRgb(hex) {
144
+ let value = hex.slice(1);
145
+ if (value.length === 3) {
146
+ value = value[0] + value[0] + value[1] + value[1] + value[2] + value[2];
147
+ }
148
+ if (value.length !== 6) {
149
+ return null;
150
+ }
151
+ return {
152
+ r: parseInt(value.slice(0, 2), 16),
153
+ g: parseInt(value.slice(2, 4), 16),
154
+ b: parseInt(value.slice(4, 6), 16)
155
+ };
156
+ }
157
+ //# sourceMappingURL=color.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"color.js","sourceRoot":"","sources":["../src/color.ts"],"names":[],"mappings":"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"}
package/lib/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /*! Copyright (c) 2021, XAPPmedia */
2
2
  export * from "./ActionBar";
3
+ export * from "./buildChatWidgetChannel";
3
4
  export * from "./ChannelData";
5
+ export * from "./color";
4
6
  export * from "./Constants";
5
7
  export * from "./Types";
6
8
  export * from "./UserInfo";
package/lib/index.js CHANGED
@@ -16,7 +16,9 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  /*! Copyright (c) 2021, XAPPmedia */
18
18
  __exportStar(require("./ActionBar"), exports);
19
+ __exportStar(require("./buildChatWidgetChannel"), exports);
19
20
  __exportStar(require("./ChannelData"), exports);
21
+ __exportStar(require("./color"), exports);
20
22
  __exportStar(require("./Constants"), exports);
21
23
  __exportStar(require("./Types"), exports);
22
24
  __exportStar(require("./UserInfo"), exports);
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,oCAAoC;AACpC,8CAA4B;AAC5B,gDAA8B;AAC9B,8CAA4B;AAC5B,0CAAwB;AACxB,6CAA2B;AAC3B,8CAA4B;AAC5B,gDAA8B;AAC9B,2CAAyB"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,oCAAoC;AACpC,8CAA4B;AAC5B,2DAAyC;AACzC,gDAA8B;AAC9B,0CAAwB;AACxB,8CAA4B;AAC5B,0CAAwB;AACxB,6CAA2B;AAC3B,8CAA4B;AAC5B,gDAA8B;AAC9B,2CAAyB"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "1.95.2",
7
+ "version": "1.96.0",
8
8
  "description": "Chat Widget channel for stentor",
9
9
  "types": "lib/index",
10
10
  "main": "lib/index",
@@ -16,15 +16,18 @@
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/chai": "4.3.20",
19
+ "@types/jest": "27.5.2",
19
20
  "@types/react": "19.2.14",
20
21
  "@types/sinon": "10.0.20",
21
22
  "@types/sinon-chai": "3.2.12",
22
23
  "@xapp/config": "0.3.0",
23
24
  "chai": "4.5.0",
25
+ "jest": "29.7.0",
24
26
  "mocha": "9.2.2",
25
27
  "sinon": "13.0.2",
26
28
  "sinon-chai": "3.7.0",
27
29
  "stentor-models": "1.79.0",
30
+ "ts-jest": "29.4.12",
28
31
  "ts-node": "10.9.2",
29
32
  "typescript": "5.9.3"
30
33
  },
@@ -37,7 +40,8 @@
37
40
  },
38
41
  "scripts": {
39
42
  "build": "tsc -d true -p .",
40
- "clean": "rm -rf ./lib/*"
43
+ "clean": "rm -rf ./lib/*",
44
+ "test": "jest"
41
45
  },
42
46
  "gitHead": "488151c23d71460d3a22522a4a3b99bcef1c1c85"
43
47
  }