@yak-io/javascript 0.5.0 → 0.7.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 ADDED
@@ -0,0 +1,181 @@
1
+ # @yak-io/javascript
2
+
3
+ Framework-agnostic core SDK for embedding the Yak chat widget. This package provides the low-level client and DOM rendering layer. Most developers should use a framework-specific package (`@yak-io/react`, `@yak-io/vue`, etc.) instead.
4
+
5
+ ## When to use this package directly
6
+
7
+ - You are building a vanilla JS / TypeScript app
8
+ - You are building a new framework adapter
9
+ - You need the server-side handler utilities (`./server` export) outside Next.js
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pnpm add @yak-io/javascript
15
+ ```
16
+
17
+ ## Quickstart — Vanilla JS
18
+
19
+ ```ts
20
+ import { YakEmbed } from "@yak-io/javascript";
21
+
22
+ const embed = new YakEmbed({
23
+ appId: "your-app-id",
24
+ theme: { position: "bottom-right", colorMode: "system" },
25
+ trigger: { label: "Ask with AI" },
26
+ getConfig: async () => ({
27
+ routes: {
28
+ routes: [
29
+ { path: "/", title: "Home", description: "Landing page" },
30
+ { path: "/docs", title: "Docs", description: "Documentation" },
31
+ ],
32
+ generated_at: new Date().toISOString(),
33
+ },
34
+ tools: {
35
+ tools: [
36
+ {
37
+ name: "tasks.list",
38
+ displayName: "List Tasks",
39
+ description: "Return all tasks",
40
+ inputSchema: { type: "object", properties: {} },
41
+ },
42
+ ],
43
+ generated_at: new Date().toISOString(),
44
+ },
45
+ }),
46
+ onToolCall: async (name, args) => {
47
+ if (name === "tasks.list") {
48
+ return { tasks: [] };
49
+ }
50
+ throw new Error(`Unknown tool: ${name}`);
51
+ },
52
+ onRedirect: (path) => {
53
+ window.location.assign(path);
54
+ },
55
+ });
56
+
57
+ embed.mount();
58
+ ```
59
+
60
+ ## Server-side utilities
61
+
62
+ Use `@yak-io/javascript/server` to build framework-agnostic API handlers:
63
+
64
+ ```ts
65
+ import { createYakHandler } from "@yak-io/javascript/server";
66
+
67
+ // Works with any Request/Response runtime (Remix, Fastify, etc.)
68
+ const { GET, POST } = createYakHandler({
69
+ routes: [
70
+ { path: "/", title: "Home" },
71
+ { path: "/tasks", title: "Tasks" },
72
+ ],
73
+ tools: {
74
+ getTools: async () => [
75
+ {
76
+ name: "tasks.list",
77
+ displayName: "List Tasks",
78
+ description: "Return all tasks",
79
+ inputSchema: { type: "object", properties: {} },
80
+ },
81
+ ],
82
+ executeTool: async (name, args) => {
83
+ if (name === "tasks.list") return { tasks: [] };
84
+ throw new Error(`Unknown tool: ${name}`);
85
+ },
86
+ },
87
+ });
88
+ ```
89
+
90
+ ## API Reference
91
+
92
+ ### `YakEmbed`
93
+
94
+ High-level class that handles DOM rendering (panel, iframe, trigger button) and client wiring.
95
+
96
+ ```ts
97
+ new YakEmbed(config: YakEmbedConfig)
98
+ ```
99
+
100
+ **Key methods:**
101
+
102
+ | Method | Description |
103
+ |--------|-------------|
104
+ | `mount()` | Injects the widget into the DOM |
105
+ | `destroy()` | Removes the widget from the DOM |
106
+ | `open()` | Open the chat panel |
107
+ | `close()` | Close the chat panel |
108
+ | `toggle()` | Toggle open/close |
109
+ | `openWithPrompt(prompt)` | Open and pre-fill a prompt |
110
+ | `getState()` | Get current `{ isOpen, isReady }` state |
111
+ | `onStateChange(fn)` | Subscribe to state changes, returns unsubscribe |
112
+ | `getClient()` | Access the underlying `YakClient` |
113
+
114
+ **Configuration (`YakEmbedConfig`):**
115
+
116
+ | Option | Type | Description |
117
+ |--------|------|-------------|
118
+ | `appId` | `string` | Your Yak app ID |
119
+ | `theme` | `Theme` | Position, color mode, and widget colors |
120
+ | `trigger` | `boolean \| TriggerButtonConfig` | Show built-in trigger button |
121
+ | `getConfig` | `ChatConfigProvider` | Async function returning routes + tools config |
122
+ | `chatConfig` | `ChatConfig` | Static config (alternative to `getConfig`) |
123
+ | `onToolCall` | `ToolCallHandler` | Handle tool calls from the assistant |
124
+ | `onGraphQLSchemaCall` | `GraphQLSchemaHandler` | Handle GraphQL schema tool calls |
125
+ | `onRESTSchemaCall` | `RESTSchemaHandler` | Handle REST/OpenAPI schema tool calls |
126
+ | `onRedirect` | `(path: string) => void` | Handle navigation requests |
127
+ | `onToolCallComplete` | `(event: ToolCallEvent) => void` | Called after each tool call |
128
+ | `options.disableRestartButton` | `boolean` | Hide the restart session button |
129
+
130
+ ### `YakClient`
131
+
132
+ Low-level iframe communication client. Use `YakEmbed` for most cases.
133
+
134
+ ### Logging utilities
135
+
136
+ ```ts
137
+ import { enableYakLogging, disableYakLogging, isYakLoggingEnabled } from "@yak-io/javascript";
138
+
139
+ enableYakLogging(); // Turn on verbose SDK logging
140
+ disableYakLogging(); // Turn off SDK logging
141
+ isYakLoggingEnabled(); // Returns current state
142
+ ```
143
+
144
+ In development, set `window.__YAK_INTERNAL_DEV__ = true` before mounting to connect to a locally running chat UI.
145
+
146
+ ## Types
147
+
148
+ All types are exported from the package root:
149
+
150
+ ```ts
151
+ import type {
152
+ ChatConfig,
153
+ ChatConfigProvider,
154
+ RouteManifest,
155
+ RouteInfo,
156
+ ToolManifest,
157
+ ToolDefinition,
158
+ ToolCallHandler,
159
+ ToolCallEvent,
160
+ ToolCallPayload,
161
+ ToolCallResult,
162
+ GraphQLSchemaHandler,
163
+ RESTSchemaHandler,
164
+ GraphQLRequest,
165
+ RESTRequest,
166
+ SchemaSource,
167
+ GraphQLSchemaSource,
168
+ OpenAPISchemaSource,
169
+ Theme,
170
+ ThemeColors,
171
+ TriggerButtonConfig,
172
+ WidgetPosition,
173
+ YakClientConfig,
174
+ YakEmbedConfig,
175
+ YakEmbedState,
176
+ } from "@yak-io/javascript";
177
+ ```
178
+
179
+ ## License
180
+
181
+ Proprietary — see LICENSE file.
@@ -0,0 +1,97 @@
1
+ import { YakClient, type YakClientConfig } from "./client.js";
2
+ export type TriggerButtonConfig = {
3
+ /** Label displayed on the trigger button. Default: "Ask with AI" */
4
+ label?: string;
5
+ /** Custom color overrides for light mode */
6
+ lightButton?: {
7
+ background?: string;
8
+ color?: string;
9
+ border?: string;
10
+ };
11
+ /** Custom color overrides for dark mode */
12
+ darkButton?: {
13
+ background?: string;
14
+ color?: string;
15
+ border?: string;
16
+ };
17
+ };
18
+ export type YakEmbedConfig = YakClientConfig & {
19
+ /** DOM element to append the widget into. Defaults to document.body. */
20
+ target?: HTMLElement;
21
+ /** Show the floating trigger button. Default: true */
22
+ trigger?: boolean | TriggerButtonConfig;
23
+ };
24
+ export type YakEmbedState = {
25
+ isOpen: boolean;
26
+ isReady: boolean;
27
+ isExpanded: boolean;
28
+ };
29
+ export type YakEmbedStateListener = (state: YakEmbedState) => void;
30
+ /**
31
+ * Drop-in widget that renders the yak chat iframe + optional trigger button.
32
+ * Wraps YakClient with DOM rendering, lazy iframe mounting, and consistent styling.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * const embed = new YakEmbed({
37
+ * appId: "my-app",
38
+ * theme: { position: "bottom-right" },
39
+ * onToolCall: async (name, args) => { ... },
40
+ * });
41
+ * embed.mount();
42
+ * ```
43
+ */
44
+ export declare class YakEmbed {
45
+ private readonly client;
46
+ private readonly config;
47
+ private styleEl;
48
+ private panelRoot;
49
+ private container;
50
+ private iframe;
51
+ private triggerButton;
52
+ private isOpen;
53
+ private isReady;
54
+ private isExpanded;
55
+ private hasBeenOpened;
56
+ private pendingPrompt;
57
+ private mounted;
58
+ private stateListeners;
59
+ private mobileQuery;
60
+ private mobileHandler;
61
+ private expandHandler;
62
+ constructor(config: YakEmbedConfig);
63
+ /** The underlying headless YakClient for advanced usage */
64
+ getClient(): YakClient;
65
+ /**
66
+ * Mount the widget into the DOM. Call once after construction.
67
+ * Inserts styles and trigger button (if enabled). The iframe is lazily
68
+ * created on the first call to open().
69
+ */
70
+ mount(target?: HTMLElement): void;
71
+ /** Remove all DOM elements and event listeners. */
72
+ destroy(): void;
73
+ /** Open the chat widget. Creates the iframe on first call (lazy mount). */
74
+ open(): void;
75
+ /** Close the chat widget. The iframe remains in the DOM for instant re-open. */
76
+ close(): void;
77
+ /** Toggle the chat widget open/closed. */
78
+ toggle(): void;
79
+ /** Open the chat and immediately send a prompt. */
80
+ openWithPrompt(prompt: string): void;
81
+ /** Get the current widget state. */
82
+ getState(): YakEmbedState;
83
+ /** Subscribe to state changes. Returns an unsubscribe function. */
84
+ onStateChange(listener: YakEmbedStateListener): () => void;
85
+ private createPanel;
86
+ private createTrigger;
87
+ private buildTriggerClasses;
88
+ private applyTriggerCustomColors;
89
+ private updatePanelState;
90
+ private updateTriggerState;
91
+ private sendPendingPrompt;
92
+ private sendFocusIfOpen;
93
+ private notifyMobileState;
94
+ private notifyIframeFullscreen;
95
+ private notifyListeners;
96
+ }
97
+ //# sourceMappingURL=embed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embed.d.ts","sourceRoot":"","sources":["../src/embed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAM9D,MAAM,MAAM,mBAAmB,GAAG;IAChC,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4CAA4C;IAC5C,WAAW,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACvE,2CAA2C;IAC3C,UAAU,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACvE,CAAC;AAIF,MAAM,MAAM,cAAc,GAAG,eAAe,GAAG;IAC7C,wEAAwE;IACxE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,sDAAsD;IACtD,OAAO,CAAC,EAAE,OAAO,GAAG,mBAAmB,CAAC;CACzC,CAAC;AAIF,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;AAgNnE;;;;;;;;;;;;;GAaG;AACH,qBAAa,QAAQ;IACnB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAY;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IAGxC,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,SAAS,CAA+B;IAChD,OAAO,CAAC,SAAS,CAA+B;IAChD,OAAO,CAAC,MAAM,CAAkC;IAChD,OAAO,CAAC,aAAa,CAAkC;IAGvD,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,OAAO,CAAS;IAGxB,OAAO,CAAC,cAAc,CAAoC;IAC1D,OAAO,CAAC,WAAW,CAA+B;IAClD,OAAO,CAAC,aAAa,CAAmD;IACxE,OAAO,CAAC,aAAa,CAA4C;gBAErD,MAAM,EAAE,cAAc;IAsBlC,2DAA2D;IACpD,SAAS,IAAI,SAAS;IAM7B;;;;OAIG;IACI,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,IAAI;IA8BxC,mDAAmD;IAC5C,OAAO,IAAI,IAAI;IAoCtB,2EAA2E;IACpE,IAAI,IAAI,IAAI;IAkBnB,gFAAgF;IACzE,KAAK,IAAI,IAAI;IAQpB,0CAA0C;IACnC,MAAM,IAAI,IAAI;IAQrB,mDAAmD;IAC5C,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAM3C,oCAAoC;IAC7B,QAAQ,IAAI,aAAa;IAIhC,mEAAmE;IAC5D,aAAa,CAAC,QAAQ,EAAE,qBAAqB,GAAG,MAAM,IAAI;IASjE,OAAO,CAAC,WAAW;IA2CnB,OAAO,CAAC,aAAa;IAyCrB,OAAO,CAAC,mBAAmB;IAuB3B,OAAO,CAAC,wBAAwB;IA2BhC,OAAO,CAAC,gBAAgB;IASxB,OAAO,CAAC,kBAAkB;IA2B1B,OAAO,CAAC,iBAAiB;IAOzB,OAAO,CAAC,eAAe;IAMvB,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,sBAAsB;IAS9B,OAAO,CAAC,eAAe;CAUxB"}
package/dist/embed.js ADDED
@@ -0,0 +1,564 @@
1
+ import { YakClient } from "./client.js";
2
+ import { logger } from "./logger.js";
3
+ // ── CSS ─────────────────────────────────────────────────────────────────────
4
+ function getPanelStyles() {
5
+ return `
6
+ .yak-panel-root {
7
+ position: fixed;
8
+ top: 0; left: 0; right: 0; bottom: 0;
9
+ width: 100vw; height: 100vh;
10
+ pointer-events: none;
11
+ z-index: 9998;
12
+ }
13
+
14
+ .yak-panel-container {
15
+ position: absolute;
16
+ width: 500px; height: 600px;
17
+ max-width: calc(100vw - 40px);
18
+ max-height: calc(100vh - 120px);
19
+ border-radius: 15px;
20
+ overflow: hidden;
21
+ background-color: transparent;
22
+ pointer-events: auto;
23
+ }
24
+
25
+ .yak-panel-container[data-position="top-left"]:not(.yak-panel-drawer) { top: 16px; left: 16px; }
26
+ .yak-panel-container[data-position="top-center"]:not(.yak-panel-drawer) { top: 16px; left: 50%; transform: translateX(-50%); }
27
+ .yak-panel-container[data-position="top-right"]:not(.yak-panel-drawer) { top: 16px; right: 16px; }
28
+ .yak-panel-container[data-position="left-center"]:not(.yak-panel-drawer) { top: 50%; left: 16px; transform: translateY(-50%); }
29
+ .yak-panel-container[data-position="right-center"]:not(.yak-panel-drawer) { top: 50%; right: 16px; transform: translateY(-50%); }
30
+ .yak-panel-container[data-position="bottom-left"]:not(.yak-panel-drawer) { bottom: 16px; left: 16px; }
31
+ .yak-panel-container[data-position="bottom-center"]:not(.yak-panel-drawer) { bottom: 16px; left: 50%; transform: translateX(-50%); }
32
+ .yak-panel-container[data-position="bottom-right"]:not(.yak-panel-drawer) { bottom: 16px; right: 16px; }
33
+
34
+ .yak-panel-container:not(.yak-panel-drawer) { display: none; }
35
+ .yak-panel-container:not(.yak-panel-drawer)[data-open="true"] { display: block; }
36
+
37
+ .yak-panel-container[data-expanded="true"] {
38
+ width: calc(100vw - 32px) !important;
39
+ height: calc(100vh - 32px) !important;
40
+ max-width: none !important; max-height: none !important;
41
+ top: 16px !important; left: 16px !important; right: 16px !important; bottom: 16px !important;
42
+ border-radius: 15px !important;
43
+ border: 1px solid rgba(0, 0, 0, 0.1) !important;
44
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15) !important;
45
+ }
46
+ @media (prefers-color-scheme: dark) {
47
+ .yak-panel-container[data-expanded="true"]:not(.yak-panel-light) { border-color: rgba(255,255,255,0.1) !important; }
48
+ }
49
+ .yak-panel-container.yak-panel-dark[data-expanded="true"] { border-color: rgba(255,255,255,0.1) !important; }
50
+ .yak-panel-container.yak-panel-light[data-expanded="true"] { border-color: rgba(0,0,0,0.1) !important; }
51
+
52
+ .yak-panel-container.yak-panel-drawer {
53
+ height: calc(100% - 32px); max-width: 100vw; max-height: none;
54
+ border-radius: 15px;
55
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
56
+ }
57
+ .yak-panel-container.yak-panel-drawer[data-position="left-center"],
58
+ .yak-panel-container.yak-panel-drawer[data-position="top-left"],
59
+ .yak-panel-container.yak-panel-drawer[data-position="bottom-left"] {
60
+ top: 16px; left: 16px; bottom: 16px;
61
+ transform: translateX(calc(-100% - 16px));
62
+ }
63
+ .yak-panel-container.yak-panel-drawer[data-position="right-center"],
64
+ .yak-panel-container.yak-panel-drawer[data-position="top-right"],
65
+ .yak-panel-container.yak-panel-drawer[data-position="bottom-right"] {
66
+ top: 16px; right: 16px; bottom: 16px;
67
+ transform: translateX(calc(100% + 16px));
68
+ }
69
+ .yak-panel-container.yak-panel-drawer[data-position="top-center"],
70
+ .yak-panel-container.yak-panel-drawer[data-position="bottom-center"] {
71
+ top: 16px; right: 16px; bottom: 16px;
72
+ transform: translateX(calc(100% + 16px));
73
+ }
74
+ .yak-panel-container.yak-panel-drawer[data-open="true"] { transform: translateX(0); }
75
+
76
+ .yak-panel-iframe {
77
+ position: absolute; inset: 0;
78
+ width: 100%; height: 100%; border: none;
79
+ }
80
+
81
+ @media (max-width: 640px) {
82
+ .yak-panel-container:not(.yak-panel-drawer) {
83
+ width: 100% !important; height: 100% !important; height: 100dvh !important;
84
+ max-width: none !important; max-height: none !important;
85
+ top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
86
+ border-radius: 0 !important;
87
+ }
88
+ .yak-panel-container.yak-panel-drawer { width: 100% !important; max-width: none !important; }
89
+ }
90
+ `;
91
+ }
92
+ function getTriggerStyles() {
93
+ return `
94
+ .yak-widget-trigger {
95
+ position: fixed; z-index: 9997;
96
+ display: flex; align-items: center; gap: 12px;
97
+ border: none; border-radius: 30px;
98
+ padding: 0 5px 0 20px; height: 45px; min-width: 45px; width: auto;
99
+ cursor: pointer;
100
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
101
+ overflow: hidden;
102
+ background-color: #000; color: #fff;
103
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
104
+ font-family: system-ui, -apple-system, sans-serif;
105
+ }
106
+
107
+ .yak-widget-trigger[data-position="top-left"] { top: 28px; left: 28px; flex-direction: row-reverse; }
108
+ .yak-widget-trigger[data-position="top-center"] { top: 28px; left: 50%; transform: translateX(-50%); }
109
+ .yak-widget-trigger[data-position="top-right"] { top: 28px; right: 28px; }
110
+ .yak-widget-trigger[data-position="left-center"] { top: 50%; left: 28px; transform: translateY(-50%); flex-direction: row-reverse; }
111
+ .yak-widget-trigger[data-position="right-center"] { top: 50%; right: 28px; transform: translateY(-50%); }
112
+ .yak-widget-trigger[data-position="bottom-left"] { bottom: 28px; left: 28px; flex-direction: row-reverse; }
113
+ .yak-widget-trigger[data-position="bottom-center"] { bottom: 28px; left: 50%; transform: translateX(-50%); }
114
+ .yak-widget-trigger[data-position="bottom-right"] { bottom: 28px; right: 28px; }
115
+
116
+ .yak-widget-trigger-label { font-size: 14px; font-weight: 600; white-space: nowrap; }
117
+
118
+ .yak-widget-icon-bg {
119
+ display: flex; align-items: center; justify-content: center;
120
+ width: 36px; height: 36px; border-radius: 50%;
121
+ background-color: rgba(255, 255, 255, 0.1);
122
+ }
123
+
124
+ .yak-widget-icon { width: 20px; height: 20px; color: currentColor; }
125
+
126
+ @media (prefers-color-scheme: dark) {
127
+ .yak-widget-trigger:not(.yak-widget-light) .yak-widget-icon { filter: invert(1); }
128
+ }
129
+ .yak-widget-trigger.yak-widget-dark .yak-widget-icon { filter: invert(1); }
130
+ .yak-widget-trigger.yak-widget-light .yak-widget-icon { filter: none; }
131
+
132
+ .yak-widget-spinner {
133
+ width: 20px; height: 20px;
134
+ border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%;
135
+ animation: yak-widget-spin 0.8s linear infinite;
136
+ }
137
+ @keyframes yak-widget-spin { to { transform: rotate(360deg); } }
138
+
139
+ .yak-widget-trigger:disabled { cursor: wait; }
140
+
141
+ .yak-widget-trigger.yak-widget-custom-light {
142
+ background-color: var(--yak-btn-light-bg, #fff); color: var(--yak-btn-light-color, #000);
143
+ border: 1px solid var(--yak-btn-light-border, transparent);
144
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
145
+ }
146
+ .yak-widget-trigger.yak-widget-custom-dark {
147
+ background-color: var(--yak-btn-dark-bg, #000); color: var(--yak-btn-dark-color, #fff);
148
+ border: 1px solid var(--yak-btn-dark-border, transparent);
149
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
150
+ }
151
+
152
+ @media (prefers-color-scheme: light) {
153
+ .yak-widget-trigger[data-has-light-custom]:not(.yak-widget-dark) {
154
+ background-color: var(--yak-btn-light-bg, #fff); color: var(--yak-btn-light-color, #000);
155
+ border: 1px solid var(--yak-btn-light-border, transparent);
156
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
157
+ }
158
+ }
159
+ @media (prefers-color-scheme: dark) {
160
+ .yak-widget-trigger[data-has-dark-custom]:not(.yak-widget-light) {
161
+ background-color: var(--yak-btn-dark-bg, #000); color: var(--yak-btn-dark-color, #fff);
162
+ border: 1px solid var(--yak-btn-dark-border, transparent);
163
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
164
+ }
165
+ }
166
+
167
+ @media (prefers-color-scheme: light) {
168
+ .yak-widget-trigger:not(.yak-widget-dark):not([data-has-light-custom]) {
169
+ background-color: #fff; color: #000;
170
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border: 1px solid #e5e5e5;
171
+ }
172
+ .yak-widget-trigger:not(.yak-widget-dark):not([data-has-light-custom]) .yak-widget-icon-bg {
173
+ background-color: rgba(0, 0, 0, 0.05);
174
+ }
175
+ }
176
+
177
+ @media (prefers-color-scheme: dark) {
178
+ .yak-widget-trigger:not(.yak-widget-light):not([data-has-dark-custom]) {
179
+ background-color: #000; color: #fff;
180
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: none;
181
+ }
182
+ .yak-widget-trigger:not(.yak-widget-light):not([data-has-dark-custom]) .yak-widget-icon-bg {
183
+ background-color: rgba(255, 255, 255, 0.1);
184
+ }
185
+ }
186
+
187
+ .yak-widget-trigger.yak-widget-light:not(.yak-widget-custom-light) {
188
+ background-color: #fff; color: #000;
189
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border: 1px solid #e5e5e5;
190
+ }
191
+ .yak-widget-trigger.yak-widget-light:not(.yak-widget-custom-light) .yak-widget-icon-bg {
192
+ background-color: rgba(0, 0, 0, 0.05);
193
+ }
194
+
195
+ .yak-widget-trigger.yak-widget-dark:not(.yak-widget-custom-dark) {
196
+ background-color: #000; color: #fff;
197
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: none;
198
+ }
199
+ .yak-widget-trigger.yak-widget-dark:not(.yak-widget-custom-dark) .yak-widget-icon-bg {
200
+ background-color: rgba(255, 255, 255, 0.1);
201
+ }
202
+ `;
203
+ }
204
+ // ── YakEmbed class ──────────────────────────────────────────────────────────
205
+ /**
206
+ * Drop-in widget that renders the yak chat iframe + optional trigger button.
207
+ * Wraps YakClient with DOM rendering, lazy iframe mounting, and consistent styling.
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * const embed = new YakEmbed({
212
+ * appId: "my-app",
213
+ * theme: { position: "bottom-right" },
214
+ * onToolCall: async (name, args) => { ... },
215
+ * });
216
+ * embed.mount();
217
+ * ```
218
+ */
219
+ export class YakEmbed {
220
+ client;
221
+ config;
222
+ // DOM elements
223
+ styleEl = null;
224
+ panelRoot = null;
225
+ container = null;
226
+ iframe = null;
227
+ triggerButton = null;
228
+ // State
229
+ isOpen = false;
230
+ isReady = false;
231
+ isExpanded = false;
232
+ hasBeenOpened = false;
233
+ pendingPrompt = null;
234
+ mounted = false;
235
+ // Listeners
236
+ stateListeners = new Set();
237
+ mobileQuery = null;
238
+ mobileHandler = null;
239
+ expandHandler = null;
240
+ constructor(config) {
241
+ this.config = config;
242
+ // Wrap callbacks to integrate with our state
243
+ this.client = new YakClient({
244
+ ...config,
245
+ onReady: () => {
246
+ this.isReady = true;
247
+ this.updatePanelState();
248
+ this.updateTriggerState();
249
+ this.sendPendingPrompt();
250
+ this.sendFocusIfOpen();
251
+ this.notifyMobileState();
252
+ config.onReady?.();
253
+ },
254
+ onClose: () => {
255
+ this.close();
256
+ config.onClose?.();
257
+ },
258
+ });
259
+ }
260
+ /** The underlying headless YakClient for advanced usage */
261
+ getClient() {
262
+ return this.client;
263
+ }
264
+ // ── Lifecycle ───────────────────────────────────────────────────────────
265
+ /**
266
+ * Mount the widget into the DOM. Call once after construction.
267
+ * Inserts styles and trigger button (if enabled). The iframe is lazily
268
+ * created on the first call to open().
269
+ */
270
+ mount(target) {
271
+ if (this.mounted)
272
+ return;
273
+ this.mounted = true;
274
+ const parent = target ?? this.config.target ?? document.body;
275
+ // Inject styles
276
+ this.styleEl = document.createElement("style");
277
+ this.styleEl.textContent = getPanelStyles() + getTriggerStyles();
278
+ parent.appendChild(this.styleEl);
279
+ // Create trigger button
280
+ if (this.config.trigger !== false) {
281
+ this.createTrigger(parent);
282
+ }
283
+ // Listen for expansion messages from iframe
284
+ this.expandHandler = (event) => {
285
+ if (event.data?.type === "YAK_SET_EXPANDED") {
286
+ this.isExpanded = Boolean(event.data.expanded);
287
+ this.updatePanelState();
288
+ this.notifyListeners();
289
+ }
290
+ };
291
+ window.addEventListener("message", this.expandHandler);
292
+ // Start the client's message listeners
293
+ this.client.mount();
294
+ }
295
+ /** Remove all DOM elements and event listeners. */
296
+ destroy() {
297
+ if (!this.mounted)
298
+ return;
299
+ this.mounted = false;
300
+ this.client.unmount();
301
+ if (this.expandHandler) {
302
+ window.removeEventListener("message", this.expandHandler);
303
+ this.expandHandler = null;
304
+ }
305
+ if (this.mobileQuery && this.mobileHandler) {
306
+ this.mobileQuery.removeEventListener("change", this.mobileHandler);
307
+ this.mobileQuery = null;
308
+ this.mobileHandler = null;
309
+ }
310
+ this.panelRoot?.remove();
311
+ this.triggerButton?.remove();
312
+ this.styleEl?.remove();
313
+ this.panelRoot = null;
314
+ this.container = null;
315
+ this.iframe = null;
316
+ this.triggerButton = null;
317
+ this.styleEl = null;
318
+ this.isOpen = false;
319
+ this.isReady = false;
320
+ this.isExpanded = false;
321
+ this.hasBeenOpened = false;
322
+ this.stateListeners.clear();
323
+ }
324
+ // ── Public API ──────────────────────────────────────────────────────────
325
+ /** Open the chat widget. Creates the iframe on first call (lazy mount). */
326
+ open() {
327
+ if (!this.mounted)
328
+ return;
329
+ if (!this.hasBeenOpened) {
330
+ this.hasBeenOpened = true;
331
+ const parent = this.config.target ?? document.body;
332
+ this.createPanel(parent);
333
+ }
334
+ this.isOpen = true;
335
+ this.client.setWidgetOpen(true);
336
+ this.updatePanelState();
337
+ this.updateTriggerState();
338
+ this.sendFocusIfOpen();
339
+ this.notifyListeners();
340
+ }
341
+ /** Close the chat widget. The iframe remains in the DOM for instant re-open. */
342
+ close() {
343
+ this.isOpen = false;
344
+ this.client.setWidgetOpen(false);
345
+ this.updatePanelState();
346
+ this.updateTriggerState();
347
+ this.notifyListeners();
348
+ }
349
+ /** Toggle the chat widget open/closed. */
350
+ toggle() {
351
+ if (this.isOpen) {
352
+ this.close();
353
+ }
354
+ else {
355
+ this.open();
356
+ }
357
+ }
358
+ /** Open the chat and immediately send a prompt. */
359
+ openWithPrompt(prompt) {
360
+ this.pendingPrompt = prompt;
361
+ this.open();
362
+ this.sendPendingPrompt();
363
+ }
364
+ /** Get the current widget state. */
365
+ getState() {
366
+ return { isOpen: this.isOpen, isReady: this.isReady, isExpanded: this.isExpanded };
367
+ }
368
+ /** Subscribe to state changes. Returns an unsubscribe function. */
369
+ onStateChange(listener) {
370
+ this.stateListeners.add(listener);
371
+ return () => {
372
+ this.stateListeners.delete(listener);
373
+ };
374
+ }
375
+ // ── DOM creation ────────────────────────────────────────────────────────
376
+ createPanel(parent) {
377
+ const theme = this.config.theme;
378
+ const position = theme?.position ?? "bottom-right";
379
+ const colorMode = theme?.colorMode;
380
+ const displayMode = theme?.displayMode ?? "chatbox";
381
+ const isDrawer = displayMode === "drawer";
382
+ // Root overlay (pointer-events: none)
383
+ this.panelRoot = document.createElement("div");
384
+ this.panelRoot.className = "yak-panel-root";
385
+ // Container
386
+ this.container = document.createElement("div");
387
+ const classes = ["yak-panel-container"];
388
+ if (isDrawer)
389
+ classes.push("yak-panel-drawer");
390
+ if (colorMode === "light")
391
+ classes.push("yak-panel-light");
392
+ else if (colorMode === "dark")
393
+ classes.push("yak-panel-dark");
394
+ this.container.className = classes.join(" ");
395
+ this.container.dataset.position = position;
396
+ // Iframe
397
+ this.iframe = document.createElement("iframe");
398
+ this.iframe.src = this.client.getEmbedUrl();
399
+ this.iframe.className = "yak-panel-iframe";
400
+ this.iframe.title = "yak-chat-host";
401
+ this.iframe.allow = "clipboard-write";
402
+ this.iframe.addEventListener("load", () => {
403
+ this.client.setIframeWindow(this.iframe?.contentWindow ?? null);
404
+ });
405
+ this.container.appendChild(this.iframe);
406
+ this.panelRoot.appendChild(this.container);
407
+ parent.appendChild(this.panelRoot);
408
+ // Set up mobile detection
409
+ this.mobileQuery = window.matchMedia("(max-width: 640px)");
410
+ this.mobileHandler = (e) => {
411
+ this.notifyIframeFullscreen(e.matches);
412
+ };
413
+ this.mobileQuery.addEventListener("change", this.mobileHandler);
414
+ }
415
+ createTrigger(parent) {
416
+ const theme = this.config.theme;
417
+ const position = theme?.position ?? "bottom-right";
418
+ const colorMode = theme?.colorMode;
419
+ const triggerConfig = typeof this.config.trigger === "object" ? this.config.trigger : {};
420
+ const label = triggerConfig.label ?? "Ask with AI";
421
+ this.triggerButton = document.createElement("button");
422
+ this.triggerButton.type = "button";
423
+ this.triggerButton.setAttribute("aria-label", "Open chat");
424
+ this.triggerButton.dataset.position = position;
425
+ this.triggerButton.className = this.buildTriggerClasses(colorMode, triggerConfig);
426
+ this.applyTriggerCustomColors(triggerConfig);
427
+ // Inner content
428
+ const labelEl = document.createElement("span");
429
+ labelEl.className = "yak-widget-trigger-label";
430
+ labelEl.textContent = label;
431
+ const iconBg = document.createElement("div");
432
+ iconBg.className = "yak-widget-icon-bg";
433
+ const logoImg = document.createElement("img");
434
+ logoImg.src = `${this.client.getIframeOrigin()}/logo.svg`;
435
+ logoImg.alt = "";
436
+ logoImg.width = 20;
437
+ logoImg.height = 20;
438
+ logoImg.className = "yak-widget-icon";
439
+ iconBg.appendChild(logoImg);
440
+ this.triggerButton.appendChild(labelEl);
441
+ this.triggerButton.appendChild(iconBg);
442
+ this.triggerButton.addEventListener("click", () => {
443
+ this.open();
444
+ });
445
+ parent.appendChild(this.triggerButton);
446
+ }
447
+ buildTriggerClasses(colorMode, triggerConfig) {
448
+ const classes = ["yak-widget-trigger"];
449
+ if (colorMode === "light")
450
+ classes.push("yak-widget-light");
451
+ else if (colorMode === "dark")
452
+ classes.push("yak-widget-dark");
453
+ const hasLightCustom = triggerConfig.lightButton?.background ||
454
+ triggerConfig.lightButton?.color ||
455
+ triggerConfig.lightButton?.border;
456
+ const hasDarkCustom = triggerConfig.darkButton?.background ||
457
+ triggerConfig.darkButton?.color ||
458
+ triggerConfig.darkButton?.border;
459
+ if (colorMode === "light" && hasLightCustom)
460
+ classes.push("yak-widget-custom-light");
461
+ else if (colorMode === "dark" && hasDarkCustom)
462
+ classes.push("yak-widget-custom-dark");
463
+ return classes.join(" ");
464
+ }
465
+ applyTriggerCustomColors(triggerConfig) {
466
+ if (!this.triggerButton)
467
+ return;
468
+ const { lightButton, darkButton } = triggerConfig;
469
+ const hasLightCustom = lightButton?.background || lightButton?.color || lightButton?.border;
470
+ const hasDarkCustom = darkButton?.background || darkButton?.color || darkButton?.border;
471
+ if (hasLightCustom || hasDarkCustom) {
472
+ const vars = [
473
+ ["--yak-btn-light-bg", lightButton?.background],
474
+ ["--yak-btn-light-color", lightButton?.color],
475
+ ["--yak-btn-light-border", lightButton?.border],
476
+ ["--yak-btn-dark-bg", darkButton?.background],
477
+ ["--yak-btn-dark-color", darkButton?.color],
478
+ ["--yak-btn-dark-border", darkButton?.border],
479
+ ];
480
+ for (const [prop, value] of vars) {
481
+ if (value)
482
+ this.triggerButton.style.setProperty(prop, value);
483
+ }
484
+ }
485
+ if (hasLightCustom)
486
+ this.triggerButton.dataset.hasLightCustom = "true";
487
+ if (hasDarkCustom)
488
+ this.triggerButton.dataset.hasDarkCustom = "true";
489
+ }
490
+ // ── Internal state management ───────────────────────────────────────────
491
+ updatePanelState() {
492
+ if (!this.container)
493
+ return;
494
+ this.container.dataset.open = String(this.isOpen && this.isReady);
495
+ this.container.dataset.expanded = String(this.isExpanded);
496
+ if (this.panelRoot) {
497
+ this.panelRoot.dataset.expanded = String(this.isExpanded);
498
+ }
499
+ }
500
+ updateTriggerState() {
501
+ if (!this.triggerButton)
502
+ return;
503
+ const isLoading = this.isOpen && !this.isReady;
504
+ this.triggerButton.disabled = isLoading;
505
+ this.triggerButton.setAttribute("aria-label", isLoading ? "Loading chat" : "Open chat");
506
+ // Swap icon content: spinner vs logo
507
+ const iconBg = this.triggerButton.querySelector(".yak-widget-icon-bg");
508
+ if (!iconBg)
509
+ return;
510
+ if (isLoading) {
511
+ iconBg.innerHTML = '<div class="yak-widget-spinner" aria-hidden="true"></div>';
512
+ }
513
+ else {
514
+ const existing = iconBg.querySelector(".yak-widget-icon");
515
+ if (!existing) {
516
+ const logoImg = document.createElement("img");
517
+ logoImg.src = `${this.client.getIframeOrigin()}/logo.svg`;
518
+ logoImg.alt = "";
519
+ logoImg.width = 20;
520
+ logoImg.height = 20;
521
+ logoImg.className = "yak-widget-icon";
522
+ iconBg.innerHTML = "";
523
+ iconBg.appendChild(logoImg);
524
+ }
525
+ }
526
+ }
527
+ sendPendingPrompt() {
528
+ if (!this.pendingPrompt || !this.isReady)
529
+ return;
530
+ logger.debug("Sending pending prompt:", this.pendingPrompt);
531
+ this.client.sendPrompt(this.pendingPrompt);
532
+ this.pendingPrompt = null;
533
+ }
534
+ sendFocusIfOpen() {
535
+ if (this.isOpen && this.isReady) {
536
+ this.client.sendFocus();
537
+ }
538
+ }
539
+ notifyMobileState() {
540
+ if (this.mobileQuery) {
541
+ this.notifyIframeFullscreen(this.mobileQuery.matches);
542
+ }
543
+ }
544
+ notifyIframeFullscreen(isFullscreen) {
545
+ if (!this.iframe?.contentWindow)
546
+ return;
547
+ const msg = {
548
+ type: "yak:viewport",
549
+ payload: { fullscreen: isFullscreen },
550
+ };
551
+ this.iframe.contentWindow.postMessage(msg, this.client.getIframeOrigin());
552
+ }
553
+ notifyListeners() {
554
+ const state = this.getState();
555
+ for (const listener of this.stateListeners) {
556
+ try {
557
+ listener(state);
558
+ }
559
+ catch (err) {
560
+ logger.warn("Error in state listener:", err);
561
+ }
562
+ }
563
+ }
564
+ }
package/dist/index.d.ts CHANGED
@@ -6,5 +6,7 @@ export { EMBED_PROTOCOL_VERSION } from "./version.js";
6
6
  export type { EmbedProtocolVersion } from "./version.js";
