rift-ds 1.2.0 → 1.4.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 (68) hide show
  1. package/README.md +2 -2
  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/Chart/BarChart.js +1 -1
  18. package/components/Chart/ComboChart.js +1 -1
  19. package/components/Chart/Treemap.js +83 -6
  20. package/components/ChatMessage/ChatMessage.css +24 -0
  21. package/components/ChatMessage/ChatMessage.d.ts +6 -0
  22. package/components/ChatMessage/ChatMessage.js +8 -5
  23. package/components/ColorPicker/ColorPicker.js +1 -1
  24. package/components/Composer/Composer.css +140 -5
  25. package/components/Composer/Composer.d.ts +71 -14
  26. package/components/Composer/Composer.js +204 -11
  27. package/components/DatePicker/DatePicker.css +1 -1
  28. package/components/DocumentChip/DocumentChip.d.ts +7 -5
  29. package/components/EventCalendar/EventCalendar.css +2 -2
  30. package/components/FileInput/FileInput.css +1 -1
  31. package/components/FileInput/FileInput.js +1 -7
  32. package/components/Globe/Globe.d.ts +5 -3
  33. package/components/Globe/Globe.js +18 -2
  34. package/components/Inspector/Inspector.css +108 -3
  35. package/components/Inspector/InspectorColorPicker.d.ts +43 -0
  36. package/components/Inspector/InspectorColorPicker.js +51 -0
  37. package/components/Instructions/Instructions.css +4 -4
  38. package/components/MessageCard/MessageCard.d.ts +1 -1
  39. package/components/Prose/Prose.css +27 -0
  40. package/components/Sparkline/Sparkline.css +2 -2
  41. package/components/Spinner/Spinner.css +1 -1
  42. package/components/Timeline/Timeline.css +1 -1
  43. package/components/WorldMap/land.d.ts +1 -1
  44. package/components/registry.json +48 -3
  45. package/components/registry.json.d.ts +48 -3
  46. package/components/registry.json.js +1 -1
  47. package/index.d.ts +5 -0
  48. package/index.js +21 -0
  49. package/package.json +9 -1
  50. package/tokens/motion.d.ts +2 -0
  51. package/tokens/motion.js +2 -0
  52. package/tokens/presets/contrast.css +2 -4
  53. package/tokens/presets/coral.css +2 -4
  54. package/tokens/presets/forest.css +2 -4
  55. package/tokens/presets/gold.css +4 -4
  56. package/tokens/presets/mono.css +0 -2
  57. package/tokens/presets/pink.css +4 -4
  58. package/tokens/presets/terminal.css +5 -4
  59. package/tokens/presets/violet.css +2 -4
  60. package/tokens/presets/warm.css +3 -4
  61. package/tokens/presets/zest.css +2 -4
  62. package/tokens/registry.json +5 -0
  63. package/tokens/registry.json.d.ts +5 -0
  64. package/tokens/registry.json.js +1 -1
  65. package/tokens/tokens-dark.css +14 -5
  66. package/tokens/tokens-light.css +16 -2
  67. package/tokens/tokens-motion.css +2 -0
  68. package/tokens/tokens-primitives.css +10 -0
