@robr0/design-system 0.15.0 → 0.16.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 (45) hide show
  1. package/README.md +3 -3
  2. package/behaviors/focusable.d.ts +26 -0
  3. package/behaviors/focusable.js +18 -0
  4. package/behaviors/useFocusScope.d.ts +38 -0
  5. package/behaviors/useFocusScope.js +82 -0
  6. package/behaviors/useLayer.d.ts +43 -0
  7. package/behaviors/useLayer.js +55 -0
  8. package/behaviors/useMounted.d.ts +7 -0
  9. package/behaviors/useMounted.js +14 -0
  10. package/behaviors/useScrollLock.d.ts +55 -0
  11. package/behaviors/useScrollLock.js +59 -0
  12. package/components/AgentPlan/AgentPlan.css +12 -3
  13. package/components/AiButton/AiButton.css +327 -0
  14. package/components/AiButton/AiButton.d.ts +42 -0
  15. package/components/AiButton/AiButton.js +140 -16
  16. package/components/AlertDialog/AlertDialog.js +13 -58
  17. package/components/Button/Button.css +3 -0
  18. package/components/Combobox/Combobox.js +2 -1
  19. package/components/CommandPalette/CommandPalette.css +10 -7
  20. package/components/CommandPalette/CommandPalette.js +9 -58
  21. package/components/Composer/Composer.css +54 -0
  22. package/components/Composer/Composer.d.ts +21 -4
  23. package/components/Composer/Composer.js +7 -0
  24. package/components/Dialog/Dialog.js +9 -62
  25. package/components/Drawer/Drawer.js +9 -62
  26. package/components/Dropdown/Dropdown.js +2 -1
  27. package/components/DropdownMenu/DropdownMenu.js +11 -3
  28. package/components/ModelPicker/ModelPicker.js +2 -1
  29. package/components/Popover/Popover.js +3 -0
  30. package/components/SegmentedControl/SegmentedControl.css +4 -2
  31. package/components/SourceTrail/SourceTrail.css +2 -2
  32. package/components/TimePicker/TimePicker.js +2 -1
  33. package/components/ToolCall/ToolCall.css +9 -1
  34. package/components/Tooltip/Tooltip.js +6 -0
  35. package/components/registry.json +2 -2
  36. package/components/registry.json.d.ts +2 -2
  37. package/components/registry.json.js +1 -1
  38. package/package.json +5 -1
  39. package/tokens/motion.d.ts +10 -4
  40. package/tokens/motion.js +7 -1
  41. package/tokens/registry.json +6 -0
  42. package/tokens/registry.json.d.ts +6 -0
  43. package/tokens/registry.json.js +1 -1
  44. package/tokens/tokens-dark.css +11 -0
  45. package/tokens/tokens-light.css +13 -0
@@ -38,6 +38,9 @@
38
38
  line-height: var(--font-paragraph-em-line-height);
39
39
  letter-spacing: var(--font-paragraph-em-letter-spacing);
40
40
  text-align: center;
41
+ /* A pill's label never wraps — a squeezed layout shortens around the
42
+ button, it doesn't fold the silhouette into two lines. */
43
+ white-space: nowrap;
41
44
 
42
45
  /* The field is transparent by design: the ring and glow are the button */
43
46
  background: transparent;
@@ -161,3 +164,327 @@
161
164
  animation: none;
162
165
  }
163
166
  }
