@artooi/ag-ui-web-component 0.33.1 → 0.35.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 (62) hide show
  1. package/CHANGELOG.md +563 -1
  2. package/README.md +279 -16
  3. package/dist/ag-ui-web-component.bundle.js +676 -101
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +80 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +37 -2
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/dom/animations.d.ts +14 -0
  10. package/dist/dom/animations.d.ts.map +1 -1
  11. package/dist/dom/highlight_overlay.d.ts +47 -0
  12. package/dist/dom/highlight_overlay.d.ts.map +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2288 -388
  16. package/dist/index.js.map +4 -4
  17. package/dist/tools/chat_surface_tools.d.ts +96 -0
  18. package/dist/tools/chat_surface_tools.d.ts.map +1 -0
  19. package/dist/tools/page_action_tools.d.ts +2 -0
  20. package/dist/tools/page_action_tools.d.ts.map +1 -1
  21. package/dist/ui/chart_block.d.ts +18 -0
  22. package/dist/ui/chart_block.d.ts.map +1 -1
  23. package/dist/ui/clamp_launcher.d.ts +10 -5
  24. package/dist/ui/clamp_launcher.d.ts.map +1 -1
  25. package/dist/ui/clamp_panel.d.ts +13 -0
  26. package/dist/ui/clamp_panel.d.ts.map +1 -0
  27. package/dist/ui/launcher_drag.d.ts +2 -2
  28. package/dist/ui/launcher_drag.d.ts.map +1 -1
  29. package/dist/ui/launcher_placement.d.ts +14 -1
  30. package/dist/ui/launcher_placement.d.ts.map +1 -1
  31. package/dist/ui/panel_drag.d.ts +40 -0
  32. package/dist/ui/panel_drag.d.ts.map +1 -0
  33. package/dist/ui/place_widget.d.ts +31 -0
  34. package/dist/ui/place_widget.d.ts.map +1 -0
  35. package/dist/ui/run_notice.d.ts +14 -3
  36. package/dist/ui/run_notice.d.ts.map +1 -1
  37. package/dist/ui/styles.d.ts +1 -1
  38. package/dist/ui/styles.d.ts.map +1 -1
  39. package/dist/ui/thread_drawer.d.ts +21 -0
  40. package/dist/ui/thread_drawer.d.ts.map +1 -1
  41. package/dist/ui/ui_strings.d.ts +16 -0
  42. package/dist/ui/ui_strings.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/constants.ts +87 -0
  45. package/src/core/ag_ui_chat.ts +1146 -38
  46. package/src/dom/animations.ts +30 -0
  47. package/src/dom/highlight_overlay.ts +256 -0
  48. package/src/index.ts +12 -0
  49. package/src/tools/chat_surface_tools.ts +207 -0
  50. package/src/tools/page_action_tools.ts +2 -0
  51. package/src/ui/chart_block.ts +222 -57
  52. package/src/ui/clamp_launcher.ts +25 -7
  53. package/src/ui/clamp_panel.ts +30 -0
  54. package/src/ui/launcher_drag.ts +11 -2
  55. package/src/ui/launcher_placement.ts +50 -60
  56. package/src/ui/panel_drag.ts +138 -0
  57. package/src/ui/place_widget.ts +64 -0
  58. package/src/ui/run_notice.ts +32 -3
  59. package/src/ui/styles.ts +622 -47
  60. package/src/ui/thread_drawer.ts +138 -8
  61. package/src/ui/ui_strings.ts +24 -0
  62. package/src/version.ts +1 -1
@@ -11,6 +11,20 @@
11
11
  * Hand-rolled rather than a charting library, and the difference is not
12
12
  * marginal: the whole renderer costs single-digit kilobytes where a library
13
13
  * costs roughly half this bundle again, in a component distributed over a CDN.
14
+ *
15
+ * **The drawing is sized in CSS pixels, not scaled to them.** An SVG with a
16
+ * fixed viewBox and `width: 100%` is a picture the browser magnifies: widen the
17
+ * panel and every stroke, every label and the whole frame grow with it. So the
18
+ * geometry is computed for the width the block actually has, one user unit to
19
+ * one CSS pixel, and recomputed when that width changes -- a 10px label is
20
+ * 10px at every size the block is given.
21
+ *
22
+ * How wide the block is allowed to get is the stylesheet's question, not this
23
+ * module's, and the answer there is that a chart stops at its own width rather
24
+ * than stretching with the panel, the way a message does. The two halves are
25
+ * separate on purpose: this one keeps a chart from being magnified, that one
26
+ * keeps it from being stretched, and a host that raises the cap gets a bigger
27
+ * chart with the same 10px labels rather than a magnified one.
14
28
  */
