rift-ds 1.3.0 → 1.5.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 (71) hide show
  1. package/README.md +3 -3
  2. package/components/Attachment/Attachment.css +1036 -0
  3. package/components/Attachment/AttachmentDropZone.d.ts +39 -0
  4. package/components/Attachment/AttachmentDropZone.js +76 -0
  5. package/components/Attachment/AttachmentGroup.d.ts +60 -0
  6. package/components/Attachment/AttachmentGroup.js +179 -0
  7. package/components/Attachment/AttachmentTile.d.ts +80 -0
  8. package/components/Attachment/AttachmentTile.js +143 -0
  9. package/components/Attachment/AttachmentViewer.d.ts +66 -0
  10. package/components/Attachment/AttachmentViewer.js +215 -0
  11. package/components/Attachment/fileTypes.d.ts +101 -0
  12. package/components/Attachment/fileTypes.js +182 -0
  13. package/components/Attachment/typeMark.d.ts +11 -0
  14. package/components/Attachment/typeMark.js +51 -0
  15. package/components/Attachment/useAttachments.d.ts +65 -0
  16. package/components/Attachment/useAttachments.js +201 -0
  17. package/components/ChatMessage/ChatMessage.css +24 -0
  18. package/components/ChatMessage/ChatMessage.d.ts +6 -0
  19. package/components/ChatMessage/ChatMessage.js +8 -5
  20. package/components/Composer/Composer.css +95 -2
  21. package/components/Composer/Composer.d.ts +57 -8
  22. package/components/Composer/Composer.js +179 -6
  23. package/components/ContextMenu/ContextMenu.css +25 -0
  24. package/components/ContextMenu/ContextMenu.js +14 -6
  25. package/components/DocumentChip/DocumentChip.d.ts +7 -5
  26. package/components/DropdownMenu/DropdownMenu.css +29 -0
  27. package/components/DropdownMenu/DropdownMenu.d.ts +2 -0
  28. package/components/DropdownMenu/DropdownMenu.js +15 -7
  29. package/components/FileInput/FileInput.js +1 -7
  30. package/components/MessageCard/MessageCard.d.ts +1 -1
  31. package/components/Quote/Quote.css +2 -2
  32. package/components/ShaderField/useShaderField.d.ts +4 -4
  33. package/components/Stat/Stat.css +2 -2
  34. package/components/registry.json +38 -2
  35. package/components/registry.json.d.ts +38 -2
  36. package/components/registry.json.js +1 -1
  37. package/fonts/presets/fredoka-300-700-latin-ext.woff2 +0 -0
  38. package/fonts/presets/fredoka-300-700-latin.woff2 +0 -0
  39. package/fonts/presets/manrope-300-800-latin-ext.woff2 +0 -0
  40. package/fonts/presets/manrope-300-800-latin.woff2 +0 -0
  41. package/fonts/presets/nunito-300-800-latin-ext.woff2 +0 -0
  42. package/fonts/presets/nunito-300-800-latin.woff2 +0 -0
  43. package/fonts/presets/playfair-display-400-800-latin-ext.woff2 +0 -0
  44. package/fonts/presets/playfair-display-400-800-latin.woff2 +0 -0
  45. package/fonts/presets/plus-jakarta-sans-300-800-latin-ext.woff2 +0 -0
  46. package/fonts/presets/plus-jakarta-sans-300-800-latin.woff2 +0 -0
  47. package/fonts/presets/source-sans-3-300-700-latin.woff2 +0 -0
  48. package/index.d.ts +4 -0
  49. package/index.js +19 -0
  50. package/package.json +9 -1
  51. package/tokens/presets/contrast.css +9 -9
  52. package/tokens/presets/coral.css +6 -6
  53. package/tokens/presets/forest.css +10 -10
  54. package/tokens/presets/gold.css +119 -11
  55. package/tokens/presets/mono.css +2 -2
  56. package/tokens/presets/pink.css +104 -24
  57. package/tokens/presets/terminal.css +13 -13
  58. package/tokens/presets/violet.css +114 -26
  59. package/tokens/presets/warm.css +10 -10
  60. package/tokens/presets/zest.css +10 -10
  61. package/tokens/registry.json +4 -0
  62. package/tokens/registry.json.d.ts +4 -0
  63. package/tokens/registry.json.js +1 -1
  64. package/tokens/tokens-dark.css +6 -0
  65. package/tokens/tokens-light.css +12 -0
  66. package/tokens/tokens-primitives.css +10 -0
  67. package/tokens/tokens-typography.css +1 -1
  68. package/fonts/presets/fraunces-300-700-latin-ext.woff2 +0 -0
  69. package/fonts/presets/fraunces-300-700-latin.woff2 +0 -0
  70. package/fonts/presets/work-sans-300-700-latin-ext.woff2 +0 -0
  71. package/fonts/presets/work-sans-300-700-latin.woff2 +0 -0
