@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
package/src/ui/styles.ts CHANGED
@@ -48,6 +48,11 @@ export const STYLES = `
48
48
  /* Message action row: the control box and the mark inside it. The box has a
49
49
  floor of 24px so it stays a reliable target at every density. */
50
50
  --_action-size: var(--ag-ui-action-size, 28px);
51
+ /* The header's own controls. Sized rather than left to their contents: a row
52
+ of buttons each as wide as the glyph inside it comes out uneven, and uneven
53
+ buttons two pixels apart read as one smudge rather than five controls. */
54
+ --_header-btn-size: var(--ag-ui-header-btn-size, 30px);
55
+ --_header-gap: var(--ag-ui-header-gap, 4px);
51
56
  --_action-icon-size: var(--ag-ui-action-icon-size, 15px);
52
57
  --_tooltip-bg: var(--ag-ui-tooltip-bg, #1f2430);
53
58
  --_tooltip-fg: var(--ag-ui-tooltip-fg, #f5f6fa);
@@ -109,6 +114,11 @@ export const STYLES = `
109
114
  --_glyph-size: var(--ag-ui-glyph-size, 18px);
110
115
  --_glyph-stroke: var(--ag-ui-glyph-stroke, 1.75);
111
116
 
117
+ /* How wide a chart is allowed to get. A cap rather than a width: below it
118
+ the chart fills its column, and above it a wider panel is just a wider
119
+ panel. */
120
+ --_chart-max-width: var(--ag-ui-chart-max-width, 480px);
121
+
112
122
  /* Unread badge on the launcher. */
113
123
  --_badge-bg: var(--ag-ui-badge-bg, var(--_danger));
