@hidemikimura/chit-ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +314 -11
  3. package/dist/chit-ui.iife.min.js +416 -94
  4. package/dist/chit-ui.iife.min.js.map +1 -1
  5. package/dist/chit-ui.min.js +407 -85
  6. package/dist/chit-ui.min.js.map +1 -1
  7. package/dist/types/bundle.d.ts +1 -0
  8. package/dist/types/chit-ui.d.ts +89 -4
  9. package/dist/types/controllers/composer-controller.d.ts +4 -0
  10. package/dist/types/controllers/drag-controller.d.ts +118 -0
  11. package/dist/types/events.d.ts +3 -0
  12. package/dist/types/global.d.ts +9 -1
  13. package/dist/types/i18n/labels.d.ts +16 -0
  14. package/dist/types/render/composer.d.ts +0 -1
  15. package/dist/types/render/message-list.d.ts +0 -1
  16. package/dist/types/theme/default-theme.d.ts +30 -1
  17. package/dist/types/themes/green.d.ts +16 -0
  18. package/dist/types/themes/index.d.ts +1 -0
  19. package/dist/types/types.d.ts +105 -4
  20. package/package.json +6 -1
  21. package/src/bundle.js +1 -0
  22. package/src/chit-ui.js +292 -4
  23. package/src/controllers/composer-controller.js +2 -0
  24. package/src/controllers/drag-controller.js +319 -0
  25. package/src/events.js +3 -0
  26. package/src/global.d.ts +9 -1
  27. package/src/i18n/labels.js +12 -0
  28. package/src/render/composer.js +58 -0
  29. package/src/render/launcher.js +9 -1
  30. package/src/render/message-list.js +40 -10
  31. package/src/render/message.js +13 -4
  32. package/src/render/panel.js +73 -11
  33. package/src/styles/composer.css.js +39 -0
  34. package/src/styles/host.css.js +2 -0
  35. package/src/styles/launcher.css.js +13 -0
  36. package/src/styles/message.css.js +168 -9
  37. package/src/styles/panel.css.js +44 -4
  38. package/src/theme/default-theme.js +37 -2
  39. package/src/theme/theme-to-css.js +5 -0
  40. package/src/themes/green.js +47 -0
  41. package/src/themes/index.js +15 -0
  42. package/src/types.js +38 -6
package/src/global.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ChitUI } from './chit-ui.js';
2
- import type { Message, ChatState, Trigger, Device } from './types.js';
2
+ import type { Message, ChatState, Trigger, Device, Position } from './types.js';
3
3
 