@@ -1,9 +1,21 @@
1
1
  "use client";
2
- import { jsxs, jsx } from "react/jsx-runtime";
3
- import React, { useRef, useState, useLayoutEffect } from "react";
2
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
3
+ import React, { useRef, useState, useLayoutEffect, useEffect } from "react";
4
+ import { AttachmentTile } from "../Attachment/AttachmentTile.js";
5
+ import { formatBytes } from "../Attachment/fileTypes.js";
4
6
  import { CircularButton } from "../CircularButton/CircularButton.js";
5
7
  import "../../fonts/material-symbols.css";
6
8
  import "./Composer.css";
9
+ const defaultFileAnnouncement = ({
10
+ type,
11
+ names
12
+ }) => {
13
+ const list = names.join(", ");
14
+ if (type === "added") return `Attached ${list}`;
15
+ if (type === "removed") return `Removed ${list}`;
16
+ return `Could not attach ${list}`;
17
+ };
18
+ const dragCarriesFiles = (event) => Array.from(event.dataTransfer?.types ?? []).includes("Files");
7
19
  const Composer = React.forwardRef(
8
20
  ({
9
21
  value,
@@ -17,6 +29,19 @@ const Composer = React.forwardRef(
17
29
  context,
18
30
  contextIcon,
19
31
  contextPlacement = "inside",
32
+ files,
33
+ onFilesSelected,
34
+ onFileRemove,
35
+ onFileClick,
36
+ accept,
37
+ multiple = true,
38
+ attachButton = true,
39
+ pasteThreshold,
40
+ onPasteAsAttachment,
41
+ attachLabel = "Attach files",
42
+ dropLabel = "Drop files to attach",
43
+ filesLabel = "Attachments",
44
+ formatFileAnnouncement = defaultFileAnnouncement,
20
45
  attachments,
21
46
  actions,
22
47
  trailingActions,
@@ -25,6 +50,7 @@ const Composer = React.forwardRef(
25
50
  className = "",
26
51
  onChange,
27
52
  onKeyDown,
53
+ onPaste,
28
54
  ...rest
29
55
  }, ref) => {
30
56
  const baseClass = "ds-composer";
@@ -34,7 +60,17 @@ const Composer = React.forwardRef(
34
60
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
35
61
  const currentValue = isControlled ? value : uncontrolledValue;
36
62
  const disabled = Boolean(rest.disabled);
37
- const canSend = !disabled && currentValue.trim() !== "";
63
+ const queued = files ?? [];
64
+ const hasFiles = queued.length > 0;
65
+ const armed = Boolean(onFilesSelected);
66
+ const anyUploading = queued.some((file) => file.status === "uploading");
67
+ const anyReady = queued.some((file) => file.status === "ready");
68
+ const canSend = !disabled && !anyUploading && (currentValue.trim() !== "" || anyReady);
69
+ const fileInputRef = useRef(null);
70
+ const trayRef = useRef(null);
71
+ const [dragging, setDragging] = useState(false);
72
+ const dragDepth = useRef(0);
73
+ const [announcement, setAnnouncement] = useState("");
38
74
  const setTextareaRef = (node) => {
39
75
  textareaRef.current = node;
40
76
  if (typeof ref === "function") ref(node);
@@ -57,6 +93,92 @@ const Composer = React.forwardRef(
57
93
  observer.observe(node);
58
94
  return () => observer.disconnect();
59
95
  }, []);
96
+ const previousFiles = useRef(queued);
97
+ const announceRef = useRef(formatFileAnnouncement);
98
+ announceRef.current = formatFileAnnouncement;
99
+ useEffect(() => {
100
+ const before = previousFiles.current;
101
+ const now = files ?? [];
102
+ previousFiles.current = now;
103
+ if (before === now) return;
104
+ const beforeById = new Map(before.map((file) => [file.id, file]));
105
+ const nowIds = new Set(now.map((file) => file.id));
106
+ const added = now.filter((file) => !beforeById.has(file.id)).map((file) => file.name);
107
+ const removed = before.filter((file) => !nowIds.has(file.id)).map((file) => file.name);
108
+ const failed = now.filter((file) => file.status === "error" && beforeById.get(file.id)?.status !== "error").map((file) => file.name);
109
+ const parts = [
110
+ failed.length > 0 ? announceRef.current({ type: "failed", names: failed }) : "",
111
+ added.length > 0 ? announceRef.current({ type: "added", names: added }) : "",
112
+ removed.length > 0 ? announceRef.current({ type: "removed", names: removed }) : ""
113
+ ].filter(Boolean);
114
+ if (parts.length > 0) setAnnouncement(parts.join(". "));
115
+ }, [files]);
116
+ const focusAfterRemove = useRef(null);
117
+ useEffect(() => {
118
+ const index = focusAfterRemove.current;
119
+ if (index === null) return;
120
+ focusAfterRemove.current = null;
121
+ const buttons = trayRef.current?.querySelectorAll(
122
+ ".ds-attachment-tile__remove"
123
+ );
124
+ const next = buttons?.[Math.min(index, (buttons?.length ?? 0) - 1)];
125
+ if (next) next.focus();
126
+ else textareaRef.current?.focus();
127
+ }, [files]);
128
+ const handleFileRemove = (id, index) => {
129
+ focusAfterRemove.current = index;
130
+ onFileRemove?.(id);
131
+ };
132
+ const handlePick = (event) => {
133
+ const picked = Array.from(event.target.files ?? []);
134
+ event.target.value = "";
135
+ if (picked.length > 0) onFilesSelected?.(picked);
136
+ };
137
+ const handlePaste = (event) => {
138
+ onPaste?.(event);
139
+ if (event.defaultPrevented) return;
140
+ const pasted = Array.from(event.clipboardData?.files ?? []);
141
+ if (armed && pasted.length > 0) {
142
+ event.preventDefault();
143
+ onFilesSelected?.(pasted);
144
+ return;
145
+ }
146
+ if (pasteThreshold === void 0 || !onPasteAsAttachment) return;
147
+ const text = event.clipboardData?.getData("text/plain") ?? "";
148
+ if (text.length >= pasteThreshold) {
149
+ event.preventDefault();
150
+ onPasteAsAttachment(text);
151
+ }
152
+ };
153
+ const takesDrag = (event) => armed && !disabled && dragCarriesFiles(event);
154
+ const handleDragEnter = (event) => {
155
+ if (!takesDrag(event)) return;
156
+ event.preventDefault();
157
+ event.stopPropagation();
158
+ dragDepth.current += 1;
159
+ setDragging(true);
160
+ };
161
+ const handleDragOver = (event) => {
162
+ if (!takesDrag(event)) return;
163
+ event.preventDefault();
164
+ event.stopPropagation();
165
+ event.dataTransfer.dropEffect = "copy";
166
+ };
167
+ const handleDragLeave = (event) => {
168
+ if (!takesDrag(event)) return;
169
+ event.stopPropagation();
170
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
171
+ if (dragDepth.current === 0) setDragging(false);
172
+ };
173
+ const handleDrop = (event) => {
174
+ if (!takesDrag(event)) return;
175
+ event.preventDefault();
176
+ event.stopPropagation();
177
+ dragDepth.current = 0;
178
+ setDragging(false);
179
+ const dropped = Array.from(event.dataTransfer.files ?? []);
180
+ if (dropped.length > 0) onFilesSelected?.(dropped);
181
+ };
60
182
  const submit = () => {
61
183
  if (streaming || !canSend) return;
62
184
  onSubmit?.(currentValue);
@@ -83,6 +205,7 @@ const Composer = React.forwardRef(
83
205
  aiGlow ? `${baseClass}--ai-glow` : "",
84
206
  streaming ? `${baseClass}--streaming` : "",
85
207
  disabled ? `${baseClass}--disabled` : "",
208
+ dragging ? `${baseClass}--dragging` : "",
86
209
  // The above placement moves the text onto the chip's rail; the shell
87
210
  // needs to know, since the chip is no longer inside it.
88
211
  context && contextPlacement === "above" ? `${baseClass}--context-above` : "",
@@ -107,8 +230,28 @@ const Composer = React.forwardRef(
107
230
  className: classes,
108
231
  style: { "--ds-composer-max-rows": maxRows },
109
232
  onClick: handleShellClick,
233
+ onDragEnter: armed ? handleDragEnter : void 0,
234
+ onDragOver: armed ? handleDragOver : void 0,
235
+ onDragLeave: armed ? handleDragLeave : void 0,
236
+ onDrop: armed ? handleDrop : void 0,
110
237
  children: [
111
238
  !contextAbove && contextChip,
239
+ hasFiles && /* @__PURE__ */ jsx("ul", { className: `${baseClass}__tray`, ref: trayRef, "aria-label": filesLabel, children: queued.map((file, index) => /* @__PURE__ */ jsx("li", { className: `${baseClass}__tray-item`, children: /* @__PURE__ */ jsx(
240
+ AttachmentTile,
241
+ {
242
+ name: file.name,
243
+ kind: file.kind,
244
+ status: file.status,
245
+ progress: file.progress,
246
+ error: file.error,
247
+ previewSrc: file.previewSrc,
248
+ previewAlt: file.previewAlt,
249
+ excerpt: file.excerpt,
250
+ meta: file.size === void 0 ? void 0 : formatBytes(file.size),
251
+ onClick: onFileClick ? () => onFileClick(file) : void 0,
252
+ onRemove: onFileRemove && !disabled ? () => handleFileRemove(file.id, index) : void 0
253
+ }
254
+ ) }, file.id)) }),
112
255
  attachments && /* @__PURE__ */ jsx("div", { className: `${baseClass}__attachments`, children: attachments }),
113
256
  /* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, ref: contentRef, children: /* @__PURE__ */ jsx(
114
257
  "textarea",
@@ -120,11 +263,24 @@ const Composer = React.forwardRef(
120
263
  value: currentValue,
121
264
  "aria-label": ariaLabel,
122
265
  onChange: handleChange,
123
- onKeyDown: handleKeyDown
266
+ onKeyDown: handleKeyDown,
267
+ onPaste: handlePaste
124
268
  }
125
269
  ) }),
126
270
  /* @__PURE__ */ jsxs("div", { className: `${baseClass}__footer`, children: [
127
- actions && /* @__PURE__ */ jsx("div", { className: `${baseClass}__actions`, children: actions }),
271
+ (actions || armed && attachButton) && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__actions`, children: [
272
+ armed && attachButton && /* @__PURE__ */ jsx(
273
+ CircularButton,
274
+ {
275
+ icon: "add",
276
+ variant: "tertiary",
277
+ ariaLabel: attachLabel,
278
+ disabled,
279
+ onClick: () => fileInputRef.current?.click()
280
+ }
281
+ ),
282
+ actions
283
+ ] }),
128
284
  /* @__PURE__ */ jsxs("div", { className: `${baseClass}__trailing`, children: [
129
285
  trailingActions,
130
286
  streaming ? /* @__PURE__ */ jsx(
@@ -147,7 +303,24 @@ const Composer = React.forwardRef(
147
303
  }
148
304
  )
149
305
  ] })
150
- ] })
306
+ ] }),
307
+ armed && /* @__PURE__ */ jsxs(Fragment, { children: [
308
+ /* @__PURE__ */ jsx(
309
+ "input",
310
+ {
311
+ ref: fileInputRef,
312
+ className: `${baseClass}__file-input`,
313
+ type: "file",
314
+ accept,
315
+ multiple,
316
+ tabIndex: -1,
317
+ "aria-hidden": "true",
318
+ onChange: handlePick
319
+ }
320
+ ),
321
+ /* @__PURE__ */ jsx("div", { className: `${baseClass}__drop-hint`, "aria-hidden": "true", children: dropLabel })
322
+ ] }),
323
+ (armed || hasFiles) && /* @__PURE__ */ jsx("span", { className: `${baseClass}__sr-only`, role: "status", children: announcement })
151
324
  ]
152
325
  }
153
326
  );
@@ -114,6 +114,31 @@
114
114
  background-color: var(--color-action-passive-bg-hover);
115
115
  }
116
116
 
117
+ /* States — selected: the current choice of a set. DropdownMenu.css owns
118
+ the recipe (passive wash held, full-strength text and icon, trailing
119
+ check); the two menus share the entry model, so they share the look. */
120
+ .ds-context-menu__item--selected {
121
+ background-color: var(--color-action-passive-bg-hover);
122
+ color: var(--color-text-primary);
123
+ }
124
+
125
+ .ds-context-menu__item--selected:hover,
126
+ .ds-context-menu__item--selected.ds-context-menu__item--focused {
127
+ background-color: var(--color-action-passive-bg-active);
128
+ }
129
+
130
+ .ds-context-menu__item--selected .ds-context-menu__item-icon {
131
+ color: var(--color-icon-primary);
132
+ }
133
+
134
+ .ds-context-menu__check {
135
+ --icon-size: var(--icon-size-500);
136
+ color: var(--color-icon-primary);
137
+ margin-left: auto;
138
+ flex-shrink: 0;
139
+ line-height: 1;
140
+ }
141
+
117
142
  /* States — disabled */
118
143
  .ds-context-menu__item--disabled {
119
144
  opacity: 0.4;
@@ -121,14 +121,17 @@ const SubEntry = ({
121
121
  const itemClasses = [
122
122
  `${baseClass}__item`,
123
123
  entry.disabled ? `${baseClass}__item--disabled` : "",
124
- entry.destructive ? `${baseClass}__item--destructive` : ""
124
+ entry.destructive ? `${baseClass}__item--destructive` : "",
125
+ entry.selected ? `${baseClass}__item--selected` : ""
125
126
  ].filter(Boolean).join(" ");
127
+ const isChoice = entry.selected !== void 0;
126
128
  return /* @__PURE__ */ jsxs(
127
129
  "li",
128
130
  {
129
131
  id: itemId,
130
132
  className: itemClasses,
131
- role: "menuitem",
133
+ role: isChoice ? "menuitemradio" : "menuitem",
134
+ "aria-checked": isChoice ? entry.selected : void 0,
132
135
  "aria-disabled": entry.disabled || void 0,
133
136
  onClick: (e) => {
134
137
  e.stopPropagation();
@@ -142,7 +145,8 @@ const SubEntry = ({
142
145
  entry.icon && /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon material-symbols-rounded`, "aria-hidden": "true", children: entry.icon }),
143
146
  entry.label
144
147
  ] }),
145
- entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut })
148
+ entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut }),
149
+ entry.selected && /* @__PURE__ */ jsx("span", { className: `${baseClass}__check material-symbols-rounded`, "aria-hidden": "true", children: "check" })
146
150
  ]