114
124
  --_badge-fg: var(--ag-ui-badge-fg, #ffffff);
@@ -137,6 +147,68 @@ export const STYLES = `
137
147
  --_msg-pad: var(--ag-ui-msg-pad, 8px 12px);
138
148
  --_msg-radius: var(--ag-ui-msg-radius, 14px);
139
149
 
150
+ /* Edges of the viewport the host has already spent, and that a fixed
151
+ placement must therefore stay out of: a sticky nav bar, a docked toolbar,
152
+ a device's safe area. Four longhands rather than one shorthand because a
153
+ custom property is a token stream and CSS cannot index one -- the height
154
+ arithmetic below needs the vertical pair on its own.
155
+
156
+ Every fixed placement derives from these, and the heights subtract them in
157
+ one place. A host that reserved its chrome by restating --ag-ui-inset per
158
+ placement had to keep --ag-ui-height in step by hand, and forgetting it
159
+ overflowed the panel past the bottom of the screen with nothing to say so.
160
+
161
+ These take env(safe-area-inset-*) verbatim, which is what a full-bleed
162
+ placement wants on a device with a notch. */
163
+ --_viewport-inset-top: var(--ag-ui-viewport-inset-top, 0px);
164
+ --_viewport-inset-right: var(--ag-ui-viewport-inset-right, 0px);
165
+ --_viewport-inset-bottom: var(--ag-ui-viewport-inset-bottom, 0px);
166
+ --_viewport-inset-left: var(--ag-ui-viewport-inset-left, 0px);
167
+ /* What is left of the viewport once the host's chrome is taken out, on both
168
+ axes, so a placement never composes this itself and no host can subtract
169
+ an edge from the position and forget it in the height.
170
+
171
+ Settable in their own right, and that is not only convention: an on-screen
172
+ keyboard changes no viewport-percentage length -- not vh, not dvh, not svh
173
+ -- so a full-bleed panel on a phone has to be told the height rather than
174
+ deriving it. The value to publish there is the visual viewport's. */
175
+ --_viewport-height: var(--ag-ui-viewport-height, var(--_visual-viewport-height));
176
+ /* The measured height of the part of the screen the user can actually see,
177
+ written by the element from the visual viewport and falling back to the
178
+ layout viewport where nothing has measured yet.
179
+
180
+ This is the on-screen keyboard, and it needs measuring because no CSS
181
+ length describes it: an on-screen keyboard has no effect on any
182
+ viewport-percentage unit, so 100vh, 100dvh and 100svh are all the same
183
+ number with the keyboard up as without it. A full-bleed panel sized from
184
+ any of them puts its composer behind the keyboard the user is typing into.
185
+
186
+ Separate from the token above so a host that states the usable height
187
+ outright still wins: the element writes this one inline, and an inline
188
+ value would otherwise outrank the host's own rule. */
189
+ --_visual-viewport-height: var(
190
+ --ag-ui-visual-viewport-height,
191
+ calc(100vh - var(--_viewport-inset-top) - var(--_viewport-inset-bottom))
192
+ );
193
+ /* How much of the layout viewport is hidden below the visible one, measured
194
+ and written by the element alongside the height above.
195
+
196
+ A shorter panel is not enough on its own for anything anchored to the
197
+ bottom. A floating widget is positioned against the layout viewport, so
198
+ with a keyboard up its bottom edge -- and the launcher that lives at that
199
+ corner -- sits behind the keyboard however tall the panel is. This is what
200
+ lifts it clear. */
201
+ /* Two tokens, for the same reason the height above has two: the element
202
+ writes the measurement inline, and a host that states its own value needs
203
+ a knob that outranks that write rather than one the next write replaces.
204
+ A host wanting no keyboard lift at all sets --ag-ui-keyboard-inset: 0px. */
205
+ --_keyboard-inset: var(--ag-ui-keyboard-inset, var(--_visual-viewport-inset-bottom));
206
+ --_visual-viewport-inset-bottom: var(--ag-ui-visual-viewport-inset-bottom, 0px);
207
+ --_viewport-width: var(
208
+ --ag-ui-viewport-width,
209
+ calc(100vw - var(--_viewport-inset-left) - var(--_viewport-inset-right))
210
+ );
211
+
140
212
  /* Layout — override from outside to dock the widget anywhere.
141
213
  Set --ag-ui-position: static (and place this element in your own
142
214
  grid/flex layout) to embed it in the page flow instead of floating. */
@@ -144,14 +216,49 @@ export const STYLES = `
144
216
  --_z-index: var(--ag-ui-z-index, 2147483000);
145
217
  --_width: var(--ag-ui-width, 380px);
146
218
  --_height: var(--ag-ui-height, 560px);
147
- --_inset: var(--ag-ui-inset, auto 24px 24px auto);
148
- --_max-width: var(--ag-ui-max-width, calc(100vw - 48px));
149
- --_max-height: var(--ag-ui-max-height, calc(100vh - 48px));
219
+ /* The gutter a resting floating panel keeps between itself and the edge of
220
+ the box the host left free. One number, because the inset below spends it
221
+ and the cap beneath has to know it was spent. */
222
+ --_edge-gutter: var(--ag-ui-edge-gutter, 24px);
223
+ --_inset: var(
224
+ --ag-ui-inset,
225
+ auto calc(var(--_edge-gutter) + var(--_viewport-inset-right))
226
+ calc(var(--_edge-gutter) + var(--_viewport-inset-bottom) + var(--_keyboard-inset)) auto
227
+ );
228
+ /* The cap is what the host left free, less the *one* gutter the inset above
229
+ spends on the anchored edge -- not two, and not none.
230
+
231
+ Two was the first answer and it was felt on one axis only. The arithmetic
232
+ says why: the default panel is 560 tall against a cap of the viewport
233
+ minus 48, which on an 800px screen with a header reserved is 72px of
234
+ headroom, so the height reaches its cap almost immediately; the default
235
+ width is 380 against a cap near 1230, which is 850px nothing ever reaches.
236
+ Once the size is capped a grip cannot grow the panel, so a pull on the
237
+ anchored edge is written as position instead and the panel travels, and a
238
+ pull on the free edge stops a whole gutter short of an edge a drag can
239
+ reach.
240
+
241
+ None was the correction, and it overshot: the panel is anchored bottom-
242
+ right with a gutter already spent there, so a cap of the full usable
243
+ height puts the far edge exactly one gutter outside it. On an 800px screen
244
+ with nothing reserved that is a top of -24 -- the header, and every
245
+ control in it, off the top of the window. Reserve a 120px header and it
246
+ sits inside that instead, which is the one thing the reservation exists to
247
+ prevent.
248
+
249
+ One is the fixed point of both complaints. A resting panel grown to the
250
+ cap runs from the usable near edge to its gutter on the far one, so the
251
+ near edge is reachable by a resize exactly as it is by a drag, and neither
252
+ can put any part of the panel outside the box. */
253
+ --_max-width: var(--ag-ui-max-width, calc(var(--_viewport-width) - var(--_edge-gutter)));
254
+ --_max-height: var(--ag-ui-max-height, calc(var(--_viewport-height) - var(--_edge-gutter)));
150
255
  /* Reading-column width for placement="page" (full-bleed, centred content). */
151
256
  --_content-max-width: var(--ag-ui-content-max-width, 820px);
152
257
  /* Slim rail the sidebar placement collapses to. Only that placement reads
153
258
  it, but it is declared here so every alias has a default in one place. */
154
259
  --_rail-width: var(--ag-ui-rail-width, 52px);
260
+ /* Width of the docked conversation list on a full-page chat. */
261
+ --_threads-rail-width: var(--ag-ui-threads-rail-width, 280px);
155
262
 
156
263
  position: var(--_position);
157
264
  inset: var(--_inset);
@@ -222,23 +329,30 @@ export const STYLES = `
222
329
 
223
330
  /* ── Placement presets ──────────────────────────────────────────────────── */
224
331
  :host([placement="bottom-left"]) {
225
- --_inset: var(--ag-ui-inset, auto auto 24px 24px);
332
+ --_inset: var(
333
+ --ag-ui-inset,
334
+ auto auto calc(var(--_edge-gutter) + var(--_viewport-inset-bottom) + var(--_keyboard-inset))
335
+ calc(var(--_edge-gutter) + var(--_viewport-inset-left))
336
+ );
226
337
  }
227
338
 
228
339
  :host([placement="side"]) {
229
- --_inset: var(--ag-ui-inset, 0 0 0 auto);
340
+ --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
230
341
  --_width: var(--ag-ui-width, 420px);
231
- --_height: var(--ag-ui-height, 100vh);
232
- --_max-height: var(--ag-ui-max-height, 100vh);
342
+ --_height: var(--ag-ui-height, var(--_viewport-height));
343
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
233
344
  --_radius: var(--ag-ui-radius, 0);
234
345
  }
235
346
 
236
347
  :host([placement="full"]) {
237
- --_inset: var(--ag-ui-inset, 0);
238
- --_width: var(--ag-ui-width, 100vw);
239
- --_height: var(--ag-ui-height, 100vh);
240
- --_max-width: var(--ag-ui-max-width, 100vw);
241
- --_max-height: var(--ag-ui-max-height, 100vh);
348
+ --_inset: var(
349
+ --ag-ui-inset,
350
+ var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left)
351
+ );
352
+ --_width: var(--ag-ui-width, var(--_viewport-width));
353
+ --_height: var(--ag-ui-height, var(--_viewport-height));
354
+ --_max-width: var(--ag-ui-max-width, var(--_viewport-width));
355
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
242
356
  --_radius: var(--ag-ui-radius, 0);
243
357
  }
244
358
 
@@ -248,11 +362,14 @@ export const STYLES = `
248
362
  composer rather than a per-row wrapper, so user pills still right-align and
249
363
  the assistant well spans the column. */
250
364
  :host([placement="page"]) {
251
- --_inset: var(--ag-ui-inset, 0);
252
- --_width: var(--ag-ui-width, 100vw);
253
- --_height: var(--ag-ui-height, 100vh);
254
- --_max-width: var(--ag-ui-max-width, 100vw);
255
- --_max-height: var(--ag-ui-max-height, 100vh);
365
+ --_inset: var(
366
+ --ag-ui-inset,
367
+ var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left)
368
+ );
369
+ --_width: var(--ag-ui-width, var(--_viewport-width));
370
+ --_height: var(--ag-ui-height, var(--_viewport-height));
371
+ --_max-width: var(--ag-ui-max-width, var(--_viewport-width));
372
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
256
373
  --_radius: var(--ag-ui-radius, 0);
257
374
  }