15
29
 
16
30
  const SVG_NS = "http://www.w3.org/2000/svg";
@@ -32,11 +46,55 @@ export interface ChartSpec {
32
46
  readonly series: readonly ChartSeries[];
33
47
  }
34
48
 
35
- const WIDTH = 480;
36
- const HEIGHT = 220;
49
+ /** The frame a chart is drawn into, in CSS pixels. */
50
+ interface Geometry {
51
+ readonly width: number;
52
+ readonly height: number;
53
+ /** The plotting area inside the padding. */
54
+ readonly plotW: number;
55
+ readonly plotH: number;
56
+ }
57
+
37
58
  const PAD = { top: 20, right: 12, bottom: 30, left: 44 };
38
- const PLOT_W = WIDTH - PAD.left - PAD.right;
39
- const PLOT_H = HEIGHT - PAD.top - PAD.bottom;
59
+
60
+ /**
61
+ * The width drawn before the block has been measured -- while it is still
62
+ * detached, and in a caller that never puts it in a document at all. It is the
63
+ * width this renderer drew at unconditionally before it could measure, so a
64
+ * chart that is never measured looks exactly as it always did.
65
+ */
66
+ const DEFAULT_WIDTH = 480;
67
+
68
+ /**
69
+ * The narrowest frame worth computing. Below this the SVG scales down as it
70
+ * always did, which is the right answer at the bottom end: a 200px chart with
71
+ * 10px labels has no room for the labels either way, and shrinking them keeps
72
+ * the shape readable.
73
+ */
74
+ const MIN_WIDTH = 220;
75
+
76
+ /**
77
+ * How tall a chart is for its width, and the band that holds. The ratio is the
78
+ * old fixed 480x220 frame, so the default width draws precisely what it drew
79
+ * before; the band is what stops a wide panel from turning a chart into a
80
+ * banner or a narrow one into a strip.
81
+ */
82
+ const HEIGHT_RATIO = 220 / 480;
83
+ const MIN_HEIGHT = 160;
84
+ const MAX_HEIGHT = 320;
85
+
86
+ /**
87
+ * The step a measured width is rounded to before it is redrawn.
88
+ *
89
+ * The SVG keeps `width="100%"`, so it fills its block exactly whatever the
90
+ * viewBox says; rounding the viewBox to 8px therefore costs at most a 3%
91
+ * scale at the narrow end and nothing anyone can see, while cutting the
92
+ * redraws during a panel drag from one per pixel to one per eight.
93
+ */
94
+ const WIDTH_STEP = 8;
95
+
96
+ /** A rough advance width per character for the 10px axis font. */
97
+ const AXIS_CHAR_WIDTH = 5.6;
40
98
 
41
99
  // Read from the host's own palette rather than a fixed ramp: the component
42
100
  // themes through custom properties everywhere else, and a chart that ignored
@@ -57,6 +115,18 @@ export function seriesColor(index: number): string {
57
115
  return SERIES_COLORS[index % SERIES_COLORS.length] as string;
58
116
  }
59
117
 
