@morgan-and-morgan/intake-agent 0.1.0-alpha.1

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,17 @@
1
+ # @morgan-and-morgan/intake-agent
2
+
3
+ Load the widget with a script tag, pinning an exact version:
4
+
5
+ ```html
6
+ <script
7
+ src="https://cdn.jsdelivr.net/npm/@morgan-and-morgan/intake-agent@<version>/dist/mm-intake-widget.js"
8
+ data-chat-url="https://example.com/chat"
9
+ ></script>
10
+ ```
11
+
12
+ ## Attributes
13
+
14
+ | Attribute | Default | Purpose |
15
+ | --- | --- | --- |
16
+ | `data-chat-url` | `/embed/intake` | Chat iframe target (resolved against the loader's origin) |
17
+ | `data-auto-open` | `false` | Open the chat immediately after mount |
@@ -0,0 +1,587 @@
1
+ (function() {
2
+ "use strict";
3
+ const DEFAULT_CHAT_PATH = "/embed/intake";
4
+ const readLoaderConfig = (script, baseUri) => {
5
+ var _a;
6
+ const scriptUrl = new URL(script.src, baseUri);
7
+ const chatUrl = new URL((_a = script.dataset.chatUrl) != null ? _a : DEFAULT_CHAT_PATH, scriptUrl).href;
8
+ return {
9
+ autoOpen: script.dataset.autoOpen === "true",
10
+ chatOrigin: new URL(chatUrl).origin,
11
+ chatUrl
12
+ };
13
+ };
14
+ const OPEN_STATE_KEY = "mm-intake-widget-open";
15
+ const UNREAD_STATE_KEY = "mm-intake-widget-unread";
16
+ const readFlag = (key, storage) => {
17
+ try {
18
+ return storage.getItem(key) === "true";
19
+ } catch {
20
+ return false;
21
+ }
22
+ };
23
+ const writeFlag = (key, value, storage) => {
24
+ try {
25
+ storage.setItem(key, String(value));
26
+ } catch {
27
+ }
28
+ };
29
+ const readOpenState = (storage = sessionStorage) => readFlag(OPEN_STATE_KEY, storage);
30
+ const writeOpenState = (isOpen, storage = sessionStorage) => writeFlag(OPEN_STATE_KEY, isOpen, storage);
31
+ const readUnreadState = (storage = sessionStorage) => readFlag(UNREAD_STATE_KEY, storage);
32
+ const writeUnreadState = (hasUnread, storage = sessionStorage) => {
33
+ if (hasUnread) {
34
+ writeFlag(UNREAD_STATE_KEY, true, storage);
35
+ return;
36
+ }
37
+ try {
38
+ storage.removeItem(UNREAD_STATE_KEY);
39
+ } catch {
40
+ }
41
+ };
42
+ const EMBED_PROTOCOL_VERSION = 1;
43
+ const EMBED_MESSAGE_TYPE = Object.freeze({
44
+ HOST_READY: "mm:intake:host-ready",
45
+ CHILD_READY: "mm:intake:child-ready",
46
+ SIGNING_REQUESTED: "mm:intake:signing-requested",
47
+ SIGNING_RECORDED: "mm:intake:signing-recorded",
48
+ // Chat asks the host page for a signed-in token; the host answers with
49
+ // `{ token?: string }`. A script-tag mount has no getAuthToken, so it always
50
+ // answers tokenless and the chat stays anonymous.
51
+ AUTH_TOKEN_REQUEST: "mm:intake:auth-token-request",
52
+ AUTH_TOKEN: "mm:intake:auth-token",
53
+ // Host tells the chat whether an on-screen keyboard is up, as
54
+ // `{ isOpen: boolean }`. The chat cannot measure this itself once framed: the
55
+ // host already shrinks the iframe to the visible area, so inside it the layout
56
+ // and visual viewports agree and the keyboard is invisible.
57
+ KEYBOARD_STATE: "mm:intake:keyboard-state",
58
+ // Chat tells the host whether its composer holds the caret, as
59
+ // `{ isFocused: boolean }`. The answer back, on the way out: the keyboard
60
+ // belongs to the focused field, so losing it means the keyboard is already
61
+ // leaving — which the host's own visual viewport only admits ~500ms later.
62
+ CARET_STATE: "mm:intake:caret-state",
63
+ // Chat tells the host an inbound AI or operator message just landed, so the
64
+ // launcher's unread dot can mean something. Payload-free: the dot is a dot,
65
+ // and only the chat knows a message arrived while only the host knows whether
66
+ // the panel was closed at the time.
67
+ MESSAGE_RECEIVED: "mm:intake:message-received"
68
+ });
69
+ const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
70
+ const isEmbedProtocolMessage = (value, type) => isRecord(value) && value.type === type && value.version === EMBED_PROTOCOL_VERSION && isRecord(value.payload);
71
+ const createEmbedProtocolMessage = (type, payload = {}) => ({
72
+ type,
73
+ version: EMBED_PROTOCOL_VERSION,
74
+ payload
75
+ });
76
+ const SIGNING_COMPLETE_EVENT = "signing_complete";
77
+ const isLocalHostname = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
78
+ const isAllowedSigningProtocol = (url) => url.protocol === "https:" || url.protocol === "http:" && isLocalHostname(url.hostname);
79
+ const buildSigningReturnUrl = (currentUrl = globalThis.location.href) => {
80
+ try {
81
+ const url = new URL(currentUrl);
82
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
83
+ return null;
84
+ }
85
+ url.searchParams.delete("event");
86
+ url.searchParams.delete("envelope_id");
87
+ return url.href;
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+ const parseSigningRequest = (payload) => {
93
+ if (typeof payload.envelopeId !== "string" || payload.envelopeId.trim() === "") {
94
+ return null;
95
+ }
96
+ if (typeof payload.signingUrl !== "string") {
97
+ return null;
98
+ }
99
+ try {
100
+ const url = new URL(payload.signingUrl);
101
+ if (!isAllowedSigningProtocol(url)) {
102
+ return null;
103
+ }
104
+ return {
105
+ envelopeId: payload.envelopeId,
106
+ signingUrl: url.href
107
+ };
108
+ } catch {
109
+ return null;
110
+ }
111
+ };
112
+ const readCompletedSigningEnvelopeId = (currentUrl = globalThis.location.href) => {
113
+ var _a;
114
+ try {
115
+ const params = new URL(currentUrl).searchParams;
116
+ const envelopeId = (_a = params.get("envelope_id")) == null ? void 0 : _a.trim();
117
+ return params.get("event") === SIGNING_COMPLETE_EVENT && envelopeId !== void 0 && envelopeId !== "" ? envelopeId : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ };
122
+ const LUCIDE_ICON_PATHS = {
123
+ "message-circle": '<path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>',
124
+ "x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
125
+ "scale": '<path d="M12 3v18"/><path d="m19 8 3 8a5 5 0 0 1-6 0zV7"/><path d="M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1"/><path d="m5 8 3 8a5 5 0 0 1-6 0zV7"/><path d="M7 21h10"/>',
126
+ "shield-check": '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
127
+ "lock": '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
128
+ "sparkles": '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>',
129
+ "send-horizontal": '<path d="M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z"/><path d="M6 12h16"/>',
130
+ "file-check": '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m9 15 2 2 4-4"/>',
131
+ "circle-check": '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
132
+ "chevron-down": '<path d="m6 9 6 6 6-6"/>'
133
+ };
134
+ const FULL_PAGE_PANEL_QUERY = "(max-width:600px),(max-height:500px)";
135
+ const lucideIcon = (name, className, strokeWidth = 2) => `<svg class="${className}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${String(strokeWidth)}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${LUCIDE_ICON_PATHS[name]}</svg>`;
136
+ const CHAT_ICON = lucideIcon("message-circle", "mm-icon mm-icon-chat");
137
+ const OPEN_CLOSE_ICON = lucideIcon("x", "mm-icon mm-icon-close");
138
+ const WIDGET_STYLES = [
139
+ ':host{all:initial;color-scheme:light;--mm-yellow:#fdeb01;--mm-yellow-hover:#f5de00;--mm-yellow-pressed:#e6cc00;--mm-blue:#115099;--mm-blue-hover:#0b3568;--mm-ink:#111;--mm-white:#fff;--mm-surface-blue:#f3f8ff;--mm-chrome:#e8eef2;--mm-line:#e2e8f0;--mm-muted:#545454;--mm-green:#0f9d58;--mm-font:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}',
140
+ "*,*::before,*::after{box-sizing:border-box}",
141
+ "button{font:inherit}",
142
+ "@keyframes mm-launcher-in{from{opacity:0;transform:translateY(10px) scale(.85)}to{opacity:1;transform:none}}",
143
+ "@keyframes mm-panel-in{from{opacity:0;transform:translateY(10px) scale(.98)}to{opacity:1;transform:none}}",
144
+ "@keyframes mm-panel-out{from{opacity:1;transform:none}to{opacity:0;transform:translateY(10px) scale(.98)}}",
145
+ "@keyframes mm-ping{0%{transform:scale(1);opacity:.55}70%,100%{transform:scale(2.4);opacity:0}}",
146
+ // Same register as the chat's own typing dots (typing-indicator.tsx): rest at
147
+ // .4 opacity, pulse to full with a 1.2x scale, each dot a beat behind the last.
148
+ "@keyframes mm-dot{0%,100%{opacity:.4;transform:scale(1)}50%{opacity:1;transform:scale(1.2)}}",
149
+ // The launcher stacks above the chat panel: it is the only close affordance
150
+ // (the panel is chrome-free), so the full-screen mobile panel must not cover it.
151
+ ".mm-launcher{position:fixed;right:24px;bottom:24px;z-index:2147483001;display:grid;width:64px;height:64px;place-items:center;border:2px solid var(--mm-white);border-radius:999px;outline:none;background:var(--mm-yellow);color:var(--mm-ink);box-shadow:0 10px 28px rgba(17,17,17,.24);cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-user-drag:none;animation:mm-launcher-in .25s ease-out;transition:background-color .15s ease,transform .15s ease,box-shadow .15s ease}",
152
+ ".mm-launcher *{user-select:none;-webkit-user-select:none;-webkit-user-drag:none}",
153
+ ".mm-launcher:hover{background:var(--mm-yellow-hover);box-shadow:0 12px 32px rgba(17,17,17,.28);transform:translateY(-1px)}",
154
+ ".mm-launcher:active{background:var(--mm-yellow-pressed);transform:translateY(0)}",
155
+ ".mm-launcher:focus-visible{outline:3px solid var(--mm-blue);outline-offset:3px}",
156
+ ".mm-badge{position:absolute;top:-2px;right:-2px;width:16px;height:16px;border:2px solid var(--mm-white);border-radius:999px;background:var(--mm-blue)}",
157
+ '.mm-badge::after{content:"";position:absolute;inset:-2px;border-radius:999px;background:var(--mm-blue);animation:mm-ping 2.4s ease-out infinite}',
158
+ ".mm-icon{position:absolute;top:50%;left:50%;display:block;width:28px;height:28px;pointer-events:none;transform:translate(-50%,-50%)}",
159
+ ".mm-icon-close{display:none}",
160
+ '.mm-launcher[aria-expanded="true"] .mm-icon-chat{display:none}',
161
+ '.mm-launcher[aria-expanded="true"] .mm-icon-close{display:block}',
162
+ // The panel wears the chat's header/trust-line tint, not its conversation
163
+ // tint. Measured on iOS 26: while the panel is full-page, Safari takes the
164
+ // colour of its own status-bar and URL-bar bands straight off this background
165
+ // — ahead of the host page's `theme-color` and root background, which it
166
+ // ignores outright once the chat frame has committed a navigation. So this is
167
+ // the lever that decides what the bands look like, and the bands sit right
168
+ // against the chat's header and trust line.
169
+ // Two flat halves rather than one colour, because the two bands are sampled
170
+ // independently: the top one always sits against the chat header, while the
171
+ // bottom one sits against the trust line or, with a keyboard up, the composer.
172
+ ".mm-chat{position:fixed;right:24px;bottom:100px;z-index:2147483000;display:flex;flex-direction:column;width:min(400px,calc(100vw - 32px));height:min(640px,calc(100vh - 132px));overflow:hidden;border:1px solid var(--mm-line);border-radius:20px;background:linear-gradient(to bottom,var(--mm-chrome) 0 50%,var(--mm-panel-bottom,var(--mm-chrome)) 50% 100%);box-shadow:0 5px 25px rgba(0,0,0,.2);animation:mm-panel-in .18s ease-out both;font-family:var(--mm-font)}",
173
+ // Keyboard up, the chat drops its trust line and the composer becomes the
174
+ // bottom-most surface, so the bottom band has to follow the composer. Only
175
+ // the bottom: the header is still up there against the top one.
176
+ '.mm-chat[data-keyboard="open"]{--mm-panel-bottom:var(--mm-surface-blue)}',
177
+ '.mm-chat[data-state="closing"]{pointer-events:none;animation-name:mm-panel-out}',
178
+ '.mm-chat[data-state="closed"]{visibility:hidden;animation-name:none}',
179
+ ".mm-frame-wrap{position:relative;min-height:0;flex:1;background:linear-gradient(to bottom,var(--mm-chrome) 0 50%,var(--mm-panel-bottom,var(--mm-chrome)) 50% 100%)}",
180
+ // A chat tone, never white: the frame paints this in the gap between the
181
+ // document's load event and the app's first frame, and on a phone that gap is
182
+ // the whole screen.
183
+ ".mm-frame{display:block;width:100%;height:100%;border:0;background:var(--mm-chrome)}",
184
+ ".mm-loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:5px;background:var(--mm-chrome)}",
185
+ ".mm-dot{width:8px;height:8px;border-radius:999px;background:var(--mm-muted);animation:mm-dot 1.2s ease-in-out infinite}",
186
+ ".mm-dot:nth-child(2){animation-delay:.15s}",
187
+ ".mm-dot:nth-child(3){animation-delay:.3s}",
188
+ "[hidden]{display:none!important}",
189
+ // The close affordance rides the same tracked viewport as the panel: iOS
190
+ // Safari pans the page when the keyboard opens, and a launcher pinned to the
191
+ // layout viewport's top would be panned clean off the screen with it.
192
+ //
193
+ // `min-height:0` on the panel so a short landscape viewport shrinks it all the
194
+ // way down instead of overflowing the screen and clipping the chat's own
195
+ // rotate-your-phone overlay.
196
+ //
197
+ // 16px from the corner is right for a normal host site, which has nothing of
198
+ // its own down there. A host that mounts its own bottom chrome — the client
199
+ // portal and its tab bar — sets `--mm-launcher-bottom` on any ancestor of the
200
+ // mount (custom properties reach into the shadow tree) to lift the launcher
201
+ // clear of it. Only the closed launcher: open, it moves to the top corner and
202
+ // tracks the viewport instead.
203
+ `@media ${FULL_PAGE_PANEL_QUERY}{.mm-launcher{right:16px;bottom:var(--mm-launcher-bottom,16px)}.mm-launcher[aria-expanded="true"]{top:calc(var(--mm-viewport-top,0px) + 10px);right:10px;bottom:auto;width:44px;height:44px;box-shadow:0 4px 14px rgba(17,17,17,.2)}.mm-launcher[aria-expanded="true"] .mm-icon{width:22px;height:22px}.mm-chat{right:0;top:var(--mm-viewport-top,auto);bottom:0;width:100vw;height:var(--mm-viewport-height,100dvh);min-height:0;border:0;border-radius:0}}`,
204
+ "@media(prefers-reduced-motion:reduce){.mm-launcher,.mm-chat{animation:none;transition:none}.mm-badge::after,.mm-dot{animation:none}}"
205
+ ].join("");
206
+ const WIDGET_MARKUP = [
207
+ `<style>${WIDGET_STYLES}</style>`,
208
+ `<button class="mm-launcher" type="button" aria-label="Open intake chat" aria-expanded="false" draggable="false">${CHAT_ICON}${OPEN_CLOSE_ICON}<span class="mm-badge" aria-hidden="true" hidden></span></button>`,
209
+ '<section class="mm-chat" role="dialog" aria-label="Morgan and Morgan intake chat" inert hidden>',
210
+ // Dots rather than a sentence: the panel is up for a second or two and a line
211
+ // of copy reads like an error the visitor has to act on. The label carries the
212
+ // same information for a screen reader.
213
+ '<div class="mm-frame-wrap"><div class="mm-loading" role="status" aria-label="Loading secure chat"><span class="mm-dot"></span><span class="mm-dot"></span><span class="mm-dot"></span></div><iframe class="mm-frame mm-chat-frame" title="Morgan and Morgan intake chat" referrerpolicy="strict-origin-when-cross-origin"></iframe></div>',
214
+ "</section>"
215
+ ].join("");
216
+ const requireElement = (shadow, selector) => {
217
+ const element = shadow.querySelector(selector);
218
+ if (element === null) {
219
+ throw new Error(`Intake widget shell is missing "${selector}"`);
220
+ }
221
+ return element;
222
+ };
223
+ const renderWidgetShell = (shadow) => {
224
+ shadow.innerHTML = WIDGET_MARKUP;
225
+ return {
226
+ chatDialog: requireElement(shadow, ".mm-chat"),
227
+ chatFrame: requireElement(shadow, ".mm-chat-frame"),
228
+ chatLoading: requireElement(shadow, ".mm-loading"),
229
+ launcher: requireElement(shadow, ".mm-launcher"),
230
+ unreadBadge: requireElement(shadow, ".mm-badge")
231
+ };
232
+ };
233
+ const PANEL_ANIMATION_MS = 180;
234
+ const CHAT_CHROME_COLOR = "#e8eef2";
235
+ const KEYBOARD_MIN_SHRINK_PX = 100;
236
+ const CHAT_READY_FALLBACK_MS = 4e3;
237
+ const getLauncherLabel = (isOpen, hasUnread) => {
238
+ if (isOpen) {
239
+ return "Close intake chat";
240
+ }
241
+ return hasUnread ? "Open intake chat, new message" : "Open intake chat";
242
+ };
243
+ const cleanSigningReturnUrl = () => {
244
+ const cleanedReturnUrl = buildSigningReturnUrl();
245
+ if (cleanedReturnUrl !== null) {
246
+ globalThis.history.replaceState(globalThis.history.state, "", cleanedReturnUrl);
247
+ }
248
+ };
249
+ const mountIntakeWidget = (config, onDestroy) => {
250
+ var _a;
251
+ const mount = document.createElement("div");
252
+ mount.dataset.mmIntakeWidget = "";
253
+ const shadow = mount.attachShadow({ mode: "open" });
254
+ const elements = renderWidgetShell(shadow);
255
+ document.body.append(mount);
256
+ const completedSigningEnvelopeId = readCompletedSigningEnvelopeId();
257
+ let chatHasLoaded = false;
258
+ let chatCloseTimer;
259
+ let chatReadyTimer;
260
+ let isKeyboardOpen = false;
261
+ let hasReleasedKeyboardEarly = false;
262
+ let hasUnread = readUnreadState();
263
+ const setHasUnread = (next) => {
264
+ hasUnread = next;
265
+ elements.unreadBadge.hidden = !next;
266
+ writeUnreadState(next);
267
+ syncLauncherLabel();
268
+ };
269
+ const syncLauncherLabel = () => {
270
+ const isOpen = elements.chatDialog.dataset.state === "open";
271
+ elements.launcher.setAttribute("aria-expanded", String(isOpen));
272
+ elements.launcher.setAttribute("aria-label", getLauncherLabel(isOpen, hasUnread));
273
+ };
274
+ const postToChat = (message) => {
275
+ var _a2;
276
+ (_a2 = elements.chatFrame.contentWindow) == null ? void 0 : _a2.postMessage(message, config.chatOrigin);
277
+ };
278
+ const postHostReady = () => {
279
+ const currentUrl = globalThis.location.href;
280
+ const completedEnvelopeId = readCompletedSigningEnvelopeId(currentUrl);
281
+ const hostUrl = buildSigningReturnUrl(currentUrl);
282
+ postToChat(
283
+ createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.HOST_READY, {
284
+ ...hostUrl === null ? {} : { hostUrl },
285
+ ...completedEnvelopeId === null ? {} : { completedEnvelopeId }
286
+ })
287
+ );
288
+ };
289
+ const answerAuthTokenRequest = async () => {
290
+ var _a2;
291
+ let token;
292
+ try {
293
+ token = await ((_a2 = config.getAuthToken) == null ? void 0 : _a2.call(config));
294
+ } catch {
295
+ token = void 0;
296
+ }
297
+ postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.AUTH_TOKEN, token === void 0 ? {} : { token }));
298
+ };
299
+ const ensureChatLoaded = () => {
300
+ if (elements.chatFrame.getAttribute("src") !== null) {
301
+ return;
302
+ }
303
+ elements.chatLoading.hidden = false;
304
+ elements.chatFrame.src = config.chatUrl;
305
+ };
306
+ const revealChat = () => {
307
+ globalThis.clearTimeout(chatReadyTimer);
308
+ chatReadyTimer = void 0;
309
+ elements.chatLoading.hidden = true;
310
+ };
311
+ const syncViewport = () => {
312
+ const viewport = globalThis.visualViewport;
313
+ if (!viewport || viewport.height === 0) {
314
+ return;
315
+ }
316
+ const keyboardShrink = document.documentElement.clientHeight - viewport.height;
317
+ if (hasReleasedKeyboardEarly) {
318
+ if (keyboardShrink > KEYBOARD_MIN_SHRINK_PX) {
319
+ return;
320
+ }
321
+ hasReleasedKeyboardEarly = false;
322
+ }
323
+ mount.style.setProperty("--mm-viewport-height", `${viewport.height}px`);
324
+ mount.style.setProperty("--mm-viewport-top", `${viewport.offsetTop}px`);
325
+ setKeyboardOpen(keyboardShrink > KEYBOARD_MIN_SHRINK_PX);
326
+ };
327
+ const releaseKeyboardEarly = () => {
328
+ if (!isKeyboardOpen) {
329
+ return;
330
+ }
331
+ mount.style.setProperty("--mm-viewport-height", `${document.documentElement.clientHeight}px`);
332
+ mount.style.setProperty("--mm-viewport-top", "0px");
333
+ hasReleasedKeyboardEarly = true;
334
+ setKeyboardOpen(false);
335
+ };
336
+ const fullPagePanelQuery = (_a = globalThis.matchMedia) == null ? void 0 : _a.call(globalThis, FULL_PAGE_PANEL_QUERY);
337
+ let themeColorBackup = null;
338
+ let createdThemeColor = null;
339
+ const hostRoots = [document.documentElement, document.body];
340
+ let hostBackgroundColorBackup = null;
341
+ const borrowHostChrome = () => {
342
+ if (themeColorBackup === null) {
343
+ hostBackgroundColorBackup = hostRoots.map((root) => root.style.backgroundColor);
344
+ const metas = [...document.head.querySelectorAll('meta[name="theme-color"]')];
345
+ if (metas.length === 0) {
346
+ createdThemeColor = document.createElement("meta");
347
+ createdThemeColor.name = "theme-color";
348
+ document.head.append(createdThemeColor);
349
+ metas.push(createdThemeColor);
350
+ }
351
+ themeColorBackup = metas.map((meta) => ({ content: meta.content, meta }));
352
+ }
353
+ for (const root of hostRoots) {
354
+ root.style.setProperty("background-color", CHAT_CHROME_COLOR, "important");
355
+ }
356
+ for (const { meta } of themeColorBackup) {
357
+ meta.content = CHAT_CHROME_COLOR;
358
+ }
359
+ };
360
+ const restoreHostChrome = () => {
361
+ var _a2;
362
+ if (themeColorBackup === null) {
363
+ return;
364
+ }
365
+ for (const { content, meta } of themeColorBackup) {
366
+ meta.content = content;
367
+ }
368
+ createdThemeColor == null ? void 0 : createdThemeColor.remove();
369
+ createdThemeColor = null;
370
+ themeColorBackup = null;
371
+ for (const [index, root] of hostRoots.entries()) {
372
+ root.style.backgroundColor = (_a2 = hostBackgroundColorBackup == null ? void 0 : hostBackgroundColorBackup[index]) != null ? _a2 : "";
373
+ }
374
+ hostBackgroundColorBackup = null;
375
+ };
376
+ const syncHostChrome = () => {
377
+ if (elements.chatDialog.dataset.state !== "open") {
378
+ return;
379
+ }
380
+ if (fullPagePanelQuery == null ? void 0 : fullPagePanelQuery.matches) {
381
+ borrowHostChrome();
382
+ } else {
383
+ restoreHostChrome();
384
+ }
385
+ };
386
+ function setKeyboardOpen(next) {
387
+ if (next === isKeyboardOpen) {
388
+ return;
389
+ }
390
+ isKeyboardOpen = next;
391
+ elements.chatDialog.dataset.keyboard = next ? "open" : "closed";
392
+ postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.KEYBOARD_STATE, { isOpen: next }));
393
+ syncHostChrome();
394
+ }
395
+ const startViewportTracking = () => {
396
+ var _a2, _b;
397
+ (_a2 = globalThis.visualViewport) == null ? void 0 : _a2.addEventListener("resize", syncViewport);
398
+ (_b = globalThis.visualViewport) == null ? void 0 : _b.addEventListener("scroll", syncViewport);
399
+ fullPagePanelQuery == null ? void 0 : fullPagePanelQuery.addEventListener("change", syncHostChrome);
400
+ syncViewport();
401
+ syncHostChrome();
402
+ };
403
+ const stopViewportTracking = () => {
404
+ var _a2, _b;
405
+ (_a2 = globalThis.visualViewport) == null ? void 0 : _a2.removeEventListener("resize", syncViewport);
406
+ (_b = globalThis.visualViewport) == null ? void 0 : _b.removeEventListener("scroll", syncViewport);
407
+ fullPagePanelQuery == null ? void 0 : fullPagePanelQuery.removeEventListener("change", syncHostChrome);
408
+ setKeyboardOpen(false);
409
+ hasReleasedKeyboardEarly = false;
410
+ restoreHostChrome();
411
+ mount.style.removeProperty("--mm-viewport-height");
412
+ mount.style.removeProperty("--mm-viewport-top");
413
+ };
414
+ const finishChatClose = () => {
415
+ if (elements.chatDialog.dataset.state !== "closing") {
416
+ return;
417
+ }
418
+ globalThis.clearTimeout(chatCloseTimer);
419
+ chatCloseTimer = void 0;
420
+ elements.chatDialog.dataset.state = "closed";
421
+ stopViewportTracking();
422
+ };
423
+ const openChat = () => {
424
+ globalThis.clearTimeout(chatCloseTimer);
425
+ chatCloseTimer = void 0;
426
+ elements.chatDialog.hidden = false;
427
+ elements.chatDialog.dataset.state = "open";
428
+ elements.chatDialog.removeAttribute("aria-hidden");
429
+ elements.chatDialog.removeAttribute("inert");
430
+ setHasUnread(false);
431
+ startViewportTracking();
432
+ ensureChatLoaded();
433
+ elements.chatFrame.focus();
434
+ writeOpenState(true);
435
+ if (chatHasLoaded) {
436
+ postHostReady();
437
+ }
438
+ };
439
+ const closeChat = () => {
440
+ var _a2;
441
+ elements.launcher.focus();
442
+ writeOpenState(false);
443
+ const wasOpen = elements.chatDialog.dataset.state === "open";
444
+ if (wasOpen) {
445
+ elements.chatDialog.dataset.state = "closing";
446
+ elements.chatDialog.setAttribute("aria-hidden", "true");
447
+ elements.chatDialog.setAttribute("inert", "");
448
+ }
449
+ syncLauncherLabel();
450
+ if (!wasOpen) {
451
+ return;
452
+ }
453
+ if ((_a2 = globalThis.matchMedia) == null ? void 0 : _a2.call(globalThis, "(prefers-reduced-motion: reduce)").matches) {
454
+ finishChatClose();
455
+ return;
456
+ }
457
+ chatCloseTimer = globalThis.setTimeout(finishChatClose, PANEL_ANIMATION_MS + 50);
458
+ };
459
+ const openSigning = (payload) => {
460
+ const request = parseSigningRequest(payload);
461
+ if (request === null) {
462
+ return;
463
+ }
464
+ globalThis.location.assign(request.signingUrl);
465
+ };
466
+ const handleMessage = (event) => {
467
+ const message = event.data;
468
+ if (event.source === elements.chatFrame.contentWindow && event.origin === config.chatOrigin) {
469
+ if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CHILD_READY)) {
470
+ revealChat();
471
+ postHostReady();
472
+ return;
473
+ }
474
+ if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.AUTH_TOKEN_REQUEST)) {
475
+ void answerAuthTokenRequest();
476
+ return;
477
+ }
478
+ if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CARET_STATE)) {
479
+ if (message.payload.isFocused === true) {
480
+ hasReleasedKeyboardEarly = false;
481
+ } else {
482
+ releaseKeyboardEarly();
483
+ }
484
+ return;
485
+ }
486
+ if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.MESSAGE_RECEIVED)) {
487
+ if (elements.chatDialog.dataset.state !== "open") {
488
+ setHasUnread(true);
489
+ }
490
+ return;
491
+ }
492
+ if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.SIGNING_REQUESTED)) {
493
+ openSigning(message.payload);
494
+ return;
495
+ }
496
+ if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.SIGNING_RECORDED)) {
497
+ cleanSigningReturnUrl();
498
+ }
499
+ }
500
+ };
501
+ const handleKeydown = (event) => {
502
+ if (event.key !== "Escape") {
503
+ return;
504
+ }
505
+ if (elements.chatDialog.dataset.state === "open") {
506
+ closeChat();
507
+ }
508
+ };
509
+ const destroy = () => {
510
+ stopViewportTracking();
511
+ globalThis.clearTimeout(chatCloseTimer);
512
+ globalThis.clearTimeout(chatReadyTimer);
513
+ globalThis.removeEventListener("message", handleMessage);
514
+ document.removeEventListener("keydown", handleKeydown);
515
+ mount.remove();
516
+ onDestroy == null ? void 0 : onDestroy();
517
+ };
518
+ elements.launcher.addEventListener("click", () => {
519
+ if (elements.chatDialog.dataset.state === "open") {
520
+ closeChat();
521
+ } else {
522
+ openChat();
523
+ }
524
+ });
525
+ elements.chatDialog.addEventListener("animationend", (event) => {
526
+ if (event.target === elements.chatDialog) {
527
+ finishChatClose();
528
+ }
529
+ });
530
+ elements.chatFrame.addEventListener("load", () => {
531
+ chatHasLoaded = true;
532
+ syncHostChrome();
533
+ chatReadyTimer != null ? chatReadyTimer : chatReadyTimer = globalThis.setTimeout(revealChat, CHAT_READY_FALLBACK_MS);
534
+ postHostReady();
535
+ });
536
+ globalThis.addEventListener("message", handleMessage);
537
+ document.addEventListener("keydown", handleKeydown);
538
+ setHasUnread(hasUnread);
539
+ if (completedSigningEnvelopeId !== null || readOpenState()) {
540
+ openChat();
541
+ }
542
+ return Object.freeze({
543
+ close: closeChat,
544
+ destroy,
545
+ open: openChat,
546
+ version: EMBED_PROTOCOL_VERSION
547
+ });
548
+ };
549
+ const clearWidgetGlobals = () => {
550
+ try {
551
+ delete globalThis.MMIntakeWidget;
552
+ delete globalThis.__MMIntakeWidgetBootstrapping;
553
+ } catch {
554
+ globalThis.MMIntakeWidget = void 0;
555
+ globalThis.__MMIntakeWidgetBootstrapping = void 0;
556
+ }
557
+ };
558
+ const documentReady = async () => {
559
+ if (document.readyState !== "loading") {
560
+ return;
561
+ }
562
+ await new Promise((resolve) => {
563
+ document.addEventListener(
564
+ "DOMContentLoaded",
565
+ () => {
566
+ resolve();
567
+ },
568
+ { once: true }
569
+ );
570
+ });
571
+ };
572
+ const bootstrap = async () => {
573
+ const script = document.currentScript;
574
+ if (!(script instanceof HTMLScriptElement) || globalThis.MMIntakeWidget !== void 0 || globalThis.__MMIntakeWidgetBootstrapping === true) {
575
+ return;
576
+ }
577
+ globalThis.__MMIntakeWidgetBootstrapping = true;
578
+ const config = readLoaderConfig(script, document.baseURI);
579
+ await documentReady();
580
+ const api = mountIntakeWidget(config, clearWidgetGlobals);
581
+ globalThis.MMIntakeWidget = api;
582
+ if (config.autoOpen) {
583
+ api.open();
584
+ }
585
+ };
586
+ void bootstrap();
587
+ })();
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@morgan-and-morgan/intake-agent",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Embeddable chat widget loader.",
5
+ "license": "UNLICENSED",
6
+ "files": [
7
+ "dist",
8
+ "README.md"
9
+ ],
10
+ "main": "./dist/mm-intake-widget.js",
11
+ "exports": {
12
+ ".": "./dist/mm-intake-widget.js",
13
+ "./widget": "./src/widget.ts",
14
+ "./protocol": "./src/protocol.ts",
15
+ "./signing": "./src/signing.ts",
16
+ "./loader-config": "./src/loader-config.ts"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "devDependencies": {
22
+ "eslint": "^9.39.1",
23
+ "lucide-react-native": "^0.562.0",
24
+ "typescript": "~5.9.3",
25
+ "vite": "^7.3.6",
26
+ "@mmcp/eslint-config": "0.1.0",
27
+ "@mmcp/tsconfig": "0.1.0"
28
+ },
29
+ "scripts": {
30
+ "build": "pnpm run build:bundle",
31
+ "build:bundle": "node scripts/generate-icons.mjs && vite build",
32
+ "generate:icons": "node scripts/generate-icons.mjs",
33
+ "typecheck": "tsc --noEmit",
34
+ "lint": "eslint \"src/**/*.ts\" --quiet",
35
+ "lint:fix": "eslint \"src/**/*.ts\" --quiet --fix"
36
+ }
37
+ }