4
4
  export interface ChitUIEventMap {
5
5
  'chat-submit': CustomEvent<{ text: string }>;
@@ -15,6 +15,14 @@ export interface ChitUIEventMap {
15
15
  'chat-message-click': CustomEvent<{ message: Message; target: Element; originalEvent: MouseEvent }>;
16
16
  'chat-scroll-top': CustomEvent<Record<string, never>>;
17
17
  'chat-breakpoint-change': CustomEvent<{ device: Device }>;
18
+ 'chat-home': CustomEvent<{ trigger: Trigger }>;
19
+ 'chat-attach': CustomEvent<{ files: File[] }>;
20
+ 'chat-move': CustomEvent<{
21
+ target: 'launcher' | 'panel';
22
+ position: Position;
23
+ offset: { x: number; y: number };
24
+ displacement: { x: number; y: number };
25
+ }>;
18
26
  }
19
27
 
20
28
  declare global {
@@ -6,9 +6,13 @@
6
6
  * @property {string} panel Accessible name of the dialog.
7
7
  * @property {string} conversation Accessible name of the message log.
8
8
  * @property {string} close Close button.
9
+ * @property {string} home Home button: back to the start of the conversation.
9
10
  * @property {string} send Send button.
11
+ * @property {string} attach Attach button beside the composer.
10
12
  * @property {string} input Composer textarea.
11
13
  * @property {string} typing Announced while the other side is typing.
14
+ * @property {string} loading Announced while waiting for an answer.
15
+ * @property {string} move The panel's drag handle.
12
16
  * @property {string} toLatest "Jump to newest" button.
13
17
  * @property {{ sending: string, sent: string, error: string }} status Delivery state of one's own message.
14
18
  * @property {string} charactersLeft Counter, with {n} for the number remaining.
@@ -22,9 +26,13 @@ const BUILT_IN = {
22
26
  panel: 'チャット',
23
27
  conversation: '会話',
24
28
  close: 'チャットを閉じる',
29
+ home: '最初に戻る',
25
30
  send: '送信',
31
+ attach: '画像や動画を添付',
26
32
  input: 'メッセージを入力',
27
33
  typing: '入力中',
34
+ loading: '応答を待っています',
35
+ move: 'チャットの位置を移動(矢印キー)',
28
36
  toLatest: '最新へ',
29
37
  status: { sending: '送信中', sent: '送信済み', error: '送信できませんでした' },
30
38
  charactersLeft: '残り {n} 文字',
@@ -35,9 +43,13 @@ const BUILT_IN = {
35
43
  panel: 'Chat',
36
44
  conversation: 'Conversation',
37
45
  close: 'Close chat',
46
+ home: 'Back to the start',
38
47
  send: 'Send',
48
+ attach: 'Attach an image or video',
39
49
  input: 'Type a message',
40
50
  typing: 'Typing',
51
+ loading: 'Waiting for a reply',
52
+ move: 'Move the chat (arrow keys)',
41
53
  toLatest: 'Jump to latest',
42
54
  status: { sending: 'Sending', sent: 'Sent', error: 'Not delivered' },
43
55
  charactersLeft: '{n} characters left',
@@ -4,6 +4,62 @@ import { live } from 'lit/directives/live.js';
4
4
 
5
5
  /** @import { ChitUI } from '../chit-ui.js' */
6
6
 
7
+ /**
8
+ * The attach button and the file input it opens.
9
+ *
10
+ * The widget owns the picking and nothing else: it hands the chosen files to
11
+ * the consumer through `chat-attach` and forgets them. Uploading them, showing
12
+ * a preview and adding a message are all things only the consumer can do, and
13
+ * a half-done version here would be in the way of the real one.
14
+ *
15
+ * The input is reset after every pick so that choosing the same file twice in
16
+ * a row still reports the second time.
17
+ *
18
+ * @param {ChitUI} host
19
+ * @param {boolean} disabled
20
+ * @returns {import('lit').TemplateResult}
21
+ */
22
+ function renderAttach(host, disabled) {
23
+ const labels = host.currentLabels;
24
+ const input = host.currentTheme.open.input;
25
+
26
+ return html`
27
+ <button
28
+ part="attach-button"
29
+ type="button"
30
+ aria-label=${labels.attach}
31
+ title=${labels.attach}
32
+ ?disabled=${disabled}
33
+ @click=${() => host.openAttach()}
34
+ >
35
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
36
+ <path
37
+ fill="none"
38
+ stroke="currentColor"
39
+ stroke-width="2"
40
+ stroke-linecap="round"
41
+ stroke-linejoin="round"
42
+ d="M20 11.5 12.2 19.3a4.6 4.6 0 0 1-6.5-6.5l7.8-7.8a3 3 0 0 1 4.3 4.3l-7.8 7.8a1.5 1.5 0 0 1-2.1-2.1l7.1-7.1"
43
+ />
44
+ </svg>
45
+ </button>
46
+ <input
47
+ part="attach-input"
48
+ type="file"
49
+ tabindex="-1"
50
+ aria-hidden="true"
51
+ accept=${input.accept}
52
+ ?multiple=${input.multiple}
53
+ @change=${(/** @type {Event} */ event) => {
54
+ const field = /** @type {HTMLInputElement} */ (event.target);
55
+ const files = Array.from(field.files ?? []);
56
+ field.value = '';
57
+ if (files.length) host.handleAttach(files);
58
+ }}
59
+ />
60
+ `;
61
+ }
62
+
7
63
  /**
8
64
  * The composer: a textarea that grows with its content, and a send button.
9
65
  *
@@ -32,6 +88,8 @@ export function renderComposer(host) {
32
88
  }}
33
89
  >
34
90
  <slot name="composer">
91
+ ${theme.input.attach ? renderAttach(host, disabled) : nothing}
92
+
35
93
  <slot name="input-before"></slot>
36
94
 
37
95
  <textarea
@@ -34,7 +34,15 @@ export function renderLauncher(host) {
34
34
  aria-expanded=${host.state === 'open'}
35
35
  ?data-has-image=${!custom && !!closed.image}
36
36
  ?data-custom=${!!custom}
37
- @click=${() => host.toggleFromUser()}
37
+ ?data-draggable=${closed.draggable}
38
+ @pointerdown=${(/** @type {PointerEvent} */ event) => host.launcherDrag.start(event)}
39
+ @keydown=${(/** @type {KeyboardEvent} */ event) => host.launcherDrag.nudge(event)}
40
+ @click=${() => {
41
+ // The pointer-up that ends a drag is followed by a click; opening the
42
+ // panel then would punish the reader for having moved the button.
43
+ if (host.launcherDrag.consumeDrag()) return;
44
+ host.toggleFromUser();
45
+ }}
38
46
  >
39
47
  ${custom ?? html`<slot name="launcher">${defaultContent(closed)}</slot>`}
40
48
  </button>
@@ -5,6 +5,34 @@ import { renderMessage } from './message.js';
5
5
 
6
6
  /** @import { ChitUI } from '../chit-ui.js' */
7
7
 
8
+ /**
9
+ * The wait for an answer, at the end of the conversation.
10
+ *
11
+ * It stands where the typing bubble stands and replaces it while it is up:
12
+ * "typing" and "waiting for the server" are the same moment of the
13
+ * conversation, and two indicators for one wait would only ask the reader to
14
+ * work out the difference.
15
+ *
16
+ * @param {ChitUI} host
17
+ * @returns {import('lit').TemplateResult}
18
+ */
19
+ function renderLoading(host) {
20
+ const { style, text } = host.currentTheme.open.loading;
21
+ const label = text ?? host.currentLabels.loading;
22
+
23
+ return html`
24
+ <div part="loading" data-style=${style} role="status" aria-label=${label}>
25
+ ${style === 'dots'
26
+ ? html`<span class="dots" aria-hidden="true"><i></i><i></i><i></i></span>`
27
+ : nothing}
28
+ ${style === 'spinner' ? html`<span part="spinner" aria-hidden="true"></span>` : nothing}
29
+ ${style === 'text' || text
30
+ ? html`<span part="loading-text">${label}</span>`
31
+ : nothing}
32
+ </div>
33
+ `;
34
+ }
35
+
8
36
  /**
9
37
  * The scrolling conversation.
10
38
  *
@@ -22,7 +50,7 @@ import { renderMessage } from './message.js';
22
50
  export function renderMessageList(host) {
23
51
  const labels = host.currentLabels;
24
52
  const typing = host.typing;
25
- const empty = host.messages.length === 0 && !typing;
53
+ const empty = host.messages.length === 0 && !typing && !host.loading;
26
54
 
27
55
  return html`
28
56
  <div
@@ -40,15 +68,17 @@ export function renderMessageList(host) {
40
68
  (message) => message.id,
41
69
  (message) => renderMessage(host, message),
42
70
  )}
43
- ${typing
44
- ? html`
45
- <div part="typing" role="status" aria-label=${labels.typing}>
46
- ${typeof typing === 'object' && typing.html
47
- ? host.renderTypingHtml(typing.html)
48
- : html`<span class="dots" aria-hidden="true"><i></i><i></i><i></i></span>`}
49
- </div>
50
- `
51
- : nothing}
71
+ ${host.loading
72
+ ? renderLoading(host)
73
+ : typing
74
+ ? html`
75
+ <div part="typing" role="status" aria-label=${labels.typing}>
76
+ ${typeof typing === 'object' && typing.html
77
+ ? host.renderTypingHtml(typing.html)
78
+ : html`<span class="dots" aria-hidden="true"><i></i><i></i><i></i></span>`}
79
+ </div>
80
+ `
81
+ : nothing}
52
82
  </div>
53
83
  </div>
54
84
 
@@ -45,6 +45,13 @@ export function renderMessage(host, message) {
45
45
 
46
46
  const meta = message.time || message.status;
47
47
 
48
+ // The theme says who is talking; a message overrides either half. `null` on
49
+ // the message hides what the theme set, which is how a run of replies shows
50
+ // the icon only on the first of them.
51
+ const speaker = host.currentTheme.open.speaker[message.role];
52
+ const avatar = message.avatar === undefined ? speaker.avatar : message.avatar;
53
+ const name = message.name === undefined ? speaker.name : message.name;
54
+
48
55
  return html`
49
56
  <article
50
57
  part="message message-${message.role}"
@@ -53,11 +60,13 @@ export function renderMessage(host, message) {
53
60
  ?data-streaming=${!!message.streaming}
54
61
  aria-busy=${message.streaming ? 'true' : 'false'}
55
62
  >
56
- ${message.avatar
57
- ? html`<img part="avatar" src=${message.avatar} alt="" loading="lazy" />`
58
- : nothing}
63
+ ${avatar
64
+ ? html`<img part="avatar" src=${avatar} alt="" loading="lazy" />`
65
+ : speaker.avatar
66
+ ? html`<span part="avatar" data-placeholder aria-hidden="true"></span>`
67
+ : nothing}
59
68
  <div class="body">
60
- ${message.name ? html`<span part="name">${message.name}</span>` : nothing}
69
+ ${name ? html`<span part="name">${name}</span>` : nothing}
61
70
  <div part="bubble">
62
71
  <div part="message-content" data-content=${contentField(message)}>${renderContent(host, message)}</div>
63
72
  ${message.streaming ? html`<span part="cursor" aria-hidden="true"></span>` : nothing}
@@ -15,7 +15,7 @@ import { renderComposer } from './composer.js';
15
15
  export function renderPanel(host) {
16
16
  const open = host.currentTheme.open;
17
17
  const labels = host.currentLabels;
18
- const title = open.header?.title ?? labels.panel;
18
+ const title = open.header.title ?? labels.panel;
19
19
 
20
20
  return html`
21
21
  <section
@@ -26,16 +26,83 @@ export function renderPanel(host) {
26
26
  tabindex="-1"
27
27
  @keydown=${(/** @type {KeyboardEvent} */ event) => onKeydown(host, event)}
