@hidemikimura/chit-ui 0.2.0 → 0.3.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +148 -3
  3. package/dist/chit-ui.iife.min.js +218 -99
  4. package/dist/chit-ui.iife.min.js.map +1 -1
  5. package/dist/chit-ui.min.js +209 -90
  6. package/dist/chit-ui.min.js.map +1 -1
  7. package/dist/types/chit-ui.d.ts +64 -4
  8. package/dist/types/controllers/composer-controller.d.ts +4 -0
  9. package/dist/types/controllers/drag-controller.d.ts +118 -0
  10. package/dist/types/controllers/scroll-lock-controller.d.ts +37 -0
  11. package/dist/types/controllers/viewport-controller.d.ts +38 -0
  12. package/dist/types/events.d.ts +1 -0
  13. package/dist/types/global.d.ts +7 -1
  14. package/dist/types/i18n/labels.d.ts +8 -0
  15. package/dist/types/index.d.ts +162 -0
  16. package/dist/types/render/message-list.d.ts +0 -1
  17. package/dist/types/theme/default-theme.d.ts +8 -0
  18. package/dist/types/types.d.ts +40 -0
  19. package/package.json +1 -1
  20. package/src/chit-ui.js +261 -4
  21. package/src/controllers/composer-controller.js +2 -0
  22. package/src/controllers/drag-controller.js +319 -0
  23. package/src/controllers/scroll-controller.js +21 -1
  24. package/src/controllers/scroll-lock-controller.js +185 -0
  25. package/src/controllers/viewport-controller.js +102 -0
  26. package/src/events.js +1 -0
  27. package/src/global.d.ts +7 -1
  28. package/src/i18n/labels.js +6 -0
  29. package/src/index.js +34 -0
  30. package/src/render/launcher.js +9 -1
  31. package/src/render/message-list.js +40 -10
  32. package/src/render/panel.js +17 -1
  33. package/src/styles/composer.css.js +18 -0
  34. package/src/styles/launcher.css.js +13 -0
  35. package/src/styles/message.css.js +60 -15
  36. package/src/styles/panel.css.js +30 -3
  37. package/src/theme/default-theme.js +16 -0
  38. package/src/types.js +18 -0
package/src/chit-ui.js CHANGED
@@ -8,6 +8,9 @@ import { BreakpointController } from './controllers/breakpoint-controller.js';
8
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
+ import { DragController } from './controllers/drag-controller.js';
12
+ import { ScrollLockController } from './controllers/scroll-lock-controller.js';
13
+ import { ViewportController } from './controllers/viewport-controller.js';
11
14
  import { renderLauncher } from './render/launcher.js';
12
15
  import { renderPanel } from './render/panel.js';
13
16
  import { resolveLabels, resolveLocale } from './i18n/labels.js';
