@hidemikimura/chit-ui 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ # Chit UI レシピ
2
+
3
+ よくある組み方。どれも「発言を持っているのは利用者側」という前提の上に乗っている。
4
+
5
+ ## 送信から返事まで(基本形)
6
+
7
+ ```js
8
+ chat.addEventListener('chat-submit', async (event) => {
9
+ const text = event.detail.text.trim();
10
+ if (!text) { event.preventDefault(); return; }
11
+
12
+ const mine = { id: crypto.randomUUID(), role: 'user', text, status: 'sending' };
13
+ chat.messages = [...chat.messages, mine];
14
+ chat.busy = true;
15
+ chat.loading = true;
16
+
17
+ try {
18
+ const reply = await api.send(text);
19
+ chat.messages = chat.messages
20
+ .map((m) => (m.id === mine.id ? { ...m, status: 'sent' } : m))
21
+ .concat({ id: crypto.randomUUID(), role: 'assistant', text: reply });
22
+ } catch {
23
+ chat.messages = chat.messages.map((m) =>
24
+ m.id === mine.id ? { ...m, status: 'error' } : m,
25
+ );
26
+ chat.messages = [...chat.messages,
27
+ { id: crypto.randomUUID(), role: 'system', text: '送信できませんでした' }];
28
+ } finally {
29
+ chat.loading = false;
30
+ chat.busy = false;
31
+ }
32
+ });
33
+ ```
34
+
35
+ `open.loading.auto: true` にしておけば `chat.loading` の上げ下げは要らない。送信から
36
+ 相手側の発言が増えるまで自動で出て、`timeout`(ms)を過ぎれば自分で引っ込む。
37
+
38
+ ## ストリーミング
39
+
40
+ 同じ `id` の発言を書き換えていく。`streaming: true` の間は末尾にカーソルが出て、
41
+ `aria-busy` が立つので 1 文字ずつ読み上げられない。
42
+
43
+ ```js
44
+ const id = crypto.randomUUID();
45
+ chat.messages = [...chat.messages, { id, role: 'assistant', text: '', streaming: true }];
46
+
47
+ for await (const chunk of stream) {
48
+ chat.messages = chat.messages.map((m) =>
49
+ m.id === id ? { ...m, text: m.text + chunk } : m,
50
+ );
51
+ }
52
+
53
+ chat.messages = chat.messages.map((m) =>
54
+ m.id === id ? { ...m, streaming: false } : m,
55
+ );
56
+ ```
57
+
58
+ 最下部にいる読み手は自動で追従する。上にさかのぼっている読み手は動かされず、
59
+ 「最新へ」のボタンが出る。
60
+
61
+ ## 履歴の追い読み
62
+
63
+ ```js
64
+ chat.addEventListener('chat-scroll-top', async () => {
65
+ if (loadingOlder || !cursor) return;
66
+ loadingOlder = true;
67
+ const older = await api.history(cursor);
68
+ chat.messages = [...older, ...chat.messages]; // 前に足す
69
+ cursor = older.at(0)?.cursor ?? null;
70
+ loadingOlder = false;
71
+ });
72
+ ```
73
+
74
+ このイベントは最上部に着くたびに 1 回だけ出る(居座っても繰り返さない)。
75
+
76
+ ## シナリオ型(ホームボタンで最初に戻す)
77
+
78
+ ```js
79
+ chat.theme = { open: { header: { home: true } } };
80
+
81
+ chat.addEventListener('chat-home', async (event) => {
82
+ if (event.detail.trigger === 'user' && !confirm('最初からやり直しますか?')) return;
83
+ chat.messages = [firstMessage()];
84
+ scenario.reset();
85
+ });
86
+ ```
87
+
88
+ ボタンを押してもウィジェットは何も消さない。どこまで戻すかは利用者が決める。
89
+
90
+ ## 発言の中にコンポーネントを置く
91
+
92
+ ```js
93
+ class OrderCard extends HTMLElement { /* props を受け取って描く */ }
94
+ customElements.define('order-card', OrderCard);
95
+
96
+ chat.messages = [...chat.messages, {
97
+ id: 'order-1001',
98
+ role: 'assistant',
99
+ component: 'order-card',
100
+ props: { orderId: '1001', total: 4800 },
101
+ meta: { orderId: '1001' },
102
+ }];
103
+
104
+ chat.addEventListener('chat-message-click', (event) => {
105
+ const action = event.detail.target.closest('[data-action]')?.dataset.action;
106
+ if (action === 'detail') showOrder(event.detail.message.meta.orderId);
107
+ });
108
+ ```
109
+
110
+ 同じ `id` の間はインスタンスが再利用され、変わった `props` だけ再代入される。
111
+ `chat-message-render` の `detail.instance` でインスタンスを直接触ることもできる。
112
+
113
+ ## 未読バッジ
114
+
115
+ ```html
116
+ <chit-ui id="chat">
117
+ <span slot="launcher" class="badge" hidden></span>
118
+ </chit-ui>
119
+ ```
120
+
121
+ ```js
122
+ function pushFromServer(message) {
123
+ chat.messages = [...chat.messages, message];
124
+ if (chat.state !== 'open') {
125
+ unread += 1;
126
+ badge.textContent = String(unread);
127
+ badge.hidden = false;
128
+ }
129
+ }
130
+
131
+ chat.addEventListener('chat-open', () => { unread = 0; badge.hidden = true; });
132
+ ```
133
+
134
+ パネルの中で上にさかのぼっている読み手に新着が届いたかどうかは `chat.hasUnseen` でわかる。
135
+
136
+ ## 添付ファイル
137
+
138
+ ```js
139
+ chat.theme = { open: { input: { attach: true, accept: 'image/*', multiple: false } } };
140
+
141
+ chat.addEventListener('chat-attach', async (event) => {
142
+ const [file] = event.detail.files;
143
+ const url = await api.upload(file);
144
+ chat.messages = [...chat.messages, {
145
+ id: crypto.randomUUID(), role: 'user',
146
+ html: `<img src="${url}" alt="添付した画像">`,
147
+ }];
148
+ });
149
+ ```
150
+
151
+ ライブラリはアップロードもプレビューもしない。同じファイルを続けて選んでも毎回発火する。
152
+
153
+ ## LINE LIFF など、外側がタイトルを持つ画面
154
+
155
+ ```js
156
+ chat.theme = { open: { header: { visible: false } } };
157
+ ```
158
+
159
+ タイトルバーごと消えるので閉じるボタンも消える。Esc とランチャーでは閉じられる。
160
+ `title` は描かれなくても `role="dialog"` の読み上げ名には使われる。
161
+
162
+ ## ドラッグで動かす
163
+
164
+ ```js
165
+ chat.theme = { closed: { draggable: true }, open: { draggable: true } };
166
+
167
+ chat.addEventListener('chat-move', (event) => {
168
+ localStorage.setItem('chit-offset', JSON.stringify(event.detail.displacement));
169
+ });
170
+
171
+ const saved = localStorage.getItem('chit-offset');
172
+ if (saved) chat.dragOffset = JSON.parse(saved);
173
+ ```
174
+
175
+ 閉じた状態と開いた状態は同じずれを共有するので、片方を動かせばもう片方も動く。
176
+ `resetPosition()` でテーマの位置に戻る。保存するかどうかは利用者の判断。
177
+
178
+ ## ダークモード
179
+
180
+ ```js
181
+ const dark = matchMedia('(prefers-color-scheme: dark)');
182
+ const apply = () => { chat.theme = dark.matches ? darkTheme : lightTheme; };
183
+ dark.addEventListener('change', apply);
184
+ apply();
185
+ ```
186
+
187
+ テーマは実行中に差し替えても即座に反映される。
188
+
189
+ ## 入力欄を消して選択肢だけで進める
190
+
191
+ ```js
192
+ chat.inputHidden = true;
193
+ chat.messages = [...chat.messages, {
194
+ id: 'q1', role: 'assistant',
195
+ html: '<p>どちらにしますか?</p><button data-choice="a">A</button><button data-choice="b">B</button>',
196
+ }];
197
+
198
+ chat.addEventListener('chat-message-click', (event) => {
199
+ const choice = event.detail.target.closest('[data-choice]')?.dataset.choice;
200
+ if (choice) chat.submit(choice === 'a' ? 'A' : 'B');
201
+ });
202
+ ```
203
+
204
+ 入力欄を消しても `submit(text)` は使えるので、選択肢からの送信で会話を進められる。
package/src/chit-ui.js CHANGED
@@ -9,6 +9,8 @@ import { ThemeController } from './controllers/theme-controller.js';
9
9
  import { ScrollController } from './controllers/scroll-controller.js';