147
151
  }
148
152
  );
@@ -321,14 +325,17 @@ const ContextMenu = React.forwardRef(
321
325
  `${baseClass}__item`,
322
326
  isFocused ? `${baseClass}__item--focused` : "",
323
327
  entry.disabled ? `${baseClass}__item--disabled` : "",
324
- entry.destructive ? `${baseClass}__item--destructive` : ""
328
+ entry.destructive ? `${baseClass}__item--destructive` : "",
329
+ entry.selected ? `${baseClass}__item--selected` : ""
325
330
  ].filter(Boolean).join(" ");
331
+ const isChoice = entry.selected !== void 0;
326
332
  return /* @__PURE__ */ jsxs(
327
333
  "li",
328
334
  {
329
335
  id: `${generatedId}-item-${myIndex}`,
330
336
  className: itemClasses,
331
- role: "menuitem",
337
+ role: isChoice ? "menuitemradio" : "menuitem",
338
+ "aria-checked": isChoice ? entry.selected : void 0,
332
339
  "aria-disabled": entry.disabled || void 0,
333
340
  onClick: (e) => {
334
341
  e.stopPropagation();
@@ -349,7 +356,8 @@ const ContextMenu = React.forwardRef(
349
356
  ),
350
357
  entry.label
351
358
  ] }),