28
28
  >
29
- <header part="header">
29
+ ${open.header.visible ? renderHeader(host, title) : nothing}
30
+
31
+ ${renderMessageList(host)} ${renderComposer(host)}
32
+
33
+ <slot name="footer"></slot>
34
+ </section>
35
+ `;
36
+ }
37
+
38
+ /**
39
+ * The title bar.
40
+ *
41
+ * It can be left out entirely (`open.header.visible = false`), which is what a
42
+ * widget embedded in a host that already draws its own title bar wants — LINE
43
+ * LIFF, for one — rather than two stacked titles. The dialog keeps its
44
+ * accessible name either way: that name is read, not seen.
45
+ *
46
+ * Note that hiding it takes the close button with it. Esc and the launcher
47
+ * still close the panel, and the host's own chrome usually supplies the rest.
48
+ *
49
+ * @param {ChitUI} host
50
+ * @param {string} title
51
+ * @returns {import('lit').TemplateResult}
52
+ */
53
+ function renderHeader(host, title) {
54
+ const open = host.currentTheme.open;
55
+ const labels = host.currentLabels;
56
+ const draggable = open.draggable && host.device === 'pc';
57
+
58
+ return html`
59
+ <header
60
+ part="header"
61
+ ?data-draggable=${draggable}
62
+ tabindex=${draggable ? '0' : nothing}
63
+ aria-label=${draggable ? labels.move : nothing}
64
+ @pointerdown=${(/** @type {PointerEvent} */ event) => {
65
+ // Only the bar itself is a handle: a drag that starts on the close
66
+ // button would swallow the click that closes the panel.
67
+ if (/** @type {HTMLElement} */ (event.target).closest('button')) return;
68
+ host.panelDrag.start(event);
69
+ }}
70
+ @keydown=${(/** @type {KeyboardEvent} */ event) => {
71
+ if (event.target !== event.currentTarget) return;
72
+ host.panelDrag.nudge(event);
73
+ }}
74
+ >
30
75
  <slot name="header">