@@ -0,0 +1,201 @@
1
+ "use client";
2
+ import { useState, useRef, useEffect, useCallback } from "react";
3
+ import { matchesAccept, getFileKind } from "./fileTypes.js";
4
+ function revokeAttachmentUrls(items) {
5
+ for (const item of items) {
6
+ if (item.previewSrc?.startsWith("blob:")) URL.revokeObjectURL(item.previewSrc);
7
+ }
8
+ }
9
+ const DEFAULT_EXCERPT_LENGTH = 240;
10
+ let nextId = 0;
11
+ const createId = () => `attachment-${Date.now().toString(36)}-${(nextId++).toString(36)}`;
12
+ const fileIdentity = (file) => `${file.name}:${file.size}:${file.lastModified}`;
13
+ function useAttachments(options = {}) {
14
+ const [items, setItems] = useState([]);
15
+ const itemsRef = useRef([]);
16
+ const sourcesRef = useRef(/* @__PURE__ */ new Map());
17
+ const abortsRef = useRef(/* @__PURE__ */ new Map());
18
+ const optionsRef = useRef(options);
19
+ useEffect(() => {
20
+ optionsRef.current = options;
21
+ });
22
+ const commit = useCallback((next) => {
23
+ itemsRef.current = next;
24
+ setItems(next);
25
+ }, []);
26
+ const patch = useCallback(
27
+ (id, change) => {
28
+ if (!itemsRef.current.some((item) => item.id === id)) return;
29
+ commit(itemsRef.current.map((item) => item.id === id ? { ...item, ...change } : item));
30
+ },
31
+ [commit]
32
+ );
33
+ const startUpload = useCallback(
34
+ (id, file) => {
35
+ const upload = optionsRef.current.upload;
36
+ if (!upload) return;
37
+ const controller = new AbortController();
38
+ abortsRef.current.set(id, controller);
39
+ upload(file, {
40
+ signal: controller.signal,
41
+ onProgress: (percent) => {
42
+ if (!controller.signal.aborted)
43
+ patch(id, { progress: Math.max(0, Math.min(100, percent)) });
44
+ }
45
+ }).then(
46
+ () => {
47
+ if (controller.signal.aborted) return;
48
+ abortsRef.current.delete(id);
49
+ patch(id, { status: "ready", progress: void 0, error: void 0 });
50
+ },
51
+ (reason) => {
52
+ if (controller.signal.aborted) return;
53
+ abortsRef.current.delete(id);
54
+ patch(id, {
55
+ status: "error",
56
+ progress: void 0,
57
+ error: reason instanceof Error && reason.message !== "" ? reason.message : void 0
58
+ });
59
+ }
60
+ );
61
+ },
62
+ [patch]
63
+ );
64
+ const add = useCallback(
65
+ (files) => {
66
+ const { accept, maxSize, maxCount, allowDuplicates, upload, onReject } = optionsRef.current;
67
+ const rejections = [];
68
+ const accepted = [];
69
+ const known = /* @__PURE__ */ new Set();
70
+ for (const source of sourcesRef.current.values()) {
71
+ if (typeof source !== "string") known.add(fileIdentity(source));
72
+ }
73
+ for (const file of files) {
74
+ if (accept && !matchesAccept(file, accept)) {
75
+ rejections.push({ file, reason: "type" });
76
+ } else if (maxSize !== void 0 && file.size > maxSize) {
77
+ rejections.push({ file, reason: "size" });
78
+ } else if (!allowDuplicates && known.has(fileIdentity(file))) {
79
+ rejections.push({ file, reason: "duplicate" });
80
+ } else if (maxCount !== void 0 && itemsRef.current.length + accepted.length >= maxCount) {
81
+ rejections.push({ file, reason: "count" });
82
+ } else {
83
+ known.add(fileIdentity(file));
84
+ const kind = getFileKind(file);
85
+ accepted.push({
86
+ file,
87
+ item: {
88
+ id: createId(),
89
+ name: file.name,
90
+ kind,
91
+ size: file.size,
92
+ mimeType: file.type || void 0,
93
+ status: upload ? "uploading" : "ready",
94
+ previewSrc: kind === "image" ? URL.createObjectURL(file) : void 0
95
+ }
96
+ });
97
+ }
98
+ }
99
+ if (accepted.length > 0) {
100
+ for (const { item, file } of accepted) sourcesRef.current.set(item.id, file);
101
+ commit([...itemsRef.current, ...accepted.map(({ item }) => item)]);
102
+ for (const { item, file } of accepted) startUpload(item.id, file);
103
+ }
104
+ if (rejections.length > 0) onReject?.(rejections);
105
+ return rejections;
106
+ },
107
+ [commit, startUpload]
108
+ );
109
+ const addText = useCallback(
110
+ (text, name = "Pasted text") => {
111
+ const { maxCount, excerptLength = DEFAULT_EXCERPT_LENGTH } = optionsRef.current;
112
+ if (maxCount !== void 0 && itemsRef.current.length >= maxCount) return;
113
+ const id = createId();
114
+ sourcesRef.current.set(id, text);
115
+ commit([
116
+ ...itemsRef.current,
117
+ {
118
+ id,
119
+ name,
120
+ kind: "pasted",
121
+ // Bytes, not characters: the size line should match what a file of
122
+ // this text would weigh.
123
+ size: new Blob([text]).size,
124
+ mimeType: "text/plain",
125
+ status: "ready",
126
+ excerpt: text.trim().slice(0, excerptLength)
127
+ }
128
+ ]);
129
+ },
130
+ [commit]
131
+ );
132
+ const release = useCallback((dropped) => {
133
+ for (const item of dropped) {
134
+ abortsRef.current.get(item.id)?.abort();
135
+ abortsRef.current.delete(item.id);
136
+ sourcesRef.current.delete(item.id);
137
+ }
138
+ revokeAttachmentUrls(dropped);
139
+ }, []);
140
+ const remove = useCallback(
141
+ (id) => {
142
+ const dropped = itemsRef.current.filter((item) => item.id === id);
143
+ if (dropped.length === 0) return;
144
+ release(dropped);
145
+ commit(itemsRef.current.filter((item) => item.id !== id));
146
+ },
147
+ [commit, release]
148
+ );
149
+ const retry = useCallback(
150
+ (id) => {
151
+ const source = sourcesRef.current.get(id);
152
+ if (source === void 0 || typeof source === "string") return;
153
+ if (!optionsRef.current.upload) return;
154
+ patch(id, { status: "uploading", progress: void 0, error: void 0 });
155
+ startUpload(id, source);
156
+ },
157
+ [patch, startUpload]
158
+ );
159
+ const clear = useCallback(() => {
160
+ release(itemsRef.current);
161
+ commit([]);
162
+ }, [commit, release]);
163
+ const take = useCallback(() => {
164
+ const taken = itemsRef.current;
165
+ const files = [];
166
+ for (const item of taken) {
167
+ const source = sourcesRef.current.get(item.id);
168
+ if (source !== void 0 && typeof source !== "string") files.push(source);
169
+ abortsRef.current.get(item.id)?.abort();
170
+ }
171
+ abortsRef.current.clear();
172
+ sourcesRef.current.clear();
173
+ commit([]);
174
+ return { items: taken, files };
175
+ }, [commit]);
176
+ const getSource = useCallback((id) => sourcesRef.current.get(id), []);
177
+ useEffect(() => {
178
+ const aborts = abortsRef.current;
179
+ return () => {
180
+ for (const controller of aborts.values()) controller.abort();
181
+ aborts.clear();
182
+ revokeAttachmentUrls(itemsRef.current);
183
+ itemsRef.current = [];
184
+ };
185
+ }, []);
186
+ return {
187
+ items,
188
+ add,
189
+ addText,
190
+ remove,
191
+ retry,
192
+ clear,
193
+ take,
194
+ getSource,
195
+ isUploading: items.some((item) => item.status === "uploading")
196
+ };
197
+ }
198
+ export {
199
+ revokeAttachmentUrls,
200
+ useAttachments
201
+ };
@@ -39,7 +39,7 @@ const BarChart = ({
39
39
  const subtitleId = useId();
40
40
  const baseClass = "ds-chart";
41
41
  const classes = [baseClass, bare ? `${baseClass}--bare` : "", className].filter(Boolean).join(" ");
42
- const resolvedBarColor = barColor || getCSSVar("--color-action-primary-bg", "#0E6E8F");
42
+ const resolvedBarColor = barColor || getCSSVar("--color-chart-series-1", "#0E6E8F");
43
43
  const textSecondary = getCSSVar("--color-text-secondary", "#303030");
44
44
  const gridColor = getCSSVar("--color-divider", "rgba(214, 214, 214, 0.8)");
45
45
  const cursorColor = getCSSVar("--color-bg-container-secondary", "#F1F1F1");
@@ -45,7 +45,7 @@ const ComboChart = ({
45
45
  const baseClass = "ds-chart";
46
46
  const classes = [baseClass, bare ? `${baseClass}--bare` : "", className].filter(Boolean).join(" ");
47
47
  const seriesColors = getChartSeriesColors();
48
- const resolvedBarColor = barColor || getCSSVar("--color-action-primary-bg", "#0E6E8F");
48
+ const resolvedBarColor = barColor || getCSSVar("--color-chart-series-1", "#0E6E8F");
49
49
  const resolvedLineColor = lineColor || seriesColors[1];
50
50
  const textSecondary = getCSSVar("--color-text-secondary", "#303030");
51
51
  const gridColor = getCSSVar("--color-divider", "rgba(214, 214, 214, 0.8)");
@@ -1,22 +1,88 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
2
- import { useId, useCallback } from "react";
3
+ import { useId, useCallback, useState, useEffect, useRef } from "react";
3
4
  import { ResponsiveContainer, Treemap as Treemap$1, Tooltip } from "recharts";
4
5
  import { getChartSeriesColors } from "./palette.js";
5
6
  import "./Chart.css";
6
7
  function getCSSVar(name, fallback) {
7
8
  return `var(${name}, ${fallback})`;
8
9
  }
10
+ let colorProbe;
11
+ function toRgb(value) {
12
+ const input = value.trim();
13
+ if (!input || input === "none") return null;
14
+ if (colorProbe === void 0) {
15
+ colorProbe = document.createElement("canvas").getContext("2d");
16
+ }
17
+ if (!colorProbe) return null;
18
+ colorProbe.fillStyle = "#000000";
19
+ colorProbe.fillStyle = input;
20
+ const out = String(colorProbe.fillStyle);
21
+ if (out.startsWith("#")) {
22
+ return [1, 3, 5].map((i) => parseInt(out.slice(i, i + 2), 16));
23
+ }
24
+ const m = out.match(/rgba?\(([^)]+)\)/);
25
+ if (!m) return null;
26
+ const [r, g, b] = m[1].split(/[\s,/]+/).map(Number);
27
+ return [r, g, b];
28
+ }
29
+ const luminance = ([r, g, b]) => {
30
+ const lin = (c) => {
31
+ const s = c / 255;
32
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
33
+ };
34
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
35
+ };
36
+ const contrast = (a, b) => {
37
+ const [hi, lo] = [luminance(a), luminance(b)].sort((p, q) => q - p);
38
+ return (hi + 0.05) / (lo + 0.05);
39
+ };
40
+ const AA_TEXT = 4.5;
41
+ function pickLabelStyle(rect) {
42
+ const style = getComputedStyle(rect);
43
+ const fill = toRgb(style.fill);
44
+ const primary = toRgb(style.getPropertyValue("--color-text-primary"));
45
+ const onInverse = toRgb(style.getPropertyValue("--color-text-on-inverse"));
46
+ if (!fill || !primary || !onInverse) return { tone: "primary", halo: false };
47
+ const primaryRatio = contrast(primary, fill);
48
+ const onInverseRatio = contrast(onInverse, fill);
49
+ const tone = primaryRatio >= onInverseRatio ? "primary" : "on-inverse";
50
+ return { tone, halo: Math.max(primaryRatio, onInverseRatio) < AA_TEXT };
51
+ }
52
+ function useThemeVersion() {
53
+ const [version, setVersion] = useState(0);
54
+ useEffect(() => {
55
+ const observer = new MutationObserver(() => setVersion((v) => v + 1));
56
+ observer.observe(document.documentElement, {
57
+ attributes: true,
58
+ attributeFilter: ["data-theme", "data-brand", "style", "class"]
59
+ });
60
+ return () => observer.disconnect();
61
+ }, []);
62
+ return version;
63
+ }
9
64
  function TreemapContent(props) {
10
- const { x, y, width, height, name, index, color } = props;
11
- if (width < 4 || height < 4) return null;
65
+ const { x, y, width, height, name, index, color, themeVersion } = props;
66
+ const rectRef = useRef(null);
67
+ const [label, setLabel] = useState({ tone: "primary", halo: false });
12
68
  const seriesColors = getChartSeriesColors();
13
69
  const fill = color || seriesColors[index % seriesColors.length];
14
- const textColor = getCSSVar("--color-text-primary", "#050505");
70
+ const visible = width >= 4 && height >= 4;
15
71
  const showLabel = width > 50 && height > 28;
72
+ useEffect(() => {
73
+ if (!showLabel || !rectRef.current) return;
74
+ setLabel(pickLabelStyle(rectRef.current));
75
+ }, [fill, showLabel, themeVersion]);
76
+ if (!visible) return null;
77
+ const primaryText = getCSSVar("--color-text-primary", "#050505");
78
+ const onInverseText = getCSSVar("--color-text-on-inverse", "#F1F1F1");
79
+ const textColor = label.tone === "primary" ? primaryText : onInverseText;
80
+ const haloColor = label.tone === "primary" ? onInverseText : primaryText;
16
81
  return /* @__PURE__ */ jsxs("g", { children: [
17
82
  /* @__PURE__ */ jsx(
18
83
  "rect",
19
84
  {
85
+ ref: rectRef,
20
86
  x,
21
87
  y,
22
88
  width,
@@ -33,7 +99,17 @@ function TreemapContent(props) {
33
99
  y: y + height / 2,
34
100
  textAnchor: "middle",
35
101
  dominantBaseline: "central",
36
- style: { fill: textColor, fontSize: 12, fontWeight: 600 },
102
+ style: {
103
+ fill: textColor,
104
+ fontSize: 12,
105
+ fontWeight: 600,
106
+ ...label.halo && {
107
+ stroke: haloColor,
108
+ strokeWidth: 3,
109
+ strokeLinejoin: "round",
110
+ paintOrder: "stroke"
111
+ }
112
+ },
37
113
  children: name
38
114
  }
39
115
  )
@@ -62,6 +138,7 @@ const Treemap = ({
62
138
  }) => {
63
139
  const titleId = useId();
64
140
  const subtitleId = useId();
141
+ const themeVersion = useThemeVersion();
65
142
  const baseClass = "ds-chart";
66
143
  const classes = [baseClass, bare ? `${baseClass}--bare` : "", className].filter(Boolean).join(" ");
67
144
  const renderTooltip = useCallback(
@@ -93,7 +170,7 @@ const Treemap = ({
93
170
  data,
94
171
  dataKey,
95
172
  aspectRatio: 4 / 3,
96
- content: /* @__PURE__ */ jsx(TreemapContent, {}),
173
+ content: /* @__PURE__ */ jsx(TreemapContent, { themeVersion }),
97
174
  children: /* @__PURE__ */ jsx(Tooltip, { content: renderTooltip })
98
175
  }
99
176
  ) }) })
@@ -87,6 +87,30 @@
87
87
  color: var(--color-text-tertiary);
88
88
  }
89
89
 
90
+ /* ============================================
91
+ ATTACHMENTS
92
+ Files sent with the turn: outside the bubble, above the text, on the
93
+ speaker's side (the body's own alignment places them).
94
+ ============================================ */
95
+
96
+ .ds-chat-message__attachments {
97
+ display: flex;
98
+ /* The full row, not shrink-to-fit: the group inside sizes itself against
99
+ this width, and a row that took its width from the group would give it
100
+ nothing to measure */
101
+ width: 100%;
102
+ min-width: 0;
103
+ }
104
+
105
+ .ds-chat-message--user .ds-chat-message__attachments {
106
+ justify-content: flex-end;
107
+ }
108
+
109
+ /* A compact thread seats smaller tiles */
110
+ .ds-chat-message--compact .ds-chat-message__attachments {
111
+ --ds-attachment-tile-size: 96px;
112
+ }
113
+
90
114
  /* ============================================
91
115
  CONTENT
92
116
  ============================================ */
@@ -49,6 +49,12 @@ type ChatMessageOwnProps = {
49
49
  * feedback row — rather than a secondary affordance.
50
50
  */
51
51
  showActions?: boolean;
52
+ /**
53
+ * Files sent with this turn, drawn outside the bubble on the speaker's
54
+ * side, above the text. Pass an AttachmentGroup. A turn that carries
55
+ * files and no text draws no bubble at all.
56
+ */
57
+ attachments?: React.ReactNode;
52
58
  /** Footer slot under the content — a sources row, an edited note. */
53
59
  footer?: React.ReactNode;
54
60
  /** Additional CSS classes */
@@ -16,6 +16,7 @@ const ChatMessage = React.forwardRef(
16
16
  pendingLabel = "Waiting for a reply",
17
17
  actions,
18
18
  showActions = false,
19
+ attachments,
19
20
  footer,
20
21
  className = "",
21
22
  children,
@@ -24,6 +25,10 @@ const ChatMessage = React.forwardRef(
24
25
  const baseClass = "ds-chat-message";
25
26
  const hasBubble = bubble ?? role === "user";
26
27
  const hideAvatar = grouped || !showAvatar;
28
+ const hasChildren = React.Children.toArray(children).some(
29
+ (child) => typeof child !== "string" || child.trim() !== ""
30
+ );
31
+ const showContent = pending || hasChildren || !attachments;
27
32
  const classes = [
28
33
  baseClass,
29
34
  `${baseClass}--${role}`,
@@ -39,10 +44,7 @@ const ChatMessage = React.forwardRef(
39
44
  avatar !== void 0 && /* @__PURE__ */ jsx(
40
45
  "span",
41
46
  {
42
- className: [
43
- `${baseClass}__gutter`,
44
- hideAvatar ? `${baseClass}__gutter--hidden` : ""
45
- ].filter(Boolean).join(" "),
47
+ className: [`${baseClass}__gutter`, hideAvatar ? `${baseClass}__gutter--hidden` : ""].filter(Boolean).join(" "),
46
48
  children: avatar
47
49
  }
48
50
  ),
@@ -51,7 +53,8 @@ const ChatMessage = React.forwardRef(
51
53
  author && /* @__PURE__ */ jsx("span", { className: `${baseClass}__author`, children: author }),
52
54
  timestamp && /* @__PURE__ */ jsx("span", { className: `${baseClass}__timestamp`, children: timestamp })
53
55
  ] }),
54
- /* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, children: pending ? /* @__PURE__ */ jsxs("span", { className: `${baseClass}__pending`, role: "status", children: [
56
+ attachments && /* @__PURE__ */ jsx("div", { className: `${baseClass}__attachments`, children: attachments }),
57
+ showContent && /* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, children: pending ? /* @__PURE__ */ jsxs("span", { className: `${baseClass}__pending`, role: "status", children: [
55
58
  /* @__PURE__ */ jsx("span", { className: `${baseClass}__sr-only`, children: pendingLabel }),
56
59
  /* @__PURE__ */ jsxs("span", { className: `${baseClass}__dots`, "aria-hidden": "true", children: [
57
60
  /* @__PURE__ */ jsx("span", { className: `${baseClass}__dot` }),
@@ -243,7 +243,7 @@ const ColorPicker = React.forwardRef(
243
243
  "aria-hidden": "true"
244
244
  }
245
245
  ),
246
- showText && /* @__PURE__ */ jsx("span", { className: `${baseClass}__trigger-text`, children: displayHex })
246
+ showText && /* @__PURE__ */ jsx("span", { id: `${inputId}-value`, className: `${baseClass}__trigger-text`, children: displayHex })
247
247
  ]
248
248
  }
249
249
  ),
@@ -1,6 +1,6 @@
1
1
  /* ============================================
2
2
  COMPOSER COMPONENT
3
- The chat input shell: attachments, auto-growing textarea, send/stop
3
+ The chat input shell: file tray, auto-growing textarea, send/stop
4
4
  ============================================ */
5
5
 
6
6
  /* The focus treatment animates a registered custom property, exactly as
@@ -224,9 +224,10 @@
224
224
 
225
225
  /* ============================================
226
226
  CONTEXT CHIP
227
- A full-width, non-interactive note at the very top of the shell — what
228
- the model is currently looking at. Composer owns the chrome; the caller
229
- passes the text.
227
+ A full-width, non-interactive note — what the model is currently looking
228
+ at. At the very top of the shell by default, or lifted above it as its
229
+ own bar (contextPlacement="above", styled at the end of this section).
230
+ Composer owns the chrome; the caller passes the text.
230
231
  ============================================ */
231
232
 
232
233
  .ds-composer__context {
@@ -267,6 +268,47 @@
267
268
  --icon-size: var(--icon-size-500);
268
269
  }
269
270
 
271
+ /* ---------- contextPlacement="above" ----------
272
+ The chip leaves the shell for its own bar, full width, a small gap clear
273
+ of the shell's top edge, and the layout aligns on one optical rail: the
274
+ bar's --padding-200 ring plus the --padding-400 pill inset (24px). The
275
+ typed text, the attachments, the chip's icon ink and a ModelPicker
276
+ trigger's label all start on it. The inside placement keeps its own
277
+ geometry above, untouched. */
278
+
279
+ /* The bar and the shell travel as one block, so a host's gap never lands
280
+ between them */
281
+ .ds-composer-group {
282
+ display: flex;
283
+ flex-direction: column;
284
+ gap: var(--gap-200);
285
+ width: 100%;
286
+ }
287
+
288
+ .ds-composer__context--above {
289
+ margin: 0;
290
+ /* Outside the shell, so the shell's custom properties do not reach it:
291
+ the send button's raw 40px, restated */
292
+ height: 40px;
293
+ /* The rail, plus the shell border this bar does not sit inside */
294
+ padding: 0 var(--padding-400) 0
295
+ calc(var(--padding-200) + var(--padding-400) + var(--border-025));
296
+ }
297
+
298
+ /* Optical alignment: a glyph's ink starts inside its box, so the box is
299
+ pulled back by that side bearing to land the ink on the rail. The
300
+ default fits the portrait document glyphs a context note usually
301
+ carries (ink starts 4/24 into the box); the fallback is a consumer hook
302
+ for a glyph with a different bearing. */
303
+ .ds-composer__context--above .ds-composer__context-icon {
304
+ margin-left: calc(-1 * var(--ds-composer-context-icon-bearing, var(--icon-size-500) * 4 / 24));
305
+ }
306
+
307
+ .ds-composer--context-above .ds-composer__attachments,
308
+ .ds-composer--context-above .ds-composer__content {
309
+ padding-inline: calc(var(--padding-200) + var(--padding-400));
310
+ }
311
+
270
312
  /* The text owns the truncation: one line, ellipsized only when the note
271
313
  actually outgrows the shell — an ellipsis is a truncation mark, not
272
314
  punctuation. The icon beside it never shrinks. */
@@ -277,7 +319,100 @@
277
319
  }
278
320
 
279
321
  /* ============================================
280
- ATTACHMENTS ROW
322
+ FILE TRAY
323
+ The queued files: one row of square tiles that scrolls sideways rather
324
+ than growing the shell.
325
+ ============================================ */
326
+
327
+ .ds-composer__tray {
328
+ /* How far the tiles sit in from the shell's edge. The tile corner below
329
+ is derived from it, so the two can only move together. */
330
+ --ds-composer-tray-inset: var(--padding-300);
331
+ /* Concentric with the shell: its radius, less the border, less the inset.
332
+ Floored so a squared theme never computes a negative corner. */
333
+ --ds-attachment-tile-radius: max(
334
+ var(--radius-100),
335
+ calc(var(--ds-composer-radius) - var(--border-025) - var(--ds-composer-tray-inset))
336
+ );
337
+
338
+ display: flex;
339
+ /* A scroller may be squeezed by the column it sits in; the tray keeps
340
+ its tiles' full height */
341
+ flex: none;
342
+ gap: var(--gap-200);
343
+ margin: 0;
344
+ /* The inset on every side but the foot, where the text zone's own top
345
+ padding takes over and only a sliver is kept so the scrollbar of an
346
+ overflowing row does not touch the tiles. The top inset also houses the part of a remove
347
+ button that hangs above its tile: a scroller clips what leaves it. */
348
+ padding: var(--ds-composer-tray-inset) var(--ds-composer-tray-inset) var(--padding-100);
349
+ list-style: none;
350
+ overflow-x: auto;
351
+ /* Tiles are objects, not text: the shell's text cursor stops at them */
352
+ cursor: default;
353
+ scrollbar-width: thin;
354
+ scrollbar-color: var(--color-bg-container-secondary) transparent;
355
+ }
356
+
357
+ .ds-composer__tray-item {
358
+ display: flex;
359
+ flex: none;
360
+ }
361
+
362
+ /* ============================================
363
+ DROP HINT
364
+ While files hover over the shell it says what letting go will do.
365
+ ============================================ */
366
+
367
+ .ds-composer--dragging {
368
+ border-color: var(--color-input-border-selected);
369
+ }
370
+
371
+ .ds-composer__drop-hint {
372
+ position: absolute;
373
+ inset: 0;
374
+ z-index: 1;
375
+ display: flex;
376
+ align-items: center;
377
+ justify-content: center;
378
+ border-radius: inherit;
379
+ background-color: var(--color-bg-container-primary-semi);
380
+ font-family: var(--font-paragraph-sm-emphasis-family);
381
+ font-size: var(--font-paragraph-sm-emphasis-size);
382
+ font-weight: var(--font-paragraph-sm-emphasis-weight);
383
+ line-height: var(--font-paragraph-sm-emphasis-line-height);
384
+ letter-spacing: var(--font-paragraph-sm-emphasis-letter-spacing);
385
+ color: var(--color-text-primary);
386
+ /* Never a target: the shell hears the drag, and a hint that caught the
387
+ pointer would fire a leave on everything under it */
388
+ pointer-events: none;
389
+ opacity: 0;
390
+ transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
391
+ }
392
+
393
+ .ds-composer--dragging .ds-composer__drop-hint {
394
+ opacity: 1;
395
+ }
396
+
397
+ /* The picker itself is never seen: the attach button opens it */
398
+ .ds-composer__file-input {
399
+ display: none;
400
+ }
401
+
402
+ .ds-composer__sr-only {
403
+ position: absolute;
404
+ width: 1px;
405
+ height: 1px;
406
+ padding: 0;
407
+ margin: -1px;
408
+ overflow: hidden;
409
+ clip: rect(0, 0, 0, 0);
410
+ white-space: nowrap;
411
+ border: 0;
412
+ }
413
+
414
+ /* ============================================
415
+ ATTACHMENTS ROW (legacy free-form slot)
281
416
  ============================================ */
282
417
 
283
418
  .ds-composer__attachments {