352
- entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut })
359
+ entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut }),
360
+ entry.selected && /* @__PURE__ */ jsx("span", { className: `${baseClass}__check material-symbols-rounded`, "aria-hidden": "true", children: "check" })
353
361
  ]
354
362
  },
355
363
  `item-${index}`
@@ -28,14 +28,16 @@ type DocumentChipOwnProps = {
28
28
  export interface DocumentChipProps extends DocumentChipOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof DocumentChipOwnProps> {
29
29
  }
30
30
  /**
31
- * DocumentChip is a compact file reference: a two-line tile with a type
32
- * icon, name, metadata line, upload progress, and an optional remove
33
- * button. It references documents attached to chat messages or queued
34
- * above a composer — Chip stays the one-line pill for attributes, and
35
- * FileInput stays the form control that owns selection.
31
+ * DocumentChip is the earlier, one-row file reference: a type icon, name,
32
+ * metadata line, upload progress, and an optional remove button. It keeps
33
+ * working for existing consumers, and new work uses the attachment family:
34
+ * AttachmentTile for a file, Composer's `files` for a queue, and
35
+ * AttachmentGroup for the files on a sent message.
36
36
  *
37
37
  * The root is always a `<div>`; passing `onClick` turns the body into a
38
38
  * `<button>` so click and remove coexist without nesting controls.
39
+ *
40
+ * @deprecated Use `AttachmentTile` instead.
39
41
  */
40
42
  export declare const DocumentChip: React.ForwardRefExoticComponent<DocumentChipProps & React.RefAttributes<HTMLDivElement>>;
41
43
  export {};
@@ -145,6 +145,35 @@
145
145
  background-color: var(--color-action-passive-bg-hover);
146
146
  }