31
76
  <slot name="header-title">
32
- ${open.header?.logo
33
- ? html`<img part="header-logo" src=${open.header.logo} alt="" />`
34
- : nothing}
35
- <span part="header-heading">${title}</span>
77
+ <span part="header-title">
78
+ ${open.header.logo
79
+ ? html`<img part="header-logo" src=${open.header.logo} alt="" />`
80
+ : nothing}
81
+ <span part="header-heading">${title}</span>
82
+ </span>
36
83
  </slot>
37
84
  <span part="header-actions">
38
85
  <slot name="header-actions"></slot>
86
+ ${open.header.home
87
+ ? html`<button
88
+ part="home-button"
89
+ type="button"
90
+ aria-label=${labels.home}
91
+ title=${labels.home}
92
+ @click=${() => host.home('user')}
93
+ >
94
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
95
+ <path
96
+ fill="none"
97
+ stroke="currentColor"
98
+ stroke-width="2"
99
+ stroke-linejoin="round"
100
+ stroke-linecap="round"
101
+ d="M4 11.2 12 4l8 7.2M6.5 9.8V19h11V9.8M10 19v-4.6h4V19"
102
+ />
103
+ </svg>
104
+ </button>`
105
+ : nothing}
39
106
  <button
40
107
  part="close-button"
