@ahmedramez-org/nexus-chat 1.0.0-beta.6 → 1.0.0-beta.7

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 CHANGED
@@ -1,14 +1,31 @@
1
1
  # @incorta/nexus-chat
2
2
 
3
- NexusChat, the IncortaCopilot chat UI, for any app on any framework: `<NexusChat />` for React,
4
- `mountNexusChat()` for everything else.
3
+ NexusChat, the IncortaCopilot chat UI, for any app on any framework: one `<script>` tag for any
4
+ site, `<NexusChat />` for React, `mountNexusChat()` for everything else.
5
5
 
6
6
  The chat always runs as a prebuilt Module Federation bundle with its own React and isolated
7
7
  styles, never re-compiled by the app's bundler, so it never collides with the app's React, its
8
- dependencies or its CSS. Importing the connector and the component adds about 3 KB to an app. The
9
- chat build itself is not in this package: Copilot serves it, and each GitHub Release attaches it as
8
+ dependencies or its CSS. The connector adds about 6 KB gzipped to an app (half of it the Incorta
9
+ Intelligence mark its loading skeleton draws), and the React component about 7 KB more. The chat
10
+ build itself is not in this package: Copilot serves it, and each GitHub Release attaches it as
10
11
  `nexus-chat-dist.tar.gz`.
11
12
 
13
+ ## Any site: one script
14
+
15
+ ```html
16
+ <script src="https://<incorta>/incorta/copilot/nexus-chat/widget.js" defer></script>
17
+ ```
18
+
19
+ Incorta serves this script next to `embed.html`; it is not in this package and needs no build. It
20
+ draws the Incorta Intelligence launcher at the bottom right and opens the embedded chat (below)
21
+ floating, docked or full screen, on the user's Incorta session, so the page holds no token. It is
22
+ one classic script with no imports, so a page on any origin loads it without CORS.
23
+
24
+ Optional `data-` attributes set it up: `data-theme="dark"`, `data-modes="floating,docked"` (the
25
+ windows allowed, in that order; default all three), `data-default-mode="docked"` (open on load;
26
+ default only the launcher) and `data-locale="fr"`. The page drives it afterwards through
27
+ `window.NexusChatWidget`: `open()`, `close()`, `setMode('docked')`, `setTheme('dark')`.
28
+
12
29
  ## React
13
30
 