167
+
168
+ /* ============================================
169
+ THE SUMMARY PANEL
170
+ AiButton's TLDR surface: a frosted glass card
171
+ summoned above (or below) the button. The
172
+ button and panel share one hover zone — the
173
+ host — and the AI gradient sweeps once around
174
+ the panel's border as it opens, then fades to
175
+ the ordinary container border.
176
+ ============================================ */
177
+
178
+ .ds-ai-button-host {
179
+ position: relative;
180
+ display: inline-flex;
181
+ }
182
+
183
+ /* The button acknowledges the panel's arrival with one gentle pulse. */
184
+ .ds-ai-button-host--open .ds-ai-button {
185
+ animation: ds-ai-button-acknowledge var(--motion-duration-deliberate) var(--motion-ease-spring) both;
186
+ }
187
+
188
+ @keyframes ds-ai-button-acknowledge {
189
+ from {
190
+ transform: scale(1);
191
+ }
192
+ 50% {
193
+ transform: scale(1.05);
194
+ }
195
+ to {
196
+ transform: scale(1);
197
+ }
198
+ }
199
+
200
+ .ds-ai-button__summary {
201
+ position: absolute;
202
+ right: 0;
203
+ z-index: 1;
204
+ /* Component-local lengths, chosen by eye like the button's glow: a
205
+ readable measure, capped so a phone viewport keeps its margins. */
206
+ --ds-ai-summary-width: 340px;
207
+ --ds-ai-summary-viewport-margin: 40px;
208
+ width: min(var(--ds-ai-summary-width), calc(100vw - var(--ds-ai-summary-viewport-margin)));
209
+
210
+ display: flex;
211
+ flex-direction: column;
212
+ gap: var(--gap-sm);
213
+
214
+ padding: var(--padding-lg);
215
+ border-radius: var(--radius-xl);
216
+ /* ds-allow(color): the light glass token reads solid on a floating card;
217
+ thinned so the page shows through the frost */
218
+ background: color-mix(in srgb, var(--color-bg-glass) 62%, transparent);
219
+ /* The frost: heavier than the 24px glass pairing so the blur reads
220
+ unmistakably over imagery — a component-local effect length, chosen by
221
+ eye. Standard property only: the build pipeline prefixes it, and a
222
+ hand-written -webkit- duplicate makes its merging drop the declaration. */
223
+ --ds-ai-summary-frost: 36px;
224
+ backdrop-filter: blur(var(--ds-ai-summary-frost)) saturate(1.5);
225
+ border: var(--border-xs) solid var(--color-bg-container-border);
226
+ box-shadow: var(--shadow-floating);
227
+
228
+ /* Mounted but resting: the closed state mirrors the pop's `from`, so
229
+ leaving transitions back down the same path the entrance sprang up.
230
+ visibility keeps the hidden panel out of the accessibility tree and
231
+ the tab order, the way HoverCard hides. */
232
+ visibility: hidden;
233
+ opacity: 0;
234
+ transform: translateY(10px) scale(0.9);
235
+ transition:
236
+ opacity var(--motion-duration-fast) var(--motion-ease-standard),
237
+ transform var(--motion-duration-fast) var(--motion-ease-standard),
238
+ visibility var(--motion-duration-fast) var(--motion-ease-standard);
239
+ }
240
+
241
+ .ds-ai-button-host--open .ds-ai-button__summary {
242
+ visibility: visible;
243
+ opacity: 1;
244
+ transform: none;
245
+ animation: ds-ai-summary-pop var(--motion-duration-deliberate) var(--motion-ease-entrance) both;
246
+ }
247
+
248
+ /* Dark glass is already translucent; thinning it further puts body text
249
+ straight onto whatever bright imagery sits beneath. */
250
+ [data-theme='dark'] .ds-ai-button__summary {
251
+ background: var(--color-bg-glass);
252
+ }
253
+
254
+ /* Placement: the panel grows out of the button, so its transform origin
255
+ points back at the button's side of the gap. */
256
+ .ds-ai-button-host--top .ds-ai-button__summary {
257
+ bottom: calc(100% + var(--gap-sm));
258
+ transform-origin: calc(100% - 60px) calc(100% + 40px);
259
+ }
260
+
261
+ .ds-ai-button-host--bottom .ds-ai-button__summary {
262
+ top: calc(100% + var(--gap-sm));
263
+ transform-origin: calc(100% - 60px) -40px;
264
+ transform: translateY(-10px) scale(0.9);
265
+ }
266
+
267
+ .ds-ai-button-host--bottom.ds-ai-button-host--open .ds-ai-button__summary {
268
+ transform: none;
269
+ }
270
+
271
+ @keyframes ds-ai-summary-pop {
272
+ from {
273
+ opacity: 0;
274
+ transform: translateY(10px) scale(0.9);
275
+ }
276
+ to {
277
+ opacity: 1;
278
+ transform: translateY(0) scale(1);
279
+ }
280
+ }
281
+
282
+ .ds-ai-button-host--bottom.ds-ai-button-host--open .ds-ai-button__summary {
283
+ animation-name: ds-ai-summary-pop-down;
284
+ }
285
+
286
+ @keyframes ds-ai-summary-pop-down {
287
+ from {
288
+ opacity: 0;
289
+ transform: translateY(-10px) scale(0.9);
290
+ }
291
+ to {
292
+ opacity: 1;
293
+ transform: translateY(0) scale(1);
294
+ }
295
+ }
296
+
297
+ /* The opening signature: the AI gradient sweeps once around the whole
298
+ border — the button's masked conic ring, run as a single swoosh — then
299
+ fades away, leaving the regular border. The rotation animates a
300
+ registered custom property; without @property support the ring still
301
+ fades in and out, just without turning. */
302
+ @property --ds-ai-summary-ring-angle {
303
+ syntax: '<angle>';
304
+ initial-value: 0deg;
305
+ inherits: false;
306
+ }
307
+
308
+ .ds-ai-button__summary::before {
309
+ content: '';
310
+ position: absolute;
311
+ inset: calc(-1 * var(--border-xs));
312
+ border-radius: var(--radius-xl);
313
+ padding: var(--border-md);
314
+ background: conic-gradient(
315
+ from var(--ds-ai-summary-ring-angle),
316
+ var(--color-ai-gradient-start),
317
+ var(--color-ai-gradient-mid),
318
+ var(--color-ai-gradient-end),
319
+ var(--color-ai-gradient-mid),
320
+ var(--color-ai-gradient-start)
321
+ );
322
+ mask:
323
+ linear-gradient(#fff 0 0) content-box,
324
+ linear-gradient(#fff 0 0);
325
+ mask-composite: exclude;
326
+ pointer-events: none;
327
+ opacity: 0;
328
+ }
329
+
330
+ .ds-ai-button-host--open .ds-ai-button__summary::before {
331
+ /* ds-allow(motion): one-shot signature sweep timed to the panel's
332
+ entrance, tuned by eye — it runs once, then the border is ordinary */
333
+ animation: ds-ai-summary-ring 1500ms var(--motion-ease-standard) both;
334
+ animation-delay: 100ms;
335
+ }
336
+
337
+ @keyframes ds-ai-summary-ring {
338
+ from {
339
+ --ds-ai-summary-ring-angle: 0deg;
340
+ opacity: 0;
341
+ }
342
+ 12% {
343
+ opacity: 1;
344
+ }
345
+ 65% {
346
+ opacity: 1;
347
+ }
348
+ to {
349
+ --ds-ai-summary-ring-angle: 360deg;
350
+ opacity: 0;
351
+ }
352
+ }
353
+
354
+ /* The content settles a beat behind the surface — each row fades up in
355
+ sequence, so the panel and its contents arrive as one gesture. */
356
+ .ds-ai-button-host--open .ds-ai-button__summary-meta,
357
+ .ds-ai-button-host--open .ds-ai-button__summary-title,
358
+ .ds-ai-button-host--open .ds-ai-button__summary-text,
359
+ .ds-ai-button-host--open .ds-ai-button__summary-actions {
360
+ animation: ds-ai-summary-rise var(--motion-duration-slow) var(--motion-ease-entrance) both;
361
+ }
362
+
363
+ /* ds-allow(motion): stagger offsets between rows, tuned by eye — the paced
364
+ durations themselves are the motion tokens above */
365
+ .ds-ai-button-host--open .ds-ai-button__summary-meta { animation-delay: 80ms; }
366
+ .ds-ai-button-host--open .ds-ai-button__summary-title { animation-delay: 130ms; }
367
+ .ds-ai-button-host--open .ds-ai-button__summary-text { animation-delay: 180ms; }
368
+ .ds-ai-button-host--open .ds-ai-button__summary-actions { animation-delay: 230ms; }
369
+
370
+ @keyframes ds-ai-summary-rise {
371
+ from {
372
+ opacity: 0;
373
+ transform: translateY(6px);
374
+ }
375
+ to {
376
+ opacity: 1;
377
+ transform: translateY(0);
378
+ }
379
+ }
380
+
381
+ .ds-ai-button__summary-dismiss {
382
+ position: absolute;
383
+ top: var(--gap-sm);
384
+ right: var(--gap-sm);
385
+ display: inline-flex;
386
+ align-items: center;
387
+ justify-content: center;
388
+ /* Icon-button hit target — a component-local size, chosen by eye. */
389
+ --ds-ai-summary-dismiss-size: 28px;
390
+ width: var(--ds-ai-summary-dismiss-size);
391
+ height: var(--ds-ai-summary-dismiss-size);
392
+ border: none;
393
+ border-radius: var(--radius-full);
394
+ background: transparent;
395
+ color: var(--color-text-secondary);
396
+ cursor: pointer;
397
+ }
398
+
399
+ .ds-ai-button__summary-dismiss:hover {
400
+ background: var(--color-bg-container-secondary);
401
+ color: var(--color-text-primary);
402
+ }
403
+
404
+ .ds-ai-button__summary-dismiss .material-symbols-rounded {
405
+ font-size: var(--icon-size-sm);
406
+ }
407
+
408
+ .ds-ai-button__summary-meta {
409
+ display: flex;
410
+ align-items: baseline;
411
+ gap: var(--gap-sm);
412
+ /* Keeps the row clear of the dismiss button in the corner. */
413
+ padding-right: var(--gap-xl);
414
+ }
415
+
416
+ .ds-ai-button__summary-overline {
417
+ font-family: var(--font-overline-family);
418
+ font-size: var(--font-overline-size);
419
+ font-weight: var(--font-overline-weight);
420
+ line-height: var(--font-overline-line-height);
421
+ letter-spacing: var(--font-overline-letter-spacing);
422
+ text-transform: uppercase;
423
+ color: var(--color-text-secondary);
424
+ }
425
+
426
+ .ds-ai-button__summary-caption {
427
+ font-family: var(--font-family-primary);
428
+ font-size: var(--font-caption-size);
429
+ font-weight: var(--font-caption-weight);
430
+ line-height: var(--font-caption-line-height);
431
+ color: var(--color-text-tertiary);
432
+ }
433
+
434
+ .ds-ai-button__summary-title {
435
+ /* Long titles wrap short of the dismiss corner. */
436
+ padding-right: var(--gap-lg);
437
+ font-family: var(--font-family-primary);
438
+ font-size: var(--font-title-body-size);
439
+ font-weight: var(--font-title-body-weight);
440
+ line-height: var(--font-title-body-line-height);
441
+ color: var(--color-text-primary);
442
+ }
443
+
444
+ .ds-ai-button__summary-text {
445
+ display: block;
446
+ font-family: var(--font-family-primary);
447
+ font-size: var(--font-paragraph-size);
448
+ font-weight: var(--font-paragraph-weight);
449
+ line-height: var(--font-paragraph-line-height);
450
+ color: var(--color-text-secondary);
451
+ /* Seats the ghost/reveal stack below. */
452
+ position: relative;
453
+ }
454
+
455
+ /* During the type-out, an invisible ghost of the finished text sizes the
456
+ container and the stream paints over it — the panel takes its final
457
+ height the moment "generation" completes, and a short summary reserves
458
+ nothing it won't use. */
459
+ .ds-ai-button__summary-text-ghost {
460
+ visibility: hidden;
461
+ }
462
+
463
+ .ds-ai-button__summary-text-reveal {
464
+ position: absolute;
465
+ inset: 0;
466
+ }
467
+
468
+ .ds-ai-button__summary-actions {
469
+ display: flex;
470
+ flex-direction: column;
471
+ align-items: flex-start;
472
+ gap: var(--gap-sm);
473
+ margin-top: var(--gap-xs);
474
+ }
475
+
476
+ @media (prefers-reduced-motion: reduce) {
477
+ .ds-ai-button-host--open .ds-ai-button,
478
+ .ds-ai-button-host--open .ds-ai-button__summary,
479
+ .ds-ai-button-host--open .ds-ai-button__summary::before,
480
+ .ds-ai-button-host--open .ds-ai-button__summary-meta,
481
+ .ds-ai-button-host--open .ds-ai-button__summary-title,
482
+ .ds-ai-button-host--open .ds-ai-button__summary-text,
483
+ .ds-ai-button-host--open .ds-ai-button__summary-actions {
484
+ animation: none;
485
+ }
486
+
487
+ .ds-ai-button__summary {
488
+ transition: none;
489
+ }
490
+ }
@@ -1,4 +1,18 @@
1
1
  import { default as React } from 'react';
2
+ import { PromptSuggestion } from '../PromptSuggestions/PromptSuggestions';
3
+ /** The content of AiButton's summary panel. */
4
+ export interface AiButtonSummary {
5
+ /** Overline label above the title. */
6
+ overline?: string;
7
+ /** Title line — usually the page or subject being summarized. */
8
+ title: string;
9
+ /** Small caption beside the overline, e.g. a reading time. */
10
+ caption?: string;
11
+ /** The pre-written summary the panel reveals. */
12
+ text: string;
13
+ /** Prompt chips under the summary; a tap reports through `onSummarySuggestion`. */
14
+ suggestions?: PromptSuggestion[];
15
+ }
2
16
  /** Props owned by AiButton itself — everything else falls through to the DOM node. */
3
17
  type AiButtonOwnProps = {
4
18
  /** Button text content */
@@ -15,6 +29,28 @@ type AiButtonOwnProps = {
15
29
  target?: string;
16
30
  /** Optional rel attribute for links */
17
31
  rel?: string;
32
+ /**
33
+ * A TLDR panel the button can summon: overline, title, a pre-written
34
+ * summary revealed through a skeleton beat and a typed stream (once per
35
+ * mount — later openings show it instantly, as if cached), and optional
36
+ * prompt chips. Hovering the button opens it after the standard hover
37
+ * delay; the pointer keeps it alive anywhere over the button or panel,
38
+ * and it hides a grace period after leaving both. Omit for the plain
39
+ * button.
40
+ */
41
+ summary?: AiButtonSummary;
42
+ /** Which side of the button the summary panel opens on. */
43
+ summaryPlacement?: 'top' | 'bottom';
44
+ /**
45
+ * Holds the summary panel open regardless of hover — for callers that
46
+ * reveal it on their own signal (scroll depth, dwell). Hover mechanics
47
+ * still run when it flips back off.
48
+ */
49
+ summaryPinned?: boolean;
50
+ /** Fires when the panel's dismiss button is pressed. The panel closes itself either way; hovering away and back re-summons it. */
51
+ onSummaryDismiss?: () => void;
52
+ /** Fires with the tapped summary suggestion's `id`. */
53
+ onSummarySuggestion?: (id: string) => void;
18
54
  /** Additional CSS classes */
19
55
  className?: string;
20
56
  };
@@ -28,6 +64,12 @@ export interface AiButtonProps extends AiButtonOwnProps, Omit<React.ComponentPro
28
64
  * and this ring is reserved for AI surfaces so neither affordance dilutes
29
65
  * the other.
30
66
  *
67
+ * With `summary`, the button can also summon a TLDR panel — a glass card
68
+ * that opens above (or below) it on hover or when pinned, fakes a one-time
69
+ * generation beat (skeleton, then a typed reveal), and offers prompt chips.
70
+ * The panel and button share one hover zone, and the gradient sweeps once
71
+ * around the panel's border as it opens.
72
+ *
31
73
  * Renders a `<button>`, or an `<a>` when `href` is supplied. Forwards a ref
32
74
  * to whichever element it renders, and spreads unrecognised props onto it.
33
75
  * The rotation pauses under `prefers-reduced-motion`; the ring and glow stay.
@@ -1,6 +1,10 @@
1
1
  "use client";
2
2
  import { jsx, jsxs, Fragment } from "react/jsx-runtime";
3
- import React from "react";
3
+ import React, { useState, useRef, useEffect } from "react";
4
+ import { PromptSuggestions } from "../PromptSuggestions/PromptSuggestions.js";
5
+ import { Skeleton } from "../Skeleton/Skeleton.js";
6
+ import { StreamingText } from "../StreamingText/StreamingText.js";
7
+ import { MOTION_SUMMARY_THINK_MS, MOTION_SUMMARY_REVEAL_MS, MOTION_HOVER_EXIT_GRACE_MS, MOTION_HOVER_SHOW_DELAY_MS } from "../../tokens/motion.js";
4
8
  import "./AiButton.css";
5
9
  import "../../fonts/material-symbols.css";
6
10
  const AiButton = React.forwardRef(
@@ -12,10 +16,67 @@ const AiButton = React.forwardRef(
12
16
  href,
13
17
  target,
14
18
  rel,
19
+ summary,
20
+ summaryPlacement = "top",
21
+ summaryPinned = false,
22
+ onSummaryDismiss,
23
+ onSummarySuggestion,
15
24
  className = "",
16
25
  ...rest
17
26
  }, ref) => {
18
27
  const baseClass = "ds-ai-button";
28
+ const [hoverOpen, setHoverOpen] = useState(false);
29
+ const [dismissed, setDismissed] = useState(false);
30
+ const [phase, setPhase] = useState("skeleton");
31
+ const generated = useRef(false);
32
+ const showTimer = useRef(null);
33
+ const hideTimer = useRef(null);
34
+ const panelOpen = Boolean(summary) && !disabled && !dismissed && (summaryPinned || hoverOpen);
35
+ useEffect(() => {
36
+ if (!panelOpen) return;
37
+ if (generated.current) {
38
+ setPhase("cached");
39
+ return;
40
+ }
41
+ setPhase("skeleton");
42
+ const t = window.setTimeout(() => {
43
+ generated.current = true;
44
+ setPhase("typing");
45
+ }, MOTION_SUMMARY_THINK_MS);
46
+ return () => window.clearTimeout(t);
47
+ }, [panelOpen]);
48
+ useEffect(
49
+ () => () => {
50
+ if (showTimer.current) window.clearTimeout(showTimer.current);
51
+ if (hideTimer.current) window.clearTimeout(hideTimer.current);
52
+ },
53
+ []
54
+ );
55
+ const hostEnter = () => {
56
+ if (hideTimer.current) {
57
+ window.clearTimeout(hideTimer.current);
58
+ hideTimer.current = null;
59
+ }
60
+ if (!hoverOpen && !showTimer.current) {
61
+ showTimer.current = window.setTimeout(() => {
62
+ showTimer.current = null;
63
+ setHoverOpen(true);
64
+ }, MOTION_HOVER_SHOW_DELAY_MS);
65
+ }
66
+ };
67
+ const hostLeave = () => {
68
+ setDismissed(false);
69
+ if (showTimer.current) {
70
+ window.clearTimeout(showTimer.current);
71
+ showTimer.current = null;
72
+ }
73
+ if (hoverOpen && !hideTimer.current) {
74
+ hideTimer.current = window.setTimeout(() => {
75
+ hideTimer.current = null;
76
+ setHoverOpen(false);
77
+ }, MOTION_HOVER_EXIT_GRACE_MS);
78
+ }
79
+ };
19
80
  const classes = [
20
81
  baseClass,
21
82
  `${baseClass}--${size}`,
@@ -26,21 +87,18 @@ const AiButton = React.forwardRef(
26
87
  icon && (typeof icon === "string" ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__icon material-symbols-rounded`, "aria-hidden": "true", children: icon }) : /* @__PURE__ */ jsx("span", { className: `${baseClass}__icon`, "aria-hidden": "true", children: icon })),
27
88
  /* @__PURE__ */ jsx("span", { className: `${baseClass}__text`, children: label })
28
89
  ] });
29
- if (href && !disabled) {
30
- return /* @__PURE__ */ jsx(
31
- "a",
32
- {
33
- ...rest,
34
- ref,
35
- className: classes,
36
- href,
37
- target,
38
- rel,
39
- children: content
40
- }
41
- );
42
- }
43
- return /* @__PURE__ */ jsx(
90
+ const control = href && !disabled ? /* @__PURE__ */ jsx(
91
+ "a",
92
+ {
93
+ ...rest,
94
+ ref,
95
+ className: classes,
96
+ href,
97
+ target,
98
+ rel,
99
+ children: content
100
+ }
101
+ ) : /* @__PURE__ */ jsx(
44
102
  "button",
45
103
  {
46
104
  ...rest,
@@ -51,6 +109,72 @@ const AiButton = React.forwardRef(
51
109
  children: content
52
110
  }
53
111
  );
112
+ if (!summary) return control;
113
+ const panelClass = `${baseClass}__summary`;
114
+ return /* @__PURE__ */ jsxs(
115
+ "div",
116
+ {
117
+ className: [
118
+ `${baseClass}-host`,
119
+ `${baseClass}-host--${summaryPlacement}`,
120
+ panelOpen && `${baseClass}-host--open`
121
+ ].filter(Boolean).join(" "),
122
+ onMouseEnter: hostEnter,
123
+ onMouseLeave: hostLeave,
124
+ children: [
125
+ control,
126
+ /* @__PURE__ */ jsxs("aside", { className: panelClass, "aria-label": `Summary: ${summary.title}`, children: [
127
+ /* @__PURE__ */ jsx(
128
+ "button",
129
+ {
130
+ type: "button",
131
+ className: `${panelClass}-dismiss`,
132
+ "aria-label": "Dismiss summary",
133
+ onClick: () => {
134
+ setDismissed(true);
135
+ setHoverOpen(false);
136
+ onSummaryDismiss?.();
137
+ },
138
+ children: /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", "aria-hidden": "true", children: "close" })
139
+ }
140
+ ),
141
+ /* @__PURE__ */ jsxs("span", { className: `${panelClass}-meta`, children: [
142
+ /* @__PURE__ */ jsx("span", { className: `${panelClass}-overline`, children: summary.overline ?? "AI summary" }),
143
+ summary.caption && /* @__PURE__ */ jsx("span", { className: `${panelClass}-caption`, children: summary.caption })
144
+ ] }),
145
+ /* @__PURE__ */ jsx("span", { className: `${panelClass}-title`, children: summary.title }),
146
+ /* @__PURE__ */ jsx("span", { className: `${panelClass}-text`, children: phase === "skeleton" ? /* @__PURE__ */ jsx(Skeleton, { variant: "text", lines: 3 }) : phase === "typing" ? (
147
+ /* An invisible ghost of the finished text sizes the
148
+ container, so the type-out never jumps the panel and a
149
+ short summary leaves no reserved space behind it. */
150
+ /* @__PURE__ */ jsxs(Fragment, { children: [
151
+ /* @__PURE__ */ jsx("span", { className: `${panelClass}-text-ghost`, "aria-hidden": "true", children: summary.text }),
152
+ /* @__PURE__ */ jsx("span", { className: `${panelClass}-text-reveal`, children: /* @__PURE__ */ jsx(
153
+ StreamingText,
154
+ {
155
+ text: summary.text,
156
+ streaming: false,
157
+ cursor: true,
158
+ drainMs: MOTION_SUMMARY_REVEAL_MS,
159
+ onRevealComplete: () => setPhase("cached")
160
+ }
161
+ ) })
162
+ ] })
163
+ ) : summary.text }),
164
+ summary.suggestions && summary.suggestions.length > 0 && phase === "cached" && /* @__PURE__ */ jsx("span", { className: `${panelClass}-actions`, children: /* @__PURE__ */ jsx(
165
+ PromptSuggestions,
166
+ {
167
+ layout: "stack",
168
+ size: "compact",
169
+ ariaLabel: "Suggested prompts",
170
+ suggestions: summary.suggestions,
171
+ onValueChange: onSummarySuggestion
172
+ }
173
+ ) })
174
+ ] })
175
+ ]
176
+ }
177
+ );
54
178
  }
55
179
  );
56
180
  AiButton.displayName = "AiButton";
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import { useRef, useId, useSyncExternalStore, useCallback, useEffect } from "react";
3
+ import { useRef, useId, useCallback } from "react";
4
4
  import ReactDOM from "react-dom";
5
5
  import { Button } from "../Button/Button.js";
6
6
  import "./AlertDialog.css";
7
7
  import "../../fonts/material-symbols.css";
8
- const emptySubscribe = () => () => {
9
- };
10
- const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
8
+ import { useMounted } from "../../behaviors/useMounted.js";
9
+ import { useLayer } from "../../behaviors/useLayer.js";
10
+ import { useFocusScope } from "../../behaviors/useFocusScope.js";
11
+ import { useScrollLock } from "../../behaviors/useScrollLock.js";
11
12
  const AlertDialog = ({
12
13
  open,
13
14
  onOpenChange,
@@ -21,11 +22,10 @@ const AlertDialog = ({
21
22
  className = ""
22
23
  }) => {
23
24
  const panelRef = useRef(null);
24
- const previousFocusRef = useRef(null);
25
25
  const titleId = useId();
26
26
  const descId = useId();
27
27
  const baseClass = "ds-alert-dialog";
28
- const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
28
+ const mounted = useMounted();
29
29
  const handleCancel = useCallback(() => {
30
30
  onCancel?.();
31
31
  onOpenChange(false);
@@ -34,58 +34,12 @@ const AlertDialog = ({
34
34
  onConfirm?.();
35
35
  onOpenChange(false);
36
36
  }, [onConfirm, onOpenChange]);
37
- useEffect(() => {
38
- if (open) {
39
- previousFocusRef.current = document.activeElement;
40
- }
41
- }, [open]);
42
- useEffect(() => {
43
- if (!open) return;
44
- const panel = panelRef.current;
45
- if (panel) {
46
- const cancelBtn = panel.querySelector(
47
- `.${baseClass}__actions button:first-child`
48
- );
49
- cancelBtn?.focus();
50
- }
51
- const handleKeyDown = (e) => {
52
- if (e.key === "Escape") {
53
- handleCancel();
54
- return;
55
- }
56
- if (e.key === "Tab" && panel) {
57
- const focusable = panel.querySelectorAll(FOCUSABLE);
58
- if (focusable.length === 0) return;
59
- const first = focusable[0];
60
- const last = focusable[focusable.length - 1];
61
- if (e.shiftKey && document.activeElement === first) {
62
- e.preventDefault();
63
- last.focus();
64
- } else if (!e.shiftKey && document.activeElement === last) {
65
- e.preventDefault();
66
- first.focus();
67
- }
68
- }
69
- };
70
- document.addEventListener("keydown", handleKeyDown);
71
- return () => document.removeEventListener("keydown", handleKeyDown);
72
- }, [open, handleCancel]);
73
- useEffect(() => {
74
- if (!open && previousFocusRef.current) {
75
- previousFocusRef.current.focus();
76
- previousFocusRef.current = null;
77
- }
78
- }, [open]);
79
- useEffect(() => {
80
- if (open) {
81
- document.body.style.overflow = "hidden";
82
- } else {
83
- document.body.style.overflow = "";
84
- }
85
- return () => {
86
- document.body.style.overflow = "";
87
- };
88
- }, [open]);
37
+ useLayer({ open, onDismiss: handleCancel });
38
+ useFocusScope(panelRef, {
39
+ active: open,
40
+ initialFocus: () => panelRef.current?.querySelector(`.${baseClass}__actions button:first-child`) ?? null
41
+ });
42
+ useScrollLock(open);
89
43
  const variantClass = variant !== "default" ? `${baseClass}--${variant}` : "";
90
44
  const openClass = open ? `${baseClass}--open` : "";
91
45
  const containerClasses = [baseClass, variantClass, openClass, className].filter(Boolean).join(" ");
@@ -100,6 +54,7 @@ const AlertDialog = ({
100
54
  "aria-modal": "true",
101
55
  "aria-labelledby": titleId,
102
56
  "aria-describedby": description ? descId : void 0,
57
+ tabIndex: -1,
103
58
  children: [
104
59
  /* @__PURE__ */ jsxs("div", { className: `${baseClass}__header`, children: [
105
60
  /* @__PURE__ */ jsx(
@@ -23,6 +23,9 @@
23
23
  line-height: var(--font-paragraph-em-line-height);
24
24
  letter-spacing: var(--font-paragraph-em-letter-spacing);
25
25
  text-align: center;
26
+ /* A pill's label never wraps — a squeezed layout shortens around the
27
+ button, it doesn't fold the silhouette into two lines. */
28
+ white-space: nowrap;
26
29
 
27
30
  /* Transitions */
28
31
  transition: background-color var(--motion-duration-base) var(--motion-ease-standard), border-color var(--motion-duration-base) var(--motion-ease-standard), opacity var(--motion-duration-base) var(--motion-ease-standard);