41
108
  type="button"
@@ -55,11 +122,6 @@ export function renderPanel(host) {
55
122
  </span>
56
123
  </slot>
57
124
  </header>
58
-
59
- ${renderMessageList(host)} ${renderComposer(host)}
60
-
61
- <slot name="footer"></slot>
62
- </section>
63
125
  `;
64
126
  }
65
127
 
@@ -55,6 +55,45 @@ export const composerStyles = css`
55
55
  font-weight: 600;
56
56
  }
57
57
 
58
+ /* Out of sight, but still clickable from the button beside it. */
59
+ [part~='attach-input'] {
60
+ display: none;
61
+ }
62
+
63
+ [part~='attach-button'] {
64
+ display: grid;
65
+ flex: none;
66
+ place-items: center;
67
+ width: 2.4em;
68
+ height: 2.4em;
69
+ padding: 0;
70
+ border: 0;
71
+ border-radius: 50%;
72
+ background: transparent;
73
+ color: var(--chit-color-system-text);
74
+ cursor: pointer;
75
+ transition: opacity 120ms ease;
76
+ }
77
+
78
+ [part~='attach-button'] svg {
79
+ width: 1.3em;
80
+ height: 1.3em;
81
+ }
82
+
83
+ [part~='attach-button']:hover:not(:disabled) {
84
+ background: color-mix(in srgb, currentColor 10%, transparent);
85
+ }
86
+
87
+ [part~='attach-button']:focus-visible {
88
+ outline: 2px solid var(--chit-color-accent);
89
+ outline-offset: 2px;
90
+ }
91
+
92
+ [part~='attach-button']:disabled {
93
+ opacity: 0.4;
94
+ cursor: default;
95
+ }
96
+
58
97
  [part~='send-button'] {
59
98
  display: grid;
60
99
  flex: none;
@@ -37,6 +37,8 @@ export const hostStyles = css`
37
37
  --chit-color-header-bg: #ffffff;
38
38
  --chit-color-header-text: #1f1f1f;
39
39
 
40
+ --chit-bubble-radius: 14px;
41
+
40
42
  position: fixed;
41
43
  inset: 0;
42
44
  z-index: var(--chit-z-index);
@@ -23,6 +23,19 @@ export const launcherStyles = css`
23
23
  transition: filter 120ms ease;
24
24
  }