@@ -56,7 +59,8 @@ export class ChitUI extends LitElement {
56
59
  state: { type: String, reflect: true, noAccessor: true },
57
60
  theme: { type: Object },
58
61
  messages: { type: Array },
59
- typing: { type: Object },
62
+ typing: { type: Object, noAccessor: true },
63
+ loading: { type: Boolean, reflect: true, noAccessor: true },
60
64
  busy: { type: Boolean, reflect: true },
61
65
  inputDisabled: { type: Boolean, reflect: true, attribute: 'input-disabled' },
62
66
  inputHidden: { type: Boolean, reflect: true, attribute: 'input-hidden' },
@@ -72,6 +76,12 @@ export class ChitUI extends LitElement {
72
76
  /** @type {ChatState} */
73
77
  #state = 'closed';
74
78
 
79
+ /** @type {boolean | { html: string }} */
80
+ #typing = false;
81
+
82
+ /** @type {boolean} */
83
+ #loading = false;
84
+
75
85
  /**
76
86
  * Set by the widget's own event handlers just before they move the state, so
77
87
  * the events can say the move came from a person rather than from code.
@@ -115,9 +125,6 @@ export class ChitUI extends LitElement {
115
125
  /** @type {Message[]} Rendered as given. The library never mutates this. */
116
126
  this.messages = [];
117
127
 
118
- /** @type {boolean | { html: string }} Show the "typing" bubble. */
119
- this.typing = false;
120
-
121
128
  /** @type {boolean} Lock the composer while a reply is in flight. */
122
129
  this.busy = false;
123
130
 
@@ -175,6 +182,224 @@ export class ChitUI extends LitElement {
175
182
  behavior: () => this.currentTheme.open.animation.scroll,
176
183
  });
177
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' });
189
+
190
+ this.#launcherDrag = new DragController(this, {
191
+ name: 'launcher',
192
+ enabled: () => this.currentTheme.closed.draggable,
193
+ element: () => this.#part('launcher'),
194
+ size: () => boxOf(this.#part('launcher')),
195
+ corner: () => this.currentTheme.closed.position,
196
+ base: () => this.currentTheme.closed.offset,
197
+ current: () => this.#dragOffset,
198
+ onMove: (displacement) => this.#moveTo(displacement),
199
+ onSettle: (name) => this.#announceMove(name),
200
+ vars: { x: '--chit-launcher-offset-x', y: '--chit-launcher-offset-y' },
201
+ });
202
+
203
+ this.#panelDrag = new DragController(this, {
204
+ name: 'panel',
205
+ // On a phone the panel is the whole screen; there is nowhere to move it.
206
+ enabled: () => this.currentTheme.open.draggable && this.device === 'pc',
207
+ element: () => this.#part('panel'),
208
+ // What the theme asks for, capped by the window — never the measured
209
+ // box, which shrinks as the panel is pushed towards its own corner.
210
+ size: () => {
211
+ const open = this.currentTheme.open;
212
+ return {
213
+ width: Math.min(open.width, window.innerWidth - 16),
214
+ height: Math.min(open.height, window.innerHeight - 16),
215
+ };
216
+ },
217
+ corner: () => this.currentTheme.open.position,
218
+ base: () => this.currentTheme.open.offset,
219
+ current: () => this.#dragOffset,
220
+ onMove: (displacement) => this.#moveTo(displacement),
221
+ onSettle: (name) => this.#announceMove(name),
222
+ vars: { x: '--chit-panel-offset-x', y: '--chit-panel-offset-y' },
223
+ });
224
+ }
225
+
226
+ /** @type {DragController} */
227
+ #launcherDrag;
228
+
229
+ /** @type {DragController} */
230
+ #panelDrag;
231
+
232
+ /**
233
+ * @param {string} name
234
+ * @returns {HTMLElement | null}
235
+ */
236
+ #part(name) {
237
+ return /** @type {HTMLElement | null} */ (
238
+ this.renderRoot?.querySelector(`[part~="${name}"]`) ?? null
239
+ );
240
+ }
241
+
242
+ /** The launcher's drag, for the render function to hook up. */
243
+ get launcherDrag() {
244
+ return this.#launcherDrag;
245
+ }
246
+
247
+ /** The panel's drag, likewise. */
248
+ get panelDrag() {
249
+ return this.#panelDrag;
250
+ }
251
+
252
+ /**
253
+ * How far the reader has dragged the widget from where the theme put it, in
254
+ * screen pixels, or null when it is still there.
255
+ *
256
+ * There is one of these for the whole widget rather than one per state: the
257
+ * launcher and the panel are the same widget wearing two shapes, so moving
258
+ * either moves both, and the panel opens where the launcher was left.
259
+ *
260
+ * @type {{ x: number, y: number } | null}
261
+ */
262
+ get dragOffset() {
263
+ return this.#dragOffset;
264
+ }
265
+
266
+ set dragOffset(value) {
267
+ this.#dragOffset = value ? { x: value.x, y: value.y } : null;
268
+ this.#place();
269
+ }
270
+
271
+ /** Forget the dragged position and go back to what the theme says. */
272
+ resetPosition() {
273
+ this.dragOffset = null;
274
+ }
275
+
276
+ /** @type {{ x: number, y: number } | null} */
277
+ #dragOffset = null;
278
+
279
+ /**
280
+ * @param {{ x: number, y: number }} displacement
281
+ * @returns {void}
282
+ */
283
+ #moveTo(displacement) {
284
+ this.#dragOffset = displacement;
285
+ this.#place();
286
+ }
287
+
288
+ /** Put both shapes where the displacement says. */
289
+ #place() {
290
+ this.#launcherDrag.place(this.#dragOffset);
291
+ this.#panelDrag.place(this.#dragOffset);
292
+ }
293
+
294
+ /**
295
+ * @param {'launcher' | 'panel'} name
296
+ * @returns {void}
297
+ */
298
+ #announceMove(name) {
299
+ if (!this.#dragOffset) return;
300
+ const theme = this.currentTheme;
301
+ const side = name === 'launcher' ? theme.closed : theme.open;
302
+ const drag = name === 'launcher' ? this.#launcherDrag : this.#panelDrag;
303
+ emit(this, Events.MOVE, {
304
+ target: name,
305
+ position: side.position,
306
+ // The thing that was dragged is on screen by definition, so `place`
307
+ // gives the offset it actually landed on, clamp included.
308
+ offset: drag.place(this.#dragOffset) ?? side.offset,
309
+ displacement: { ...this.#dragOffset },
310
+ });
311
+ }
312
+
313
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
314
+ #loadingTimer;
315
+
316
+ /**
317
+ * The ids present when the wait began. A reply is anything from the other
318
+ * side that was not among them; the consumer's own echo of what the reader
319
+ * just sent is not an answer and must not end the wait.
320
+ *
321
+ * @type {Set<string> | undefined}
322
+ */
323
+ #waitFrom;
324
+
325
+ /**
326
+ * The composer calls this once a submit has gone out and no listener
327
+ * cancelled it. Listening for `chat-submit` here instead would be wrong:
328
+ * this element's own listener runs before the consumer's, so it cannot see
329
+ * a `preventDefault()` that is still to come.
330
+ *
331
+ * @returns {void}
332
+ */
333
+ handleSubmitted() {
334
+ this.#startWaiting();
335
+ }
336
+
337
+ /** Begin the automatic wait, if the theme asked for one. */
338
+ #startWaiting() {
339
+ const loading = this.currentTheme.open.loading;
340
+ if (!loading.auto) return;
341
+
342
+ this.#waitFrom = new Set(this.messages.map((message) => message.id));
343
+ this.loading = true;
344
+ this.#clearLoadingTimer();
345
+ if (loading.timeout > 0) {
346
+ this.#loadingTimer = setTimeout(() => {
347
+ this.#loadingTimer = undefined;
348
+ this.#stopWaiting();
349
+ }, loading.timeout);
350
+ }
351
+ }
352
+
353
+ /** End it, however it ended. */
354
+ #stopWaiting() {
355
+ this.#clearLoadingTimer();
356
+ this.#waitFrom = undefined;
357
+ this.loading = false;
358
+ }
359
+
360
+ #clearLoadingTimer() {
361
+ if (this.#loadingTimer !== undefined) clearTimeout(this.#loadingTimer);
362
+ this.#loadingTimer = undefined;
363
+ }
364
+
365
+ /**
366
+ * Show the "typing" bubble: `true`, or `{ html }` for your own markup.
367
+ *
368
+ * Turning this on turns `loading` off. The two describe the same pause in
369
+ * the conversation — one says a person is writing, the other that a server
370
+ * has not answered yet — and showing both would leave the reader to work
371
+ * out the difference.
372
+ *
373
+ * @type {boolean | { html: string }}
374
+ */
375
+ get typing() {
376
+ return this.#typing;
377
+ }
378
+
379
+ set typing(value) {
380
+ const previous = this.#typing;
381
+ if (value === previous) return;
382
+ this.#typing = value;
383
+ this.requestUpdate('typing', previous);
384
+ if (value) this.loading = false;
385
+ }
386
+
387
+ /**
388
+ * Show the "waiting for an answer" indicator. Turning this on turns
389
+ * `typing` off, for the reason given above.
390
+ *
391
+ * @type {boolean}
392
+ */
393
+ get loading() {
394
+ return this.#loading;
395
+ }
396
+
397
+ set loading(value) {
398
+ const previous = this.#loading;
399
+ if (value === previous) return;
400
+ this.#loading = value;
401
+ this.requestUpdate('loading', previous);
402
+ if (value) this.typing = false;
178
403
  }
