@artooi/ag-ui-web-component 0.49.0 → 0.50.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.
@@ -59,6 +59,16 @@ const LAUNCHER_KEY = "ag-ui-chat:launcher";
59
59
  */
60
60
  const DOCK_KEY = "ag-ui-chat:dock";
61
61
 
62
+ /**
63
+ * Where the user last put a corner panel's launcher, and the panel itself when
64
+ * they stated that too. The shape the launcher's record is stored in.
65
+ */
66
+ interface CornerSpot {
67
+ readonly left: number;
68
+ readonly top: number;
69
+ readonly panel?: { readonly left: number; readonly top: number };
70
+ }
71
+
62
72
  /** A stored `{ left, top }` pair, or null for anything that is not one. */
63
73
  function asPoint(value: unknown): { readonly left: number; readonly top: number } | null {
64
74
  if (typeof value !== "object" || value === null) {
@@ -79,6 +89,34 @@ function coversViewport(box: Extent, viewport: Extent): boolean {
79
89
  return box.width >= viewport.width - 1 && box.height >= viewport.height - 1;
80
90
  }
81
91
 
92
+ /**
93
+ * Whether a box that covers the usable viewport stands off any edge of it.
94
+ *
95
+ * The far edges are read against the layout viewport rather than the usable box,
96
+ * because a keyboard shortens the second and pans the panel down inside the
97
+ * first: a phone's full-bleed panel is correctly past the usable box's foot then,
98
+ * and pinning it back would put its header above the screen.
99
+ */
100
+ function hangsOff(box: PanelRect, viewport: ViewportBox, screen: Extent): boolean {
101
+ const slack = 0.5;
102
+ return (
103
+ box.left < viewport.left - slack ||
104
+ box.top < viewport.top - slack ||
105
+ box.right > screen.width + slack ||
106
+ box.bottom > screen.height + slack
107
+ );
108
+ }
109
+
110
+ /** A box of the same size as `box`, standing at the start of the usable viewport. */
111
+ function atStartOf(viewport: ViewportBox, box: PanelRect): PanelRect {
112
+ return {
113
+ left: viewport.left,
114
+ top: viewport.top,
115
+ right: viewport.left + (box.right - box.left),
116
+ bottom: viewport.top + (box.bottom - box.top),
117
+ };
118
+ }
119
+
82
120
  /**
83
121
  * The `--ag-ui-inset` value that pins `box` by `corner`.
84
122
  *
@@ -165,6 +203,7 @@ export class PanelPlacement {
165
203
  * header on screen, and the launcher is the handle then.
166
204
  */
167
205
  enablePanelDrag(header: HTMLElement): void {
206
+ this.#header = header;
168
207
  enablePanelDrag(header, {
169
208
  enabled: () => !this.#host.collapsed() && this.#canDragPanel(),
170
209
  rect: () => this.#host.element.getBoundingClientRect(),
@@ -191,12 +230,22 @@ export class PanelPlacement {
191
230
 
192
231
  /** Build the eight resize grips into `chat`, with one of them in the tab order. */
193
232
  mountResizeGrips(chat: HTMLElement): void {
233
+ // The grips a key gesture was on are gone, and a key that comes up on nothing
234
+ // ends nothing, so an element taken out mid-gesture would otherwise come back
235
+ // deaf to every window resize.
236
+ this.#resizing = false;
194
237
  for (const grip of RESIZE_GRIPS) {
195
238
  const handle = createResizeHandle(grip, {
196
239
  axis: () => this.#resizeAxis(),
197
240
  rect: () => this.#host.element.getBoundingClientRect(),
198
- apply: (box) => this.#applyResize(grip, box),
199
- commit: (box) => this.#commitResize(grip, box),
241
+ apply: (box) => {
242
+ this.#resizing = true;
243
+ this.#applyResize(grip, box);
244
+ },
245
+ commit: (box) => {
246
+ this.#resizing = false;
247
+ this.#commitResize(grip, box);
248
+ },
200
249
  label: this.#host.strings().resizePanel,
201
250
  });
202
251
  // Only one of the eight is in the tab order. Eight separators between the
@@ -229,6 +278,7 @@ export class PanelPlacement {
229
278
  * it always has.
230
279
  */
231
280
  resetSize(): void {
281
+ const sized = this.#host.element.getBoundingClientRect();
232
282
  this.#host.element.style.removeProperty(DRAG_WIDTH);
233
283
  this.#host.element.style.removeProperty(DRAG_HEIGHT);
234
284
  this.#host.clearPreference(SIZE_KEY);
@@ -238,12 +288,55 @@ export class PanelPlacement {
238
288
  }
239
289
  this.#dockPos = null;
240
290
  this.#storeDockPosition(null);
291
+ } else {
292
+ this.#carrySpotWithSize(sized);
241
293
  }
242
294
  // The anchor was measured against the dragged box; the resting one may
243
295
  // pin different edges.
244
296
  this.syncResizeAnchor();
245
297
  }
246
298
 
299
+ /**
300
+ * Carry a stated corner spot along with a dragged size coming off.
301
+ *
302
+ * The size comes off toward the corner the layout holds still, so the top-left
303
+ * the spot is kept as moves with it, and a restore from the old one would swing
304
+ * the panel away from its launcher. Only that movement goes onto the spot, not
305
+ * where the box stands: in a window narrower than the spot the box is held
306
+ * short of it, and that is not something the user stated.
307
+ *
308
+ * Left alone while a host's own size is in force, because that cannot be told
309
+ * from the dragged size coming off, and it may be a state the host is about to
310
+ * leave: counting it would throw the spot off by as much as that state is
311
+ * bigger. A panel that is the screen needs no case of its own: the small-viewport
312
+ * block ignores a dragged size, so its box does not move.
313
+ *
314
+ * Left alone too under data-launcher-drag="false", where a spot left over from
315
+ * before the attribute is a record kept for the day it comes off, and nothing
316
+ * that happens to the box now is a statement about it.
317
+ */
318
+ #carrySpotWithSize(sized: DOMRect): void {
319
+ const element = this.#host.element;
320
+ const box = element.getBoundingClientRect();
321
+ const spot = this.#cornerSpot ?? this.#readLauncherPosition();
322
+ const style = getComputedStyle(element);
323
+ if (
324
+ spot?.panel === undefined ||
325
+ !this.#launcherDraggable() ||
326
+ style.getPropertyValue("--ag-ui-width") !== "" ||
327
+ style.getPropertyValue("--ag-ui-height") !== ""
328
+ ) {
329
+ return;
330
+ }
331
+ this.#writeCornerSpot({
332
+ ...spot,
333
+ panel: {
334
+ left: spot.panel.left + box.left - sized.left,
335
+ top: spot.panel.top + box.top - sized.top,
336
+ },
337
+ });
338
+ }
339
+
247
340
  /**
248
341
  * A zero-sized box carrying the host's viewport insets as padding, so they
249
342
  * can be read back as used pixel lengths. See the `.viewport-probe` rule for
@@ -271,6 +364,35 @@ export class PanelPlacement {
271
364
  */
272
365
  #panelPos: { readonly left: number; readonly top: number } | null = null;
273
366
 
367
+ /**
368
+ * The spot #launcherPos and #panelPos were last stated as, or null while
369
+ * nothing has been stated since this element connected.
370
+ *
371
+ * Those two say where the launcher and the panel stand now, held inside the
372
+ * viewport, because that is where a press finds them and a drag starts from.
373
+ * This is what the user asked for, kept apart so that a window narrower than
374
+ * the spot, or a phone that took the panel full-bleed, gives it back unchanged
375
+ * once there is room again. Only a gesture, a move or a dragged size coming off
376
+ * changes it; re-applying it against a viewport never does, which is the difference from a clamped
377
+ * position quietly becoming the record one resize at a time.
378
+ */
379
+ #cornerSpot: CornerSpot | null = null;
380
+
381
+ /**
382
+ * The inset a grip wrote on a corner panel with no spot, or null. A grip on an
383
+ * edge the layout holds moves the panel without stating a spot, so this is the
384
+ * only way to tell its inline inset from one a host wrote there, which stays,
385
+ * and to give it back once a phone has taken the panel full-bleed.
386
+ */
387
+ #gripInset: string | null = null;
388
+
389
+ /**
390
+ * The inset standing a panel with no spot at the start of a window a host's
391
+ * size fills, or null. Told apart from a host's own for the same reason, and it
392
+ * lasts only as long as that size does.
393
+ */
394
+ #fillInset: string | null = null;
395
+
274
396
  /**
275
397
  * Where the user dragged a docked panel, in viewport coordinates, or null
276
398
  * while it stands on the bar or the anchor.
@@ -303,6 +425,12 @@ export class PanelPlacement {
303
425
  */
304
426
  #anchor: ResizeAnchor = { x: "right", y: "bottom" };
305
427
 
428
+ /** The header the panel drag listens on, stamped by #stampHeader. */
429
+ #header: HTMLElement | null = null;
430
+
431
+ /** Whether a grip has applied a box it has not committed yet. */
432
+ #resizing = false;
433
+
306
434
  /** The eight grips, by name, so the keyboard-reachable one can be moved. */
307
435
  readonly #resizeHandles = new Map<string, HTMLDivElement>();
308
436
 
@@ -345,12 +473,16 @@ export class PanelPlacement {
345
473
  *
346
474
  * Read from the stamp the drag helpers already set, rather than tracked
347
475
  * separately: one source of truth, and it clears on `pointercancel` as well
348
- * as `pointerup`, which is the end a touch gesture usually gets.
476
+ * as `pointerup`, which is the end a touch gesture usually gets. The one
477
+ * gesture that stamps nothing is an arrow key on a grip, which applies a box
478
+ * per key and states it only when the key comes up, hence #resizing.
349
479
  */
350
480
  dragging(): boolean {
351
481
  return (
482
+ this.#resizing ||
352
483
  this.#host.launcher.hasAttribute("data-dragging") ||
353
- this.#host.root.querySelector(".header[data-dragging]") !== null
484
+ this.#host.root.querySelector(".header[data-dragging], .resize-handle[data-dragging]") !==
485
+ null
354
486
  );
355
487
  }
356
488
 
@@ -365,13 +497,12 @@ export class PanelPlacement {
365
497
  collapsed: this.#host.collapsed(),
366
498
  collapsible: this.#host.collapsible(),
367
499
  movable: this.#panelMovable() && !fullBleed,
368
- // Whether the page left moving on, which is not the same question as
369
- // whether this panel moves: a docked sidebar's position is owned by its
370
- // placement, and reporting that as the host having turned moving off is
371
- // the sentence the move refusal would then get wrong.
372
- draggable:
373
- this.#host.element.getAttribute("data-launcher-drag") !== "false" &&
374
- (this.#surface().launcherDraggable || this.#docked()),
500
+ // The page's setting alone: true for every placement unless the host set
501
+ // `data-launcher-drag="false"`. Whether this panel moves is `movable`,
502
+ // and reading the launcher or the dock in here made the field change
503
+ // with `collapse-to`, so a docked sidebar reported that the page had
504
+ // turned moving off when the placement owned its position.
505
+ draggable: this.#host.element.getAttribute("data-launcher-drag") !== "false",
375
506
  fullBleed,
376
507
  box: {
377
508
  left: Math.round(box.left),
@@ -451,6 +582,13 @@ export class PanelPlacement {
451
582
  * puts the box back and animates it out of the wrong side. Absent values are
452
583
  * captured as absent and removed on the way back, rather than written as
453
584
  * empty strings that would outrank the placement.
585
+ *
586
+ * The surface may have changed by the time of the undo, so the two halves are
587
+ * put back apart. Both records are, always, each to its own store, so the
588
+ * memory and the storage cannot disagree and the surface they belong to finds
589
+ * them when it comes back. The inline values only mean something to the surface
590
+ * they were read from: written on any other, they pin a sidebar to where a
591
+ * floating panel used to be.
454
592
  */
455
593
  #captureGeometry(): () => void {
456
594
  const inset = this.#host.element.style.getPropertyValue("--ag-ui-inset");
@@ -458,41 +596,52 @@ export class PanelPlacement {
458
596
  const corner = this.#host.element.getAttribute("data-expand-corner");
459
597
  const launcherPos = this.#launcherPos;
460
598
  const panelPos = this.#panelPos;
461
- const dockPos = this.#dockPos;
599
+ const dockPos = this.#dockPos ?? this.#readDockPosition();
462
600
  const expandCorner = this.#expandCorner;
601
+ const cornerSpot = this.#cornerSpot ?? this.#readLauncherPosition();
602
+ const owner = this.#positionOwner();
463
603
  return () => {
464
- this.#restoreProperty("--ag-ui-inset", inset);
465
- this.#restoreProperty("--ag-ui-launcher-inset", launcherInset);
466
- if (corner === null) {
467
- this.#host.element.removeAttribute("data-expand-corner");
468
- } else {
469
- this.#host.element.setAttribute("data-expand-corner", corner);
470
- }
471
- this.#launcherPos = launcherPos;
472
- this.#panelPos = panelPos;
473
604
  this.#dockPos = dockPos;
474
- this.#expandCorner = expandCorner;
475
- if (this.#docked()) {
476
- // The dock's own record, and only that: the launcher's belongs to the
477
- // corner surface and has nothing to undo here.
478
- this.#storeDockPosition(dockPos);
479
- this.syncResizeAnchor();
480
- return;
481
- }
482
- // Erased rather than rewritten when there was nothing to go back to.
483
- // #storeLauncherPosition returns early for a null position, which would
484
- // leave the move this is undoing sitting in storage -- and since that
485
- // store outlives the tab, the next resize or reload would quietly put
486
- // the panel back in the corner the user had just rejected.
487
- if (launcherPos === null) {
605
+ this.#storeDockPosition(dockPos);
606
+ // Erased rather than rewritten when there was nothing to go back to,
607
+ // which would leave the move this is undoing sitting in storage -- and
608
+ // since that store outlives the tab, the next resize or reload would
609
+ // quietly put the panel back in the corner the user had just rejected.
610
+ if (cornerSpot === null) {
611
+ this.#cornerSpot = null;
488
612
  this.#host.clearPreference(LAUNCHER_KEY);
489
613
  } else {
490
- this.#storeLauncherPosition();
614
+ this.#writeCornerSpot(cornerSpot);
491
615
  }
616
+ if (owner === this.#positionOwner()) {
617
+ this.#restoreProperty("--ag-ui-inset", inset);
618
+ this.#restoreProperty("--ag-ui-launcher-inset", launcherInset);
619
+ if (corner === null) {
620
+ this.#host.element.removeAttribute("data-expand-corner");
621
+ } else {
622
+ this.#host.element.setAttribute("data-expand-corner", corner);
623
+ }
624
+ this.#launcherPos = launcherPos;
625
+ this.#panelPos = panelPos;
626
+ this.#expandCorner = expandCorner;
627
+ } else {
628
+ this.#forgetCornerPosition();
629
+ }
630
+ // Against the viewport there is now, which may be a phone's: the insets
631
+ // put back above were written for the one the move was made in.
632
+ this.restoreLauncherPosition();
492
633
  this.syncResizeAnchor();
493
634
  };
494
635
  }
495
636
 
637
+ /** Which of the three kinds of surface decides where the panel stands. */
638
+ #positionOwner(): "dock" | "corner" | "placement" {
639
+ if (this.#docked()) {
640
+ return "dock";
641
+ }
642
+ return this.#surface().launcherDraggable ? "corner" : "placement";
643
+ }
644
+
496
645
  /**
497
646
  * Which axes the current placement allows.
498
647
  *
@@ -584,6 +733,19 @@ export class PanelPlacement {
584
733
  this.#anchor = anchor;
585
734
  this.#host.element.setAttribute("data-resize-anchor", `${anchor.y}-${anchor.x}`);
586
735
  this.#focusableGrip();
736
+ this.#stampHeader();
737
+ }
738
+
739
+ /**
740
+ * Tell the stylesheet whether the panel is the screen, for the header's cursor.
741
+ *
742
+ * Only this half of what refuses a header drag: the resting rules already answer
743
+ * for the placement and for data-launcher-drag="false", and neither of those
744
+ * re-applies anything when it changes, so a stamp taking them in would go on
745
+ * refusing, in the cursor, a drag the header would take.
746
+ */
747
+ #stampHeader(): void {
748
+ this.#header?.toggleAttribute("data-fills-screen", this.#fillsScreen());
587
749
  }
588
750
 
589
751
  /** Put a custom property back to a previous value, or remove it if there was none. */
@@ -788,18 +950,17 @@ export class PanelPlacement {
788
950
  }
789
951
 
790
952
  /**
791
- * Whether a header drag may start now. A docked panel on a phone is the
792
- * screen, and a drag there would pull it off the edges it fills; a corner
793
- * panel's header is left as it was.
953
+ * Whether a header drag may start now. A panel that is the screen, on a phone
954
+ * or by a host's own sizing, has nowhere to go, and a drag there would pull it
955
+ * off the edges it fills.
794
956
  */
795
957
  #canDragPanel(): boolean {
796
- if (!this.#panelMovable()) {
797
- return false;
798
- }
799
- return (
800
- !this.#docked() ||
801
- !coversViewport(this.#host.element.getBoundingClientRect(), this.#viewport())
802
- );
958
+ return this.#panelMovable() && !this.#fillsScreen();
959
+ }
960
+
961
+ /** Whether the panel covers the usable viewport, as it stands now. */
962
+ #fillsScreen(): boolean {
963
+ return coversViewport(this.#host.element.getBoundingClientRect(), this.#viewport());
803
964
  }
804
965
 
805
966
  /**
@@ -988,12 +1149,6 @@ export class PanelPlacement {
988
1149
  * back rather than leaving a stale inline one behind.
989
1150
  */
990
1151
  #applyLauncherPlacement(at: { readonly left: number; readonly top: number }): void {
991
- // The single gate: callers hand over a position and this decides whether
992
- // it is this element's to honour. Checking in both places instead would
993
- // leave one of the two checks permanently unreachable.
994
- if (!this.#launcherDraggable()) {
995
- return;
996
- }
997
1152
  this.#launcherPos = at;
998
1153
  // Dropping the bubble hands the panel's position back to the placement.
999
1154
  // Keeping a stated one would pin the panel where it was dragged and leave
@@ -1021,6 +1176,9 @@ export class PanelPlacement {
1021
1176
 
1022
1177
  /** Move the launcher live during a drag, without persisting. */
1023
1178
  #moveLauncher(left: number, top: number): void {
1179
+ // Stated from the first move, not the last: a key gesture only ends at its
1180
+ // keyup, and an Enter pressed before then expands by way of a restore.
1181
+ this.#cornerSpot = { left, top };
1024
1182
  this.#applyLauncherPlacement({ left, top });
1025
1183
  }
1026
1184
 
@@ -1166,9 +1324,6 @@ export class PanelPlacement {
1166
1324
  * its own right.
1167
1325
  */
1168
1326
  #restorePanelPosition(at: { readonly left: number; readonly top: number }): void {
1169
- if (!this.#launcherDraggable()) {
1170
- return;
1171
- }
1172
1327
  const rect = this.#host.element.getBoundingClientRect();
1173
1328
  // The same bound the drag itself used. Taking the default here instead is
1174
1329
  // what made a panel dragged to an edge jump a whole resting gutter inward
@@ -1199,6 +1354,9 @@ export class PanelPlacement {
1199
1354
  * with it, the panel goes back where it was put; without it, the panel is
1200
1355
  * re-derived from the launcher and opens into whatever room the viewport has
1201
1356
  * now.
1357
+ *
1358
+ * Every caller is a gesture ending or a move being made, so where the two
1359
+ * stand now is what was stated. A restore never calls this.
1202
1360
  */
1203
1361
  #storeLauncherPosition(): void {
1204
1362
  const position = this.#launcherPos;
@@ -1206,10 +1364,13 @@ export class PanelPlacement {
1206
1364
  return;
1207
1365
  }
1208
1366
  const panel = this.#panelPos;
1209
- this.#host.writePreference(
1210
- LAUNCHER_KEY,
1211
- JSON.stringify(panel === null ? position : { ...position, panel }),
1212
- );
1367
+ this.#writeCornerSpot(panel === null ? position : { ...position, panel });
1368
+ }
1369
+
1370
+ /** Make `spot` the stated one, and remember it. */
1371
+ #writeCornerSpot(spot: CornerSpot): void {
1372
+ this.#cornerSpot = spot;
1373
+ this.#host.writePreference(LAUNCHER_KEY, JSON.stringify(spot));
1213
1374
  }
1214
1375
 
1215
1376
  /**
@@ -1217,8 +1378,28 @@ export class PanelPlacement {
1217
1378
  * whenever the window resizes, and before an expand. The viewport that
1218
1379
  * stored a position may since have shrunk, and a launcher past the edge is
1219
1380
  * unreachable -- it is the only way back to a collapsed conversation.
1381
+ *
1382
+ * The one gate on whether a stored position is this element's to honour: the
1383
+ * gestures check their own before they ever reach the placing below.
1220
1384
  */
1221
1385
  restoreLauncherPosition(): void {
1386
+ this.#placeFromRecord(true);
1387
+ this.#stampHeader();
1388
+ }
1389
+
1390
+ /**
1391
+ * Re-apply it for a change in the panel's own layout, which needs no window or
1392
+ * reopen to happen: a line typed in the composer is one. A position waiting to
1393
+ * be handed back under data-launcher-drag="false" waits on, because a panel that leaves for the
1394
+ * host's corner as the reader starts typing is a jump with no cause they can see.
1395
+ */
1396
+ settleLauncherPosition(): void {
1397
+ this.#placeFromRecord(false);
1398
+ this.#stampHeader();
1399
+ }
1400
+
1401
+ /** The placing behind {@link restoreLauncherPosition}. */
1402
+ #placeFromRecord(handBack: boolean): void {
1222
1403
  // Before the launcher's record is read at all: it belongs to the corner
1223
1404
  // surface, and seeding anything from it would put a spot dragged there on
1224
1405
  // a panel that should stand on its dock.
@@ -1226,32 +1407,120 @@ export class PanelPlacement {
1226
1407
  this.#placeDocked();
1227
1408
  return;
1228
1409
  }
1229
- const stored = this.#readLauncherPosition();
1230
- const launcher = this.#launcherPos ?? stored;
1231
- if (launcher === null) {
1410
+ if (!this.#surface().launcherDraggable) {
1411
+ return;
1412
+ }
1413
+ // Under data-launcher-drag="false" the widget is wherever the host's CSS puts
1414
+ // it, so a position this element took before the attribute went on is handed
1415
+ // back here, at the first window resize or reopen, and the record stays for the
1416
+ // day the attribute comes off. Nothing watches the attribute: it is read per
1417
+ // interaction, like every other answer here.
1418
+ const draggable = this.#launcherDraggable();
1419
+ if (!draggable && this.#launcherPos !== null) {
1420
+ if (!handBack) {
1421
+ return;
1422
+ }
1423
+ this.releaseLauncherPosition();
1424
+ }
1425
+ const spot = draggable ? (this.#cornerSpot ?? this.#readLauncherPosition()) : null;
1426
+ if (spot === null) {
1427
+ this.#placeUnstated();
1232
1428
  return;
1233
1429
  }
1234
- const panel = this.#panelPos ?? stored?.panel ?? null;
1235
- if (panel !== null) {
1430
+ if (this.#standFullBleed(spot)) {
1431
+ return;
1432
+ }
1433
+ if (spot.panel !== undefined) {
1236
1434
  // Stated rather than derived: the launcher is only read here to keep the
1237
1435
  // offset the two were left with, so it has to be seeded before the panel
1238
1436
  // is placed around it.
1239
- this.#launcherPos = { left: launcher.left, top: launcher.top };
1240
- this.#restorePanelPosition(panel);
1437
+ this.#launcherPos = { left: spot.left, top: spot.top };
1438
+ this.#restorePanelPosition(spot.panel);
1241
1439
  return;
1242
1440
  }
1243
1441
  const box = this.#launcherBox();
1244
1442
  this.#applyLauncherPlacement(
1245
- clampLauncher({ ...box, left: launcher.left, top: launcher.top }, this.#viewport()),
1443
+ clampLauncher({ ...box, left: spot.left, top: spot.top }, this.#viewport()),
1246
1444
  );
1247
1445
  }
1248
1446
 
1447
+ /**
1448
+ * Leave a corner panel full-bleed if it has become the screen, and say whether
1449
+ * it has.
1450
+ *
1451
+ * The inline inset comes off, because the small-viewport block reads the
1452
+ * host's inset before its own: a spot written there holds the panel off the
1453
+ * edges it should fill, and past them by the same amount, since its size is
1454
+ * the screen's. What the panel covers is read before that, from the size
1455
+ * alone, which no inset changes.
1456
+ *
1457
+ * The launcher stays a place of its own. It is positioned from the host box,
1458
+ * so it is measured again from the box that has just moved, and it is held on
1459
+ * the screen, because it is the way back from a collapsed conversation. The
1460
+ * spot itself is untouched, for the next time there is room for it.
1461
+ *
1462
+ * Above the breakpoint nothing in the stylesheet takes the panel to the edges.
1463
+ * A host's own size makes it the screen while the resting inset still spends a
1464
+ * gutter on one corner, which hangs the box off the opposite one, so there it
1465
+ * stands at the start of the room the host left free and keeps that inset. A
1466
+ * box the placement already holds on the screen has no inset to keep, which is
1467
+ * every phone, and a host's rule that placed the box itself.
1468
+ */
1469
+ #standFullBleed(spot: CornerSpot): boolean {
1470
+ const element = this.#host.element;
1471
+ const viewport = this.#viewport();
1472
+ if (!coversViewport(element.getBoundingClientRect(), viewport)) {
1473
+ return false;
1474
+ }
1475
+ element.style.removeProperty("--ag-ui-inset");
1476
+ const rest = element.getBoundingClientRect();
1477
+ const hanging = hangsOff(rest, viewport, this.#screen());
1478
+ const box = hanging ? atStartOf(viewport, rest) : rest;
1479
+ const launcher = { ...this.#launcherBox(), left: spot.left, top: spot.top };
1480
+ this.#placePanelAndLauncher(box, { ...launcher, ...clampLauncher(launcher, viewport) });
1481
+ if (!hanging) {
1482
+ element.style.removeProperty("--ag-ui-inset");
1483
+ }
1484
+ return true;
1485
+ }
1486
+
1487
+ /**
1488
+ * Place a corner panel that has no spot, the half of {@link #standFullBleed}
1489
+ * with nothing stated to keep and no launcher of its own to measure.
1490
+ *
1491
+ * Only an inset this element wrote comes off or goes back. One the host wrote
1492
+ * inline is a token a host may state on a phone as anywhere else, and with no
1493
+ * spot it never came from here.
1494
+ */
1495
+ #placeUnstated(): void {
1496
+ const element = this.#host.element;
1497
+ const inline = element.style.getPropertyValue("--ag-ui-inset");
1498
+ const viewport = this.#viewport();
1499
+ if (!coversViewport(element.getBoundingClientRect(), viewport)) {
1500
+ if (inline === "" || inline === this.#fillInset) {
1501
+ this.#restoreProperty("--ag-ui-inset", this.#gripInset ?? "");
1502
+ this.syncResizeAnchor();
1503
+ }
1504
+ this.#fillInset = null;
1505
+ return;
1506
+ }
1507
+ if (inline === this.#gripInset || inline === this.#fillInset) {
1508
+ element.style.removeProperty("--ag-ui-inset");
1509
+ }
1510
+ const rest = element.getBoundingClientRect();
1511
+ const screen = this.#screen();
1512
+ if (
1513
+ element.style.getPropertyValue("--ag-ui-inset") === "" &&
1514
+ hangsOff(rest, viewport, screen)
1515
+ ) {
1516
+ this.#fillInset = insetFrom({ x: "left", y: "top" }, atStartOf(viewport, rest), screen);
1517
+ element.style.setProperty("--ag-ui-inset", this.#fillInset);
1518
+ this.syncResizeAnchor();
1519
+ }
1520
+ }
1521
+
1249
1522
  /** The persisted position for this instance, or null. */
1250
- #readLauncherPosition(): {
1251
- readonly left: number;
1252
- readonly top: number;
1253
- readonly panel?: { readonly left: number; readonly top: number };
1254
- } | null {
1523
+ #readLauncherPosition(): CornerSpot | null {
1255
1524
  const raw = this.#host.readPreference(LAUNCHER_KEY);
1256
1525
  if (raw === null) {
1257
1526
  return null;
@@ -1293,9 +1562,16 @@ export class PanelPlacement {
1293
1562
  if (this.#launcherDraggable()) {
1294
1563
  return;
1295
1564
  }
1565
+ this.#forgetCornerPosition();
1566
+ }
1567
+
1568
+ /** Take everything off that placed a corner panel, leaving its record alone. */
1569
+ #forgetCornerPosition(): void {
1296
1570
  this.#launcherPos = null;
1297
1571
  this.#panelPos = null;
1298
1572
  this.#expandCorner = null;
1573
+ this.#gripInset = null;
1574
+ this.#fillInset = null;
1299
1575
  this.#host.element.style.removeProperty("--ag-ui-inset");
1300
1576
  this.#host.element.style.removeProperty("--ag-ui-launcher-inset");
1301
1577
  this.#host.element.removeAttribute("data-expand-corner");
@@ -1339,28 +1615,33 @@ export class PanelPlacement {
1339
1615
  // on a free edge, which leaves the inset alone.
1340
1616
  this.#dockPos = { left: box.left, top: box.top };
1341
1617
  }
1342
- if (grip.x !== this.#anchor.x && grip.y !== this.#anchor.y) {
1343
- return box;
1344
- }
1345
- // The whole screen, not the usable box: a CSS inset on a fixed element is
1346
- // measured from the real edges, so expressing a right or bottom against a
1347
- // box the host has inset comes out short by exactly that inset.
1348
- const anchor = this.#anchor;
1349
- this.#host.element.style.setProperty("--ag-ui-inset", insetFrom(anchor, box, this.#screen()));
1350
- // The launcher lives at this corner of the panel, so a corner that moved
1351
- // takes it along. Without this the next expand would re-derive the panel's
1352
- // position from a launcher still standing where the panel used to be, and
1353
- // undo the move.
1354
- if (this.#launcherPos !== null) {
1355
- const size = this.#host.launcher.offsetWidth;
1356
- this.#launcherPos = {
1357
- left: anchor.x === "left" ? box.left : box.right - size,
1358
- top: anchor.y === "top" ? box.top : box.bottom - size,
1359
- };
1618
+ if (grip.x === this.#anchor.x || grip.y === this.#anchor.y) {
1619
+ // The whole screen, not the usable box: a CSS inset on a fixed element is
1620
+ // measured from the real edges, so expressing a right or bottom against a
1621
+ // box the host has inset comes out short by exactly that inset.
1622
+ const anchor = this.#anchor;
1623
+ const inset = insetFrom(anchor, box, this.#screen());
1624
+ this.#host.element.style.setProperty("--ag-ui-inset", inset);
1625
+ this.#gripInset = this.#launcherPos === null && !this.#docked() ? inset : null;
1626
+ // The launcher lives at this corner of the panel, so a corner that moved
1627
+ // takes it along. Without this the next expand would re-derive the panel's
1628
+ // position from a launcher still standing where the panel used to be, and
1629
+ // undo the move.
1630
+ if (this.#launcherPos !== null) {
1631
+ const size = this.#host.launcher.offsetWidth;
1632
+ this.#launcherPos = {
1633
+ left: anchor.x === "left" ? box.left : box.right - size,
1634
+ top: anchor.y === "top" ? box.top : box.bottom - size,
1635
+ };
1636
+ }
1360
1637
  }
1361
- // A stated panel position is a claim about this box, so it moves with it.
1362
- if (this.#panelPos !== null) {
1363
- this.#panelPos = { left: box.left, top: box.top };
1638
+ // A stated corner panel's spot is a claim about this box, so it moves with
1639
+ // it, by a free edge as well as by a pinned one. Read from what was drawn,
1640
+ // not what the pointer asked for, because a host's maximum stops the box short
1641
+ // of the pointer. Not while docked: this position belongs to the corner.
1642
+ if (this.#panelPos !== null && !this.#docked()) {
1643
+ const drawn = this.#host.element.getBoundingClientRect();
1644
+ this.#panelPos = { left: drawn.left, top: drawn.top };
1364
1645
  }
1365
1646
  return box;
1366
1647
  }
@@ -1372,6 +1653,14 @@ export class PanelPlacement {
1372
1653
  // mount, which is the same disagreement between apply and commit that made
1373
1654
  // the header drag jump on release.
1374
1655
  const held = this.#applyResize(grip, box);
1656
+ // A grip on the free edges of a panel standing at the start of a filled window
1657
+ // leaves that inset alone, and the size it stated takes the panel off the
1658
+ // screen's shape. It has to stay where the grip left it, so the inset stops
1659
+ // being one that lasts only as long as the host's size does.
1660
+ if (this.#host.element.style.getPropertyValue("--ag-ui-inset") === this.#fillInset) {
1661
+ this.#gripInset = this.#fillInset;
1662
+ this.#fillInset = null;
1663
+ }
1375
1664
  this.#persistSize({ width: held.right - held.left, height: held.bottom - held.top });
1376
1665
  this.#storeLauncherPosition();
1377
1666
  if (this.#dockPos !== null) {