7
7
  export { YakClient } from "./client.js";
8
8
  export type { YakClientConfig } from "./client.js";
9
+ export { YakEmbed } from "./embed.js";
10
+ export type { YakEmbedConfig, YakEmbedState, YakEmbedStateListener, TriggerButtonConfig, } from "./embed.js";
9
11
  export { enableYakLogging, disableYakLogging, isYakLoggingEnabled, logger } from "./logger.js";
10
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AACtD,YAAY,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAGzD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGnD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AACtD,YAAY,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAGzD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGnD,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EACV,cAAc,EACd,aAAa,EACb,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -7,5 +7,7 @@ export * from "./types/tools.js";
7
7
  export { EMBED_PROTOCOL_VERSION } from "./version.js";
8
8
  // Public client API
9
9
  export { YakClient } from "./client.js";
10
+ // Embed (DOM rendering layer)
11
+ export { YakEmbed } from "./embed.js";
10
12
  // Logging utilities
11
13
  export { enableYakLogging, disableYakLogging, isYakLoggingEnabled, logger } from "./logger.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yak-io/javascript",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Core JavaScript SDK for embedding yak chatbot",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -56,7 +56,7 @@
56
56
  }
57
57
  },
58
58
  "devDependencies": {
59
- "@types/node": "^24.10.4",
59
+ "@types/node": "^24.12.0",
60
60
  "typescript": "^5.3.0",
61
61
  "@repo/typescript-config": "0.0.0"
62
62
  },