@robr0/design-system 0.10.0 → 0.12.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 (125) hide show
  1. package/README.md +9 -8
  2. package/charts.d.ts +1 -0
  3. package/charts.js +2 -0
  4. package/components/AgentPlan/AgentPlan.css +14 -3
  5. package/components/AiButton/AiButton.css +1 -1
  6. package/components/Alert/Alert.css +9 -0
  7. package/components/AlertDialog/AlertDialog.css +3 -1
  8. package/components/AlertDialog/AlertDialog.js +2 -3
  9. package/components/AppLayout/AppLayout.d.ts +7 -1
  10. package/components/AppLayout/AppLayout.js +2 -1
  11. package/components/AppSidebar/AppSidebar.css +113 -40
  12. package/components/AppSidebar/AppSidebar.d.ts +14 -1
  13. package/components/AppSidebar/AppSidebar.js +26 -17
  14. package/components/Badge/Badge.css +4 -5
  15. package/components/Card/Card.css +7 -2
  16. package/components/CardStack/CardStack.css +106 -0
  17. package/components/CardStack/CardStack.d.ts +44 -0
  18. package/components/CardStack/CardStack.js +140 -0
  19. package/components/Carousel/Carousel.css +9 -0
  20. package/components/Carousel/Carousel.js +37 -4
  21. package/components/Chart/AreaChart.d.ts +5 -1
  22. package/components/Chart/AreaChart.js +11 -9
  23. package/components/Chart/BarChart.d.ts +3 -1
  24. package/components/Chart/BarChart.js +3 -3
  25. package/components/Chart/Chart.css +45 -0
  26. package/components/Chart/ComboChart.d.ts +40 -0
  27. package/components/Chart/ComboChart.js +142 -0
  28. package/components/Chart/LineChart.d.ts +3 -1
  29. package/components/Chart/LineChart.js +7 -6
  30. package/components/Chart/PieChart.d.ts +3 -1
  31. package/components/Chart/PieChart.js +5 -18
  32. package/components/Chart/RadarChart.d.ts +3 -1
  33. package/components/Chart/RadarChart.js +8 -7
  34. package/components/Chart/RadialChart.d.ts +7 -1
  35. package/components/Chart/RadialChart.js +53 -58
  36. package/components/Chart/ScatterChart.d.ts +3 -1
  37. package/components/Chart/ScatterChart.js +6 -16
  38. package/components/Chart/StackedBarChart.d.ts +3 -1
  39. package/components/Chart/StackedBarChart.js +5 -18
  40. package/components/Chart/Treemap.d.ts +3 -1
  41. package/components/Chart/Treemap.js +6 -20
  42. package/components/Chart/palette.d.ts +1 -0
  43. package/components/Chart/palette.js +17 -0
  44. package/components/ChatMarker/ChatMarker.css +9 -0
  45. package/components/ChatMessage/ChatMessage.css +16 -0
  46. package/components/ChatThread/ChatThread.js +2 -2
  47. package/components/Chip/Chip.css +9 -0
  48. package/components/CodeBlock/CodeBlock.css +10 -1
  49. package/components/CodeBlock/CodeBlock.js +2 -1
  50. package/components/ColorPicker/ColorPicker.js +10 -0
  51. package/components/Combobox/Combobox.css +9 -0
  52. package/components/CommandPalette/CommandPalette.css +9 -0
  53. package/components/CommandPalette/CommandPalette.js +31 -5
  54. package/components/Composer/Composer.css +118 -0
  55. package/components/Composer/Composer.d.ts +7 -0
  56. package/components/Composer/Composer.js +7 -1
  57. package/components/ContactCard/ContactCard.css +9 -0
  58. package/components/ContextMenu/ContextMenu.js +39 -3
  59. package/components/DatePicker/DatePicker.js +16 -0
  60. package/components/Dialog/Dialog.css +3 -1
  61. package/components/Dialog/Dialog.js +5 -4
  62. package/components/DocumentChip/DocumentChip.css +9 -0
  63. package/components/Dropdown/Dropdown.js +22 -3
  64. package/components/DropdownMenu/DropdownMenu.js +43 -6
  65. package/components/EmptyState/EmptyState.css +9 -0
  66. package/components/FileInput/FileInput.css +9 -0
  67. package/components/FunnelChart/FunnelChart.css +41 -0
  68. package/components/FunnelChart/FunnelChart.d.ts +40 -0
  69. package/components/FunnelChart/FunnelChart.js +48 -0
  70. package/components/Globe/Globe.css +155 -0
  71. package/components/Globe/Globe.d.ts +96 -0
  72. package/components/Globe/Globe.js +415 -0
  73. package/components/InterruptCard/InterruptCard.css +27 -17
  74. package/components/InterruptCard/InterruptCard.d.ts +1 -1
  75. package/components/InterruptCard/InterruptCard.js +1 -1
  76. package/components/LegendTile/LegendTile.css +60 -0
  77. package/components/LegendTile/LegendTile.d.ts +30 -0
  78. package/components/LegendTile/LegendTile.js +21 -0
  79. package/components/MapCallout/MapCallout.css +46 -0
  80. package/components/MapCallout/MapCallout.d.ts +29 -0
  81. package/components/MapCallout/MapCallout.js +17 -0
  82. package/components/MapLegend/MapLegend.css +88 -0
  83. package/components/MapLegend/MapLegend.d.ts +39 -0
  84. package/components/MapLegend/MapLegend.js +68 -0
  85. package/components/MessageActions/MessageActions.css +9 -0
  86. package/components/MessageCard/MessageCard.css +68 -16
  87. package/components/MessageCard/MessageCard.d.ts +1 -1
  88. package/components/MessageCard/MessageCard.js +6 -4
  89. package/components/ModelPicker/ModelPicker.css +4 -1
  90. package/components/ModelPicker/ModelPicker.js +43 -1
  91. package/components/NotificationCenter/NotificationCenter.css +9 -0
  92. package/components/NotificationCenter/NotificationCenter.js +26 -10
  93. package/components/NumberInput/NumberInput.css +9 -0
  94. package/components/Panel/Panel.css +26 -0
  95. package/components/Panel/Panel.d.ts +22 -0
  96. package/components/Panel/Panel.js +19 -0
  97. package/components/Popover/Popover.css +3 -1
  98. package/components/Popover/Popover.js +2 -1
  99. package/components/SourceChip/SourceChip.css +9 -0
  100. package/components/Stat/Stat.css +44 -2
  101. package/components/Stat/Stat.d.ts +3 -1
  102. package/components/Stat/Stat.js +7 -1
  103. package/components/Stepper/Stepper.css +9 -0
  104. package/components/TagInput/TagInput.css +9 -0
  105. package/components/TimePicker/TimePicker.js +4 -1
  106. package/components/Toast/Toast.css +32 -11
  107. package/components/Toast/Toast.js +5 -4
  108. package/components/ToggleGroup/ToggleGroup.css +9 -0
  109. package/components/ToolCall/ToolCall.css +18 -6
  110. package/components/Tooltip/Tooltip.js +20 -16
  111. package/components/registry.json +70 -0
  112. package/components/registry.json.d.ts +70 -0
  113. package/components/registry.json.js +2 -2
  114. package/index.d.ts +7 -0
  115. package/index.js +14 -0
  116. package/package.json +5 -1
  117. package/tokens/motion.d.ts +23 -0
  118. package/tokens/motion.js +14 -0
  119. package/tokens/registry.json +21 -1
  120. package/tokens/registry.json.d.ts +21 -1
  121. package/tokens/registry.json.js +1 -1
  122. package/tokens/tokens-dark.css +14 -0
  123. package/tokens/tokens-light.css +22 -0
  124. package/tokens/tokens-primitives.css +2 -0
  125. package/tokens/tokens-typography.css +32 -5