179
404
 
180
405
  /**
@@ -537,6 +762,26 @@ export class ChitUI extends LitElement {
537
762
  const live = new Set(this.messages.map((message) => message.id));
538
763
  pruneComponents(this, live);
539
764
  this.#announceRenders(live);
765
+
766
+ // An answer has arrived when a message from the other side appears that
767
+ // was not there when the wait began.
768
+ // The shape on screen has just changed, so whatever is there now needs to
769
+ // be put where the drag left the widget — and clamped to today's window.
770
+ if (this.#dragOffset) this.#place();
771
+
772
+ if (this.#waitFrom) {
773
+ const answered = this.messages.some(
774
+ (message) =>
775
+ message.role !== 'user' && !(/** @type {Set<string>} */ (this.#waitFrom).has(message.id)),
776
+ );
777
+ if (answered) this.#stopWaiting();
778
+ }
779
+ }
780
+
781
+ /** @override */
782
+ disconnectedCallback() {
783
+ super.disconnectedCallback();
784
+ this.#clearLoadingTimer();
540
785
  }
541
786
 
542
787
  /**
@@ -583,3 +828,15 @@ export class ChitUI extends LitElement {
583
828
  `;
584
829
  }
585
830
  }
831
+
832
+ /**
833
+ * The size of something on screen, or nothing at all when it is not there.
834
+ *
835
+ * @param {HTMLElement | null} element
836
+ * @returns {{ width: number, height: number }}
837
+ */
838
+ function boxOf(element) {
839
+ if (!element) return { width: 0, height: 0 };
840
+ const box = element.getBoundingClientRect();
841
+ return { width: box.width, height: box.height };
842
+ }
@@ -152,6 +152,7 @@ export class ComposerController {
152
152
  if (!allowed) return false;
153
153
 
154
154
  this.clear();
155
+ this.#host.handleSubmitted();
155
156
  return true;
156
157
  }