258
375
 
@@ -283,28 +400,100 @@ export const STYLES = `
283
400
  max-width: 100%;
284
401
  }
285
402
 
403
+ /* Small viewports: one shape, reached from whichever placement the host chose.
404
+
405
+ A phone is not an eighth placement, it is an override that collapses the
406
+ others onto one of them. The host picked a placement for the desktop it was
407
+ designing; a 380x560 panel with a 24px margin is not a smaller version of
408
+ that decision, it is most of the screen with a frame drawn round it.
409
+
410
+ The breakpoint is a literal because a custom property cannot be read in a
411
+ media query -- which is exactly why there is an opt-out. Everything the block
412
+ sets is a token a host can re-state, but the *trigger* is unreachable, so a
413
+ host whose layout wants a different threshold, or none, sets
414
+ data-small-viewport="off" and keeps its desktop shape at every width. 600px is above every common phone in portrait and below every
415
+ tablet in landscape, and it is a width rather than a pointer test on purpose:
416
+ a touch laptop is coarse-pointered and wide, a narrow desktop window is
417
+ fine-pointered and small, and conflating the two gets both wrong. What the
418
+ pointer decides is which controls make sense, further down.
419
+
420
+ Two placements are left alone. "page" is already this shape. "embedded" sits
421
+ in a box the host sized and placed, and taking that over would break the app
422
+ shell it was embedded into -- the host is the only party that knows whether
423
+ its column should become the whole screen. */
424
+ @media (max-width: 600px) {
425
+ :host([placement="floating"]:not([data-small-viewport="off"])),
426
+ :host([placement="bottom-left"]:not([data-small-viewport="off"])),
427
+ :host([placement="sidebar"]:not([data-small-viewport="off"])),
428
+ :host([placement="side"]:not([data-small-viewport="off"])),
429
+ :host(:not([placement]):not([data-small-viewport="off"])),
430
+ :host([placement=""]:not([data-small-viewport="off"])) {
431
+ --_inset: var(
432
+ --ag-ui-inset,
433
+ var(--_viewport-inset-top) var(--_viewport-inset-right)
434
+ calc(var(--_viewport-inset-bottom) + var(--_keyboard-inset))
435
+ var(--_viewport-inset-left)
436
+ );
437
+ --_width: var(--ag-ui-width, var(--_viewport-width));
438
+ --_height: var(--ag-ui-height, var(--_viewport-height));
439
+ --_max-width: var(--ag-ui-max-width, var(--_viewport-width));
440
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
441
+ --_radius: var(--ag-ui-radius, 0);
442
+ --_shadow: var(--ag-ui-shadow, none);
443
+ }
444
+
445
+ /* Nothing to resize once the panel is the screen, and the grips would sit
446
+ under the thumbs holding the phone. */
447
+ :host([placement="floating"]:not([data-small-viewport="off"])) .resize-handle,
448
+ :host([placement="bottom-left"]:not([data-small-viewport="off"])) .resize-handle,
449
+ :host([placement="sidebar"]:not([data-small-viewport="off"])) .resize-handle,
450
+ :host([placement="side"]:not([data-small-viewport="off"])) .resize-handle,
451
+ :host(:not([placement]):not([data-small-viewport="off"])) .resize-handle,
452
+ :host([placement=""]:not([data-small-viewport="off"])) .resize-handle {
453
+ display: none;
454
+ }
455
+ }
456
+
286
457
  /* Sidebar: a full-height docked panel that slides open/closed and
287
458
  collapses to a slim icon rail (not the floating launcher). Docked right by
288
459
  default; data-side="left" docks it left. Overlay by default — set
289
460
  --ag-ui-position: static (and place this element in your own layout) for a
290
461
  host-managed push instead. */
291
462
  :host([placement="sidebar"]) {
292
- --_inset: var(--ag-ui-inset, 0 0 0 auto);
463
+ --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
293
464
  --_width: var(--ag-ui-width, 420px);
294
- --_height: var(--ag-ui-height, 100vh);
295
- --_max-height: var(--ag-ui-max-height, 100vh);
465
+ --_height: var(--ag-ui-height, var(--_viewport-height));
466
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
296
467
  --_radius: var(--ag-ui-radius, 0);
297
468
  transition: width var(--_motion) var(--_ease);
298
469
  }
299
470
 
300
471
  :host([placement="sidebar"][data-side="left"]) {
301
- --_inset: var(--ag-ui-inset, 0 auto 0 0);
472
+ --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) auto var(--_viewport-inset-bottom) var(--_viewport-inset-left));
302
473
  }
303
474
 
304
475
  /* The docked panel is pinned to the edge it docks against rather than filling
305
476
  the host as a flex child. Collapsing shrinks the host to the rail width, and
306
477
  a flex child would be squashed to 52px on the way out instead of sliding out
307
478
  at full width. */
479
+ /* The panel is taken out of flow so the collapse can slide it out at full
480
+ width; see the note above. That makes the host its containing block, and the
481
+ host is only one by accident: it is position: fixed by default. A host that
482
+ takes the documented route to a pushed layout instead of an overlay sets
483
+ --ag-ui-position: static, and a static element establishes nothing -- so the
484
+ panel resolved against the initial containing block, landed at the document
485
+ origin, and scrolled away with the page. Docked left it pinned to the
486
+ document's left edge rather than the host's.
487
+
488
+ It looked correct wherever it was first tried, because a full-height column
489
+ at the top of an unscrolled document is exactly where those two answers
490
+ coincide. Containment makes the host a containing block whatever its
491
+ position, so the panel stays in the box the host was given. It is a no-op in
492
+ the default case, which is already positioned. */
493
+ :host([placement="sidebar"]) {
494
+ contain: layout;
495
+ }
496
+
308
497
  :host([placement="sidebar"]) .chat {
309
498
  position: absolute;
310
499
  inset: 0 0 0 auto;
@@ -320,11 +509,15 @@ export const STYLES = `
320
509
  /* Collapsed sidebar: shrink the host to the rail width and slide the panel out
321
510
  through the edge it docks against. Higher specificity than the generic
322
511
  collapse rules, so it wins regardless of source order. */