@@ -23,6 +23,8 @@ export interface TreemapProps {
23
23
  summaryItems?: ChartSummaryItem[];
24
24
  /** Chart area height in pixels */
25
25
  height?: number;
26
+ /** Strip the card chrome (border, padding, fill) when the chart sits inside another panel that supplies the surface */
27
+ bare?: boolean;
26
28
  /** Additional CSS classes on the wrapper */
27
29
  className?: string;
28
30
  }
@@ -30,4 +32,4 @@ export interface TreemapProps {
30
32
  * Treemap chart built on Recharts with design-system tokens.
31
33
  * Displays hierarchical data as nested coloured rectangles sized by value.
32
34
  */
33
- export declare const Treemap: ({ data, dataKey, title, subtitle, summaryItems, height, className, }: TreemapProps) => import("react").JSX.Element;
35
+ export declare const Treemap: ({ data, dataKey, title, subtitle, summaryItems, height, bare, className, }: TreemapProps) => import("react").JSX.Element;
@@ -1,31 +1,16 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useCallback } from "react";
3
3
  import { ResponsiveContainer, Treemap as Treemap$1, Tooltip } from "recharts";
4
+ import { getChartSeriesColors } from "./palette.js";
4
5
  import "./Chart.css";
5
- const PALETTE = [
6
- "#118AB2",
7
- // teal-07
8
- "#06D6A0",
9
- // green-07
10
- "#EF476F",
11
- // red-07
12
- "#FFD166",
13
- // yellow-07
14
- "#9E47EF",
15
- // purple-07
16
- "#1E47B0",
17
- // blue-07
18
- "#EF8247"
19
- // orange
20
- ];
21
6
  function getCSSVar(name, fallback) {
22
- if (typeof window === "undefined") return fallback;
23
- return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
7
+ return `var(${name}, ${fallback})`;
24
8
  }
