@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
@@ -125,9 +125,10 @@ const Combobox = React.forwardRef(({
125
125
  }
126
126
  }, []);
127
127
  useEffect(() => {
128
+ if (!isOpen) return;
128
129
  document.addEventListener("mousedown", handleClickOutside);
129
130
  return () => document.removeEventListener("mousedown", handleClickOutside);
130
- }, [handleClickOutside]);
131
+ }, [isOpen, handleClickOutside]);
131
132
  const moveFocus = (direction) => {
132
133
  setFocusedIndex((prev) => {
133
134
  let next = prev + direction;
@@ -54,9 +54,12 @@
54
54
  max-height: 60vh;
55
55
  margin-top: 10vh;
56
56
  border: var(--border-xs) solid var(--color-bg-container-border);
57
- border-radius: var(--radius-md); /* 12px */
58
- background-color: var(--color-bg-page-primary);
59
- box-shadow: var(--shadow-modal);
57
+ border-radius: var(--radius-xl); /* 24px — the floating-surface shell */
58
+ background-color: var(--color-bg-glass);
59
+ box-shadow: var(--shadow-floating);
60
+ /* The glass token is deliberately translucent; this blur is what keeps
61
+ the page readable through the panel. */
62
+ backdrop-filter: blur(24px);
60
63
  overflow: hidden;
61
64
  transform: scale(0.98);
62
65
  transition: transform var(--motion-duration-fast) var(--motion-ease-standard);
@@ -74,7 +77,7 @@
74
77
  display: flex;
75
78
  align-items: center;
76
79
  gap: var(--gap-sm); /* 8px */
77
- padding: var(--padding-sm-md) var(--padding-md); /* 12px 16px */
80
+ padding: var(--padding-md) var(--padding-lg); /* 16px 20px */
78
81
  border-bottom: var(--border-xs) solid var(--color-divider);
79
82
  flex-shrink: 0;
80
83
  transition: border-color var(--motion-duration-fast) var(--motion-ease-standard);
@@ -148,7 +151,7 @@
148
151
  flex: 1;
149
152
  min-height: 0;
150
153
  overflow-y: auto;
151
- padding: var(--padding-xxs);
154
+ padding: var(--padding-sm);
152
155
  /* Quiet scrollbar, ChatThread's recipe: thin and token-coloured instead
153
156
  of the 15px native chrome rail a classic-scrollbar platform would draw
154
157
  inside the panel. The stable gutter matters here because filtering
@@ -202,7 +205,7 @@
202
205
  display: flex;
203
206
  align-items: center;
204
207
  gap: var(--gap-sm-md);
205
- padding: var(--padding-sm) var(--padding-sm-md); /* 8px 12px */
208
+ padding: var(--padding-sm-md); /* 12px — 8px list inset keeps the text edge on the search row's 20px */
206
209
  border-radius: var(--radius-sm);
207
210
  cursor: pointer;
208
211
  transition: background-color var(--motion-duration-fast) var(--motion-ease-standard);
@@ -276,7 +279,7 @@
276
279
  display: flex;
277
280
  align-items: center;
278
281
  gap: var(--gap-md);
279
- padding: var(--padding-sm) var(--padding-md);
282
+ padding: var(--padding-sm-md) var(--padding-lg);
280
283
  border-top: var(--border-xs) solid var(--color-divider);
281
284
  flex-shrink: 0;
282
285
  flex-wrap: wrap;
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import { useState, useRef, useId, useSyncExternalStore, useCallback, useMemo, useEffect } from "react";
3
+ import { useState, useRef, useId, useCallback, useMemo, useEffect } from "react";
4
4
  import ReactDOM from "react-dom";
5
5
  import { Kbd } from "../Kbd/Kbd.js";
6
6
  import "./CommandPalette.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 CommandPalette = ({
12
13
  open,
13
14
  onOpenChange,
@@ -28,10 +29,9 @@ const CommandPalette = ({
28
29
  const inputRef = useRef(null);
29
30
  const panelRef = useRef(null);
30
31
  const listRef = useRef(null);
31
- const previousFocusRef = useRef(null);
32
32
  const generatedId = useId();
33
33
  const baseClass = "ds-command-palette";
34
- const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
34
+ const mounted = useMounted();
35
35
  const matches = useCallback(
36
36
  (command) => {
37
37
  const q = query.trim().toLowerCase();
@@ -68,58 +68,9 @@ const CommandPalette = ({
68
68
  document.addEventListener("keydown", handleHotkey);
69
69
  return () => document.removeEventListener("keydown", handleHotkey);
70
70
  }, [hotkey, open, onOpenChange]);
71
- useEffect(() => {
72
- if (open) {
73
- previousFocusRef.current = document.activeElement;
74
- const raf = requestAnimationFrame(() => inputRef.current?.focus());
75
- return () => cancelAnimationFrame(raf);
76
- }
77
- if (previousFocusRef.current) {
78
- previousFocusRef.current.focus();
79
- previousFocusRef.current = null;
80
- }
81
- }, [open]);
82
- useEffect(() => {
83
- if (!open) return;
84
- const block = (event) => {
85
- const target = event.target instanceof Element ? event.target : null;
86
- if (target?.closest(`.${baseClass}__list`)) return;
87
- event.preventDefault();
88
- };
89
- document.addEventListener("wheel", block, { passive: false });
90
- document.addEventListener("touchmove", block, { passive: false });
91
- return () => {
92
- document.removeEventListener("wheel", block);
93
- document.removeEventListener("touchmove", block);
94
- };
95
- }, [open, baseClass]);
96
- useEffect(() => {
97
- if (!open) return;
98
- const handleTrapKeyDown = (e) => {
99
- if (e.key === "Escape") {
100
- e.preventDefault();
101
- onOpenChange(false);
102
- return;
103
- }
104
- if (e.key === "Tab") {
105
- const panel = panelRef.current;
106
- if (!panel) return;
107
- const focusable = panel.querySelectorAll(FOCUSABLE);
108
- if (focusable.length === 0) return;
109
- const first = focusable[0];
110
- const last = focusable[focusable.length - 1];
111
- if (e.shiftKey && document.activeElement === first) {
112
- e.preventDefault();
113
- last.focus();
114
- } else if (!e.shiftKey && document.activeElement === last) {
115
- e.preventDefault();
116
- first.focus();
117
- }
118
- }
119
- };
120
- document.addEventListener("keydown", handleTrapKeyDown);
121
- return () => document.removeEventListener("keydown", handleTrapKeyDown);
122
- }, [open, onOpenChange]);
71
+ useLayer({ open, onDismiss: () => onOpenChange(false) });
72
+ useFocusScope(panelRef, { active: open, initialFocus: () => inputRef.current });
73
+ useScrollLock(open, { strategy: "cancel-events", allowWithin: listRef });
123
74
  const runCommand = (command) => {
124
75
  if (command.disabled) return;
125
76
  command.onSelect?.();
@@ -166,6 +166,60 @@
166
166
  }
167
167
  }
168
168
 
169
+ /* ============================================
170
+ CONTEXT CHIP
171
+ A full-width, non-interactive note at the very top of the shell — what
172
+ the model is currently looking at. Composer owns the chrome; the caller
173
+ passes the text.
174
+ ============================================ */
175
+
176
+ .ds-composer__context {
177
+ box-sizing: border-box;
178
+ display: flex;
179
+ align-items: center;
180
+ gap: var(--gap-xs);
181
+ /* The action bar's tight --padding-sm ring, open toward the text below */
182
+ margin: var(--padding-sm) var(--padding-sm) 0;
183
+ /* ModelPicker's trigger geometry — the send button's height with the same
184
+ 0/padding-md inset — so the chip and the bar's pills read as one family */
185
+ height: var(--ds-composer-send-size);
186
+ padding: 0 var(--padding-md);
187
+ background-color: var(--color-chat-context-bg);
188
+ /* A full pill, matching the suggestion chips around the composer */
189
+ border-radius: var(--radius-full);
190
+ font-family: var(--font-paragraph-sm-family);
191
+ font-size: var(--font-paragraph-sm-size);
192
+ font-weight: var(--font-paragraph-sm-weight);
193
+ line-height: var(--font-paragraph-sm-line-height);
194
+ letter-spacing: var(--font-paragraph-sm-letter-spacing);
195
+ /* Tertiary, like DocumentChip's metadata line — the note is ambient
196
+ context, quieter than anything a person typed. The icon inherits it:
197
+ one tone for the whole note. */
198
+ color: var(--color-text-tertiary);
199
+ /* Not editable, not a control — but a click still falls through the shell
200
+ handler and focuses the textarea */
201
+ cursor: default;
202
+ }
203
+
204
+ .ds-composer__context-icon {
205
+ display: inline-flex;
206
+ align-items: center;
207
+ flex-shrink: 0;
208
+ }
209
+
210
+ .ds-composer__context-icon .material-symbols-rounded {
211
+ --icon-size: var(--icon-size-sm);
212
+ }
213
+
214
+ /* The text owns the truncation: one line, ellipsized only when the note
215
+ actually outgrows the shell — an ellipsis is a truncation mark, not
216
+ punctuation. The icon beside it never shrinks. */
217
+ .ds-composer__context-text {
218
+ overflow: hidden;
219
+ text-overflow: ellipsis;
220
+ white-space: nowrap;
221
+ }
222
+
169
223
  /* ============================================
170
224
  ATTACHMENTS ROW
171
225
  ============================================ */
@@ -37,6 +37,22 @@ type ComposerOwnProps = {
37
37
  * field holds focus. Off by default.
38
38
  */
39
39
  aiGlow?: boolean;
40
+ /**
41
+ * Contextual note rendered as a full-width, non-interactive chip at the
42
+ * very top of the shell, above any attachments — the "what the model is
43
+ * looking at" line a chat host pins over the message ("Looking at
44
+ * “Page name”"). One line: a note too long for the shell truncates with
45
+ * an ellipsis. Composer owns the chip's chrome; the caller passes the
46
+ * text.
47
+ */
48
+ context?: React.ReactNode;
49
+ /**
50
+ * Material Symbol name rendered at the left of the context chip
51
+ * (`visibility`, `article`…). Decorative and hidden from assistive
52
+ * technology — the chip's text carries the meaning. None by default,
53
+ * matching the ai set's icon-free-unless-asked convention.
54
+ */
55
+ contextIcon?: string;
40
56
  /**
41
57
  * Attachment row rendered above the textarea (DocumentChips). Fully
42
58
  * controlled by the caller — Composer never owns the list.
@@ -59,10 +75,11 @@ type ComposerOwnProps = {
59
75
  export interface ComposerProps extends ComposerOwnProps, Omit<React.ComponentPropsWithoutRef<'textarea'>, keyof ComposerOwnProps> {
60
76
  }
61
77
  /**
62
- * Composer is the chat input shell: an attachments row, an auto-growing
63
- * textarea, a leading actions slot, and a trailing send button — the one
64
- * sanctioned primary-action teal in the chat set, because sending a message
65
- * is a genuine primary CTA. While `streaming`, send becomes stop and Enter
78
+ * Composer is the chat input shell: an optional context note ("Looking at
79
+ * “Page name”"), an attachments row, an auto-growing textarea, a leading
80
+ * actions slot, and a trailing send button the one sanctioned
81
+ * primary-action teal in the chat set, because sending a message is a
82
+ * genuine primary CTA. While `streaming`, send becomes stop and Enter
66
83
  * is inert.
67
84
  *
68
85
  * The textarea grows with its content up to `maxRows`, then scrolls
@@ -2,6 +2,7 @@
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
3
  import React, { useRef, useState, useLayoutEffect } from "react";
4
4
  import { CircularButton } from "../CircularButton/CircularButton.js";
5
+ import "../../fonts/material-symbols.css";
5
6
  import "./Composer.css";
6
7
  const Composer = React.forwardRef(
7
8
  ({
@@ -13,6 +14,8 @@ const Composer = React.forwardRef(
13
14
  onStop,
14
15
  maxRows = 8,
15
16
  aiGlow = false,
17
+ context,
18
+ contextIcon,
16
19
  attachments,
17
20
  actions,
18
21
  trailingActions,
@@ -89,6 +92,10 @@ const Composer = React.forwardRef(
89
92
  style: { "--ds-composer-max-rows": maxRows },
90
93
  onClick: handleShellClick,
91
94
  children: [
95
+ context && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__context`, children: [
96
+ contextIcon && /* @__PURE__ */ jsx("span", { className: `${baseClass}__context-icon`, "aria-hidden": "true", children: /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", children: contextIcon }) }),
97
+ /* @__PURE__ */ jsx("span", { className: `${baseClass}__context-text`, children: context })
98
+ ] }),
92
99
  attachments && /* @__PURE__ */ jsx("div", { className: `${baseClass}__attachments`, children: attachments }),
93
100
  /* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, ref: contentRef, children: /* @__PURE__ */ jsx(
94
101
  "textarea",
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import React, { useRef, useId, useSyncExternalStore, useEffect } from "react";
3
+ import React, { useRef, useId } from "react";
4
4
  import ReactDOM from "react-dom";
5
5
  import "./Dialog.css";
6
6
  import "../../fonts/material-symbols.css";
7
- const emptySubscribe = () => () => {
8
- };
9
- const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
+ import { useMounted } from "../../behaviors/useMounted.js";
8
+ import { useLayer } from "../../behaviors/useLayer.js";
9
+ import { useFocusScope } from "../../behaviors/useFocusScope.js";
10
+ import { useScrollLock } from "../../behaviors/useScrollLock.js";
10
11
  const Dialog = React.forwardRef(
11
12
  ({
12
13
  open,
@@ -21,7 +22,6 @@ const Dialog = React.forwardRef(
21
22
  ...rest
22
23
  }, ref) => {
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-dialog";
@@ -30,63 +30,10 @@ const Dialog = React.forwardRef(
30
30
  if (typeof ref === "function") ref(node);
31
31
  else if (ref) ref.current = node;
32
32
  };
33
- const mounted = useSyncExternalStore(
34
- emptySubscribe,
35
- () => true,
36
- () => false
37
- );
38
- useEffect(() => {
39
- if (open) {
40
- previousFocusRef.current = document.activeElement;
41
- }
42
- }, [open]);
43
- useEffect(() => {
44
- if (!open) return;
45
- const panel = panelRef.current;
46
- if (panel) {
47
- const first = panel.querySelector(
48
- "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])"
49
- ) ?? panel.querySelector(FOCUSABLE);
50
- (first ?? panel).focus();
51
- }
52
- const handleKeyDown = (e) => {
53
- if (e.key === "Escape" && dismissible) {
54
- onOpenChange(false);
55
- return;
56
- }
57
- if (e.key === "Tab" && panel) {
58
- const focusable = panel.querySelectorAll(FOCUSABLE);
59
- if (focusable.length === 0) return;
60
- const first = focusable[0];
61
- const last = focusable[focusable.length - 1];
62
- if (e.shiftKey && document.activeElement === first) {
63
- e.preventDefault();
64
- last.focus();
65
- } else if (!e.shiftKey && document.activeElement === last) {
66
- e.preventDefault();
67
- first.focus();
68
- }
69
- }
70
- };
71
- document.addEventListener("keydown", handleKeyDown);
72
- return () => document.removeEventListener("keydown", handleKeyDown);
73
- }, [open, dismissible, onOpenChange]);
74
- useEffect(() => {
75
- if (!open && previousFocusRef.current) {
76
- previousFocusRef.current.focus();
77
- previousFocusRef.current = null;
78
- }
79
- }, [open]);
80
- useEffect(() => {
81
- if (open) {
82
- document.body.style.overflow = "hidden";
83
- } else {
84
- document.body.style.overflow = "";
85
- }
86
- return () => {
87
- document.body.style.overflow = "";
88
- };
89
- }, [open]);
33
+ const mounted = useMounted();
34
+ useLayer({ open, dismissOnEscape: dismissible, onDismiss: () => onOpenChange(false) });
35
+ useFocusScope(panelRef, { active: open });
36
+ useScrollLock(open);
90
37
  const handleBackdropClick = () => {
91
38
  if (dismissible) onOpenChange(false);
92
39
  };
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import React, { useRef, useId, useSyncExternalStore, useEffect } from "react";
3
+ import React, { useRef, useId } from "react";
4
4
  import ReactDOM from "react-dom";
5
5
  import "./Drawer.css";
6
6
  import "../../fonts/material-symbols.css";
7
- const emptySubscribe = () => () => {
8
- };
9
- const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
+ import { useMounted } from "../../behaviors/useMounted.js";
8
+ import { useLayer } from "../../behaviors/useLayer.js";
9
+ import { useFocusScope } from "../../behaviors/useFocusScope.js";
10
+ import { useScrollLock } from "../../behaviors/useScrollLock.js";
10
11
  const Drawer = React.forwardRef(
11
12
  ({
12
13
  open,
@@ -22,7 +23,6 @@ const Drawer = React.forwardRef(
22
23
  ...rest
23
24
  }, ref) => {
24
25
  const panelRef = useRef(null);
25
- const previousFocusRef = useRef(null);
26
26
  const titleId = useId();
27
27
  const descId = useId();
28
28
  const baseClass = "ds-drawer";
@@ -31,63 +31,10 @@ const Drawer = React.forwardRef(
31
31
  if (typeof ref === "function") ref(node);
32
32
  else if (ref) ref.current = node;
33
33
  };
34
- const mounted = useSyncExternalStore(
35
- emptySubscribe,
36
- () => true,
37
- () => false
38
- );
39
- useEffect(() => {
40
- if (open) {
41
- previousFocusRef.current = document.activeElement;
42
- }
43
- }, [open]);
44
- useEffect(() => {
45
- if (!open) return;
46
- const panel = panelRef.current;
47
- if (panel) {
48
- const first = panel.querySelector(
49
- "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])"
50
- ) ?? panel.querySelector(FOCUSABLE);
51
- (first ?? panel).focus();
52
- }
53
- const handleKeyDown = (e) => {
54
- if (e.key === "Escape" && dismissible) {
55
- onOpenChange(false);
56
- return;
57
- }
58
- if (e.key === "Tab" && panel) {
59
- const focusable = panel.querySelectorAll(FOCUSABLE);
60
- if (focusable.length === 0) return;
61
- const first = focusable[0];
62
- const last = focusable[focusable.length - 1];
63
- if (e.shiftKey && document.activeElement === first) {
64
- e.preventDefault();
65
- last.focus();
66
- } else if (!e.shiftKey && document.activeElement === last) {
67
- e.preventDefault();
68
- first.focus();
69
- }
70
- }
71
- };
72
- document.addEventListener("keydown", handleKeyDown);
73
- return () => document.removeEventListener("keydown", handleKeyDown);
74
- }, [open, dismissible, onOpenChange]);
75
- useEffect(() => {
76
- if (!open && previousFocusRef.current) {
77
- previousFocusRef.current.focus();
78
- previousFocusRef.current = null;
79
- }
80
- }, [open]);
81
- useEffect(() => {
82
- if (open) {
83
- document.body.style.overflow = "hidden";
84
- } else {
85
- document.body.style.overflow = "";
86
- }
87
- return () => {
88
- document.body.style.overflow = "";
89
- };
90
- }, [open]);
34
+ const mounted = useMounted();
35
+ useLayer({ open, dismissOnEscape: dismissible, onDismiss: () => onOpenChange(false) });
36
+ useFocusScope(panelRef, { active: open });
37
+ useScrollLock(open);
91
38
  const handleScrimClick = () => {
92
39
  if (dismissible) onOpenChange(false);
93
40
  };
@@ -63,9 +63,10 @@ const Dropdown = React.forwardRef(
63
63
  }
64
64
  }, []);
65
65
  useEffect(() => {
66
+ if (!isOpen) return;
66
67
  document.addEventListener("mousedown", handleClickOutside);
67
68
  return () => document.removeEventListener("mousedown", handleClickOutside);
68
- }, [handleClickOutside]);
69
+ }, [isOpen, handleClickOutside]);
69
70
  const handleKeyDown = (e) => {
70
71
  if (disabled) return;
71
72
  switch (e.key) {
@@ -177,6 +177,7 @@ const DropdownMenu = ({
177
177
  setFocusedIndex(-1);
178
178
  }, []);
179
179
  useEffect(() => {
180
+ if (!isOpen) return;
180
181
  const handler = (e) => {
181
182
  if (rootRef.current && !rootRef.current.contains(e.target)) {
182
183
  handleClose();
@@ -184,7 +185,7 @@ const DropdownMenu = ({
184
185
  };
185
186
  document.addEventListener("mousedown", handler);
186
187
  return () => document.removeEventListener("mousedown", handler);
187
- }, [handleClose]);
188
+ }, [isOpen, handleClose]);
188
189
  const handleKeyDown = (e) => {
189
190
  switch (e.key) {
190
191
  case "Enter":
@@ -323,14 +324,21 @@ const DropdownMenu = ({
323
324
  size === "compact" ? `${baseClass}__panel--compact` : ""
324
325
  ].filter(Boolean).join(" ");
325
326
  const itemCounter = { count: 0 };
327
+ const triggerProps = React.isValidElement(trigger) ? trigger.props : void 0;
326
328
  return /* @__PURE__ */ jsxs("div", { className: classes, ref: rootRef, children: [
327
329
  /* @__PURE__ */ jsx("div", { className: `${baseClass}__trigger`, children: React.isValidElement(trigger) ? React.cloneElement(trigger, {
328
330
  "aria-haspopup": "menu",
329
331
  "aria-expanded": isOpen,
330
332
  "aria-controls": isOpen ? menuId : void 0,
331
333
  "aria-activedescendant": activeDescendantId,
332
- onClick: handleToggle,
333
- onKeyDown: handleKeyDown
334
+ onClick: (e) => {
335
+ triggerProps?.onClick?.(e);
336
+ handleToggle();
337
+ },
338
+ onKeyDown: (e) => {
339
+ triggerProps?.onKeyDown?.(e);
340
+ handleKeyDown(e);
341
+ }
334
342
  }) : /* @__PURE__ */ jsx(
335
343
  "button",
336
344
  {
@@ -60,9 +60,10 @@ const ModelPicker = React.forwardRef(
60
60
  }
61
61
  }, []);
62
62
  useEffect(() => {
63
+ if (!isOpen) return;
63
64
  document.addEventListener("mousedown", handleClickOutside);
64
65
  return () => document.removeEventListener("mousedown", handleClickOutside);
65
- }, [handleClickOutside]);
66
+ }, [isOpen, handleClickOutside]);
66
67
  const handleTriggerKeyDown = (e) => {
67
68
  if (disabled) return;
68
69
  switch (e.key) {
@@ -27,6 +27,9 @@ const Popover = ({
27
27
  }
28
28
  onOpenChange?.(next);
29
29
  }, [isControlled, onOpenChange]);
30
+ useEffect(() => {
31
+ return () => clearTimeout(hoverTimeoutRef.current);
32
+ }, []);
30
33
  useEffect(() => {
31
34
  if (!isOpen || trigger !== "click") return;
32
35
  const handler = (e) => {
@@ -55,12 +55,14 @@
55
55
  transition: color var(--motion-duration-base) var(--motion-ease-standard);
56
56
  }
57
57
 
58
- /* Icon */
58
+ /* Icon — same idle tier as the label, so an icon never reads dimmer than
59
+ the text beside it, and an icon-only segment (the site's theme toggle)
60
+ holds label-strength contrast in both modes. */
59
61
  .ds-segmented-control__icon {
60
62
  flex-shrink: 0;
61
63
  --icon-size: var(--icon-size-md);
62
64
  line-height: 1;
63
- color: var(--color-icon-secondary);
65
+ color: var(--color-text-secondary);
64
66
  transition: color var(--motion-duration-base) var(--motion-ease-standard);
65
67
  }
66
68
 
@@ -161,11 +161,11 @@
161
161
  }
162
162
 
163
163
  .ds-source-trail__row--active .ds-source-trail__row-indicator {
164
- color: var(--color-status-info-text);
164
+ color: var(--color-status-info-icon);
165
165
  }
166
166
 
167
167
  .ds-source-trail__row--done .ds-source-trail__row-indicator {
168
- color: var(--color-status-positive-text);
168
+ color: var(--color-status-positive-icon);
169
169
  }
170
170
 
171
171
  /* A pending source has not been opened yet, so its chip sits quieter */
@@ -101,9 +101,10 @@ const TimePicker = React.forwardRef(
101
101
  }
102
102
  }, []);
103
103
  useEffect(() => {
104
+ if (!isOpen) return;
104
105
  document.addEventListener("mousedown", handleClickOutside);
105
106
  return () => document.removeEventListener("mousedown", handleClickOutside);
106
- }, [handleClickOutside]);
107
+ }, [isOpen, handleClickOutside]);
107
108
  const handleKeyDown = (e) => {
108
109
  if (disabled) return;
109
110
  switch (e.key) {
@@ -13,7 +13,11 @@
13
13
  ============================================ */
14
14
 
15
15
  .ds-tool-call {
16
+ /* Two colour channels per status: -text for the status word (tuned for
17
+ text contrast) and -icon for the indicator glyph, which needs a hue
18
+ that survives a plain surface — see the status token comment. */
16
19
  --ds-tool-call-color: var(--color-text-secondary);
20
+ --ds-tool-call-icon-color: var(--color-icon-secondary);
17
21
 
18
22
  display: flex;
19
23
  flex-direction: column;
@@ -71,7 +75,7 @@
71
75
  align-items: center;
72
76
  justify-content: center;
73
77
  flex: none;
74
- color: var(--ds-tool-call-color);
78
+ color: var(--ds-tool-call-icon-color);
75
79
  }
76
80
 
77
81
  .ds-tool-call__indicator .material-symbols-rounded {
@@ -150,18 +154,22 @@
150
154
 
151
155
  .ds-tool-call--pending {
152
156
  --ds-tool-call-color: var(--color-status-warning-text);
157
+ --ds-tool-call-icon-color: var(--color-status-warning-icon);
153
158
  }
154
159
 
155
160
  .ds-tool-call--running {
156
161
  --ds-tool-call-color: var(--color-status-info-text);
162
+ --ds-tool-call-icon-color: var(--color-status-info-icon);
157
163
  }
158
164
 
159
165
  .ds-tool-call--success {
160
166
  --ds-tool-call-color: var(--color-status-positive-text);
167
+ --ds-tool-call-icon-color: var(--color-status-positive-icon);
161
168
  }
162
169
 
163
170
  .ds-tool-call--error {
164
171
  --ds-tool-call-color: var(--color-status-error-text);
172
+ --ds-tool-call-icon-color: var(--color-status-error-icon);
165
173
  }
166
174
 
167
175
  /* ============================================
@@ -24,6 +24,12 @@ const Tooltip = ({
24
24
  clearTimeout(showTimeoutRef.current);
25
25
  hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelay);
26
26
  };
27
+ useEffect(() => {
28
+ return () => {
29
+ clearTimeout(showTimeoutRef.current);
30
+ clearTimeout(hideTimeoutRef.current);
31
+ };
32
+ }, []);
27
33
  useEffect(() => {
28
34
  if (!visible) return;
29
35
  const onKeyDown = (e) => {