512
+ /* The rail is the full height of what the host left, not of the screen. It
513
+ said 100vh and pinned its own bottom, which put it under any chrome the host
514
+ had reserved -- and the icon lives at the top of the rail, so the one control
515
+ that reopens the panel was the first thing to disappear behind a sticky
516
+ header. */
323
517
  :host([placement="sidebar"][collapsed]) {
324
518
  width: var(--_rail-width);
325
- height: 100vh;
326
- max-height: 100vh;
327
- bottom: 0;
519
+ height: var(--_viewport-height);
520
+ max-height: var(--_viewport-height);
328
521
  pointer-events: auto;
329
522
  }
330
523
 
@@ -440,17 +633,83 @@ export const STYLES = `
440
633
 
441
634
  /* The sidebar collapses to an edge rail instead: full height, square, flush
442
635
  against the dock. It slides in with the panel rather than popping. */
636
+ /* The edge rail. It reads as the docked edge of a panel rather than a coloured
637
+ stripe: the surface the panel is made of, a border on the side it docks
638
+ against, and the accent kept for the icon -- a full-height slab of accent is
639
+ the loudest thing on the page and says the least about itself.
640
+
641
+ Content sits at the top rather than centred, because a control floating in
642
+ the middle of a screen-high column has nothing to belong to. */
443
643
  :host([placement="sidebar"][collapsed]) .launcher {
444
644
  inset: 0;
445
645
  width: auto;
446
646
  height: auto;
447
- align-items: flex-start;
448
- padding-top: 16px;
449
- border: 1px solid var(--_border);
647
+ flex-direction: column;
648
+ align-items: center;
649
+ justify-content: flex-start;
650
+ gap: 12px;
651
+ padding-top: 14px;
652
+ border: 0;
653
+ border-inline-start: 1px solid var(--_border);
450
654
  border-radius: 0;
655
+ background: var(--_bg);
656
+ color: var(--_fg);
657
+ box-shadow: none;
658
+ transform: none;
659
+ }
660
+
661
+ :host([placement="sidebar"][data-side="left"][collapsed]) .launcher {
662
+ border-inline-start: 0;
663
+ border-inline-end: 1px solid var(--_border);
664
+ }
665
+
666
+ /* The icon keeps the accent, so there is one obvious thing to press.
667
+
668
+ The glyph is sized here rather than left to fill the holder. A glyph in an
669
+ icon holder takes the holder's whole box by default, which is right where the
670
+ holder is only a box and wrong the moment it becomes a filled circle: the
671
+ mark then runs edge to edge and reads as a square crammed into a disc. The
672
+ proportion is the floating launcher's own -- a 26px glyph in a 56px bubble --
673
+ so the two collapsed states look like the same widget. */
674
+ :host([placement="sidebar"][collapsed]) .launcher .icon-holder {
675
+ display: inline-flex;
676
+ align-items: center;
677
+ justify-content: center;
678
+ width: 34px;
679
+ height: 34px;
680
+ border-radius: 50%;
451
681
  background: var(--_header-bg);
452
682
  color: var(--_header-fg);
453
- box-shadow: none;
683
+ }
684
+
685
+ :host([placement="sidebar"][collapsed]) .launcher .icon-holder .glyph,
686
+ :host([placement="sidebar"][collapsed]) .launcher .icon-holder .icon-img {
687
+ width: 16px;
688
+ height: 16px;
689
+ }
690
+
691
+ /* Written down the rail, which is the only direction it fits. Reading upward
692
+ is the convention for a right-hand edge and matches how a docked panel's
693
+ label is set everywhere it appears. */
694
+ .rail-label {
695
+ display: none;
696
+ }
697
+
698
+ :host([placement="sidebar"][collapsed]) .rail-label {
699
+ display: block;
700
+ writing-mode: vertical-rl;
701
+ transform: rotate(180deg);
702
+ max-height: calc(100% - 96px);
703
+ overflow: hidden;
704
+ font-size: 0.9em;
705
+ font-weight: 600;
706
+ letter-spacing: 0.02em;
707
+ white-space: nowrap;
708
+ text-overflow: ellipsis;
709
+ }
710
+
711
+ :host([placement="sidebar"][data-side="left"][collapsed]) .rail-label {
712
+ writing-mode: vertical-rl;
454
713
  transform: none;
455
714
  }
456
715
 
@@ -499,6 +758,41 @@ export const STYLES = `
499
758
  color: var(--_header-fg);
500
759
  }
501
760
 