10
10
  import { ComposerController } from './controllers/composer-controller.js';
11
11
  import { DragController } from './controllers/drag-controller.js';
12
+ import { ScrollLockController } from './controllers/scroll-lock-controller.js';
13
+ import { ViewportController } from './controllers/viewport-controller.js';
12
14
  import { renderLauncher } from './render/launcher.js';
13
15
  import { renderPanel } from './render/panel.js';
14
16
  import { resolveLabels, resolveLocale } from './i18n/labels.js';
@@ -180,6 +182,10 @@ export class ChitUI extends LitElement {
180
182
  behavior: () => this.currentTheme.open.animation.scroll,
181
183
  });
182
184
  this.#composer = new ComposerController(this);
185
+ // Both register themselves with the host and need nothing from us
186
+ // afterwards.
187
+ new ScrollLockController(this);
188
+ new ViewportController(this, { enabled: () => this.device === 'mobile' });
183
189
 
184
190
  this.#launcherDrag = new DragController(this, {
185
191
  name: 'launcher',
@@ -49,6 +49,13 @@ export class ScrollController {
49
49
  /** The first jump after the panel opens has nothing to animate from. */
50
50
  #openingJump = true;
51
51
 
52
+ /**
53
+ * The viewport's height as of the last reading. A scroll event that arrives
54
+ * with a different height is the box having been resized under the reader —
55
+ * a keyboard opening, say — and says nothing about where they chose to be.
56
+ */
57
+ #viewportHeight = 0;
58
+
52
59
  /** @type {string | undefined} */
53
60
  #lastId;
54
61
  #lastCount = 0;
@@ -127,16 +134,21 @@ export class ScrollController {
127
134
 
128
135
  #bind() {
129
136
  if (!this.#viewport) return;
137
+ this.#viewportHeight = this.#viewport.clientHeight;
130
138
  this.#viewport.addEventListener('scroll', this.#onScroll, { passive: true });
131
139
  // Precise end-of-scroll where it exists (Chrome 114, Firefox 109,
132
140
  // Safari 17.4); the timer below covers the rest.
133
141
  this.#viewport.addEventListener('scrollend', this.#onScrollEnd);
134
142
 
135
143
  // Images finishing and streamed text growing both change the height
136
- // without a scroll event, so the follow has to react to size too.
144
+ // without a scroll event, so the follow has to react to size too. The
145
+ // viewport is watched alongside the content because a keyboard opening
146
+ // shortens the list without touching what is in it, and the newest
147
+ // message would otherwise slide out of sight.
137
148
  const inner = this.#viewport.firstElementChild;
138
149
  if (inner && typeof ResizeObserver !== 'undefined') {
139
150
  this.#resize = new ResizeObserver(() => {
151
+ this.#viewportHeight = this.#viewport?.clientHeight ?? 0;
140
152
  if (!this.#atBottom) return;
141
153
  // Content settling (an image loading, a tall block laying out) right
142
154
  // after a message arrived must not cut the follow short, so while our
@@ -147,6 +159,7 @@ export class ScrollController {
147
159
  this.#scrollNow({ behavior: this.#selfScrolling ? 'smooth' : 'instant' });
148
160
  });
149
161
  this.#resize.observe(inner);
162
+ this.#resize.observe(this.#viewport);
150
163
  }
151
164
  }
152
165
 
@@ -166,6 +179,13 @@ export class ScrollController {
166
179
  const viewport = this.#viewport;
167
180
  if (!viewport) return;
168
181
 
182
+ // The box changed size under this scroll, so the reader did not move:
183
+ // the resize handler below puts them back where they were.
184
+ if (viewport.clientHeight !== this.#viewportHeight) {
185
+ this.#viewportHeight = viewport.clientHeight;
186
+ return;
187
+ }
188
+
169
189
  const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
170
190
  const atBottom = distance <= BOTTOM_SLACK;
171
191
 
@@ -0,0 +1,185 @@
1
+ // @ts-check
2
+
3
+ /** @import { ReactiveController, LitElement } from 'lit' */
4
+
5
+ /** How far a finger must travel before the gesture's direction is trusted. */
6
+ const THRESHOLD = 3;
7
+
8
+ /** Slack on the "is there anywhere left to scroll" reading, in pixels. */
9
+ const EDGE_SLACK = 1;
10
+
11
+ /** Overflow values that make an element a scroll container. */
12
+ const SCROLLABLE = /^(auto|scroll|overlay)$/;
13
+
14
+ /**
15
+ * Keeps a touch that lands on the open panel from scrolling the page behind
16
+ * it.
17
+ *
18
+ * `overscroll-behavior: contain` is on the message list already, and it does
19
+ * the job for the case it covers: a list that can scroll, dragged past its
20
+ * own end. It has nothing to say about the cases that actually leak. A short
21
+ * conversation does not overflow, so the list is not a scroll container at
22
+ * all and the touch goes straight through to the document — which is what a
23
+ * reader on an iPhone sees as the page sliding around underneath a chat that
24
+ * looks like it should be holding still. The same happens for a touch that
25
+ * starts on the title bar or beside the input.
26
+ *
27
+ * So the rule is decided here instead: a touch on the panel may scroll
28
+ * something inside the panel, and otherwise it does nothing. On the first
29
+ * move of each gesture the controller looks along the touch's own path —
30
+ * `composedPath`, so a scroller inside a consumer's component counts too —
31
+ * for something that can still travel in the direction the finger is going.
32
+ * Finding one, it stands aside for the rest of the gesture; finding none, it
33
+ * cancels the move, and iOS then takes the whole gesture as "not a scroll".
34
+ *
35
+ * Two touches are left alone: that is a pinch, and zooming is the reader's.
36
+ * A move that is no longer cancelable is left alone too — the browser has
37
+ * already committed to scrolling, and `overscroll-behavior` is what keeps
38
+ * that inside the list.
39
+ *
40
+ * @implements {ReactiveController}
41
+ */
42
+ export class ScrollLockController {
43
+ /** @type {LitElement} */
44
+ #host;
45
+
46
+ /** @type {HTMLElement | null} */
47
+ #panel = null;
48
+
49
+ /**
50
+ * The gesture in flight: where it started, and what was decided about it.
51
+ *
52
+ * @type {{ x: number, y: number, verdict: 'undecided' | 'allow' | 'block' } | undefined}
53
+ */
54
+ #touch;
55
+
56
+ /** @param {LitElement} host */
57
+ constructor(host) {
58
+ this.#host = host;
59
+ host.addController(this);
60
+ }
61
+
62
+ hostUpdated() {
63
+ const panel = /** @type {HTMLElement | null} */ (
64
+ this.#host.renderRoot?.querySelector('[part~="panel"]') ?? null
65
+ );
66
+ if (panel === this.#panel) return;
67
+
68
+ this.#unbind();
69
+ this.#panel = panel;
70
+ this.#bind();
71
+ }
72
+
73
+ hostDisconnected() {
74
+ this.#unbind();
75
+ }
76
+
77
+ #bind() {
78
+ const panel = this.#panel;
79
+ if (!panel) return;
80
+ panel.addEventListener('touchstart', this.#onStart, { passive: true });
81
+ // Cancelling a move is the whole point, so this one cannot be passive.
82
+ panel.addEventListener('touchmove', this.#onMove, { passive: false });
83
+ panel.addEventListener('touchend', this.#onEnd, { passive: true });
84
+ panel.addEventListener('touchcancel', this.#onEnd, { passive: true });
85
+ }
86
+
87
+ #unbind() {
88
+ const panel = this.#panel;
89
+ if (!panel) return;
90
+ panel.removeEventListener('touchstart', this.#onStart);
91
+ panel.removeEventListener('touchmove', this.#onMove);
92
+ panel.removeEventListener('touchend', this.#onEnd);
93
+ panel.removeEventListener('touchcancel', this.#onEnd);
94
+ this.#panel = null;
95
+ this.#touch = undefined;
96
+ }
97
+
98
+ /** @param {TouchEvent} event */
99
+ #onStart = (event) => {
100
+ if (event.touches.length !== 1) {
101
+ this.#touch = undefined;
102
+ return;
103
+ }
104
+ const touch = event.touches[0];
105
+ this.#touch = { x: touch.clientX, y: touch.clientY, verdict: 'undecided' };
106
+ };
107
+
108
+ #onEnd = () => {
109
+ this.#touch = undefined;
110
+ };
111
+
112
+ /** @param {TouchEvent} event */
113
+ #onMove = (event) => {
114
+ const from = this.#touch;
115
+ if (!from) return;
116
+
117
+ // A second finger arriving turns this into a pinch. Hands off for the
118
+ // rest of the gesture, zoom included.
119
+ if (event.touches.length > 1) {
120
+ this.#touch = undefined;
121
+ return;
122
+ }
123
+
124
+ if (from.verdict === 'block') {
125
+ if (event.cancelable) event.preventDefault();
126
+ return;
127
+ }
128
+ if (from.verdict === 'allow') return;
129
+
130
+ const touch = event.touches[0];
131
+ const dx = touch.clientX - from.x;
132
+ const dy = touch.clientY - from.y;
133
+ // Too small to read a direction from, and too small for the browser to
134
+ // have started scrolling either.
135
+ if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) return;
136
+
137
+ const vertical = Math.abs(dy) >= Math.abs(dx);
138
+ const allowed = this.#canScroll(event, vertical, vertical ? dy : dx);
139
+ from.verdict = allowed ? 'allow' : 'block';
140
+ if (!allowed && event.cancelable) event.preventDefault();
141
+ };
142
+
143
+ /**
144
+ * Is there something between the touch and the panel that can still scroll
145
+ * the way the finger is going?
146
+ *
147
+ * @param {TouchEvent} event
148
+ * @param {boolean} vertical
149
+ * @param {number} delta Positive when the finger moves down, or right.
150
+ * @returns {boolean}
151
+ */
152
+ #canScroll(event, vertical, delta) {
153
+ const panel = this.#panel;
154
+ if (!panel) return true;
155
+
156
+ for (const node of event.composedPath()) {
157
+ if (!(node instanceof HTMLElement)) continue;
158
+
159
+ if (this.#hasRoom(node, vertical, delta)) return true;
160
+ if (node === panel) break;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ /**
166
+ * @param {HTMLElement} element
167
+ * @param {boolean} vertical
168
+ * @param {number} delta
169
+ * @returns {boolean}
170
+ */
171
+ #hasRoom(element, vertical, delta) {
172
+ const style = getComputedStyle(element);
173
+ const overflow = vertical ? style.overflowY : style.overflowX;
174
+ if (!SCROLLABLE.test(overflow)) return false;
175
+
176
+ const position = vertical ? element.scrollTop : element.scrollLeft;
177
+ const size = vertical ? element.clientHeight : element.clientWidth;
178
+ const content = vertical ? element.scrollHeight : element.scrollWidth;
179
+ if (content <= size + EDGE_SLACK) return false;
180
+
181
+ // A finger moving down pulls the content down, which means reading from
182
+ // further up: there has to be something above the current position.
183
+ return delta > 0 ? position > EDGE_SLACK : position < content - size - EDGE_SLACK;
184
+ }
185
+ }
@@ -0,0 +1,102 @@
1
+ // @ts-check
2
+
3
+ /** @import { ReactiveController, LitElement } from 'lit' */
4
+
5
+ /**
6
+ * Past this the reader has pinched to zoom, and the widget stays out of it.
7
+ * A little over 1 because browsers report scales like 1.0000002.
8
+ */
9
+ const ZOOMED = 1.01;
10
+
11
+ /**
12
+ * Keeps the full-screen panel inside the part of the screen the reader can
13
+ * actually see.
14
+ *
15
+ * On a phone the panel is `position: fixed` across the whole viewport, and
16
+ * that viewport is not what is on screen once a keyboard slides up: the
17
+ * layout viewport keeps its full height, so the composer — and with it the
18
+ * last few messages — ends up behind the keyboard. iOS then scrolls the page
19
+ * to chase the focused field, which moves the fixed panel out of frame
20
+ * instead of helping. `dvh` is no use here either; it tracks the browser's
21
+ * own chrome collapsing, not the keyboard.
22
+ *
23
+ * `visualViewport` is the one thing that does report the visible box, so the
24
+ * panel is pinned to it: its top to `offsetTop`, its height to `height`. The
25
+ * composer lands just above the keyboard, and the conversation gets whatever
26
+ * is left.
27
+ *
28
+ * A pinch is left alone. Zooming is how a reader gets a closer look, and a
29
+ * panel that re-fitted itself to the magnified box would take that away by
30
+ * reflowing the text back to the same apparent size.
31
+ *
32
+ * @implements {ReactiveController}
33
+ */
34
+ export class ViewportController {
35
+ /** @type {LitElement} */
36
+ #host;
37
+
38
+ /** @type {() => boolean} */
39
+ #enabled;
40
+
41
+ /** @type {(() => void) | undefined} */
42
+ #onChange;
43
+
44
+ /** True while the custom properties are set, to avoid pointless writes. */
45
+ #applied = false;
46
+
47
+ /**
48
+ * @param {LitElement} host
49
+ * @param {{ enabled: () => boolean }} options
50
+ */
51
+ constructor(host, { enabled }) {
52
+ this.#host = host;
53
+ this.#enabled = enabled;
54
+ host.addController(this);
55
+ }
56
+
57
+ hostConnected() {
58
+ const viewport = window.visualViewport;
59
+ if (!viewport) return;
60
+
61
+ this.#onChange = () => this.#apply();
62
+ // Resize is the keyboard arriving and leaving; scroll is iOS shifting the
63
+ // visible box around while it is up.
64
+ viewport.addEventListener('resize', this.#onChange);
65
+ viewport.addEventListener('scroll', this.#onChange);
66
+ }
67
+
68
+ hostDisconnected() {
69
+ const viewport = window.visualViewport;
70
+ if (viewport && this.#onChange) {
71
+ viewport.removeEventListener('resize', this.#onChange);
72
+ viewport.removeEventListener('scroll', this.#onChange);
73
+ }
74
+ this.#onChange = undefined;
75
+ this.#clear();
76
+ }
77
+
78
+ hostUpdated() {
79
+ this.#apply();
80
+ }
81
+
82
+ #apply() {
83
+ const viewport = window.visualViewport;
84
+ const panel = this.#host.renderRoot?.querySelector('[part~="panel"]');
85
+
86
+ if (!viewport || !panel || !this.#enabled() || viewport.scale > ZOOMED) {
87
+ this.#clear();
88
+ return;
89
+ }
90
+
91
+ this.#host.style.setProperty('--_chit-vv-top', `${Math.round(viewport.offsetTop)}px`);
92
+ this.#host.style.setProperty('--_chit-vv-height', `${Math.round(viewport.height)}px`);
93
+ this.#applied = true;
94
+ }
95
+
96
+ #clear() {
97
+ if (!this.#applied) return;
98
+ this.#host.style.removeProperty('--_chit-vv-top');
99
+ this.#host.style.removeProperty('--_chit-vv-height');
100
+ this.#applied = false;
101
+ }
102
+ }
package/src/index.js CHANGED
@@ -7,3 +7,37 @@ if (!customElements.get('chit-ui')) {
7
7
 
8
8
  export { ChitUI };
9
9
  export { Events } from './events.js';
10
+
11
+ /**
12
+ * The public types, re-exported so a consumer can write
13
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
14
+ * into `dist/types/` for them.
15
+ *
16
+ * @typedef {import('./types.js').Role} Role
17
+ * @typedef {import('./types.js').MessageStatus} MessageStatus
18
+ * @typedef {import('./types.js').ChatState} ChatState
19
+ * @typedef {import('./types.js').Trigger} Trigger
20
+ * @typedef {import('./types.js').Device} Device
21
+ * @typedef {import('./types.js').Effect} Effect
22
+ * @typedef {import('./types.js').Position} Position
23
+ * @typedef {import('./types.js').BubbleTail} BubbleTail
24
+ * @typedef {import('./types.js').LoadingStyle} LoadingStyle
25
+ * @typedef {import('./types.js').MessageBase} MessageBase
26
+ * @typedef {import('./types.js').TextMessage} TextMessage
27
+ * @typedef {import('./types.js').HtmlMessage} HtmlMessage
28
+ * @typedef {import('./types.js').TemplateMessage} TemplateMessage
29
+ * @typedef {import('./types.js').ElementMessage} ElementMessage
30
+ * @typedef {import('./types.js').ComponentMessage} ComponentMessage
31
+ * @typedef {import('./types.js').Message} Message
32
+ * @typedef {import('./types.js').Animation} Animation
33
+ * @typedef {import('./types.js').Offset} Offset
34
+ * @typedef {import('./types.js').ClosedTheme} ClosedTheme
35
+ * @typedef {import('./types.js').OpenColors} OpenColors
36
+ * @typedef {import('./types.js').SpeakerTheme} SpeakerTheme
37
+ * @typedef {import('./types.js').LoadingTheme} LoadingTheme
38
+ * @typedef {import('./types.js').OpenTheme} OpenTheme
39
+ * @typedef {import('./types.js').Theme} Theme
40
+ * @typedef {import('./types.js').ResolvedClosed} ResolvedClosed
41
+ * @typedef {import('./types.js').ResolvedOpen} ResolvedOpen
42
+ * @typedef {import('./types.js').ResolvedTheme} ResolvedTheme
43
+ */
@@ -29,6 +29,24 @@ export const composerStyles = css`
29
29
  overflow-y: auto;
30
30
  }
31
31
 
32
+ /*
33
+ * iOS Safari zooms the page in whenever a field smaller than 16px takes
34
+ * focus, and it does not zoom back out when the field is done with — the
35
+ * reader is left with a widget wider than the screen and no obvious way
36
+ * back. Nothing in the page can undo that zoom short of pinning the
37
+ * viewport's scale, which would also take pinch-zoom away from everyone.
38
+ * So the field is kept at the size that never triggers it, and larger
39
+ * still if the theme's font is larger.
40
+ *
41
+ * Coarse pointers only: on a desktop the same rule would make the field
42
+ * stand out from the rest of the widget for no reason.
43
+ */
44
+ @media (pointer: coarse) {
45
+ [part~='input'] {
46
+ font-size: max(16px, 1em);
47
+ }
48
+ }
49
+
32
50
  [part~='input']::placeholder {
33
51
  color: var(--chit-color-input-placeholder);
34
52
  }
@@ -56,12 +56,18 @@ export const panelStyles = css`
56
56
  * A phone gets the whole screen. dvh rather than vh so the browser chrome
57
57
  * collapsing does not leave a gap, and the safe-area insets keep the header
58
58
  * clear of the notch.
59
+ *
60
+ * While a keyboard is up, the two --_chit-vv-* properties carry what is
61
+ * actually on screen (ViewportController fills them in from
62
+ * visualViewport); dvh knows nothing about a keyboard and would leave the
63
+ * composer behind it.
59
64
  */
60
65
  :host([data-device='mobile']) [part~='panel'],
61
66
  :host([data-device='mobile'][data-panel-position]) [part~='panel'] {
62
67
  inset: 0;
68
+ top: var(--_chit-vv-top, 0px);
63
69
  width: 100vw;
64
- height: 100dvh;
70
+ height: var(--_chit-vv-height, 100dvh);
65
71
  max-width: none;
66
72
  max-height: none;
67
73
  border-radius: 0;