25
25
 
26
+ /*
27
+ * A draggable launcher takes the touch gestures for itself, so a finger on
28
+ * it moves the button instead of scrolling the page behind it.
29
+ */
30
+ [part~='launcher'][data-draggable] {
31
+ cursor: grab;
32
+ touch-action: none;
33
+ }
34
+
35
+ [part~='launcher'][data-draggable]:active {
36
+ cursor: grabbing;
37
+ }
38
+
26
39
  /* The image comes from the theme, so it is a background rather than an <img>. */
27
40
  [part~='launcher'][data-has-image] {
28
41
  background-image: var(--chit-launcher-image);
@@ -2,6 +2,18 @@
2
2
  import { css } from 'lit';
3
3
 
4
4
  export const messageStyles = css`
5
+ :host {
6
+ /* Internal: the box the tail's path is drawn in. The path is written in
7
+ these units, so the two move together. */
8
+ --_chit-tail-w: 18px;
9
+ --_chit-tail-h: 20px;
10
+ /* How deep the tail's root sits inside the bubble. Less than the
11
+ bubble's own padding, so it never reaches the text. */
12
+ --_chit-tail-root: 9px;
13
+ /* Far enough down that the bubble's edge has straightened out under it. */
14
+ --_chit-tail-offset: calc(var(--chit-bubble-radius) / 4 + 1px);
15
+ }
16
+
5
17
  [part~='messages'] {
6
18
  flex: 1 1 auto;
7
19
  min-height: 0;
@@ -26,7 +38,13 @@ export const messageStyles = css`
26
38
  */
27
39
  [part~='message'] {
28
40
  display: flex;
29
- align-items: flex-end;
41
+ /*
42
+ * The icon sits level with the top of the speaker's block — beside the
43
+ * name when there is one, beside the bubble's first line otherwise. At
44
+ * the bottom it would end up next to the timestamp, away from everything
45
+ * it identifies.
46
+ */
47
+ align-items: flex-start;
30
48
  gap: 0.5em;
31
49
  width: 100%;
32
50
  max-width: 100%;
@@ -52,7 +70,7 @@ export const messageStyles = css`
52
70
  [part~='bubble'] {
53
71
  position: relative;
54
72
  padding: 0.6em 0.85em;
55
- border-radius: 14px;
73
+ border-radius: var(--chit-bubble-radius);
56
74
  background: var(--chit-color-assistant-bg);
57
75
  color: var(--chit-color-assistant-text);
58
76
  }
@@ -67,6 +85,99 @@ export const messageStyles = css`
67
85
  border-bottom-left-radius: 4px;
68
86
  }
69
87
 
88
+ /* --- tails -------------------------------------------------------------
89
+ *
90
+ * A tail is one pseudo-element: a box in the bubble's colour, clipped to a
91
+ * leaf whose point sits outside the bubble and whose root is buried inside
92
+ * it. Burying the root is what makes the join disappear — the bubble's own
93
+ * background covers the seam, so every corner keeps its radius and the two
94
+ * read as one shape at any radius.
95
+ *
96
+ * It hangs a little below the corner, where the bubble's edge has
97
+ * straightened out, so the root has something flat to sit against. The
98
+ * offset follows the radius for that reason.
99
+ *
100
+ * The other side and the downward version are the same path mirrored, so
101
+ * the curve is written once.
102
+ */
103
+
104
+ [part~='bubble']::after,
105
+ [part~='typing']::after,
106
+ [part~='loading']::after {
107
+ content: '';
108
+ display: none;
109
+ position: absolute;
110
+ width: var(--_chit-tail-w);
111
+ height: var(--_chit-tail-h);
112
+ clip-path: path('M 18 0.5 Q 8 0.8 0.5 3.5 Q 0 5.2 2 7 Q 10 9.5 18 19 Z');
113
+ }
114
+
115
+ /*
116
+ * Everything below needs clip-path: path() to draw the leaf. Where that is
117
+ * missing the tail stays hidden and the bubble keeps its default shape,
118
+ * rather than a bare rectangle poking out of its side.
119
+ */
120
+ @supports (clip-path: path('M 0 0 Z')) {
121
+ :host([data-bubble-tail='top']) [part~='bubble']::after,
122
+ :host([data-bubble-tail='top']) [part~='typing']::after,
123
+ :host([data-bubble-tail='top']) [part~='loading']::after,
124
+ :host([data-bubble-tail='bottom']) [part~='bubble']::after,
125
+ :host([data-bubble-tail='bottom']) [part~='typing']::after,
126
+ :host([data-bubble-tail='bottom']) [part~='loading']::after {
127
+ display: block;
128
+ }
129
+
130
+ /*
131
+ * With a tail there is no need to clip the speaker's corner as well: one
132
+ * bubble carries one cue about who is talking, not two.
133
+ */
134
+ :host([data-bubble-tail='top']) [part~='bubble'],
135
+ :host([data-bubble-tail='top']) [part~='typing'],
136
+ :host([data-bubble-tail='top']) [part~='loading'],
137
+ :host([data-bubble-tail='bottom']) [part~='bubble'],
138
+ :host([data-bubble-tail='bottom']) [part~='typing'],
139
+ :host([data-bubble-tail='bottom']) [part~='loading'] {
140
+ border-radius: var(--chit-bubble-radius);
141
+ }
142
+
143
+ /* Their side: the leaf points left, its root inside the bubble. */
144
+ :host([data-bubble-tail]) [part~='message-assistant'] [part~='bubble']::after,
145
+ :host([data-bubble-tail]) [part~='typing']::after,
146
+ :host([data-bubble-tail]) [part~='loading']::after {
147
+ right: calc(100% - var(--_chit-tail-root));
148
+ background: var(--chit-color-assistant-bg);
149
+ }
150
+
151
+ /* Your side: the same leaf, mirrored. */
152
+ :host([data-bubble-tail]) [part~='message-user'] [part~='bubble']::after {
153
+ left: calc(100% - var(--_chit-tail-root));
154
+ background: var(--chit-color-user-bg);
155
+ transform: scaleX(-1);
156
+ }
157
+
158
+ :host([data-bubble-tail='top']) [part~='bubble']::after,
159
+ :host([data-bubble-tail='top']) [part~='typing']::after,
160
+ :host([data-bubble-tail='top']) [part~='loading']::after {
161
+ top: var(--_chit-tail-offset);
162
+ }
163
+
164
+ :host([data-bubble-tail='bottom']) [part~='bubble']::after,
165
+ :host([data-bubble-tail='bottom']) [part~='typing']::after,
166
+ :host([data-bubble-tail='bottom']) [part~='loading']::after {
167
+ bottom: var(--_chit-tail-offset);
168
+ }
169
+
170
+ :host([data-bubble-tail='bottom']) [part~='message-assistant'] [part~='bubble']::after,
171
+ :host([data-bubble-tail='bottom']) [part~='typing']::after,
172
+ :host([data-bubble-tail='bottom']) [part~='loading']::after {
173
+ transform: scaleY(-1);
174
+ }
175
+
176
+ :host([data-bubble-tail='bottom']) [part~='message-user'] [part~='bubble']::after {
177
+ transform: scale(-1, -1);
178
+ }
179
+ }
180
+
70
181
  [part~='avatar'] {
71
182
  flex: none;
72
183
  width: 2em;
@@ -75,6 +186,15 @@ export const messageStyles = css`
75
186
  object-fit: cover;
76
187
  }
77
188
 
189
+ /*
190
+ * A message that hides the theme's icon (avatar: null, for the second and
191
+ * later messages in a run) keeps its place in the row, so the bubbles below
192
+ * stay in line with the one that shows the face.
193
+ */
194
+ [part~='avatar'][data-placeholder] {
195
+ visibility: hidden;
196
+ }
197
+
78
198
  [part~='name'] {
79
199
  font-size: 0.8em;
80
200
  color: var(--chit-color-system-text);
@@ -117,21 +237,57 @@ export const messageStyles = css`
117
237
  }
118
238
  }