761
+ /* The header is the panel's title bar: on the placements that let the widget
762
+ be moved it drags the whole thing, matching the launcher's own drag. The
763
+ selector lists those placements rather than asking JS to stamp an attribute,
764
+ because a cursor that appears one frame late reads as a control that only
765
+ sometimes exists. touch-action keeps a finger drag here moving the panel
766
+ instead of scrolling the page behind it. */
767
+ :host(:not([placement])) .header,
768
+ :host([placement=""]) .header,
769
+ :host([placement="floating"]) .header,
770
+ :host([placement="bottom-left"]) .header {
771
+ cursor: move;
772
+ touch-action: none;
773
+ }
774
+
775
+ /* A host that turned the drag off keeps a header that says so. */
776
+ :host([data-launcher-drag="false"]) .header {
777
+ cursor: auto;
778
+ touch-action: auto;
779
+ }
780
+
781
+ /* :host is carried for the specificity, not the scope: the placement rules
782
+ above are a host selector plus a class plus an attribute, and a bare
783
+ .header[data-dragging] loses to them -- silently, because the drag still
784
+ works and only the cursor is wrong. */
785
+ :host .header[data-dragging] {
786
+ cursor: grabbing;
787
+ }
788
+
789
+ /* The cursor inherits, so a control a host slots into the header would show
790
+ the drag cursor over something the drag deliberately ignores. Named to
791
+ match the controls panel_drag steps aside for. */
792
+ .header ::slotted(:is(button, a, input, select, textarea)) {
793
+ cursor: pointer;
794
+ }
795
+
502
796
  .header-title {
503
797
  flex: 1;
504
798
  min-width: 0;
@@ -529,17 +823,24 @@ export const STYLES = `
529
823
 
530
824
  .header-controls {
531
825
  display: flex;
532
- gap: 2px;
826
+ gap: var(--_header-gap);
533
827
  flex: none;
534
828
  }
535
829
 
536
830
  .header-btn {
831
+ display: inline-flex;
832
+ flex: 0 0 auto;
833
+ align-items: center;
834
+ justify-content: center;
835
+ box-sizing: border-box;
836
+ width: var(--_header-btn-size);
837
+ height: var(--_header-btn-size);
537
838
  border: none;
538
839
  background: transparent;
539
840
  color: inherit;
540
841
  font: inherit;
541
842
  line-height: 1;
542
- padding: 4px 7px;
843
+ padding: 0;
543
844
  border-radius: 6px;
544
845
  cursor: pointer;
545
846
  opacity: 0.85;
@@ -561,9 +862,9 @@ export const STYLES = `
561
862
  Nothing paints there once the panel is gone, so the box only has to stop
562
863
  swallowing clicks: pointer events go to none and the launcher takes them.
563
864
 
564
- Two placements collapse differently: "sidebar" slides to its rail (below),
565
- while "embedded" and "page" keep the header bar, having no corner for a
566
- floating circle that would escape the host's layout. */
865
+ Two placements do not use it: "sidebar" slides to its rail (below), and
866
+ "embedded" keeps its header bar, having no corner for a floating circle that
867
+ would escape the host's layout. "page" has no collapsed state at all. */
567
868
  :host([collapsed]) {
568
869
  pointer-events: none;
569
870
  /* A collapsed host has to be allowed to shrink, and in the layout hosts
@@ -625,37 +926,107 @@ export const STYLES = `
625
926
  transform-origin: bottom right;
626
927
  }
627
928
 
628
- /* The two in-flow placements keep the original collapse: hide the body, let
629
- the host shrink to the header bar. */
630
- :host([collapsed]:is([placement="embedded"], [placement="page"])) {
929
+ /* The in-flow placement keeps the original collapse: hide the body, let the
930
+ host shrink to the header bar. */
931
+ :host([collapsed][placement="embedded"]) {
631
932
  height: auto;
632
933
  max-height: none;
633
934
  pointer-events: auto;
634
935
  }
635
936
 
636
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .chat {
937
+ :host([collapsed][placement="embedded"]) .chat {
637
938
  opacity: 1;
638
939
  transform: none;
639
940
  visibility: visible;
640
941
  }
641
942
 
642
- /* These two keep the header bar, so the launcher must stay out of the way: an
943
+ /* It keeps the header bar, so the launcher must stay out of the way: an
643
944
  embedded host is position: static, which would let an absolutely-positioned
644
945
  circle escape the layout and land against whatever the page positions. */
645
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .launcher {
946
+ :host([collapsed][placement="embedded"]) .launcher {
646
947
  visibility: hidden;
647
948
  opacity: 0;
648
949
  }
649
950
 
650
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages-wrap,
651
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages,
652
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .input-row,
653
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-chips,
654
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-palette,
655
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-hint {
951
+ :host([collapsed][placement="embedded"]) .messages-wrap,
952
+ :host([collapsed][placement="embedded"]) .messages,
953
+ :host([collapsed][placement="embedded"]) .input-row,
954
+ :host([collapsed][placement="embedded"]) .skill-chips,
955
+ :host([collapsed][placement="embedded"]) .skill-palette,
956
+ :host([collapsed][placement="embedded"]) .skill-hint {
957
+ display: none;
958
+ }
959
+
960
+ /* A docked conversation list: beside the transcript rather than over it.
961
+
962
+ A full-page chat is the one surface with width to spare, and covering the
963
+ conversation to show the list of conversations is the wrong trade there --
964
+ it hides the thing you are trying to get back to. Everywhere else the panel
965
+ is a few hundred pixels wide and a docked list would leave a column of
966
+ transcript narrower than the messages in it, so this is the only placement
967
+ that gets it.
968
+
969
+ The transcript is moved by padding on the shell rather than by making the
970
+ list a flex sibling: the drawer is the last child of the panel, and no
971
+ selector reaches backwards from it to the rows that have to shift. That is
972
+ also why the state is stamped on the host. */
973
+ :host([data-threads-docked]) .drawer {
974
+ /* Not a scrim: the page behind it is still the user's to work in. */
975
+ pointer-events: none;
976
+ }
977
+
978
+ :host([data-threads-docked]) .drawer-backdrop {
979
+ display: none;
980
+ }
981
+
982
+ :host([data-threads-docked]) .drawer-panel {
983
+ width: var(--_threads-rail-width);
984
+ pointer-events: auto;
985
+ border-inline-end: 1px solid var(--_border);
986
+ box-shadow: none;
987
+ }
988
+
989
+ :host([data-threads-docked]) .messages,
990
+ :host([data-threads-docked]) .input-row,
991
+ :host([data-threads-docked]) .skill-chips,
992
+ :host([data-threads-docked]) .attachment-tray {
993
+ padding-inline-start: calc(
994
+ var(--_threads-rail-width) + max(var(--_pad), (100% - var(--_content-max-width)) / 2)
995
+ );
996
+ }
997
+
998
+ :host([data-threads-docked]) .header {
999
+ padding-inline-start: calc(var(--_threads-rail-width) + var(--_pad));
1000
+ }
1001
+
1002
+ /* The page placement has no collapsed state, so it offers no control for one.
1003
+ A dedicated route has no "away" to go to: shrinking it left a strip of
1004
+ application chrome fixed over a route that no longer had an owner, and this
1005
+ is the one placement where the launcher that would bring it back is hidden.
1006
+
1007
+ Hiding the control is only half of it. The state has another way in -- the
1008
+ attribute can be written straight onto the element, and a value stored under
1009
+ a different placement is restored on connect -- and the JS guards that catch
1010
+ those cannot see an attribute set directly. So the placement neutralises the
1011
+ state here as well: with the collapse rules above no longer naming "page", an
1012
+ unguarded collapsed attribute would otherwise fall through to the generic
1013
+ rule that scales the panel away and drops pointer events, leaving nothing on
1014
+ screen and no launcher to press. */
1015
+ :host([placement="page"]) .header-btn--collapse {
656
1016
  display: none;
657
1017
  }
658
1018
 
1019
+ :host([placement="page"][collapsed]) {
1020
+ pointer-events: auto;
1021
+ align-self: auto;
1022
+ }
1023
+
1024
+ :host([placement="page"][collapsed]) .chat {
1025
+ opacity: 1;
1026
+ transform: none;
1027
+ visibility: visible;
1028
+ }
1029
+
659
1030
  /* Jump-to-latest: shown only once the reader has scrolled away *and* missed
660
1031
  something. Anchored to the panel rather than the list so it does not scroll
661
1032
  with the content it is offering to scroll to. */
@@ -743,6 +1114,28 @@ export const STYLES = `
743
1114
 
744
1115
  The 1px box with clip-path, rather than width/height 0, is the shape that
745
1116
  survives: a zero-sized element is dropped from the tree by some engines. */
1117
+ /* A used-value reader for the four viewport-inset tokens, and the only
1118
+ reliable one. getComputedStyle().getPropertyValue() on an unregistered
1119
+ custom property hands back the substituted token stream, not a length: it
1120
+ returns "4rem" verbatim, and "calc(56px + env(safe-area-inset-top))" as
1121
+ "calc(56px + 0px)" -- which parses as NaN and takes the whole inset with it.
1122
+ Padding is a real property, so the same tokens come back resolved to px.
1123
+
1124
+ visibility rather than display: none, because a box that generates no
1125
+ layout has no used values to read. Zero-sized, absolutely positioned and
1126
+ inert, so it costs nothing but the read. */
1127
+ .viewport-probe {
1128
+ position: absolute;
1129
+ top: 0;
1130
+ left: 0;
1131
+ width: 0;
1132
+ height: 0;
1133
+ visibility: hidden;
1134
+ pointer-events: none;
1135
+ padding: var(--_viewport-inset-top) var(--_viewport-inset-right)
1136
+ var(--_viewport-inset-bottom) var(--_viewport-inset-left);
1137
+ }
1138
+
746
1139
  .sr-only {
747
1140
  position: absolute;
748
1141
  width: 1px;
@@ -758,6 +1151,8 @@ export const STYLES = `
758
1151
  .messages {
759
1152
  flex: 1;
760
1153
  overflow-y: auto;
1154
+ /* Scrolling past the end of this must not scroll the page behind it. */
1155
+ overscroll-behavior: contain;
761
1156
  /* The browser's own scroll anchoring competes with the scroller for the same
762
1157
  job and wins unpredictably -- it can hold the view still exactly when we
763
1158
  want to follow. Turned off so following is decided in one place. Safari
@@ -910,6 +1305,8 @@ export const STYLES = `
910
1305
  .message--assistant pre {
911
1306
  padding: 8px 10px;
912
1307
  overflow: auto;
1308
+ /* Scrolling past the end of this must not scroll the page behind it. */
1309
+ overscroll-behavior: contain;
913
1310
  background: var(--_bg);
914
1311
  border: 1px solid var(--_border);
915
1312
  border-radius: 6px;
@@ -1088,6 +1485,8 @@ export const STYLES = `
1088
1485
  border-left: 2px solid var(--_border);
1089
1486
  max-height: 220px;
1090
1487
  overflow: auto;
1488
+ /* Scrolling past the end of this must not scroll the page behind it. */
1489
+ overscroll-behavior: contain;
1091
1490
  white-space: pre-wrap;
1092
1491
  word-break: break-word;
1093
1492
  font-family: inherit;
@@ -1278,6 +1677,8 @@ export const STYLES = `
1278
1677
  padding: 6px 8px;
1279
1678
  max-height: 160px;
1280
1679
  overflow: auto;
1680
+ /* Scrolling past the end of this must not scroll the page behind it. */
1681
+ overscroll-behavior: contain;
1281
1682
  background: var(--_bg);
1282
1683
  border: 1px solid var(--_border);
1283
1684
  border-radius: 6px;
@@ -1772,6 +2173,8 @@ export const STYLES = `
1772
2173
  resize: none;
1773
2174
  max-height: var(--_composer-max-height);
1774
2175
  overflow-y: auto;
2176
+ /* Scrolling past the end of this must not scroll the page behind it. */
2177
+ overscroll-behavior: contain;
1775
2178
  padding: 6px 4px 2px;
1776
2179
  background: transparent;
1777
2180
  border: none;
@@ -1982,13 +2385,30 @@ export const STYLES = `
1982
2385
  * values, and the axis furniture inherits currentColor at low opacity so it
1983
2386
  * reads correctly in either theme without a second palette.
1984
2387
  */
2388
+ /* A chart is sized like a message, not like a panel: it takes the width it
2389
+ needs and stops, rather than stretching into whatever room the transcript
2390
+ has. A message bubble caps at 80% for the same reason -- widening the panel
2391
+ should not resize what is already in it. The cap is a token so a host with a
2392
+ wide panel and a real reason can raise it; the default is the width this
2393
+ renderer has always drawn at. Below the cap the block still fills its column,
2394
+ which is what a narrow panel needs. */
1985
2395
  .chart-block {
1986
- align-self: stretch;
1987
- max-width: 100%;
2396
+ align-self: flex-start;
2397
+ width: 100%;
2398
+ max-width: var(--_chart-max-width);
1988
2399
  margin: 6px 0;
1989
2400
  color: var(--_fg);
1990
2401
  }
1991
2402
 
2403
+ /* The drawing fills the block's width and carries its own height in the
2404
+ viewBox, so it is laid out rather than magnified: block display keeps the
2405
+ inline baseline gap from adding a stripe under every chart. */
2406
+ .chart-block svg {
2407
+ display: block;
2408
+ width: 100%;
2409
+ height: auto;
2410
+ }
2411
+
1992
2412
  .chart-title {
1993
2413
  margin-bottom: 2px;
1994
2414
  font-size: 0.85em;
@@ -2039,6 +2459,30 @@ export const STYLES = `
2039
2459
  opacity: 0.75;
2040
2460
  }
2041
2461
 
2462
+ /* The one control a notice may carry. Quiet, because it reports something
2463
+ already done rather than asking for a decision. */
2464
+ .run-notice-undo {
2465
+ flex: 0 0 auto;
2466
+ margin-inline-start: auto;
2467
+ padding: 2px 8px;
2468
+ border: 1px solid var(--_border);
2469
+ border-radius: var(--_radius);
2470
+ background: var(--_bg);
2471
+ color: var(--_accent);
2472
+ font: inherit;
2473
+ font-size: 0.9em;
2474
+ cursor: pointer;
2475
+ }
2476
+
2477
+ .run-notice-undo:hover:not(:disabled) {
2478
+ background: var(--_hover);
2479
+ }
2480
+
2481
+ .run-notice-undo:disabled {
2482
+ opacity: 0.5;
2483
+ cursor: default;
2484
+ }
2485
+
2042
2486
  .run-notice-text {
2043
2487
  min-width: 0;
2044
2488
  overflow-wrap: anywhere;
@@ -2183,6 +2627,8 @@ export const STYLES = `
2183
2627
  padding: 8px 10px;
2184
2628
  max-height: 140px;
2185
2629
  overflow: auto;
2630
+ /* Scrolling past the end of this must not scroll the page behind it. */
2631
+ overscroll-behavior: contain;
2186
2632
  font-size: 12px;
2187
2633
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
2188
2634
  background: var(--_assistant-bg);
@@ -2371,6 +2817,43 @@ export const STYLES = `
2371
2817
  }
2372
2818
  }
2373
2819
 
2820
+ /* Touch. Separate from the width breakpoint above on purpose: width decides
2821
+ the layout, the pointer decides which controls make sense, and a touch
2822
+ laptop is coarse-pointered and wide while a narrow desktop window is
2823
+ fine-pointered and small. */
2824
+ @media (pointer: coarse) {
2825
+ /* A 6px edge strip is not a control, it is a trap that eats a scroll. The
2826
+ corners are 14px, still under the 24px floor a target is meant to clear,
2827
+ and the panel has other ways to be resized on a device that has a pointer
2828
+ precise enough to grab one. */
2829
+ .resize-handle {
2830
+ display: none;
2831
+ }
2832
+
2833
+ /* Platform guidance is 44pt on iOS and 48dp on Android. These sit at 28-30px,
2834
+ which clears the WCAG minimum and misses both. */
2835
+ :host {
2836
+ --_action-size: var(--ag-ui-action-size, 44px);
2837
+ --_tool-btn-size: var(--ag-ui-tool-btn-size, 44px);
2838
+ --_send-size: var(--ag-ui-send-size, 44px);
2839
+ /* Missed the first time these were raised, which left the header -- the
2840
+ widget's primary controls, and the only way to reach history, a new chat
2841
+ or the collapse -- at half the size of everything beside it. */
2842
+ --_header-btn-size: var(--ag-ui-header-btn-size, 44px);
2843
+ --_header-gap: var(--ag-ui-header-gap, 6px);
2844
+ }
2845
+
2846
+ /* iOS Safari zooms the page when a control under 16px takes focus, which
2847
+ drags the whole fixed panel with it and leaves the user pinching back out
2848
+ of a chat they only wanted to type into. The composer inherits the widget
2849
+ font, which is 14px by default and 13px at compact density, so it has to
2850
+ say 16 outright -- and only ever upward, so a host that has deliberately
2851
+ set something larger keeps it. */
2852
+ .input {
2853
+ font-size: max(16px, var(--_font-size));
2854
+ }
2855
+ }
2856
+
2374
2857
  .message-action[aria-pressed="true"] {
2375
2858
  opacity: 1;
2376
2859
  color: var(--_accent);
@@ -2559,6 +3042,8 @@ export const STYLES = `
2559
3042
  flex-direction: column;
2560
3043
  max-height: 220px;
2561
3044
  overflow: auto;
3045
+ /* Scrolling past the end of this must not scroll the page behind it. */
3046
+ overscroll-behavior: contain;
2562
3047
  background: var(--_bg);
2563
3048
  border: 1px solid var(--_border);
2564
3049
  border-radius: 8px;
@@ -2647,6 +3132,8 @@ export const STYLES = `
2647
3132
  box-shadow: 0 6px 24px rgb(0 0 0 / 12%);
2648
3133
  max-height: 60%;
2649
3134
  overflow-y: auto;
3135
+ /* Scrolling past the end of this must not scroll the page behind it. */
3136
+ overscroll-behavior: contain;
2650
3137
  transform-origin: top center;
2651
3138
  transition:
2652
3139
  opacity var(--_motion) var(--_ease),
@@ -2822,10 +3309,20 @@ export const STYLES = `
2822
3309
  }
2823
3310
 
2824
3311
  .drawer-title {
3312
+ /* Takes the slack so the two controls group at the trailing edge. With
3313
+ space-between and three items the middle one floats, which reads as the
3314
+ title and the close button being a pair with New chat wedged between
3315
+ them. Truncates rather than pushing them off the row. */
3316
+ flex: 1 1 auto;
3317
+ min-width: 0;
3318
+ overflow: hidden;
3319
+ text-overflow: ellipsis;
3320
+ white-space: nowrap;
2825
3321
  font-weight: 600;
2826
3322
  }
2827
3323
 
2828
3324
  .drawer-new {
3325
+ flex: 0 0 auto;
2829
3326
  border: 1px solid var(--_border);
2830
3327
  border-radius: var(--_radius);
2831
3328
  background: var(--_bg);
@@ -2836,10 +3333,63 @@ export const STYLES = `
2836
3333
  cursor: pointer;
2837
3334
  }
2838
3335
 
3336
+ /* Narrows the list. Sits under the header rather than in it: the header's two
3337
+ controls act on the conversation, and a field that filters what is below it
3338
+ belongs with what it filters. */
3339
+ /* What is waiting for the run to finish. Above the composer, beside the
3340
+ attachment tray, because both are things already handed over and not yet
3341
+ sent. Each chip is a button: pressing it takes that message back. */
3342
+ .queued {
3343
+ display: flex;
3344
+ flex-wrap: wrap;
3345
+ gap: var(--_space);
3346
+ padding: 0 var(--_pad) var(--_space);
3347
+ }
3348
+
3349
+ .queued-chip {
3350
+ max-width: 100%;
3351
+ overflow: hidden;
3352
+ padding: 4px 10px;
3353
+ border: 1px dashed var(--_border);
3354
+ border-radius: var(--_msg-radius);
3355
+ background: var(--_bg);
3356
+ color: var(--_muted);
3357
+ font: inherit;
3358
+ font-size: 0.9em;
3359
+ text-overflow: ellipsis;
3360
+ white-space: nowrap;
3361
+ cursor: pointer;
3362
+ }
3363
+
3364
+ .queued-chip:hover {
3365
+ border-style: solid;
3366
+ color: var(--_fg);
3367
+ }
3368
+
3369
+ .drawer-filter {
3370
+ flex: 0 0 auto;
3371
+ box-sizing: border-box;
3372
+ width: calc(100% - var(--_pad) * 2);
3373
+ margin: var(--_space) var(--_pad) 0;
3374
+ padding: 6px 10px;
3375
+ border: 1px solid var(--_border);
3376
+ border-radius: var(--_radius);
3377
+ background: var(--_input-bg);
3378
+ color: var(--_fg);
3379
+ font: inherit;
3380
+ font-size: 0.9em;
3381
+ }
3382
+
3383
+ .drawer-filter::placeholder {
3384
+ color: var(--_muted);
3385
+ }
3386
+
2839
3387
  .drawer-list {
2840
3388
  flex: 1;
2841
3389
  min-height: 0;
2842
3390
  overflow-y: auto;
3391
+ /* Scrolling past the end of this must not scroll the page behind it. */
3392
+ overscroll-behavior: contain;
2843
3393
  }
2844
3394
 
2845
3395
  .drawer-empty {
@@ -2960,6 +3510,31 @@ export const STYLES = `
2960
3510
  background: none;
2961
3511
  }
2962
3512
 
3513
+ /* The way back out of the list. It sits beside New chat, which is the control
3514
+ it must not be mistaken for: one returns you to the conversation you were
3515
+ reading, the other replaces it. */
3516
+ .drawer-close {
3517
+ flex: 0 0 auto;
3518
+ display: inline-flex;
3519
+ align-items: center;
3520
+ justify-content: center;
3521
+ width: 26px;
3522
+ height: 26px;
3523
+ padding: 0;
3524
+ border: 0;
3525
+ border-radius: 6px;
3526
+ background: none;
3527
+ color: inherit;
3528
+ font: inherit;
3529
+ font-size: 18px;
3530
+ line-height: 1;
3531
+ cursor: pointer;
3532
+ }
3533
+
3534
+ .drawer-close:hover {
3535
+ background: var(--_hover);
3536
+ }
3537
+
2963
3538
  :host([placement="embedded"]) .drawer-panel {
2964
3539
  width: 100%;
2965
3540
  border-right: none;