118
+ /** The frame for a block of this width. */
119
+ function geometryFor(width: number): Geometry {
120
+ const w = Math.max(MIN_WIDTH, width);
121
+ const h = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.round(w * HEIGHT_RATIO)));
122
+ return {
123
+ width: w,
124
+ height: h,
125
+ plotW: w - PAD.left - PAD.right,
126
+ plotH: h - PAD.top - PAD.bottom,
127
+ };
128
+ }
129
+
60
130
  function el<K extends keyof SVGElementTagNameMap>(
61
131
  name: K,
62
132
  attrs: Record<string, string | number>,
@@ -108,23 +178,44 @@ function extent(spec: ChartSpec): { min: number; max: number } {
108
178
  return max === min ? { min, max: max + 1 } : { min, max };
109
179
  }
110
180
 
111
- function scaleY(value: number, min: number, max: number): number {
112
- return PAD.top + PLOT_H - ((value - min) / (max - min)) * PLOT_H;
181
+ function scaleY(value: number, min: number, max: number, geo: Geometry): number {
182
+ return PAD.top + geo.plotH - ((value - min) / (max - min)) * geo.plotH;
113
183
  }
114
184
 
115
- function bandCentre(index: number, count: number): number {
116
- const step = PLOT_W / count;
185
+ function bandCentre(index: number, count: number, geo: Geometry): number {
186
+ const step = geo.plotW / count;
117
187
  return PAD.left + step * index + step / 2;
118
188
  }
119
189
 
120
- function drawAxes(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
190
+ /**
191
+ * Draw every nth label, for the smallest n whose labels have room.
192
+ *
193
+ * Axis text is a fixed 10px now rather than something that shrank with the
194
+ * frame, so at the narrow end the labels are the first thing to collide -- and
195
+ * a smear of overlapping words says less than half as many words with space
196
+ * around them. The character estimate only has to be good enough to pick a
197
+ * step; measuring text properly would mean laying it out first.
198
+ */
199
+ function labelStride(labels: readonly string[], geo: Geometry): number {
200
+ const band = geo.plotW / labels.length;
201
+ const widest = Math.max(...labels.map((label) => label.length)) * AXIS_CHAR_WIDTH;
202
+ return Math.max(1, Math.ceil(widest / band));
203
+ }
204
+
205
+ function drawAxes(
206
+ svg: SVGSVGElement,
207
+ spec: ChartSpec,
208
+ geo: Geometry,
209
+ min: number,
210
+ max: number,
211
+ ): void {
121
212
  for (const value of [min, max]) {
122
- const y = scaleY(value, min, max);
213
+ const y = scaleY(value, min, max, geo);
123
214
  svg.appendChild(
124
215
  el("line", {
125
216
  x1: PAD.left,
126
217
  y1: y,
127
- x2: WIDTH - PAD.right,
218
+ x2: geo.width - PAD.right,
128
219
  y2: y,
129
220
  stroke: "currentColor",
130
221
  "stroke-opacity": value === min ? 0.35 : 0.12,
@@ -134,24 +225,34 @@ function drawAxes(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number)
134
225
  text(String(Math.round(value)), { x: PAD.left - 6, y: y + 4, "text-anchor": "end" }),
135
226
  );
136
227
  }
228
+ const stride = labelStride(spec.labels, geo);
137
229
  spec.labels.forEach((label, i) => {
230
+ if (i % stride !== 0) {
231
+ return;
232
+ }
138
233
  svg.appendChild(
139
234
  text(label, {
140
- x: bandCentre(i, spec.labels.length),
141
- y: HEIGHT - PAD.bottom + 16,
235
+ x: bandCentre(i, spec.labels.length, geo),
236
+ y: geo.height - PAD.bottom + 16,
142
237
  "text-anchor": "middle",
143
238
  }),
144
239
  );
145
240
  });
146
241
  }
147
242
 
148
- function drawBars(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
149
- const step = PLOT_W / spec.labels.length;
243
+ function drawBars(
244
+ svg: SVGSVGElement,
245
+ spec: ChartSpec,
246
+ geo: Geometry,
247
+ min: number,
248
+ max: number,
249
+ ): void {
250
+ const step = geo.plotW / spec.labels.length;
150
251
  const width = (step * 0.7) / spec.series.length;
151
- const base = scaleY(min, min, max);
252
+ const base = scaleY(min, min, max, geo);
152
253
  spec.series.forEach((series, s) => {
153
254
  series.points.forEach((value, i) => {
154
- const y = scaleY(value, min, max);
255
+ const y = scaleY(value, min, max, geo);
155
256
  svg.appendChild(
156
257
  el("rect", {
157
258
  x: PAD.left + step * i + step * 0.15 + width * s,
@@ -166,8 +267,14 @@ function drawBars(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number)
166
267
  });
167
268
  }
168
269
 
169
- function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
170
- const step = PLOT_W / spec.labels.length;
270
+ function drawStacked(
271
+ svg: SVGSVGElement,
272
+ spec: ChartSpec,
273
+ geo: Geometry,
274
+ min: number,
275
+ max: number,
276
+ ): void {
277
+ const step = geo.plotW / spec.labels.length;
171
278
  const width = step * 0.7;
172
279
  // Indexed by the same `i` it was built from, so every read is a hit; cast
173
280
  // rather than defaulting, which would add a branch nothing can reach.
@@ -182,13 +289,13 @@ function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
182
289
  const from = running[i] ?? 0;
183
290
  const to = from + value;
184
291
  running[i] = to;
185
- const y = scaleY(to, min, max);
292
+ const y = scaleY(to, min, max, geo);
186
293
  svg.appendChild(
187
294
  el("rect", {
188
295
  x: PAD.left + step * i + step * 0.15,
189
296
  y,
190
297
  width,
191
- height: Math.max(1, scaleY(from, min, max) - y),
298
+ height: Math.max(1, scaleY(from, min, max, geo) - y),
192
299
  fill: seriesColor(s),
193
300
  }),
194
301
  );
@@ -196,10 +303,18 @@ function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
196
303
  });
197
304
  }
198
305
 
199
- function drawLines(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
306
+ function drawLines(
307
+ svg: SVGSVGElement,
308
+ spec: ChartSpec,
309
+ geo: Geometry,
310
+ min: number,
311
+ max: number,
312
+ ): void {
200
313
  spec.series.forEach((series, s) => {
201
314
  const points = series.points
202
- .map((value, i) => `${bandCentre(i, spec.labels.length)},${scaleY(value, min, max)}`)
315
+ .map(
316
+ (value, i) => `${bandCentre(i, spec.labels.length, geo)},${scaleY(value, min, max, geo)}`,
317
+ )
203
318
  .join(" ");
204
319
  svg.appendChild(
205
320
  el("polyline", {
@@ -213,13 +328,19 @@ function drawLines(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number
213
328
  });
214
329
  }
215
330
 
216
- function drawScatter(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
331
+ function drawScatter(
332
+ svg: SVGSVGElement,
333
+ spec: ChartSpec,
334
+ geo: Geometry,
335
+ min: number,
336
+ max: number,
337
+ ): void {
217
338
  spec.series.forEach((series, s) => {
218
339
  series.points.forEach((value, i) => {
219
340
  svg.appendChild(
220
341
  el("circle", {
221
- cx: bandCentre(i, spec.labels.length),
222
- cy: scaleY(value, min, max),
342
+ cx: bandCentre(i, spec.labels.length, geo),
343
+ cy: scaleY(value, min, max, geo),
223
344
  r: 4,
224
345
  fill: seriesColor(s),
225
346
  "fill-opacity": 0.85,
@@ -231,15 +352,15 @@ function drawScatter(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
231
352
 
232
353
  /**
233
354
  * Pie draws the **first** series' points as shares of their own total, one
234
- * wedge per label the only kind whose slices are the labels rather than the
355
+ * wedge per label -- the only kind whose slices are the labels rather than the
235
356
  * series, so a second series has nowhere to go and is ignored rather than
236
357
  * silently summed into the first.
237
358
  */
238
- function drawPie(svg: SVGSVGElement, points: readonly number[]): void {
359
+ function drawPie(svg: SVGSVGElement, points: readonly number[], geo: Geometry): void {
239
360
  const total = points.reduce((sum, value) => sum + value, 0);
240
- const cx = WIDTH / 2;
241
- const cy = PAD.top + PLOT_H / 2;
242
- const r = Math.min(PLOT_W, PLOT_H) / 2;
361
+ const cx = geo.width / 2;
362
+ const cy = PAD.top + geo.plotH / 2;
363
+ const r = Math.min(geo.plotW, geo.plotH) / 2;
243
364
  if (total === 0) {
244
365
  // Every share is zero, so there is no wedge to draw and a full circle would
245
366
  // claim one slice owns everything. An outline says "nothing here" honestly.
@@ -273,6 +394,42 @@ function drawPie(svg: SVGSVGElement, points: readonly number[]): void {
273
394
  });
274
395
  }
275
396
 
397
+ /** The whole drawing for one spec at one width. */
398
+ function drawSvg(spec: ChartSpec, geo: Geometry): SVGSVGElement {
399
+ const svg = el("svg", {
400
+ viewBox: `0 0 ${geo.width} ${geo.height}`,
401
+ width: "100%",
402
+ role: "img",
403
+ });
404
+ svg.setAttribute("aria-label", spec.title ?? `${spec.kind} chart`);
405
+
406
+ if (spec.kind === "pie") {
407
+ // `series[0]` is guaranteed by renderChart's early return; a pie's slices
408
+ // are its labels, so a second series has nowhere to go and is ignored
409
+ // rather than silently summed into the first. Negative shares are floored,
410
+ // since a wedge cannot sweep backwards.
411
+ const first = spec.series[0] as ChartSeries;
412
+ drawPie(
413
+ svg,
414
+ first.points.map((value) => Math.max(0, value)),
415
+ geo,
416
+ );
417
+ return svg;
418
+ }
419
+ const { min, max } = extent(spec);
420
+ drawAxes(svg, spec, geo, min, max);
421
+ if (spec.kind === "bar") {
422
+ drawBars(svg, spec, geo, min, max);
423
+ } else if (spec.kind === "stacked") {
424
+ drawStacked(svg, spec, geo, min, max);
425
+ } else if (spec.kind === "line") {
426
+ drawLines(svg, spec, geo, min, max);
427
+ } else {
428
+ drawScatter(svg, spec, geo, min, max);
429
+ }
430
+ return svg;
431
+ }
432
+
276
433
  function buildLegend(entries: readonly string[]): HTMLDivElement | null {
277
434
  if (entries.length < 2) {
278
435
  return null;
@@ -292,12 +449,34 @@ function buildLegend(entries: readonly string[]): HTMLDivElement | null {
292
449
  return row;
293
450
  }
294
451
 
452
+ /**
453
+ * Redraw `block`'s chart whenever the width it has to fill changes.
454
+ *
455
+ * Nothing disconnects this, and nothing needs to: the observer is referenced
456
+ * only by the closure that made it, and an active observer holds its target
457
+ * rather than the other way round -- so a chart removed from the transcript
458
+ * takes its observer with it.
459
+ *
460
+ * The block's own width is read rather than the entry's box, because that is
461
+ * the number the redraw has to match and the entry carries several.
462
+ */
463
+ function fitToWidth(block: HTMLElement, redraw: (width: number) => void): void {
464
+ const observer = new ResizeObserver(() => {
465
+ redraw(Math.round(block.clientWidth / WIDTH_STEP) * WIDTH_STEP);
466
+ });
467
+ observer.observe(block);
468
+ }
469
+
295
470
  /**
296
471
  * Render one spec as a self-contained block, or `null` when it says nothing.
297
472
  *
298
473
  * A spec with no labels or no series is not drawn: an empty frame reads as
299
474
  * "there is no data" when the truth is "the caller sent nothing", and the two
300
475
  * deserve different answers.
476
+ *
477
+ * The block returned is already drawn at {@link DEFAULT_WIDTH} and redraws
478
+ * itself once it is in a document and knows how wide it really is, so a caller
479
+ * appends it exactly as before and never has to say how big it should be.
301
480
  */
302
481
  export function renderChart(spec: ChartSpec): HTMLDivElement | null {
303
482
  if (spec.labels.length === 0 || spec.series.length === 0) {
@@ -315,32 +494,8 @@ export function renderChart(spec: ChartSpec): HTMLDivElement | null {
315
494
  block.appendChild(heading);
316
495
  }
317
496
 
318
- const svg = el("svg", { viewBox: `0 0 ${WIDTH} ${HEIGHT}`, width: "100%", role: "img" });
319
- svg.setAttribute("aria-label", spec.title ?? `${spec.kind} chart`);
320
-
321
- if (spec.kind === "pie") {
322
- // `series[0]` is guaranteed by the early return above; a pie's slices are
323
- // its labels, so a second series has nowhere to go and is ignored rather
324
- // than silently summed into the first. Negative shares are floored, since a
325
- // wedge cannot sweep backwards.
326
- const first = spec.series[0] as ChartSeries;
327
- drawPie(
328
- svg,
329
- first.points.map((value) => Math.max(0, value)),
330
- );
331
- } else {
332
- const { min, max } = extent(spec);
333
- drawAxes(svg, spec, min, max);
334
- if (spec.kind === "bar") {
335
- drawBars(svg, spec, min, max);
336
- } else if (spec.kind === "stacked") {
337
- drawStacked(svg, spec, min, max);
338
- } else if (spec.kind === "line") {
339
- drawLines(svg, spec, min, max);
340
- } else {
341
- drawScatter(svg, spec, min, max);
342
- }
343
- }
497
+ let width = DEFAULT_WIDTH;
498
+ let svg = drawSvg(spec, geometryFor(width));
344
499
  block.appendChild(svg);
345
500
 
346
501
  // Pie's slices are its labels; every other kind's are its series.
@@ -350,5 +505,15 @@ export function renderChart(spec: ChartSpec): HTMLDivElement | null {
350
505
  if (legend !== null) {
351
506
  block.appendChild(legend);
352
507
  }
508
+
509
+ fitToWidth(block, (measured) => {
510
+ if (measured === width) {
511
+ return;
512
+ }
513
+ width = measured;
514
+ const next = drawSvg(spec, geometryFor(width));
515
+ svg.replaceWith(next);
516
+ svg = next;
517
+ });
353
518
  return block;
354
519
  }
@@ -1,20 +1,38 @@
1
- import type { Extent, LauncherBox } from "./launcher_placement.js";
1
+ import { SCREEN_EDGE_MARGIN } from "../constants.js";
2
+ import type { LauncherBox, ViewportBox } from "./launcher_placement.js";
2
3
 
3
4
  /**
4
- * Keep a launcher fully on screen.
5
+ * Keep a launcher fully on screen, and a little clear of the edge.
5
6
  *
6
7
  * Applied to a dragged position and again to a restored one, because the
7
8
  * viewport that stored it may since have shrunk. A launcher parked past the
8
9
  * edge is unreachable, and it is the only way back to a collapsed
9
- * conversation -- so this clamps to the viewport itself rather than to the
10
- * panel's margin, which would refuse the corners users actually want.
10
+ * conversation.
11
+ *
12
+ * Not the panel's 24px gutter, which was rejected here on purpose: a launcher
13
+ * held that far in refuses the corners people actually drag it to. But not
14
+ * zero either -- the bubble is a circle with a drop shadow, and one flush
15
+ * against the boundary has its shadow cut and its curve running into the edge,
16
+ * which reads as clipped whether or not a pixel is actually missing.
11
17
  */
12
18
  export function clampLauncher(
13
19
  launcher: LauncherBox,
14
- viewport: Extent,
20
+ viewport: ViewportBox,
21
+ margin: number = SCREEN_EDGE_MARGIN,
15
22
  ): { readonly left: number; readonly top: number } {
23
+ // The margin is given up rather than enforced where it does not fit: a
24
+ // viewport narrower than the bubble and its two margins would otherwise
25
+ // produce a lower bound above the upper one and pin it to the wrong edge.
26
+ const room = Math.min(margin, Math.max(0, (viewport.width - launcher.width) / 2));
27
+ const vertical = Math.min(margin, Math.max(0, (viewport.height - launcher.height) / 2));
16
28
  return {
17
- left: Math.max(0, Math.min(launcher.left, viewport.width - launcher.width)),
18
- top: Math.max(0, Math.min(launcher.top, viewport.height - launcher.height)),
29
+ left: Math.max(
30
+ viewport.left + room,
31
+ Math.min(launcher.left, viewport.left + viewport.width - launcher.width - room),
32
+ ),
33
+ top: Math.max(
34
+ viewport.top + vertical,
35
+ Math.min(launcher.top, viewport.top + viewport.height - launcher.height - vertical),
36
+ ),
19
37
  };
20
38
  }
@@ -0,0 +1,30 @@
1
+ import { EDGE_MARGIN } from "../constants.js";
2
+ import type { ViewportBox } from "./launcher_placement.js";
3
+ import type { PanelRect } from "./resize_handle.js";
4
+
5
+ /**
6
+ * Hold a panel inside the viewport, keeping its size.
7
+ *
8
+ * The near edge is clamped and the far edge follows, so a panel too large for
9
+ * the viewport is held against the near margin and left to the max-width and
10
+ * max-height rules rather than centred by force. The lower bound wins a
11
+ * contradiction for the same reason: `Math.min` first would put an oversized
12
+ * panel off the left edge instead of against the margin it can still honour.
13
+ */
14
+ export function clampPanel(
15
+ host: PanelRect,
16
+ viewport: ViewportBox,
17
+ margin: number = EDGE_MARGIN,
18
+ ): PanelRect {
19
+ const width = host.right - host.left;
20
+ const height = host.bottom - host.top;
21
+ const left = Math.max(
22
+ viewport.left + margin,
23
+ Math.min(host.left, viewport.left + viewport.width - margin - width),
24
+ );
25
+ const top = Math.max(
26
+ viewport.top + margin,
27
+ Math.min(host.top, viewport.top + viewport.height - margin - height),
28
+ );
29
+ return { left, top, right: left + width, bottom: top + height };
30
+ }
@@ -1,5 +1,5 @@
1
1
  import { clampLauncher } from "./clamp_launcher.js";
2
- import type { Extent, LauncherBox } from "./launcher_placement.js";
2
+ import type { LauncherBox, ViewportBox } from "./launcher_placement.js";
3
3
 
4
4
  /** What the drag needs from its host to do its job. */
5
5
  export interface LauncherDragOptions {
@@ -12,7 +12,7 @@ export interface LauncherDragOptions {
12
12
  /** The launcher's current box, in viewport coordinates. */
13
13
  readonly rect: () => LauncherBox;
14
14
  /** The viewport the launcher has to stay inside. */
15
- readonly viewport: () => Extent;
15
+ readonly viewport: () => ViewportBox;
16
16
  /** Put the launcher's top-left at this point. Called per pointer move. */
17
17
  readonly apply: (left: number, top: number) => void;
18
18
  /**
@@ -106,6 +106,7 @@ export function enableLauncherDrag(launcher: HTMLElement, options: LauncherDragO
106
106
  const onUp = (up: PointerEvent): void => {
107
107
  window.removeEventListener("pointermove", onMove);
108
108
  window.removeEventListener("pointerup", onUp);
109
+ window.removeEventListener("pointercancel", onUp);
109
110
  if (!dragging) {
110
111
  return;
111
112
  }
@@ -124,8 +125,16 @@ export function enableLauncherDrag(launcher: HTMLElement, options: LauncherDragO
124
125
 
125
126
  // Listeners on `window`, not the launcher: a fast drag outruns the pointer
126
127
  // and would otherwise strand it mid-move with no pointerup.
128
+ //
129
+ // pointercancel matters on touch, where it is routine rather than
130
+ // exceptional: the browser takes the pointer back for a scroll or a system
131
+ // gesture and never sends pointerup. Without this the move listeners stay
132
+ // attached and the drag stamp never clears, which leaves the launcher
133
+ // following a finger that has stopped and the element believing a gesture
134
+ // is still in flight.
127
135
  window.addEventListener("pointermove", onMove);
128
136
  window.addEventListener("pointerup", onUp);
137
+ window.addEventListener("pointercancel", onUp);
129
138
  });
130
139
 
131
140
  // The position this key gesture has applied but not yet persisted. The
@@ -1,3 +1,7 @@
1
+ import { SCREEN_EDGE_MARGIN } from "../constants.js";
2
+ import { clampPanel } from "./clamp_panel.js";
3
+ import { placeWidget } from "./place_widget.js";
4
+
1
5
  /** A box in viewport coordinates. */
2
6
  export interface LauncherBox {
3
7
  readonly left: number;
@@ -6,6 +10,20 @@ export interface LauncherBox {
6
10
  readonly height: number;
7
11
  }
8
12
 
13
+ /**
14
+ * The part of the screen a widget may rest in: a width and height, and the
15
+ * corner they start from.
16
+ *
17
+ * The origin is not always zero. A host can reserve the edges its own chrome
18
+ * occupies, and a panel clamped against a viewport that starts at the top-left
19
+ * of the screen will happily park itself underneath a sticky header -- where it
20
+ * cannot be reached, and where collapsing it only hides it further.
21
+ */
22
+ export interface ViewportBox extends Extent {
23
+ readonly left: number;
24
+ readonly top: number;
25
+ }
26
+
9
27
  /** A width/height pair, in CSS pixels. */
10
28
  export interface Extent {
11
29
  readonly width: number;
@@ -33,13 +51,6 @@ export interface LauncherPlacement {
33
51
  readonly launcherInset: string;
34
52
  }
35
53
 
36
- /**
37
- * The gutter a panel keeps from the viewport edge, matching the default
38
- * `--ag-ui-inset` so an undragged widget resolves to exactly the placement it
39
- * already had. Changing this moves every clamped panel.
40
- */
41
- const EDGE_MARGIN = 24;
42
-
43
54
  /**
44
55
  * Decide where a panel should open from a launcher the user has dragged.
45
56
  *
@@ -74,70 +85,49 @@ const EDGE_MARGIN = 24;
74
85
  export function launcherPlacement(
75
86
  launcher: LauncherBox,
76
87
  panel: Extent,
77
- viewport: Extent,
78
- margin: number = EDGE_MARGIN,
88
+ viewport: ViewportBox,
89
+ screen: Extent,
90
+ margin: number = SCREEN_EDGE_MARGIN,
79
91
  ): LauncherPlacement {
80
92
  // Room for a panel pinned to each side of the launcher. A tie goes to the
81
93
  // first branch, so the result is deterministic for a centred launcher.
82
- const roomRunningRight = viewport.width - launcher.left;
83
- const roomRunningLeft = launcher.left + launcher.width;
84
- const roomRunningDown = viewport.height - launcher.top;
85
- const roomRunningUp = launcher.top + launcher.height;
94
+ //
95
+ // Measured against the usable box's own edges, not against its width and
96
+ // height as if it started at the origin. The launcher's coordinates are the
97
+ // screen's, while the extents have already had the host's reserved edges
98
+ // taken out of them, so mixing the two understates the room on one side and
99
+ // overstates it on the other -- by the same reserved inset, in opposite
100
+ // directions, which is what makes the comparison flip rather than merely
101
+ // drift. The clamp below reads `viewport.left`/`top`; this had to as well.
102
+ const roomRunningRight = viewport.left + viewport.width - launcher.left;
103
+ const roomRunningLeft = launcher.left + launcher.width - viewport.left;
104
+ const roomRunningDown = viewport.top + viewport.height - launcher.top;
105
+ const roomRunningUp = launcher.top + launcher.height - viewport.top;
86
106
  const corner: ExpandCorner = {
87
107
  x: roomRunningRight >= roomRunningLeft ? "left" : "right",
88
108
  y: roomRunningDown >= roomRunningUp ? "top" : "bottom",
89
109
  };
90
110
 
91
- // The box the panel wants, then the box it can actually have. Clamping the
92
- // near edge covers the far edge too: the panel is a fixed size here, and a
93
- // panel too large for the viewport is held against the near margin and left
94
- // to the max-width and max-height rules rather than centred by force.
111
+ // The box the panel wants, then the box it can actually have. The launcher
112
+ // is not moved by that clamp -- it is the fixed point of this gesture, so its
113
+ // own inset carries the difference and it can end up outside the host box.
95
114
  const wantedLeft =
96
115
  corner.x === "left" ? launcher.left : launcher.left + launcher.width - panel.width;
97
116
  const wantedTop =
98
117
  corner.y === "top" ? launcher.top : launcher.top + launcher.height - panel.height;
99
- const hostLeft = clamp(wantedLeft, margin, viewport.width - margin - panel.width);
100
- const hostTop = clamp(wantedTop, margin, viewport.height - margin - panel.height);
101
-
102
- const hostRight = hostLeft + panel.width;
103
- const hostBottom = hostTop + panel.height;
104
-
105
- return {
106
- corner,
107
- hostInset: inset({
108
- top: corner.y === "top" ? hostTop : null,
109
- right: corner.x === "right" ? viewport.width - hostRight : null,
110
- bottom: corner.y === "bottom" ? viewport.height - hostBottom : null,
111
- left: corner.x === "left" ? hostLeft : null,
112
- }),
113
- // Measured from the host box's pinned corner to the launcher's matching
114
- // corner, so the launcher lands exactly where it was dropped.
115
- launcherInset: inset({
116
- top: corner.y === "top" ? launcher.top - hostTop : null,
117
- right: corner.x === "right" ? hostRight - (launcher.left + launcher.width) : null,
118
- bottom: corner.y === "bottom" ? hostBottom - (launcher.top + launcher.height) : null,
119
- left: corner.x === "left" ? launcher.left - hostLeft : null,
120
- }),
121
- };
122
- }
123
-
124
- /**
125
- * Constrain a value, with the lower bound winning a contradiction. `Math.min`
126
- * first would put a panel wider than the viewport off the left edge instead of
127
- * against the near margin.
128
- */
129
- function clamp(value: number, low: number, high: number): number {
130
- return Math.max(low, Math.min(value, high));
131
- }
118
+ const host = clampPanel(
119
+ {
120
+ left: wantedLeft,
121
+ top: wantedTop,
122
+ right: wantedLeft + panel.width,
123
+ bottom: wantedTop + panel.height,
124
+ },
125
+ viewport,
126
+ margin,
127
+ );
132
128
 
133
- /** An `inset` shorthand; a null side is `auto`, so the opposite one pins it. */
134
- function inset(sides: {
135
- top: number | null;
136
- right: number | null;
137
- bottom: number | null;
138
- left: number | null;
139
- }): string {
140
- const side = (value: number | null): string =>
141
- value === null ? "auto" : `${Math.round(value)}px`;
142
- return `${side(sides.top)} ${side(sides.right)} ${side(sides.bottom)} ${side(sides.left)}`;
129
+ // The usable box decides where things may rest; the screen is what the
130
+ // resulting insets are measured from. They are the same only when the host
131
+ // has reserved nothing.
132
+ return { corner, ...placeWidget(host, launcher, corner, screen) };
143
133
  }