119
239
 
120
- [part~='typing'] {
240
+ [part~='typing'],
241
+ [part~='loading'] {
242
+ position: relative;
121
243
  align-self: flex-start;
244
+ /* Indented past the speaker's icon, when the theme gives them one. */
245
+ margin-inline-start: var(--_chit-speaker-gutter, 0px);
122
246
  padding: 0.7em 0.9em;
123
- border-radius: 14px;
247
+ /*
248
+ * Stated rather than left to the font's idea of "normal", so that both
249
+ * indicators are the same height whatever the page's typeface does, and
250
+ * so the spinner has a line box it is known to fit inside.
251
+ */
252
+ line-height: 1.5;
253
+ border-radius: var(--chit-bubble-radius);
124
254
  border-bottom-left-radius: 4px;
125
255
  background: var(--chit-color-assistant-bg);
126
256
  color: var(--chit-color-assistant-text);
127
257
  }
128
258
 
129
- [part~='typing'] .dots {
259
+ /*
260
+ * The wait is laid out inline, like the typing bubble it stands in for. A
261
+ * flex box has no line box of its own, so dots or a spinner alone would sit
262
+ * in a noticeably shorter bubble than one holding a line of text.
263
+ */
264
+ [part~='loading'] > * + * {
265
+ margin-inline-start: 0.5em;
266
+ }
267
+
268
+ [part~='loading'] [part~='spinner'] {
269
+ display: inline-block;
270
+ /*
271
+ * Centred on the line rather than sitting on the baseline: at 1.1em it
272
+ * then fits inside the line box the text would have made on its own, so
273
+ * a spinner bubble is exactly as tall as a typing bubble.
274
+ */
275
+ vertical-align: middle;
276
+ color: var(--chit-color-system-text);
277
+ }
278
+
279
+ [part~='loading-text'] {
280
+ font-size: 0.9em;
281
+ }
282
+
283
+ [part~='typing'] .dots,
284
+ [part~='loading'] .dots {
130
285
  display: inline-flex;
131
286
  gap: 0.25em;
132
287
  }
133
288
 
134
- [part~='typing'] .dots i {
289
+ [part~='typing'] .dots i,
290
+ [part~='loading'] .dots i {
135
291
  width: 0.4em;
136
292
  height: 0.4em;
137
293
  border-radius: 50%;
@@ -140,10 +296,12 @@ export const messageStyles = css`
140
296
  animation: chit-typing 1.2s ease-in-out infinite;
141
297
  }
142
298
 
143
- [part~='typing'] .dots i:nth-child(2) {
299
+ [part~='typing'] .dots i:nth-child(2),
300
+ [part~='loading'] .dots i:nth-child(2) {
144
301
  animation-delay: 0.15s;
145
302
  }
146
- [part~='typing'] .dots i:nth-child(3) {
303
+ [part~='typing'] .dots i:nth-child(3),
304
+ [part~='loading'] .dots i:nth-child(3) {
147
305
  animation-delay: 0.3s;
148
306
  }
149
307
 
@@ -185,7 +343,8 @@ export const messageStyles = css`
185
343
 
186
344
  @media (prefers-reduced-motion: reduce) {
187
345
  [part~='cursor'],
188
- [part~='typing'] .dots i {
346
+ [part~='typing'] .dots i,
347
+ [part~='loading'] .dots i {
189
348
  animation: none;
190
349
  }
191
350
  }