14
31
  Two ways in: **direct**, where the chat mounts into the page and shares its session (Incorta's own
@@ -24,12 +41,13 @@ import { NexusChat } from '@incorta/nexus-chat/react';
24
41
  ```
25
42
 
26
43
  It draws its own window: an Incorta Intelligence launcher at the bottom right opens the chat
27
- floating or docked on the right (the page makes room). The chat's own top bar takes it to full
28
- screen and back (Undock / Dock to the side) and closes it back to the launcher; nothing sits
29
- above the chat. `modes` limits the windows (default all three, in that order) and `defaultMode`
30
- opens one on load; dock returns to the window last shown beside the page. Below 768px wide the chat covers the page. The window takes the
31
- page's background colour, the chat is drawn smaller when floating or docked, and the launcher icon
32
- shimmers while the chat loads. `theme` is followed live; `locale` is read when the chat first opens.
44
+ floating (a card lifted off the page, opening out of the launcher, which stays under it to hide
45
+ it) or docked flush on the right (the page makes room). The chat's own top bar switches between
46
+ the windows (Float, Dock to the side, Enter full screen: every allowed one but the current) and
47
+ closes it back to the launcher; nothing sits above the chat. `modes` limits the windows (default
48
+ all three, in that order) and `defaultMode` opens one on load. Below 768px wide the chat covers
49
+ the page. The chat is drawn smaller in a small window, and a skeleton holds the window while the
50
+ chat loads. `theme` is followed live; `locale` is read when the chat first opens.
33
51
 
34
52
  The Incorta cluster needs `iframe.inclusion.enabled` (and `frame-ancestors` set to the app's
35
53
  domains). Where the browser keeps the Incorta session from the iframe (Safari, Firefox, private
@@ -44,23 +62,67 @@ places the iframe itself can do the same: `iframe.contentWindow.postMessage({ ne
44
62
 
45
63
  ### Direct
46
64
 
65
+ ```ts
66
+ // Copilot answers 400 without the signed-in user's token: read it from Incorta's
67
+ // session, and read it again after a 401 or 403.
68
+ let session;
69
+ const incortaHeaders = () =>
70
+ (session ??= fetch('/incorta/service/user/isLoggedIn', { credentials: 'include' }).then(
71
+ async (response) => ({
72
+ Authorization: `Bearer ${(await response.json()).accessToken}`,
73
+ 'X-XSRF-TOKEN': response.headers.get('X-XSRF-TOKEN') ?? '',
74
+ }),
75
+ ));
76
+ ```
77
+
47
78
  ```tsx
48
79
  <NexusChat
49
- connection={{ client: 'general', copilotUrl: '/incorta/copilot', credentials: 'include' }}
80
+ connection={{
81
+ client: 'general',
82
+ copilotUrl: '/incorta/copilot',
83
+ credentials: 'include',
84
+ headers: incortaHeaders, // read before each request
85
+ refreshAuth: async () => ((session = undefined), true), // after a 401 or 403: read again, retry
86
+ }}
50
87
  theme="dark"
51
88
  user={{ id: user.sub, name: user.name }}
52
89
  onLoadError={(error) => setError(error.message)}
53
90
  />
54
91
  ```
55
92
 
56
- The chat fills the component's box; the app draws any frame around it. Until the chat is mounted, the Incorta Intelligence mark sits in the middle of the box (the embedded window's loading face), and it leaves when the chat mounts or `onLoadError` fires, so the app's own failure takes the box. `theme`, `locale` (default:
93
+ The chat fills the component's box; the app draws any frame around it. Until the chat is mounted, a skeleton holds the box (the embedded window's loading face: the Incorta Intelligence mark and two lines, after 300 ms), and it fades once the chat mounts, or leaves at once when `onLoadError` fires, so the app's own failure takes the box. `theme`, `locale` (default:
57
94
  the browser's), `direction`, `tenantId`, `user` and `window` update the running chat;
58
95
  `connection`, `chat`, `actions`, `events`, `instanceId`, `assetsUrl` and `timeoutMs` are read once
59
96
  at mount. `window` (`docked`, `canDock`, `setDocked`, `close`) hands the chat's window to the app:
60
- the chat shows a dock toggle and a close button. An app that already builds the contract's
97
+ the chat shows a dock toggle and a close button; with `mode`, `modes` and `setMode` (read at
98
+ mount) it shows a button per other mode instead of the toggle. An app that already builds the contract's
61
99
  `environment` or `window` passes it as is. `onReady` receives the handle (`ask`, `attach`), and
62
100
  `null` once the chat unmounts; `loadedNexusChatVersion()` names the loaded build for error reports.
63
101
 
102
+ ### Direct, in a window
103
+
104
+ ```tsx
105
+ <NexusChat
106
+ connection={{
107
+ client: 'general',
108
+ copilotUrl: '/incorta/copilot',
109
+ credentials: 'include',
110
+ headers: incortaHeaders, // as above
111
+ refreshAuth: async () => ((session = undefined), true),
112
+ }}
113
+ theme="dark"
114
+ modes={['floating', 'docked', 'fullscreen']}
115
+ defaultMode="floating"
116
+ />
117
+ ```
118
+
119
+ `modes` or `defaultMode` instead of `window` gives the direct chat the embedded window: the
120
+ launcher, floating, docked (the page makes room) and full-screen windows, full screen below 768px.
121
+ The chat's own top bar switches windows and closes it to the launcher, where the conversation
122
+ stays. `modes` limits the windows (default all three, in that order); `defaultMode` opens one on
123
+ load, else only the launcher shows. The other props are the direct ones, and an app passing
124
+ `window` keeps drawing its own frame.
125
+
64
126
  ## Any framework
65
127
 
66
128
  ```ts
@@ -78,8 +140,10 @@ chat.unmount();
78
140
  ```
79
141
 
80
142
  The options are the host contract: `connection` and `environment` are required; `chat`, `actions`,
81
- `events` and `window` are optional, and the chat hides what a host leaves out. Mount in the browser
82
- only, never during SSR.
143
+ `events` and `window` are optional, and the chat hides what a host leaves out. The chat fills
144
+ `element`. Until it is mounted the React component's skeleton holds the element (after 300 ms, in
145
+ the `environment`'s theme), and it leaves at once if the chat cannot load, so the app's own failure
146
+ takes its place. Mount in the browser only, never during SSR.
83
147
 
84
148
  `chat={{ contexts: ['business-view', 'ai-container', 'file'] }}` lets users attach contexts, in
85
149
  that menu order: `'file'` uploads, and each other built-in kind (`business-view`, `physical-table`,
@@ -89,7 +153,8 @@ that menu order: `'file'` uploads, and each other built-in kind (`business-view`
89
153
  Stores. A `ContextSource` of a kind replaces the chat's loader for it.
90
154
 
91
155
  `loadNexusChat(source)` only loads the chat and resolves to its `mount`. It loads once per source,
92
- retries once, and gives up after `timeoutMs` (default 20 s); a failed load is forgotten so the next
156
+ retries once, and gives up after `timeoutMs` (default 20 s, counted while the page is visible: a
157
+ hidden tab gets a fresh deadline once it shows); a failed load is forgotten so the next
93
158
  call tries again. Call it when the page is idle to make the first open instant.
94
159
 
95
160
  ## Where the chat comes from
@@ -383,16 +383,27 @@ export interface NexusChatOptions {
383
383
  * The host sizes the mount and owns its window (a page-covering overlay, a
384
384
  * docked side panel) and draws its frame. Present: the chat hides its own
385
385
  * fullscreen toggle and its edge border, and shows a dock toggle while
386
- * `canDock`, and a close button. Absent: the chat's own fullscreen toggle,
387
- * and no close button.
386
+ * `canDock` (or, with `mode`, `modes` and `setMode`, one button per other
387
+ * mode), and a close button. Absent: the chat's own fullscreen toggle, and
388
+ * no close button.
388
389
  */
389
390
  window?: NexusChatWindow;
390
391
  }
392
+ /** The windows a host can show the chat in. */
393
+ export type NexusChatWindowMode = 'floating' | 'docked' | 'fullscreen';
391
394
  export interface NexusChatWindowState {
392
395
  /** The mount is the docked side panel (the toggle shows pressed). */
393
396
  docked: boolean;
394
397
  /** The host page allows docking now; `false` hides the dock toggle. */
395
398
  canDock: boolean;
399
+ /**
400
+ * The window shown now. With `modes` and `setMode`, the header offers every
401
+ * mode in `modes` but this one (float, dock, full screen, in that order)
402
+ * instead of the dock toggle.
403
+ */
404
+ mode?: NexusChatWindowMode;
405
+ /** The modes the host can switch to now, the current one included. */
406
+ modes?: readonly NexusChatWindowMode[];
396
407
  }
397
408
  export interface NexusChatWindow {
398
409
  /** May return a fresh object on every call: the chat compares the fields. */
@@ -400,6 +411,8 @@ export interface NexusChatWindow {
400
411
  /** Call `onChange` whenever `get` may return something different. */
401
412
  subscribe(onChange: () => void): () => void;
402
413
  setDocked(docked: boolean): void;
414
+ /** Shows the chat in another of `modes`; the conversation and draft stay. */
415
+ setMode?(mode: NexusChatWindowMode): void;
403
416
  close(): void;
404
417
  }
405
418
  /** What `mount` returns: the host's handle on one running chat. */
package/index.d.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { NexusChatHandle, NexusChatMount, NexusChatOptions } from './contract/index.js';
1
+ import type {
2
+ HostTheme,
3
+ NexusChatHandle,
4
+ NexusChatMount,
5
+ NexusChatOptions,
6
+ } from './contract/index.js';
2
7
 
3
8
  export type * from './contract/index.js';
4
9
 
@@ -35,3 +40,12 @@ export function mountNexusChat(
35
40
  element: HTMLElement,
36
41
  options: MountNexusChatOptions,
37
42
  ): Promise<NexusChatHandle>;
43
+
44
+ /**
45
+ * @internal The skeleton `mountNexusChat` and `<NexusChat>` draw over a box
46
+ * while the chat loads. Not part of the package's API.
47
+ */
48
+ export function showNexusChatSkeleton(
49
+ box: HTMLElement,
50
+ theme?: HostTheme,
51
+ ): { setTheme(theme?: HostTheme): void; done(): void; remove(): void };
package/index.js CHANGED
@@ -126,6 +126,24 @@ export function loadedNexusChatVersion() {
126
126
  return loadedVersion;
127
127
  }
128
128
 
129
+ /**
130
+ * Rejects after `timeoutMs`, counted while the page can be seen: a hidden tab
131
+ * runs its timers and requests slowly (Chrome throttles background tabs), so
132
+ * a chat loading there gets a fresh deadline once the tab shows. Internal to
133
+ * this package.
134
+ */
135
+ export function loadDeadline(timeoutMs) {
136
+ return new Promise((_, reject) => {
137
+ const expire = () => {
138
+ if (!document.hidden) return reject(new Error('Timed out loading NexusChat.'));
139
+ document.addEventListener('visibilitychange', () => setTimeout(expire, timeoutMs), {
140
+ once: true,
141
+ });
142
+ };
143
+ setTimeout(expire, timeoutMs);
144
+ });
145
+ }
146
+
129
147
  /**
130
148
  * Loads the chat once per source and resolves to its `mount` function. One
131
149
  * retry, since a single failure (a 502 during a rolling restart) is not an
@@ -143,9 +161,7 @@ export function loadNexusChat(options) {
143
161
  await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
144
162
  return importMount(entry);
145
163
  }),
146
- new Promise((_, reject) =>
147
- setTimeout(() => reject(new Error('Timed out loading NexusChat.')), timeoutMs),
148
- ),
164
+ loadDeadline(timeoutMs),
149
165
  ]);
150
166
  load.catch(() => loads.delete(entry));
151
167
  loads.set(entry, load);
@@ -156,13 +172,133 @@ export function loadNexusChat(options) {
156
172
  /**
157
173
  * Loads the chat (from `assetsUrl`, else `connection.copilotUrl`) and mounts
158
174
  * it into `element` with the host contract options. The chat fills the
159
- * element, so the element's box decides the presentation.
175
+ * element, so the element's box decides the presentation. Until the chat is
176
+ * up the skeleton holds the element; it leaves at once if the chat cannot load.
160
177
  */
161
178
  export async function mountNexusChat(element, { assetsUrl, timeoutMs, ...options }) {
162
- const mount = await loadNexusChat({
163
- copilotUrl: options.connection?.copilotUrl,
164
- assetsUrl,
165
- timeoutMs,
166
- });
167
- return mount(element, { version: CONTRACT_VERSION, ...options });
179
+ const skeleton = showNexusChatSkeleton(element, options.environment?.get().theme);
180
+ let handle;
181
+ try {
182
+ const mount = await loadNexusChat({
183
+ copilotUrl: options.connection?.copilotUrl,
184
+ assetsUrl,
185
+ timeoutMs,
186
+ });
187
+ handle = mount(element, { version: CONTRACT_VERSION, ...options });
188
+ } catch (error) {
189
+ skeleton.remove();
190
+ throw error;
191
+ }
192
+ skeleton.done();
193
+ return {
194
+ ...handle,
195
+ unmount() {
196
+ skeleton.remove();
197
+ handle.unmount();
198
+ },
199
+ };
200
+ }
201
+
202
+ // ---- the loading face every app shows until the chat is up (<NexusChat> draws it too)
203
+
204
+ /** Fast loads show no skeleton; one that shows stays long enough to read, then cross-fades. */
205
+ const SKELETON_DELAY_MS = 300;
206
+ const SKELETON_MIN_MS = 400;
207
+ const SKELETON_FADE_MS = 150;
208
+
209
+ /** The chat's own ground (so the swap does not flash), its line fill, its window edge. */
210
+ export const PALETTE = {
211
+ light: { ground: '#fcfdfe', fill: '#efeff2', line: '#e4e4e7' },
212
+ dark: { ground: '#151515', fill: '#242426', line: '#2c2c2e' },
213
+ };
214
+
215
+ export const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
216
+
217
+ /** The Incorta Intelligence mark. */
218
+ export const ICON = `data:image/svg+xml,${encodeURIComponent(
219
+ "<svg width='107' height='120' viewBox='0 0 107 120' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_108_873)'><path d='M84 35.4C89.8 37.6 95.2 41.2 99.8 45.9C102.4 48.6 104.6 51.5 106.5 54.6L106.7 35.4C106.7 32 104.9 28.8 102 27L63.1 3.9C66.2 5.8 69.1 8.1 71.7 10.9C78.5 17.8 82.6 26.4 84 35.4Z' fill='url(#paint0_linear_108_873)'/><path d='M22.9 84.8C17.1 82.5 11.7 79 7.1 74.3C4.4 71.5 2.1 68.4 0.2 65.2L0 84.6C0 88.1 1.7 91.2 4.7 93L43.9 116.3C40.8 114.4 37.9 112.1 35.2 109.3C28.4 102.4 24.3 93.8 22.9 84.8Z' fill='url(#paint1_linear_108_873)'/><path d='M106.1 85.8L106.5 54.6C104.6 51.5 102.4 48.6 99.8 45.9C95.2 41.2 89.8 37.6 84 35.4C86.3 49.4 81.9 64.3 71 75C57.9 87.9 38.9 91.1 22.9 84.8C24.3 93.8 28.4 102.4 35.2 109.3C37.9 112.1 40.8 114.4 43.9 116.3L45.2 117.1L47.9 118.7C48.9 119.3 49.9 119.7 51 119.9C53.2 120.3 55.5 119.9 57.5 118.8L101.3 94.1C104.2 92.4 106.1 89.2 106.1 85.8V85.8Z' fill='url(#paint2_linear_108_873)'/><path d='M35.9 45.2C49 32.3 68 29.1 84 35.4C82.6 26.4 78.5 17.8 71.7 10.8C69.1 8.1 66.2 5.8 63.1 3.9L61.7 3.1L58.8 1.3C57.7 0.7 56.6 0.3 55.4 0.1C53.3 -0.2 51.1 0.2 49.2 1.2L5.4 26C2.4 27.6 0.6 30.8 0.6 34.2L0.2 65.2C2.1 68.4 4.4 71.5 7.1 74.3C11.7 79 17.1 82.5 22.9 84.8C20.7 70.7 25 55.9 35.9 45.2V45.2Z' fill='#00A098'/><path d='M22.9 84.8C23 85.3 23.1 85.8 23.2 86.3C23.2 86.5 23.2 86.7 23.3 86.8C23.3 87.2 23.4 87.5 23.5 87.9C23.5 88 23.6 88.2 23.6 88.4C23.7 88.8 23.8 89.1 23.9 89.4C23.9 89.6 24 89.8 24 90C24.1 90.4 24.2 90.8 24.4 91.1C24.4 91.4 24.5 91.6 24.6 91.9C24.7 92.2 24.8 92.5 24.9 92.8C25 93.1 25.1 93.3 25.2 93.6C25.3 93.8 25.4 94 25.5 94.2C25.6 94.5 25.7 94.8 25.8 95C25.9 95.2 25.9 95.4 26 95.6C26.2 95.9 26.3 96.2 26.4 96.5C26.5 96.6 26.5 96.7 26.5 96.7C26.9 97.4 27.2 98.1 27.6 98.8C27.6 98.9 27.6 98.9 27.7 99C27.9 99.3 28 99.7 28.2 100C28.3 100.1 28.4 100.2 28.4 100.3C28.6 100.7 28.8 101 29 101.3C29 101.4 29.1 101.5 29.1 101.6C29.8 102.7 30.6 103.8 31.4 104.9C31.8 104.9 92.7 102.9 96.2 42.5C96.6 36.7 96.4 30.2 95.5 23.2L63.1 3.9C66.2 5.8 69.1 8.1 71.7 10.9C78.5 17.8 82.6 26.4 84 35.4C86.3 49.4 81.9 64.3 71 75C57.9 87.9 38.9 91.1 22.9 84.8V84.8Z' fill='url(#paint3_linear_108_873)'/><path d='M84 35.4C84 34.9 83.9 34.4 83.8 33.8C83.7 33.7 83.7 33.5 83.7 33.4C83.6 33 83.5 32.7 83.4 32.3C83.4 32.1 83.3 31.9 83.3 31.7C83.2 31.4 83.1 31.1 83.1 30.8C83 30.6 83 30.4 82.9 30.2C82.8 29.8 82.7 29.4 82.6 29C82.5 28.8 82.4 28.5 82.3 28.3C82.2 28 82.1 27.7 82 27.4C81.9 27.1 81.8 26.9 81.7 26.6C81.6 26.4 81.5 26.2 81.5 25.9C81.4 25.7 81.2 25.4 81.1 25.1C81.1 24.9 81 24.7 80.9 24.6C80.8 24.3 80.6 24 80.5 23.7C80.5 23.6 80.4 23.5 80.4 23.4C80.1 22.7 79.7 22.1 79.4 21.4C79.3 21.3 79.3 21.2 79.2 21.2C79.1 20.8 78.9 20.5 78.7 20.2C78.6 20.1 78.6 20 78.5 19.9C78.3 19.5 78.1 19.2 77.9 18.9C77.9 18.8 77.8 18.7 77.8 18.6C77.1 17.5 76.3 16.4 75.5 15.3C75.1 15.3 14.2 17.3 10.7 77.6C10.4 83.5 10.6 90 11.4 97L43.8 116.2C40.7 114.3 37.8 112 35.2 109.3C28.4 102.4 24.3 93.8 22.9 84.8C20.7 70.8 25 55.9 35.9 45.2C49 32.3 68 29.1 84 35.4V35.4Z' fill='url(#paint4_linear_108_873)'/></g><defs><linearGradient id='paint0_linear_108_873' x1='104.6' y1='41.1' x2='52.2' y2='1.6' gradientUnits='userSpaceOnUse'><stop stop-color='#4854FE'/><stop offset='1' stop-color='#241C55'/></linearGradient><linearGradient id='paint1_linear_108_873' x1='0' y1='90.8' x2='43.9' y2='90.8' gradientUnits='userSpaceOnUse'><stop stop-color='#4854FE'/><stop offset='1' stop-color='#241C55'/></linearGradient><linearGradient id='paint2_linear_108_873' x1='31.5' y1='40.6' x2='92.7' y2='102.9' gradientUnits='userSpaceOnUse'><stop stop-color='white'/><stop offset='1' stop-color='#4854FE'/></linearGradient><linearGradient id='paint3_linear_108_873' x1='86.3' y1='19.1' x2='41.6' y2='98.7' gradientUnits='userSpaceOnUse'><stop stop-color='#4854FE' stop-opacity='0.93'/><stop offset='1' stop-color='#006EF0' stop-opacity='0'/></linearGradient><linearGradient id='paint4_linear_108_873' x1='19.9' y1='103.3' x2='61' y2='27.5' gradientUnits='userSpaceOnUse'><stop stop-color='#006EF0'/><stop offset='1' stop-color='#006EF0' stop-opacity='0'/></linearGradient><clipPath id='clip0_108_873'><rect width='106.7' height='120' fill='white'/></clipPath></defs></svg>",
220
+ )}`;
221
+
222
+ /**
223
+ * Draws the skeleton over `box`: the mark and two lines centred in it,
224
+ * pulsing together, revealed only once loading has taken SKELETON_DELAY_MS.
225
+ * It stays on top of whatever renders into `box` (the chat's React empties its
226
+ * element when it first paints) until `done` fades it out (after showing for
227
+ * SKELETON_MIN_MS) or `remove` drops it at once. Internal to this package.
228
+ */
229
+ export function showNexusChatSkeleton(box, theme) {
230
+ const root = document.createElement('div');
231
+ root.setAttribute('role', 'status');
232
+ root.setAttribute('aria-label', 'Loading Incorta Intelligence');
233
+ root.style.cssText =
234
+ 'position:absolute;inset:0;box-sizing:border-box;padding:24px;display:flex;align-items:center;justify-content:center;pointer-events:none';
235
+ const line = (width, height) =>
236
+ `<span style="display:block;width:${width};height:${height}px;border-radius:${height / 2}px"></span>`;
237
+ root.innerHTML = `<div style="width:100%;max-width:280px"><div style="display:flex;flex-direction:column;align-items:center;gap:20px"><img alt="" width="32" height="34"><div style="display:flex;flex-direction:column;align-items:center;gap:10px;width:100%">${line('100%', 14)}${line('64%', 10)}</div></div></div>`;
238
+ root.querySelector('img').src = ICON;
239
+ const setTheme = (next) => {
240
+ const palette = PALETTE[next === 'dark' ? 'dark' : 'light'];
241
+ root.style.background = palette.ground;
242
+ for (const span of root.querySelectorAll('span')) span.style.background = palette.fill;
243
+ };
244
+ setTheme(theme);
245
+
246
+ // An unpositioned box becomes the skeleton's containing block while it shows.
247
+ const position = box.style.position;
248
+ const unpositioned = getComputedStyle(box).position === 'static';
249
+ if (unpositioned) box.style.position = 'relative';
250
+ box.append(root);
251
+ const stay = new MutationObserver(() => root.parentNode === box || box.append(root));
252
+ stay.observe(box, { childList: true });
253
+
254
+ const shownAt = Date.now() + SKELETON_DELAY_MS;
255
+ const stack = root.firstChild;
256
+ const animations = [
257
+ stack.animate?.([{ opacity: 0 }, { opacity: 1 }], {
258
+ duration: SKELETON_FADE_MS,
259
+ delay: SKELETON_DELAY_MS,
260
+ fill: 'backwards',
261
+ }),
262
+ reducedMotion()
263
+ ? undefined
264
+ : stack.firstChild.animate?.([{ opacity: 1 }, { opacity: 0.45 }, { opacity: 1 }], {
265
+ duration: 1600,
266
+ easing: 'ease-in-out',
267
+ iterations: Infinity,
268
+ }),
269
+ ];
270
+ let timer;
271
+ let gone = false;
272
+ const remove = () => {
273
+ if (gone) return;
274
+ gone = true;
275
+ clearTimeout(timer);
276
+ stay.disconnect();
277
+ animations.forEach((animation) => animation?.cancel());
278
+ root.remove();
279
+ if (unpositioned) box.style.position = position;
280
+ };
281
+ return {
282
+ setTheme,
283
+ remove,
284
+ done() {
285
+ if (gone || timer !== undefined) return;
286
+ const sinceShown = Date.now() - shownAt;
287
+ // Never shown (a fast load): gone at once, before its reveal can start.
288
+ if (sinceShown < 0) return remove();
289
+ // Shown: for at least SKELETON_MIN_MS, then it fades.
290
+ timer = setTimeout(
291
+ () => {
292
+ const fade = root.animate?.([{ opacity: 1 }, { opacity: 0 }], {
293
+ duration: SKELETON_FADE_MS,
294
+ fill: 'forwards',
295
+ });
296
+ if (!fade) return remove();
297
+ animations.push(fade);
298
+ fade.onfinish = remove;
299
+ },
300
+ Math.max(0, SKELETON_MIN_MS - sinceShown),
301
+ );
302
+ },
303
+ };
168
304
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmedramez-org/nexus-chat",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.7",
4
4
  "description": "NexusChat for any app: a connector and React component that load the prebuilt chat from IncortaCopilot (or a pinned copy), and the host contract types and checks.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -53,6 +53,7 @@
53
53
  "index.js",
54
54
  "index.d.ts",
55
55
  "react.js",
56
+ "window.js",
56
57
  "react.d.ts",
57
58
  "contract",
58
59
  "README.md"
package/react.d.ts CHANGED
@@ -8,13 +8,10 @@ import type {
8
8
  NexusChatEnvironment,
9
9
  NexusChatHandle,
10
10
  NexusChatWindow,
11
+ NexusChatWindowMode,
11
12
  } from './index.js';
12
13
 
13
- /** The chat mounted into this page, sharing its session. It fills its box. */
14
- export interface DirectNexusChatProps extends Omit<
15
- MountNexusChatOptions,
16
- 'environment' | 'window'
17
- > {
14
+ interface DirectChatProps extends Omit<MountNexusChatOptions, 'environment' | 'window'> {
18
15
  embedUrl?: undefined;
19
16
  /** Required unless `environment` is given. */
20
17
  theme?: HostTheme;
@@ -26,28 +23,52 @@ export interface DirectNexusChatProps extends Omit<
26
23
  user?: HostUser;
27
24
  /** The app's own contract environment, used as is instead of the props above. */
28
25
  environment?: NexusChatEnvironment;
26
+ /** The chat is mounted (`ask` and `attach` are available); `null` once it unmounts. */
27
+ onReady?(chat: NexusChatHandle | null): void;
28
+ /** The chat could not load (after its retry and deadline). */
29
+ onLoadError?(error: Error): void;
30
+ className?: string;
31
+ style?: CSSProperties;
32
+ }
33
+
34
+ /** The chat mounted into this page, sharing its session. It fills its box. */
35
+ export interface DirectNexusChatProps extends DirectChatProps {
29
36
  /**
30
37
  * The app owns the chat's window (a docked panel, an overlay): the chat
31
- * shows a dock toggle while `canDock`, and a close button. A contract
32
- * `NexusChatWindow` is used as is.
38
+ * shows a dock toggle while `canDock` (or, with `mode`, `modes` and
39
+ * `setMode`, a button per other mode), and a close button. `setMode` is
40
+ * read at mount. A contract `NexusChatWindow` is used as is.
33
41
  */
34
42
  window?:
35
43
  | {
36
44
  docked: boolean;
37
45
  canDock: boolean;
46
+ mode?: NexusChatMode;
47
+ modes?: readonly NexusChatMode[];
38
48
  setDocked(docked: boolean): void;
49
+ setMode?(mode: NexusChatMode): void;
39
50
  close(): void;
40
51
  }
41
52
  | NexusChatWindow;
42
- /** The chat is mounted (`ask` and `attach` are available); `null` once it unmounts. */
43
- onReady?(chat: NexusChatHandle | null): void;
44
- /** The chat could not load (after its retry and deadline). */
45
- onLoadError?(error: Error): void;
46
- className?: string;
47
- style?: CSSProperties;
53
+ modes?: undefined;
54
+ defaultMode?: undefined;
55
+ }
56
+
57
+ /**
58
+ * The chat mounted into this page, sharing its session, in a window drawn
59
+ * here as for `embedUrl`: a launcher icon that opens it floating, docked on
60
+ * the right, or fullscreen. Giving `modes` or `defaultMode` instead of a
61
+ * `window` opts in.
62
+ */
63
+ export interface WindowedNexusChatProps extends DirectChatProps {
64
+ /** Where the chat may open, in launcher order. Default: all three. */
65
+ modes?: NexusChatMode[];
66
+ /** Open on load in this mode. Default: the launcher icon only. */
67
+ defaultMode?: NexusChatMode;
68
+ window?: undefined;
48
69
  }
49
70
 
50
- export type NexusChatMode = 'floating' | 'docked' | 'fullscreen';
71
+ export type NexusChatMode = NexusChatWindowMode;
51
72
 
52
73
  /**
53
74
  * The chat in an <iframe> served by Incorta, on the user's own Incorta
@@ -67,12 +88,13 @@ export interface EmbeddedNexusChatProps {
67
88
  locale?: string;
68
89
  }
69
90
 
70
- export type NexusChatProps = DirectNexusChatProps | EmbeddedNexusChatProps;
91
+ export type NexusChatProps = DirectNexusChatProps | WindowedNexusChatProps | EmbeddedNexusChatProps;
71
92
 
72
93
  /**
73
94
  * The chat as a React component. Direct: it fills its box; `connection`,
74
95
  * `chat`, `actions`, `events`, `instanceId`, `assetsUrl` and `timeoutMs` are
75
- * read once at mount, and the other props update the running chat.
76
- * Embedded (`embedUrl`): it draws its own window.
96
+ * read once at mount, and the other props update the running chat. With
97
+ * `modes` or `defaultMode` (and no `window`), or embedded (`embedUrl`): it
98
+ * draws its own window.
77
99
  */
78
100
  export function NexusChat(props: NexusChatProps): ReactElement;