147
147
 
148
+ /* ============================================
149
+ SELECTED ITEM
150
+ The current choice of a set: the passive wash held down, text and icon
151
+ at full strength, and a trailing check — three cues, none of them the
152
+ action colour, which a menu row has no claim on.
153
+ ============================================ */
154
+
155
+ .ds-dropdown-menu__item--selected {
156
+ background-color: var(--color-action-passive-bg-hover);
157
+ color: var(--color-text-primary);
158
+ }
159
+
160
+ .ds-dropdown-menu__item--selected:hover,
161
+ .ds-dropdown-menu__item--selected.ds-dropdown-menu__item--focused {
162
+ background-color: var(--color-action-passive-bg-active);
163
+ }
164
+
165
+ .ds-dropdown-menu__item--selected .ds-dropdown-menu__item-icon {
166
+ color: var(--color-icon-primary);
167
+ }
168
+
169
+ .ds-dropdown-menu__check {
170
+ --icon-size: var(--icon-size-500);
171
+ color: var(--color-icon-primary);
172
+ margin-left: auto;
173
+ flex-shrink: 0;
174
+ line-height: 1;
175
+ }
176
+
148
177
  /* ============================================
149
178
  DISABLED ITEM
150
179
  ============================================ */
@@ -11,6 +11,8 @@ export interface DropdownMenuItem {
11
11
  disabled?: boolean;
12
12
  /** Destructive / danger styling (red text) for delete, remove, etc. */
13
13
  destructive?: boolean;
14
+ /** Marks the item as the current choice of a set: it holds the passive wash and a trailing check, and is announced as a checked radio item. Leave undefined on plain action items; pass `false` on the unchosen members of a set so they are announced as radio items too. Ignored on an item that opens a sub-menu. */
15
+ selected?: boolean;
14
16
  /** Click handler */
15
17
  onClick?: () => void;
16
18
  /** Nested sub-menu items */
@@ -120,14 +120,17 @@ const MenuPanel = ({
120
120
  const itemClasses = [
121
121
  `${baseClass}__item`,
122
122
  entry.disabled ? `${baseClass}__item--disabled` : "",
123
- entry.destructive ? `${baseClass}__item--destructive` : ""
123
+ entry.destructive ? `${baseClass}__item--destructive` : "",
124
+ entry.selected ? `${baseClass}__item--selected` : ""
124
125
  ].filter(Boolean).join(" ");
126
+ const isChoice = entry.selected !== void 0;
125
127
  return /* @__PURE__ */ jsxs(
126
128
  "li",
127
129
  {
128
130
  id: entryId,
129
131
  className: itemClasses,
130
- role: "menuitem",
132
+ role: isChoice ? "menuitemradio" : "menuitem",
133
+ "aria-checked": isChoice ? entry.selected : void 0,
131
134
  "aria-disabled": entry.disabled || void 0,
132
135
  onClick: (e) => {
133
136
  e.stopPropagation();
@@ -141,7 +144,8 @@ const MenuPanel = ({
141
144
  entry.icon && (typeof entry.icon === "string" ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon material-symbols-rounded`, "aria-hidden": "true", children: entry.icon }) : /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon`, "aria-hidden": "true", children: entry.icon })),
142
145
  entry.label
143
146
  ] }),
144
- entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut })
147
+ entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut }),
148
+ entry.selected && /* @__PURE__ */ jsx("span", { className: `${baseClass}__check material-symbols-rounded`, "aria-hidden": "true", children: "check" })
145
149
  ]