25
9
  function TreemapContent(props) {
26
10
  const { x, y, width, height, name, index, color } = props;
27
11
  if (width < 4 || height < 4) return null;
28
- const fill = color || PALETTE[index % PALETTE.length];
12
+ const seriesColors = getChartSeriesColors();
13
+ const fill = color || seriesColors[index % seriesColors.length];
29
14
  const textColor = getCSSVar("--color-text-primary", "#050505");
30
15
  const showLabel = width > 50 && height > 28;
31
16
  return /* @__PURE__ */ jsxs("g", { children: [
@@ -72,10 +57,11 @@ const Treemap = ({
72
57
  subtitle,
73
58
  summaryItems,
74
59
  height = 350,
60
+ bare = false,
75
61
  className = ""
76
62
  }) => {
77
63
  const baseClass = "ds-chart";
78
- const classes = [baseClass, className].filter(Boolean).join(" ");
64
+ const classes = [baseClass, bare ? `${baseClass}--bare` : "", className].filter(Boolean).join(" ");
79
65
  const renderTooltip = useCallback(
80
66
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
81
67
  (props) => /* @__PURE__ */ jsx(TreemapTooltip, { ...props }),
@@ -0,0 +1 @@
1
+ export declare function getChartSeriesColors(): string[];
@@ -0,0 +1,17 @@
1
+ const SERIES_FALLBACKS = [
2
+ "#0E6E8F",
3
+ "#06D6A0",
4
+ "#FFD166",
5
+ "#EF476F",
6
+ "#9E47EF",
7
+ "#EF8247",
8
+ "#1E47B0"
9
+ ];
10
+ function getChartSeriesColors() {
11
+ return SERIES_FALLBACKS.map(
12
+ (fallback, i) => `var(--color-chart-series-${i + 1}, ${fallback})`
13
+ );
14
+ }
15
+ export {
16
+ getChartSeriesColors
17
+ };
@@ -50,3 +50,12 @@
50
50
  .ds-chat-marker__icon .material-symbols-rounded {
51
51
  --icon-size: var(--icon-size-sm);
52
52
  }
53
+
54
+ /* Icons take the colour of the text around them. Explicit, not just
55
+ inherited by default: a consumer stylesheet that sets a colour on
56
+ .material-symbols-rounded itself (a common global default) would
57
+ otherwise override inheritance and detach the icon from the state
58
+ colours this component sets on its parents. */
59
+ .ds-chat-marker .material-symbols-rounded {
60
+ color: inherit;
61
+ }
@@ -110,6 +110,22 @@
110
110
  color: var(--color-text-primary);
111
111
  }
112
112
 
113
+ /* Card furniture inside a turn steps clear of the content around it — at
114
+ the body's default rhythm an adjacent text line sits close enough to read
115
+ as a caption rather than a neighbouring block. Margins, not gap, so a
116
+ card standing alone adds nothing. */
117
+ .ds-chat-message__content > .ds-message-card:not(:first-child),
118
+ .ds-chat-message__content > .ds-interrupt-card:not(:first-child),
119
+ .ds-chat-message__content > .ds-tool-call:not(:first-child) {
120
+ margin-top: var(--gap-sm-md);
121
+ }
122
+
123
+ .ds-chat-message__content > .ds-message-card:not(:last-child),
124
+ .ds-chat-message__content > .ds-interrupt-card:not(:last-child),
125
+ .ds-chat-message__content > .ds-tool-call:not(:last-child) {
126
+ margin-bottom: var(--gap-sm-md);
127
+ }
128
+
113
129
  /* Bubble — capped width so a short line never drags a surface across the
114
130
  whole row; the fallback is the sanctioned consumer-override hook */
115
131
  .ds-chat-message--bubble .ds-chat-message__content {
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
3
  import React, { useRef, useEffect, useState, useLayoutEffect } from "react";
4
+ import { MOTION_SCROLL_SETTLE_MS } from "../../tokens/motion.js";
4
5
  import "./ChatThread.css";
5
6
  import "../../fonts/material-symbols.css";
6
- const SCROLLBAR_SETTLE_MS = 600;
7
7
  const JUMP_THRESHOLD_PX = 24;
8
8
  const ChatThread = React.forwardRef(
9
9
  ({
@@ -129,7 +129,7 @@ const ChatThread = React.forwardRef(
129
129
  window.clearTimeout(settleTimer.current);
130
130
  settleTimer.current = window.setTimeout(
131
131
  () => setScrolling(false),
132
- SCROLLBAR_SETTLE_MS
132
+ MOTION_SCROLL_SETTLE_MS
133
133
  );
134
134
  };
135
135
  const jumpToBottom = () => {
@@ -220,3 +220,12 @@
220
220
  outline: 2px solid var(--color-action-primary-bg);
221
221
  outline-offset: 2px;
222
222
  }
223
+
224
+ /* Icons take the colour of the text around them. Explicit, not just
225
+ inherited by default: a consumer stylesheet that sets a colour on
226
+ .material-symbols-rounded itself (a common global default) would
227
+ otherwise override inheritance and detach the icon from the state
228
+ colours this component sets on its parents. */
229
+ .ds-chip .material-symbols-rounded {
230
+ color: inherit;
231
+ }
@@ -95,7 +95,7 @@
95
95
  font-weight: var(--font-paragraph-sm-em-weight);
96
96
  line-height: var(--font-paragraph-sm-line-height);
97
97
  text-transform: uppercase;
98
- letter-spacing: 0.08em;
98
+ letter-spacing: var(--font-overline-letter-spacing);
99
99
  color: var(--color-text-tertiary);
100
100
  flex-shrink: 0;
101
101
  }
@@ -178,3 +178,12 @@
178
178
  color: var(--color-text-primary);
179
179
  white-space: pre;
180
180
  }
181
+
182
+ /* Icons take the colour of the text around them. Explicit, not just
183
+ inherited by default: a consumer stylesheet that sets a colour on
184
+ .material-symbols-rounded itself (a common global default) would
185
+ otherwise override inheritance and detach the icon from the state
186
+ colours this component sets on its parents. */
187
+ .ds-code-block .material-symbols-rounded {
188
+ color: inherit;
189
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
3
  import { useState, useId } from "react";
4
+ import { MOTION_FEEDBACK_RESET_MS } from "../../tokens/motion.js";
4
5
  import "./CodeBlock.css";
5
6
  import "../../fonts/material-symbols.css";
6
7
  const CodeBlock = ({
@@ -44,7 +45,7 @@ const CodeBlock = ({
44
45
  fallback();
45
46
  }
46
47
  setCopied(true);
47
- window.setTimeout(() => setCopied(false), 2e3);
48
+ window.setTimeout(() => setCopied(false), MOTION_FEEDBACK_RESET_MS);
48
49
  };
49
50
  return /* @__PURE__ */ jsxs("div", { className: classes, style, children: [
50
51
  hasHeader && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__header`, children: [
@@ -170,6 +170,16 @@ const ColorPicker = React.forwardRef(
170
170
  e.preventDefault();
171
171
  commit({ h, s, v: clamp(v - step, 0, 100), a });
172
172
  break;
173
+ // The slider pattern requires Home/End; they act on the horizontal
174
+ // (saturation) axis, matching the arrow keys above.
175
+ case "Home":
176
+ e.preventDefault();
177
+ commit({ h, s: 0, v, a });
178
+ break;
179
+ case "End":
180
+ e.preventDefault();
181
+ commit({ h, s: 100, v, a });
182
+ break;
173
183
  }
174
184
  };
175
185
  const displayHex = formatHex(hsva, showAlpha);
@@ -373,3 +373,12 @@
373
373
  animation: none;
374
374
  }
375
375
  }
376
+
377
+ /* Icons take the colour of the text around them. Explicit, not just
378
+ inherited by default: a consumer stylesheet that sets a colour on
379
+ .material-symbols-rounded itself (a common global default) would
380
+ otherwise override inheritance and detach the icon from the state
381
+ colours this component sets on its parents. */
382
+ .ds-combobox .material-symbols-rounded {
383
+ color: inherit;
384
+ }
@@ -294,3 +294,12 @@
294
294
  transition: none;
295
295
  }
296
296
  }
297
+
298
+ /* Icons take the colour of the text around them. Explicit, not just
299
+ inherited by default: a consumer stylesheet that sets a colour on
300
+ .material-symbols-rounded itself (a common global default) would
301
+ otherwise override inheritance and detach the icon from the state
302
+ colours this component sets on its parents. */
303
+ .ds-command-palette .material-symbols-rounded {
304
+ color: inherit;
305
+ }
@@ -7,6 +7,7 @@ import "./CommandPalette.css";
7
7
  import "../../fonts/material-symbols.css";
8
8
  const emptySubscribe = () => () => {
9
9
  };
10
+ const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
10
11
  const CommandPalette = ({
11
12
  open,
12
13
  onOpenChange,
@@ -25,6 +26,7 @@ const CommandPalette = ({
25
26
  const [rawActiveIndex, setActiveIndex] = useState(0);
26
27
  const [prevOpen, setPrevOpen] = useState(open);
27
28
  const inputRef = useRef(null);
29
+ const panelRef = useRef(null);
28
30
  const listRef = useRef(null);
29
31
  const previousFocusRef = useRef(null);
30
32
  const generatedId = useId();
@@ -87,6 +89,33 @@ const CommandPalette = ({
87
89
  document.body.style.overflow = "";
88
90
  };
89
91
  }, [open]);
92
+ useEffect(() => {
93
+ if (!open) return;
94
+ const handleTrapKeyDown = (e) => {
95
+ if (e.key === "Escape") {
96
+ e.preventDefault();
97
+ onOpenChange(false);
98
+ return;
99
+ }
100
+ if (e.key === "Tab") {
101
+ const panel = panelRef.current;
102
+ if (!panel) return;
103
+ const focusable = panel.querySelectorAll(FOCUSABLE);
104
+ if (focusable.length === 0) return;
105
+ const first = focusable[0];
106
+ const last = focusable[focusable.length - 1];
107
+ if (e.shiftKey && document.activeElement === first) {
108
+ e.preventDefault();
109
+ last.focus();
110
+ } else if (!e.shiftKey && document.activeElement === last) {
111
+ e.preventDefault();
112
+ first.focus();
113
+ }
114
+ }
115
+ };
116
+ document.addEventListener("keydown", handleTrapKeyDown);
117
+ return () => document.removeEventListener("keydown", handleTrapKeyDown);
118
+ }, [open, onOpenChange]);
90
119
  const runCommand = (command) => {
91
120
  if (command.disabled) return;
92
121
  command.onSelect?.();
@@ -119,10 +148,6 @@ const CommandPalette = ({
119
148
  e.preventDefault();
120
149
  if (flatCommands[activeIndex]) runCommand(flatCommands[activeIndex]);
121
150
  break;
122
- case "Escape":
123
- e.preventDefault();
124
- onOpenChange(false);
125
- break;
126
151
  }
127
152
  };
128
153
  useEffect(() => {
@@ -148,6 +173,7 @@ const CommandPalette = ({
148
173
  "div",
149
174
  {
150
175
  className: `${baseClass}__panel`,
176
+ ref: panelRef,
151
177
  role: "dialog",
152
178
  "aria-modal": "true",
153
179
  "aria-label": "Command palette",
@@ -186,7 +212,7 @@ const CommandPalette = ({
186
212
  ] }),
187
213
  /* @__PURE__ */ jsxs("div", { className: `${baseClass}__list`, role: "listbox", id: listboxId, ref: listRef, children: [
188
214
  loading && /* @__PURE__ */ jsx("p", { className: `${baseClass}__status`, children: "Loading…" }),
189
- !loading && filteredGroups.length === 0 && /* @__PURE__ */ jsx("p", { className: `${baseClass}__status`, children: emptyMessage }),
215
+ !loading && filteredGroups.length === 0 && /* @__PURE__ */ jsx("p", { className: `${baseClass}__status`, "aria-live": "polite", children: emptyMessage }),
190
216
  !loading && filteredGroups.map((group) => /* @__PURE__ */ jsxs(
191
217
  "div",
192
218
  {
@@ -3,7 +3,20 @@
3
3
  The chat input shell: attachments, auto-growing textarea, send/stop
4
4
  ============================================ */
5
5
 
6
+ /* The focus treatment animates a registered custom property, exactly as
7
+ AiButton does: a conic gradient's angle cannot tween otherwise. Browsers
8
+ without @property render the ring at its initial angle, static — a
9
+ correct, quieter fallback. Registered under its own name so Composer
10
+ never depends on AiButton's stylesheet being loaded. */
11
+ @property --ds-composer-glow-angle {
12
+ syntax: '<angle>';
13
+ initial-value: 0deg;
14
+ inherits: false;
15
+ }
16
+
6
17
  .ds-composer {
18
+ /* Anchors the focus ring and glow pseudo-elements */
19
+ position: relative;
7
20
  display: flex;
8
21
  flex-direction: column;
9
22
  width: 100%;
@@ -36,6 +49,105 @@
36
49
  border-color: var(--color-input-border-selected);
37
50
  }
38
51
 
52
+ /* ============================================
53
+ AI GLOW — opt-in via the `aiGlow` prop
54
+ While focused, the plain selected border stands down and the AI gradient
55
+ ring takes its place — the same "a model answers here" signal AiButton
56
+ carries, for composers whose messages are answered by a model.
57
+ ============================================ */
58
+
59
+ .ds-composer--ai-glow:focus-within {
60
+ border-color: transparent;
61
+ }
62
+
63
+ /* The gradient ring: AiButton's treatment on the composer shell — a conic
64
+ gradient painted across the whole box, masked to the border band (two
65
+ fills, exclude-composited, inset by the border width). The gradient
66
+ repeats back to its start colour so the seam at 360° is invisible while
67
+ it turns. Negative inset lays the pseudo over the shell's own border
68
+ box, so the ring replaces the border exactly. */
69
+ .ds-composer--ai-glow::before {
70
+ content: '';
71
+ position: absolute;
72
+ inset: calc(-1 * var(--border-xs));
73
+ border-radius: inherit;
74
+ /* One step up from the shell's --border-xs border: the outer edge stays on
75
+ the border box and the extra pixel grows inward, so the thicker band
76
+ reads clearly on low-density screens without changing the geometry. */
77
+ padding: var(--border-md);
78
+ background: conic-gradient(
79
+ from var(--ds-composer-glow-angle),
80
+ var(--color-ai-gradient-start),
81
+ var(--color-ai-gradient-mid),
82
+ var(--color-ai-gradient-end),
83
+ var(--color-ai-gradient-mid),
84
+ var(--color-ai-gradient-start)
85
+ );
86
+ /* Standard properties only — the build pipeline prefixes per its browser
87
+ targets, and hand-written -webkit- duplicates confuse its merging. */
88
+ mask:
89
+ linear-gradient(#fff 0 0) content-box,
90
+ linear-gradient(#fff 0 0);
91
+ mask-composite: exclude;
92
+ pointer-events: none;
93
+ opacity: 0;
94
+ transition: opacity var(--motion-duration-base) var(--motion-ease-standard);
95
+ /* ds-allow(motion): ambient signature rotation, matched to AiButton's —
96
+ deliberately far slower than the interaction-scale motion tokens */
97
+ animation: ds-composer-glow-turn 7s linear infinite paused;
98
+ }
99
+
100
+ /* The glow: the same gradient, blurred behind the opaque shell at low
101
+ opacity, so it reads as a halo around the border. A colour effect, not
102
+ an elevation shadow — depth stays token-owned. */
103
+ .ds-composer--ai-glow::after {
104
+ content: '';
105
+ position: absolute;
106
+ inset: calc(-1 * var(--border-xs));
107
+ border-radius: inherit;
108
+ background: conic-gradient(
109
+ from var(--ds-composer-glow-angle),
110
+ var(--color-ai-gradient-start),
111
+ var(--color-ai-gradient-mid),
112
+ var(--color-ai-gradient-end),
113
+ var(--color-ai-gradient-mid),
114
+ var(--color-ai-gradient-start)
115
+ );
116
+ /* The glow's spread; a length with no token family, matched to AiButton. */
117
+ --ds-composer-glow: 14px;
118
+ filter: blur(var(--ds-composer-glow));
119
+ opacity: 0;
120
+ z-index: -1;
121
+ pointer-events: none;
122
+ transition: opacity var(--motion-duration-base) var(--motion-ease-standard);
123
+ /* ds-allow(motion): same ambient rotation as the ring, kept in phase */
124
+ animation: ds-composer-glow-turn 7s linear infinite paused;
125
+ }
126
+
127
+ .ds-composer--ai-glow:focus-within::before {
128
+ opacity: 1;
129
+ animation-play-state: running;
130
+ }
131
+
132
+ .ds-composer--ai-glow:focus-within::after {
133
+ opacity: 0.3;
134
+ animation-play-state: running;
135
+ }
136
+
137
+ @keyframes ds-composer-glow-turn {
138
+ to {
139
+ --ds-composer-glow-angle: 360deg;
140
+ }
141
+ }
142
+
143
+ /* Reduced motion: the ring and glow stay, the turning stops. */
144
+ @media (prefers-reduced-motion: reduce) {
145
+ .ds-composer--ai-glow::before,
146
+ .ds-composer--ai-glow::after {
147
+ animation: none;
148
+ }
149
+ }
150
+
39
151
  /* ============================================
40
152
  ATTACHMENTS ROW
41
153
  ============================================ */
@@ -175,6 +287,12 @@
175
287
  border-color: var(--color-input-border-disabled);
176
288
  }
177
289
 
290
+ .ds-composer--disabled:focus-within::before,
291
+ .ds-composer--disabled:focus-within::after {
292
+ opacity: 0;
293
+ animation-play-state: paused;
294
+ }
295
+
178
296
  .ds-composer--disabled .ds-composer__textarea {
179
297
  cursor: not-allowed;
180
298
  color: var(--color-input-text-disabled);
@@ -28,6 +28,13 @@ type ComposerOwnProps = {
28
28
  onStop?: () => void;
29
29
  /** Growth cap in text rows before the textarea scrolls internally. */
30
30
  maxRows?: number;
31
+ /**
32
+ * While focused, the shell wears AiButton's slowly rotating gradient ring
33
+ * and glow in place of the plain selected border — the system's "a model
34
+ * answers here" signal, for composers whose messages are answered by one.
35
+ * Off by default.
36
+ */
37
+ aiGlow?: boolean;
31
38
  /**
32
39
  * Attachment row rendered above the textarea (DocumentChips). Fully
33
40
  * controlled by the caller — Composer never owns the list.
@@ -12,6 +12,7 @@ const Composer = React.forwardRef(
12
12
  streaming = false,
13
13
  onStop,
14
14
  maxRows = 8,
15
+ aiGlow = false,
15
16
  attachments,
16
17
  actions,
17
18
  trailingActions,
@@ -73,7 +74,12 @@ const Composer = React.forwardRef(
73
74
  if (target.closest('button, a, textarea, input, select, [role="button"]')) return;
74
75
  textareaRef.current?.focus();
75
76
  };
76
- const classes = [baseClass, disabled ? `${baseClass}--disabled` : "", className].filter(Boolean).join(" ");
77
+ const classes = [
78
+ baseClass,
79
+ aiGlow ? `${baseClass}--ai-glow` : "",
80
+ disabled ? `${baseClass}--disabled` : "",
81
+ className
82
+ ].filter(Boolean).join(" ");
77
83
  const ariaLabel = rest["aria-label"] ?? (rest["aria-labelledby"] !== void 0 ? void 0 : "Message");
78
84
  return /* @__PURE__ */ jsxs(
79
85
  "div",
@@ -121,3 +121,12 @@
121
121
  color: var(--color-text-tertiary);
122
122
  flex-shrink: 0;
123
123
  }
124
+
125
+ /* Icons take the colour of the text around them. Explicit, not just
126
+ inherited by default: a consumer stylesheet that sets a colour on
127
+ .material-symbols-rounded itself (a common global default) would
128
+ otherwise override inheritance and detach the icon from the state
129
+ colours this component sets on its parents. */
130
+ .ds-contact-card .material-symbols-rounded {
131
+ color: inherit;
132
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import React, { useState, useRef, useCallback, useEffect, useLayoutEffect } from "react";
3
+ import React, { useState, useRef, useId, useCallback, useEffect, useLayoutEffect } from "react";
4
+ import { MOTION_HOVER_HIDE_DELAY_MS } from "../../tokens/motion.js";
4
5
  import "./ContextMenu.css";
5
6
  import "../../fonts/material-symbols.css";
6
7
  function collectItems(entries) {
@@ -15,7 +16,7 @@ function collectItems(entries) {
15
16
  }
16
17
  return result;
17
18
  }
18
- const SubMenuItem = ({ item, baseClass, focused, size, onActivate }) => {
19
+ const SubMenuItem = ({ item, baseClass, itemId, focused, size, onActivate }) => {
19
20
  const [subOpen, setSubOpen] = useState(false);
20
21
  const timeoutRef = useRef(null);
21
22
  const handleMouseEnter = () => {
@@ -23,7 +24,7 @@ const SubMenuItem = ({ item, baseClass, focused, size, onActivate }) => {
23
24
  setSubOpen(true);
24
25
  };
25
26
  const handleMouseLeave = () => {
26
- timeoutRef.current = setTimeout(() => setSubOpen(false), 150);
27
+ timeoutRef.current = setTimeout(() => setSubOpen(false), MOTION_HOVER_HIDE_DELAY_MS);
27
28
  };
28
29
  useEffect(() => {
29
30
  return () => {
@@ -45,6 +46,7 @@ const SubMenuItem = ({ item, baseClass, focused, size, onActivate }) => {
45
46
  return /* @__PURE__ */ jsxs(
46
47
  "li",
47
48
  {
49
+ id: itemId,
48
50
  className: classes,
49
51
  role: "menuitem",
50
52
  "aria-haspopup": "menu",
@@ -67,6 +69,7 @@ const SubMenuItem = ({ item, baseClass, focused, size, onActivate }) => {
67
69
  {
68
70
  entry,
69
71
  baseClass,
72
+ itemId: `${itemId}-sub-${index}`,
70
73
  size,
71
74
  onActivate
72
75
  },
@@ -79,6 +82,7 @@ const SubMenuItem = ({ item, baseClass, focused, size, onActivate }) => {
79
82
  const SubEntry = ({
80
83
  entry,
81
84
  baseClass,
85
+ itemId,
82
86
  size,
83
87
  onActivate
84
88
  }) => {
@@ -93,6 +97,7 @@ const SubEntry = ({
93
97
  {
94
98
  entry: subEntry,
95
99
  baseClass,
100
+ itemId: `${itemId}-sub-${subIndex}`,
96
101
  size,
97
102
  onActivate
98
103
  },
@@ -106,6 +111,7 @@ const SubEntry = ({
106
111
  {
107
112
  item: entry,
108
113
  baseClass,
114
+ itemId,
109
115
  focused: false,
110
116
  size,
111
117
  onActivate
@@ -120,6 +126,7 @@ const SubEntry = ({
120
126
  return /* @__PURE__ */ jsxs(
121
127
  "li",
122
128
  {
129
+ id: itemId,
123
130
  className: itemClasses,
124
131
  role: "menuitem",
125
132
  "aria-disabled": entry.disabled || void 0,
@@ -154,6 +161,8 @@ const ContextMenu = React.forwardRef(
154
161
  const [focusedIndex, setFocusedIndex] = useState(-1);
155
162
  const rootRef = useRef(null);
156
163
  const panelRef = useRef(null);
164
+ const previousFocusRef = useRef(null);
165
+ const generatedId = useId();
157
166
  const baseClass = "ds-context-menu";
158
167
  const classes = [baseClass, className].filter(Boolean).join(" ");
159
168
  const isOpen = position !== null;
@@ -166,6 +175,11 @@ const ContextMenu = React.forwardRef(
166
175
  const handleClose = useCallback(() => {
167
176
  setPosition(null);
168
177
  setFocusedIndex(-1);
178
+ const previous = previousFocusRef.current;
179
+ previousFocusRef.current = null;
180
+ if (previous && document.contains(previous)) {
181
+ previous.focus();
182
+ }
169
183
  }, []);
170
184
  const handleContextMenu = (e) => {
171
185
  onContextMenu?.(e);
@@ -177,6 +191,9 @@ const ContextMenu = React.forwardRef(
177
191
  x = rect.left + rect.width / 2;
178
192
  y = rect.top + rect.height / 2;
179
193
  }
194
+ if (!isOpen) {
195
+ previousFocusRef.current = document.activeElement;
196
+ }
180
197
  setPosition({ x, y });
181
198
  setFocusedIndex(-1);
182
199
  };
@@ -248,6 +265,22 @@ const ContextMenu = React.forwardRef(
248
265
  return next >= 0 ? next : prev;
249
266
  });
250
267
  break;
268
+ case "Home":
269
+ e.preventDefault();
270
+ setFocusedIndex((prev) => {
271
+ let next = 0;
272
+ while (next < flatItems.length && flatItems[next].disabled) next++;
273
+ return next < flatItems.length ? next : prev;
274
+ });
275
+ break;
276
+ case "End":
277
+ e.preventDefault();
278
+ setFocusedIndex((prev) => {
279
+ let next = flatItems.length - 1;
280
+ while (next >= 0 && flatItems[next].disabled) next--;
281
+ return next >= 0 ? next : prev;
282
+ });
283
+ break;
251
284
  case "Escape":
252
285
  case "Tab":
253
286
  e.preventDefault();
@@ -276,6 +309,7 @@ const ContextMenu = React.forwardRef(
276
309
  {
277
310
  item: entry,
278
311
  baseClass,
312
+ itemId: `${generatedId}-item-${myIndex}`,
279
313
  focused: isFocused,
280
314
  size,
281
315
  onActivate: handleClose
@@ -292,6 +326,7 @@ const ContextMenu = React.forwardRef(
292
326
  return /* @__PURE__ */ jsxs(
293
327
  "li",
294
328
  {
329
+ id: `${generatedId}-item-${myIndex}`,
295
330
  className: itemClasses,
296
331
  role: "menuitem",
297
332
  "aria-disabled": entry.disabled || void 0,
@@ -333,6 +368,7 @@ const ContextMenu = React.forwardRef(
333
368
  className: panelClasses,
334
369
  role: "menu",
335
370
  "aria-label": ariaLabel,
371
+ "aria-activedescendant": focusedIndex >= 0 ? `${generatedId}-item-${focusedIndex}` : void 0,
336
372
  tabIndex: -1,
337
373
  ref: (el) => {
338
374
  panelRef.current = el;