157
158
 
@@ -212,4 +213,5 @@ export class ComposerController {
212
213
  * @property {boolean} sendOnEnter
213
214
  * @property {number | undefined} maxLength
214
215
  * @property {import('../i18n/labels.js').Labels} currentLabels
216
+ * @property {() => void} handleSubmitted Called after a submit that no listener cancelled.
215
217
  */
@@ -0,0 +1,319 @@
1
+ // @ts-check
2
+
3
+ /** @import { ReactiveController, LitElement } from 'lit' */
4
+ /** @import { Position } from '../types.js' */
5
+
6
+ /** How far a pointer may travel before the gesture counts as a drag, not a click. */
7
+ const SLOP = 4;
8
+
9
+ /** How close to the viewport edge the dragged thing may come. */
10
+ const MARGIN = 8;
11
+
12
+ /** Arrow-key step, and the bigger step Shift asks for. */
13
+ const STEP = 8;
14
+ const BIG_STEP = 32;
15
+
16
+ /**
17
+ * Moves one thing — the launcher or the panel — around the viewport.
18
+ *
19
+ * What a gesture produces is not a position but a displacement: how far the
20
+ * reader has dragged the widget from wherever the theme put it, in screen
21
+ * pixels. The host keeps one such displacement for the whole widget and hands
22
+ * it to both controllers, which is what makes the two states travel together:
23
+ * drag the launcher into a corner and the panel opens in that same corner,
24
+ * because both are the theme's position plus the same shift.
25
+ *
26
+ * Each controller then turns the displacement into the terms its own corner
27
+ * uses — at a right-hand corner, moving right means a smaller offset — and
28
+ * writes it into the two custom properties the stylesheet already reads. The
29
+ * offset lives on the host as an inline property, which outranks the theme's
30
+ * sheet and the page's CSS: a reader who dragged the widget somewhere means
31
+ * it, and until the drag is cleared theirs is the most specific word on where
32
+ * it goes.
33
+ *
34
+ * @implements {ReactiveController}
35
+ */
36
+ export class DragController {
37
+ /** @type {LitElement} */
38
+ #host;
39
+
40
+ /** @type {DragOptions} */
41
+ #options;
42
+
43
+ /**
44
+ * Pointer state, only while a gesture is in flight.
45
+ *
46
+ * @type {{
47
+ * pointer: { x: number, y: number },
48
+ * displacement: { x: number, y: number },
49
+ * size: { width: number, height: number },
50
+ * corner: Position,
51
+ * base: { x: number, y: number },
52
+ * moved: boolean,
53
+ * pointerId: number,
54
+ * target: HTMLElement,
55
+ * } | undefined}
56
+ */
57
+ #from;
58
+
59
+ /** True once a gesture has passed the slop, until the click that follows it. */
60
+ #dragged = false;
61
+
62
+ /**
63
+ * @param {LitElement} host
64
+ * @param {DragOptions} options
65
+ */
66
+ constructor(host, options) {
67
+ this.#host = host;
68
+ this.#options = options;
69
+ host.addController(this);
70
+ }
71
+
72
+ hostConnected() {
73
+ // A smaller window can leave the widget hanging off the edge; placing it
74
+ // again runs it back through the clamp.
75
+ this.#onResize = () => {
76
+ const now = this.#options.current();
77
+ if (now) this.#options.onMove(now);
78
+ };
79
+ window.addEventListener('resize', this.#onResize);
80
+ }
81
+
82
+ hostDisconnected() {
83
+ if (this.#onResize) window.removeEventListener('resize', this.#onResize);
84
+ this.#onResize = undefined;
85
+ }
86
+
87
+ /** @type {(() => void) | undefined} */
88
+ #onResize;
89
+
90
+ /**
91
+ * True when the gesture that just ended was a drag. Reading it clears it, so
92
+ * the click a pointer-up fires can be skipped exactly once.
93
+ *
94
+ * @returns {boolean}
95
+ */
96
+ consumeDrag() {
97
+ const dragged = this.#dragged;
98
+ this.#dragged = false;
99
+ return dragged;
100
+ }
101
+
102
+ /**
103
+ * Put this thing where the displacement says, within the viewport.
104
+ *
105
+ * @param {{ x: number, y: number } | null} displacement
106
+ * @returns {{ x: number, y: number } | undefined} Where it ended up, when it is on screen.
107
+ */
108
+ place(displacement) {
109
+ if (!displacement) {
110
+ for (const name of [this.#options.vars.x, this.#options.vars.y]) {
111
+ this.#host.style.removeProperty(name);
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ const element = this.#options.element();
117
+ const offset = this.#toOffset(displacement);
118
+ // Nothing on screen yet: write it anyway, so the thing is already in
119
+ // place the moment it is rendered, and let the next pass clamp it.
120
+ const placed = element ? this.#clamp(offset, this.#options.size()) : offset;
121
+
122
+ this.#host.style.setProperty(this.#options.vars.x, `${placed.x}px`);
123
+ this.#host.style.setProperty(this.#options.vars.y, `${placed.y}px`);
124
+ return element ? placed : undefined;
125
+ }
126
+
127
+ /**
128
+ * Begin a drag.
129
+ *
130
+ * @param {PointerEvent} event
131
+ * @returns {void}
132
+ */
133
+ start(event) {
134
+ if (!this.#options.enabled() || event.button !== 0) return;
135
+ const element = this.#options.element();
136
+ if (!element) return;
137
+
138
+ this.#from = {
139
+ pointer: { x: event.clientX, y: event.clientY },
140
+ displacement: this.#options.current() ?? { x: 0, y: 0 },
141
+ size: this.#options.size(),
142
+ corner: this.#options.corner(),
143
+ base: this.#options.base(),
144
+ moved: false,
145
+ pointerId: event.pointerId,
146
+ target: /** @type {HTMLElement} */ (event.currentTarget),
147
+ };
148
+
149
+ // Capture keeps the moves coming even when the pointer leaves the handle.
150
+ // It throws if the pointer has already gone; the drag still works without
151
+ // it, so there is nothing to do about that but carry on.
152
+ try {
153
+ this.#from.target.setPointerCapture(event.pointerId);
154
+ } catch {
155
+ /* no capture available */
156
+ }
157
+
158
+ this.#from.target.addEventListener('pointermove', this.#onPointerMove);
159
+ this.#from.target.addEventListener('pointerup', this.#onPointerEnd);
160
+ this.#from.target.addEventListener('pointercancel', this.#onPointerEnd);
161
+ }
162
+
163
+ /** @param {PointerEvent} event */
164
+ #onPointerMove = (event) => {
165
+ const from = this.#from;
166
+ if (!from || event.pointerId !== from.pointerId) return;
167
+
168
+ const dx = event.clientX - from.pointer.x;
169
+ const dy = event.clientY - from.pointer.y;
170
+ if (!from.moved && Math.hypot(dx, dy) < SLOP) return;
171
+
172
+ from.moved = true;
173
+ // A drag must not also select text or scroll the page under the finger.
174
+ event.preventDefault();
175
+ this.#options.onMove(
176
+ this.#limit(
177
+ { x: from.displacement.x + dx, y: from.displacement.y + dy },
178
+ from.base,
179
+ from.corner,
180
+ from.size,
181
+ ),
182
+ );
183
+ };
184
+
185
+ /** @param {PointerEvent} event */
186
+ #onPointerEnd = (event) => {
187
+ const from = this.#from;
188
+ if (!from || event.pointerId !== from.pointerId) return;
189
+
190
+ from.target.removeEventListener('pointermove', this.#onPointerMove);
191
+ from.target.removeEventListener('pointerup', this.#onPointerEnd);
192
+ from.target.removeEventListener('pointercancel', this.#onPointerEnd);
193
+ try {
194
+ if (from.target.hasPointerCapture(event.pointerId)) {
195
+ from.target.releasePointerCapture(event.pointerId);
196
+ }
197
+ } catch {
198
+ /* never had it */
199
+ }
200
+
201
+ this.#from = undefined;
202
+ if (!from.moved) return;
203
+ this.#dragged = true;
204
+ this.#options.onSettle(this.#options.name);
205
+ };
206
+
207
+ /**
208
+ * Move with the arrow keys, so the same thing can be done without a pointer.
209
+ *
210
+ * @param {KeyboardEvent} event
211
+ * @returns {boolean} True when the key was used.
212
+ */
213
+ nudge(event) {
214
+ if (!this.#options.enabled()) return false;
215
+ if (event.altKey || event.ctrlKey || event.metaKey) return false;
216
+
217
+ /** @type {Record<string, [number, number]>} */
218
+ const directions = {
219
+ ArrowLeft: [-1, 0],
220
+ ArrowRight: [1, 0],
221
+ ArrowUp: [0, -1],
222
+ ArrowDown: [0, 1],
223
+ };
224
+ const direction = directions[event.key];
225
+ if (!direction) return false;
226
+
227
+ const element = this.#options.element();
228
+ if (!element) return false;
229
+
230
+ const step = event.shiftKey ? BIG_STEP : STEP;
231
+ const now = this.#options.current() ?? { x: 0, y: 0 };
232
+ event.preventDefault();
233
+ this.#options.onMove(
234
+ this.#limit(
235
+ { x: now.x + direction[0] * step, y: now.y + direction[1] * step },
236
+ this.#options.base(),
237
+ this.#options.corner(),
238
+ this.#options.size(),
239
+ ),
240
+ );
241
+ this.#options.onSettle(this.#options.name);
242
+ return true;
243
+ }
244
+
245
+ /**
246
+ * The displacement in the terms this corner counts in.
247
+ *
248
+ * @param {{ x: number, y: number }} displacement
249
+ * @param {{ x: number, y: number }} [base]
250
+ * @param {Position} [corner]
251
+ * @returns {{ x: number, y: number }}
252
+ */
253
+ #toOffset(displacement, base = this.#options.base(), corner = this.#options.corner()) {
254
+ return {
255
+ x: base.x + (corner.endsWith('right') ? -displacement.x : displacement.x),
256
+ y: base.y + (corner.startsWith('bottom') ? -displacement.y : displacement.y),
257
+ };
258
+ }
259
+
260
+ /**
261
+ * As much of a displacement as this thing can take without leaving the
262
+ * viewport. The dragged thing sets the shared displacement, so the limit
263
+ * has to be expressed there rather than only in the offset it writes.
264
+ *
265
+ * The size is the size the thing means to be, not the one it happens to
266
+ * have: the panel's own stylesheet caps it against the space between its
267
+ * corner and the far edge, so measuring it while it is being pushed into
268
+ * that corner would read back a smaller box and let it be pushed further,
269
+ * squeezing it flat instead of stopping it.
270
+ *
271
+ * @param {{ x: number, y: number }} displacement
272
+ * @param {{ x: number, y: number }} base
273
+ * @param {Position} corner
274
+ * @param {{ width: number, height: number }} size
275
+ * @returns {{ x: number, y: number }}
276
+ */
277
+ #limit(displacement, base, corner, size) {
278
+ const offset = this.#clamp(this.#toOffset(displacement, base, corner), size);
279
+ return {
280
+ x: corner.endsWith('right') ? base.x - offset.x : offset.x - base.x,
281
+ y: corner.startsWith('bottom') ? base.y - offset.y : offset.y - base.y,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * @param {{ x: number, y: number }} offset
287
+ * @param {{ width: number, height: number }} size
288
+ * @returns {{ x: number, y: number }}
289
+ */
290
+ #clamp(offset, size) {
291
+ return {
292
+ x: clamp(offset.x, window.innerWidth - size.width),
293
+ y: clamp(offset.y, window.innerHeight - size.height),
294
+ };
295
+ }
296
+ }
297
+
298
+ /**
299
+ * @param {number} value
300
+ * @param {number} max The offset at which the far edge touches the viewport.
301
+ * @returns {number}
302
+ */
303
+ function clamp(value, max) {
304
+ return Math.round(Math.min(Math.max(value, MARGIN), Math.max(MARGIN, max - MARGIN)));
305
+ }
306
+
307
+ /**
308
+ * @typedef {Object} DragOptions
309
+ * @property {'launcher' | 'panel'} name
310
+ * @property {() => boolean} enabled
311
+ * @property {() => HTMLElement | null} element What moves.
312
+ * @property {() => { width: number, height: number }} size How big it means to be.
313
+ * @property {() => Position} corner Which corner the offset counts from.
314
+ * @property {() => { x: number, y: number }} base The theme's offset, before any drag.
315
+ * @property {() => { x: number, y: number } | null} current The widget's displacement now.
316
+ * @property {(displacement: { x: number, y: number }) => void} onMove A new displacement.
317
+ * @property {(name: 'launcher' | 'panel') => void} onSettle The gesture ended here.
318
+ * @property {{ x: string, y: string }} vars The custom properties to write.
319
+ */
@@ -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