146
150
  },
147
151
  `item-${index}`
@@ -253,7 +257,7 @@ const DropdownMenu = ({
253
257
  };
254
258
  useEffect(() => {
255
259
  if (isOpen && focusedIndex >= 0 && panelRef.current) {
256
- const allItems = panelRef.current.querySelectorAll(`[role="menuitem"]`);
260
+ const allItems = panelRef.current.querySelectorAll(`[role="menuitem"], [role="menuitemradio"]`);
257
261
  const el = allItems[focusedIndex];
258
262
  el?.scrollIntoView({ block: "nearest" });
259
263
  }
@@ -291,14 +295,17 @@ const DropdownMenu = ({
291
295
  `${baseClass}__item`,
292
296
  isFocused ? `${baseClass}__item--focused` : "",
293
297
  entry.disabled ? `${baseClass}__item--disabled` : "",
294
- entry.destructive ? `${baseClass}__item--destructive` : ""
298
+ entry.destructive ? `${baseClass}__item--destructive` : "",
299
+ entry.selected ? `${baseClass}__item--selected` : ""
295
300
  ].filter(Boolean).join(" ");
301
+ const isChoice = entry.selected !== void 0;
296
302
  return /* @__PURE__ */ jsxs(
297
303
  "li",
298
304
  {
299
305
  id: `${generatedId}-item-${myIndex}`,
300
306
  className: itemClasses,
301
- role: "menuitem",
307
+ role: isChoice ? "menuitemradio" : "menuitem",
308
+ "aria-checked": isChoice ? entry.selected : void 0,
302
309
  "aria-disabled": entry.disabled || void 0,
303
310
  onClick: (e) => {
304
311
  e.stopPropagation();
@@ -312,7 +319,8 @@ const DropdownMenu = ({
312
319
  entry.icon && (typeof entry.icon === "string" ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon material-symbols-rounded`, "aria-hidden": "true", children: entry.icon }) : /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon`, "aria-hidden": "true", children: entry.icon })),
313
320
  entry.label
314
321
  ] }),
315
- entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut })
322
+ entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut }),
323
+ entry.selected && /* @__PURE__ */ jsx("span", { className: `${baseClass}__check material-symbols-rounded`, "aria-hidden": "true", children: "check" })
316
324
  ]
317
325
  },
318
326
  `item-${index}`
@@ -3,14 +3,8 @@ import { jsxs, jsx } from "react/jsx-runtime";
3
3
  import React, { useState, useRef, useId } from "react";
4
4
  import { Field } from "../Field/Field.js";
5
5
  import "./FileInput.css";
6
+ import { formatBytes } from "../Attachment/fileTypes.js";
6
7
  import "../../fonts/material-symbols.css";
7
- const formatBytes = (bytes) => {
8
- if (bytes === 0) return "0 B";
9
- const units = ["B", "KB", "MB", "GB", "TB"];
10
- const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
11
- const value = bytes / Math.pow(1024, exponent);
12
- return `${exponent === 0 ? value : value.toFixed(1)} ${units[exponent]}`;
13
- };
14
8
  const FileInput = React.forwardRef(
15
9
  ({
16
10
  label,
@@ -18,7 +18,7 @@ type MessageCardOwnProps = {
18
18
  actions?: React.ReactNode;
19
19
  /** Additional CSS classes */
20
20
  className?: string;
21
- /** Body content between the description and the footer — rich content, a Prose block, a DocumentChip row. */
21
+ /** Body content between the description and the footer — rich content, a Prose block, an AttachmentGroup. */
22
22
  children?: React.ReactNode;
23
23
  };
24
24
  export interface MessageCardProps extends MessageCardOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof MessageCardOwnProps> {
@@ -36,8 +36,8 @@
36
36
 
37
37
  /* ============================================
38
38
  VARIANT — PULL (large display quote)
39
- Display type per the weight-contrast rule —
40
- no border, the scale is the emphasis.
39
+ Display-tier type: no border, the scale is
40
+ the emphasis.
41
41
  ============================================ */
42
42
 
43
43
  .ds-quote--pull .ds-quote__text {
@@ -22,7 +22,7 @@ export interface ShaderParams {
22
22
  /**
23
23
  * How much the composition resists shrinking with the container, 0–1.
24
24
  *
25
- * At 0 — the default, and the behaviour a CSS blob layer has — the whole
25
+ * At 0 — the behaviour a CSS blob layer has — the whole
26
26
  * field is fitted to whatever width it is given, so a phone shows the
27
27
  * entire composition at phone scale: every source small, and more of them
28
28
  * crowded into view than the look was built for. At 1 the field holds the
@@ -31,9 +31,9 @@ export interface ShaderParams {
31
31
  * keeping some of the shrink so colours still bleed together on a small
32
32
  * screen. Above the reference width it does nothing.
33
33
  *
34
- * Raise it for a full-viewport background, which is read on a phone as a
35
- * scene rather than a diagram; leave it at 0 wherever the point is to see
36
- * the whole composition, such as a small demo tile.
34
+ * The default sits between the two, which suits a full-viewport background,
35
+ * read on a phone as a scene rather than a diagram; set it to 0 wherever
36
+ * the point is to see the whole composition, such as a small demo tile.
37
37
  */
38
38
  crop: number;
39
39
  }
@@ -13,8 +13,8 @@
13
13
  }
14
14
 
15
15
  /* ============================================
16
- VALUE — display weight (300), per the
17
- weight-contrast rule for display numerals
16
+ VALUE — the display tier's weight (300 in the
17
+ base theme), so numerals follow the theme
18
18
  ============================================ */
19
19
 
20
20
  .ds-stat__value {
@@ -161,6 +161,42 @@
161
161
  "folder": "Chart",
162
162
  "recharts": true
163
163
  },
164
+ {
165
+ "name": "AttachmentDropZone",
166
+ "label": "Attachment drop zone",
167
+ "slug": "attachment-drop-zone",
168
+ "description": "A wrapper that makes a whole surface a file drop target, with an outlined overlay while files hover over it.",
169
+ "category": "ai",
170
+ "client": true,
171
+ "folder": "Attachment"
172
+ },
173
+ {
174
+ "name": "AttachmentGroup",
175
+ "label": "Attachment group",
176
+ "slug": "attachment-group",
177
+ "description": "The files on a sent message: one picture at its own shape, or a grid of tiles that collapses behind a count.",
178
+ "category": "ai",
179
+ "client": true,
180
+ "folder": "Attachment"
181
+ },
182
+ {
183
+ "name": "AttachmentTile",
184
+ "label": "Attachment tile",
185
+ "slug": "attachment-tile",
186
+ "description": "One file as a square: a picture, a coloured mark for well-known formats, or a neutral badge, with upload states.",
187
+ "category": "ai",
188
+ "client": false,
189
+ "folder": "Attachment"
190
+ },
191
+ {
192
+ "name": "AttachmentViewer",
193
+ "label": "Attachment viewer",
194
+ "slug": "attachment-viewer",
195
+ "description": "A preview dialog for a message's files, with download, stepping, and plain states for files it cannot show.",
196
+ "category": "ai",
197
+ "client": true,
198
+ "folder": "Attachment"
199
+ },
164
200
  {
165
201
  "name": "Avatar",
166
202
  "label": "Avatar",
@@ -369,7 +405,7 @@
369
405
  "name": "Composer",
370
406
  "label": "Composer",
371
407
  "slug": "composer",
372
- "description": "An auto-growing message input with send and stop states, a page-context note, an attachment slot, and Enter-to-send.",
408
+ "description": "An auto-growing message input with send and stop states, a page-context note, a file tray fed by picker, paste and drop, and Enter-to-send.",
373
409
  "category": "ai",
374
410
  "client": true
375
411
  },
@@ -441,7 +477,7 @@
441
477
  "name": "DocumentChip",
442
478
  "label": "Document chip",
443
479
  "slug": "document-chip",
444
- "description": "A compact file reference with a type icon, name, metadata, and optional remove.",
480
+ "description": "A legacy one-row file reference, superseded by the attachment tile and kept for existing consumers.",
445
481
  "category": "ai",
446
482
  "client": false
447
483
  },