astryx-dracula 0.3.1 → 0.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 (59) hide show
  1. package/USAGE.md +25 -0
  2. package/astryx-dracula.d.ts +2 -2
  3. package/astryx-dracula.js +384 -3
  4. package/astryx-dracula.variants.d.ts +2 -14
  5. package/icons.mjs +125 -0
  6. package/package.json +21 -9
  7. package/shared/auth-chrome.tsx +156 -6
  8. package/shared/chaptered-doc.tsx +3 -4
  9. package/shared/chart-hues.ts +0 -5
  10. package/shared/chart-legend.tsx +2 -3
  11. package/shared/data-bar.tsx +5 -5
  12. package/shared/format-money.ts +3 -0
  13. package/shared/metric-card.tsx +127 -0
  14. package/shared/revenue-chart.tsx +63 -7
  15. package/shared/scene-hues.ts +0 -8
  16. package/shared/scene-tile.tsx +3 -11
  17. package/shared/settings-data.ts +71 -1
  18. package/shared/settings-rows.tsx +76 -30
  19. package/shared/stars.tsx +28 -0
  20. package/templates/ai-chat-landing.tsx +105 -67
  21. package/templates/ai-chat.tsx +67 -80
  22. package/templates/centered-hero.tsx +2 -2
  23. package/templates/contact-form.tsx +11 -10
  24. package/templates/dashboard-portfolio.tsx +77 -155
  25. package/templates/dashboard.tsx +24 -137
  26. package/templates/detail-page.tsx +25 -22
  27. package/templates/documentation-design.tsx +39 -478
  28. package/templates/documentation-technical.tsx +13 -31
  29. package/templates/editor.tsx +168 -185
  30. package/templates/file-explorer.tsx +91 -93
  31. package/templates/form-two-column.tsx +1 -1
  32. package/templates/ide.tsx +18 -24
  33. package/templates/incident-console.tsx +67 -42
  34. package/templates/kanban-board.tsx +36 -85
  35. package/templates/library.tsx +42 -66
  36. package/templates/login-card.tsx +33 -123
  37. package/templates/login-split.tsx +96 -119
  38. package/templates/login-sso.tsx +69 -74
  39. package/templates/login.tsx +21 -118
  40. package/templates/messaging-shell.tsx +162 -168
  41. package/templates/payment-form.tsx +63 -71
  42. package/templates/product-detail.tsx +11 -46
  43. package/templates/product-tour.tsx +38 -60
  44. package/templates/settings-dialog.tsx +93 -118
  45. package/templates/settings-sidebar.tsx +122 -143
  46. package/templates/settings.tsx +208 -121
  47. package/templates/shell-nav.tsx +20 -75
  48. package/templates/shell-side-nav.tsx +16 -90
  49. package/templates/shell-top-nav.tsx +20 -53
  50. package/templates/side-gallery.tsx +19 -19
  51. package/templates/table-grouped.tsx +86 -136
  52. package/templates/table-page-chart.tsx +12 -11
  53. package/templates/table-page-heatmap-status.tsx +0 -17
  54. package/templates/table-page-shoe-store-heatmap.tsx +12 -12
  55. package/templates/table-page.tsx +4 -31
  56. package/templates/table.tsx +18 -2
  57. package/templates/tech-report.tsx +59 -73
  58. package/templates/theme-showcase.tsx +124 -415
  59. package/theme.css +211 -119
@@ -42,18 +42,60 @@ export const NAV_ITEMS: SettingsNavItem[] = [
42
42
  {label: 'Travel for work', icon: Briefcase},
43
43
  ];
44
44
 
45
+ /**
46
+ * How a row's trailing Button reads. Deliberately narrower than core's
47
+ * `ButtonVariant`: a trailing row control is not a place to choose freely, and
48
+ * two of core's four options can never be right here — `primary` would
49
+ * outrank the panel's own primary, `ghost` would leave an action with no
50
+ * affordance at all. Only the two that can be are offered.
51
+ */
52
+ export type RowActionVariant = 'secondary' | 'destructive';
45
53
  export interface InfoRow {
46
54
  label: string;
47
55
  value: string;
48
56
  action: string;
57
+ /**
58
+ * What `action` actually is. 'action' (the default) changes state in place
59
+ * and renders a Button; 'destination' navigates and renders a Link. Stated
60
+ * in the data rather than inferred from `href` or read off the label, so
61
+ * renaming "View" cannot silently turn a destination back into an action.
62
+ */
63
+ actionKind?: 'action' | 'destination';
64
+ /** Where a 'destination' action goes. Read only when actionKind is 'destination'. */
65
+ href?: string;
66
+ /**
67
+ * Visual weight of the trailing Button. `secondary` is the default and the
68
+ * right answer for almost every row. `destructive` is for a row whose action
69
+ * ends or destroys something — disconnecting an account, deleting data — so
70
+ * that control agrees with the templates' inline Deactivate/Delete buttons
71
+ * instead of rendering identically to "Create" sitting above it. Stated
72
+ * here for the same reason `actionKind` is: the renderer cannot recover it
73
+ * from the label without reading the English verb off the row, which is how
74
+ * "View" became a button in the first place. Ignored for a `'destination'`
75
+ * row, which renders a Link and takes no variant.
76
+ */
77
+ variant?: RowActionVariant;
78
+ /**
79
+ * What the Button does, when something wants it to. Optional on purpose: a
80
+ * row with no `onAction` renders an inert control, which is the honest
81
+ * default for a showcase with no backend — better than a toggle that flips
82
+ * "Connected" to "Disconnected" and pretends a call happened. A consumer
83
+ * that has a real handler passes one and the row goes live. Nothing here
84
+ * simulates a result or invents state to make the button look busy.
85
+ */
86
+ onAction?: () => void;
49
87
  }
50
88
 
51
89
  export const LOGIN_ROWS: InfoRow[] = [
52
90
  {label: 'Password', value: 'Not created', action: 'Create'},
53
91
  ];
54
92
 
93
+ // Disconnecting ends an account link the user set up on purpose, so it is the
94
+ // one destructive row in this file — marked here, from the verb, not by
95
+ // position. LOGIN_ROWS' "Create" is the opposite (it adds a password) and
96
+ // keeps the default `secondary`.
55
97
  export const SOCIAL_ROWS: InfoRow[] = [
56
- {label: 'Google', value: 'Connected', action: 'Disconnect'},
98
+ {label: 'Google', value: 'Connected', action: 'Disconnect', variant: 'destructive'},
57
99
  ];
58
100
 
59
101
  export interface DeviceRow {
@@ -123,3 +165,31 @@ export const sideNavHeading: CSSProperties = {
123
165
  marginInline: 'var(--spacing-4)',
124
166
  };
125
167
 
168
+ // Locale pickers shared by the sidebar and dialog templates. The two copies
169
+ // were byte-identical walls; a single source keeps them in sync.
170
+ export const LANGUAGES: {label: string; value: string}[] = [
171
+ {label: 'English (Canada)', value: 'en-CA'},
172
+ {label: 'English (US)', value: 'en-US'},
173
+ {label: 'French', value: 'fr'},
174
+ {label: 'Spanish', value: 'es'},
175
+ {label: 'German', value: 'de'},
176
+ {label: 'Japanese', value: 'ja'},
177
+ ];
178
+
179
+ export const CURRENCIES: {label: string; value: string}[] = [
180
+ {label: 'Canadian dollar (CAD)', value: 'CAD'},
181
+ {label: 'US dollar (USD)', value: 'USD'},
182
+ {label: 'Euro (EUR)', value: 'EUR'},
183
+ {label: 'British pound (GBP)', value: 'GBP'},
184
+ {label: 'Japanese yen (JPY)', value: 'JPY'},
185
+ ];
186
+
187
+ export const TIMEZONES: {label: string; value: string}[] = [
188
+ {label: '(GMT-05:00) Eastern Time (US & Canada)', value: 'ET'},
189
+ {label: '(GMT-06:00) Central Time (US & Canada)', value: 'CT'},
190
+ {label: '(GMT-07:00) Mountain Time (US & Canada)', value: 'MT'},
191
+ {label: '(GMT-08:00) Pacific Time (US & Canada)', value: 'PT'},
192
+ {label: '(GMT+00:00) UTC', value: 'UTC'},
193
+ {label: '(GMT+01:00) London', value: 'GMT+1'},
194
+ ];
195
+
@@ -26,32 +26,73 @@ import {
26
26
  SquarePen,
27
27
  Share2,
28
28
  } from 'lucide-react';
29
- import type {CSSProperties, MouseEvent, ReactNode} from 'react';
29
+ import type {CSSProperties, ReactNode} from 'react';
30
30
  import {VStack, HStack} from '@astryxdesign/core/Layout';
31
31
  import {Text} from '@astryxdesign/core/Text';
32
- import {Link} from '@astryxdesign/core/Link';
33
32
  import {Button} from '@astryxdesign/core/Button';
33
+ import {Link} from '@astryxdesign/core/Link';
34
34
  import {Divider} from '@astryxdesign/core/Divider';
35
35
 
36
36
  // ─── Row components ──────────────────────────────────────────────────────────
37
37
  // The row renderers themselves, moved here from both templates. The two copies
38
- // were byte-identical apart from two things that genuinely differ per
39
- // template, so those are props rather than forks: `href` (each template anchors
40
- // its Links to its own route) and `style` (the sidebar pads its rows, the
41
- // dialog does not). Verified by hash before the move — the row BODIES matched
42
- // exactly; only these two call-site differences remained.
38
+ // were byte-identical apart from things that genuinely differ per template, so
39
+ // those are props rather than forks: `style` (the sidebar pads its rows, the
40
+ // dialog does not) and `hasDivider` (each template draws its own group
41
+ // boundaries). Verified by hash before the move — the row BODIES matched
42
+ // exactly; only these call-site differences remained.
43
+ //
44
+ // FOUR RULES these rows are built around, from USAGE.md "Rules":
45
+ // - A link is a destination, a button is an action. The trailing control is a
46
+ // `Button` when the row acts in place and a `Link` when it navigates, and
47
+ // the row data says which: `actionKind` is `'action'` (the default) for
48
+ // "Create"/"Disconnect", `'destination'` for "View" on a list of past
49
+ // records, with `href` carrying the destination. It is stated in the data
50
+ // rather than guessed from the label, because reading the English verb off
51
+ // the row is what made "View" a button. The Link takes the kit's
52
+ // hover-only underline — it jumps out of the panel, which is the
53
+ // navigation case, not prose — and never a hand-rolled `textDecoration`.
54
+ // - The Button is configured from the row data, never from a constant here.
55
+ // `variant` defaults to `secondary` and a row opts into `destructive` when
56
+ // its action ends or destroys something, so a trailing "Disconnect" reads
57
+ // like the panels' own inline Deactivate/Delete controls instead of like
58
+ // the "Create" sitting above it. It is never `primary`: a row action must
59
+ // not outrank the panel's own primary, and a filled accent button in a
60
+ // trailing column is what made these panels read as a form.
61
+ // `onAction` becomes the Button's `onClick` and is optional — with no
62
+ // handler the Button renders inert, which is the honest default for a
63
+ // showcase with no backend. A consumer with a real handler passes one and
64
+ // the row goes live; nothing here fabricates state to make it look live.
65
+ // - A divider is a section boundary, not a row background. Rows therefore
66
+ // render one ONLY when the caller marks `hasDivider` at a real group
67
+ // boundary; without it the row leaves the rhythm to the caller's own
68
+ // `Stack gap` / row padding rather than adding a rule of its own.
69
+ // - Spacing comes from the scale. The label/value pair is spaced with
70
+ // `Stack gap`, never a margin, and `gap={1}` matches the rows each template
71
+ // builds itself (device history, Deactivate, the info tiles) so no row on
72
+ // the page reads tighter than its neighbours.
73
+ //
74
+ // `href` is a real field on `InfoRow`: where a `'destination'` action goes,
75
+ // read only when `actionKind` says the row navigates.
43
76
 
44
77
  export function InfoRowItem({
45
78
  label,
46
79
  value,
47
80
  action,
81
+ actionKind,
48
82
  href,
83
+ variant = 'secondary',
84
+ onAction,
49
85
  style,
50
- }: InfoRow & {href: string; style?: CSSProperties}) {
86
+ hasDivider = true,
87
+ }: InfoRow & {
88
+ style?: CSSProperties;
89
+ /** Draw a Divider after the row. True only at a group boundary. */
90
+ hasDivider?: boolean;
91
+ }) {
51
92
  return (
52
93
  <>
53
94
  <HStack hAlign="between" vAlign="start" style={style}>
54
- <VStack gap={0}>
95
+ <VStack gap={1}>
55
96
  <Text type="body" weight="semibold" display="block">
56
97
  {label}
57
98
  </Text>
@@ -59,13 +100,22 @@ export function InfoRowItem({
59
100
  {value}
60
101
  </Text>
61
102
  </VStack>
62
- {action && (
63
- <Link href={href} style={actionNoWrap}>
64
- {action}
65
- </Link>
66
- )}
103
+ {action &&
104
+ (actionKind === 'destination' && href ? (
105
+ <Link href={href} style={actionNoWrap}>
106
+ {action}
107
+ </Link>
108
+ ) : (
109
+ <Button
110
+ label={action}
111
+ variant={variant}
112
+ size="sm"
113
+ style={actionNoWrap}
114
+ onClick={onAction}
115
+ />
116
+ ))}
67
117
  </HStack>
68
- <Divider />
118
+ {hasDivider && <Divider />}
69
119
  </>
70
120
  );
71
121
  }
@@ -101,18 +151,16 @@ function ExpandableRowViewing({
101
151
  label,
102
152
  value,
103
153
  onEdit,
104
- href,
105
154
  style,
106
155
  }: {
107
156
  label: string;
108
157
  value: string;
109
158
  onEdit: () => void;
110
- href: string;
111
159
  style?: CSSProperties;
112
160
  }) {
113
161
  return (
114
162
  <HStack hAlign="between" vAlign="start" style={style}>
115
- <VStack gap={0}>
163
+ <VStack gap={1}>
116
164
  <Text type="body" weight="semibold" display="block">
117
165
  {label}
118
166
  </Text>
@@ -120,15 +168,13 @@ function ExpandableRowViewing({
120
168
  {value}
121
169
  </Text>
122
170
  </VStack>
123
- <Link
124
- href={href}
171
+ <Button
172
+ label="Edit"
173
+ variant="secondary"
174
+ size="sm"
125
175
  style={actionNoWrap}
126
- onClick={(e: MouseEvent) => {
127
- e.preventDefault();
128
- onEdit();
129
- }}>
130
- Edit
131
- </Link>
176
+ onClick={onEdit}
177
+ />
132
178
  </HStack>
133
179
  );
134
180
  }
@@ -141,8 +187,8 @@ export function ExpandableRow({
141
187
  onEdit,
142
188
  onCancel,
143
189
  onSave,
144
- href,
145
190
  style,
191
+ hasDivider = true,
146
192
  }: {
147
193
  label: string;
148
194
  value: string;
@@ -151,8 +197,9 @@ export function ExpandableRow({
151
197
  onEdit: () => void;
152
198
  onCancel: () => void;
153
199
  onSave: () => void;
154
- href: string;
155
200
  style?: CSSProperties;
201
+ /** Draw a Divider after the row. True only at a group boundary. */
202
+ hasDivider?: boolean;
156
203
  }) {
157
204
  return (
158
205
  <>
@@ -169,11 +216,10 @@ export function ExpandableRow({
169
216
  label={label}
170
217
  value={value}
171
218
  onEdit={onEdit}
172
- href={href}
173
219
  style={style}
174
220
  />
175
221
  )}
176
- <Divider />
222
+ {hasDivider && <Divider />}
177
223
  </>
178
224
  );
179
225
  }
@@ -0,0 +1,28 @@
1
+ import {HStack} from '@astryxdesign/core/Stack';
2
+ import {Text} from '@astryxdesign/core/Text';
3
+ import {Icon} from '@astryxdesign/core/Icon';
4
+ import {Star} from 'lucide-react';
5
+
6
+ export function StarRating({rating, count}: {rating: number; count: number}) {
7
+ const filled = Math.round(rating);
8
+ const empty = 5 - filled;
9
+
10
+ return (
11
+ <HStack gap={1} vAlign="center">
12
+ {Array.from({length: filled}, (_, i) => (
13
+ <Icon key={`full-${i}`} icon={Star} size="sm" color="yellow" />
14
+ ))}
15
+ {/* secondary, not disabled: --color-icon-disabled #6272A4 is 2.51:1 on
16
+ the card #343746, under the 3:1 non-text floor, and reads as "this
17
+ control is unavailable" — but a star is a data mark, not a control.
18
+ --color-icon-secondary #9AA1BC is 4.60:1, one step under the filled
19
+ yellow (7.4:1), so the two halves still read as one rating. */}
20
+ {Array.from({length: empty}, (_, i) => (
21
+ <Icon key={`empty-${i}`} icon={Star} size="sm" color="secondary" />
22
+ ))}
23
+ <Text type="body" color="secondary" hasTabularNumbers>
24
+ {rating} ({count})
25
+ </Text>
26
+ </HStack>
27
+ );
28
+ }
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
  // XLE (canonical structure, validated with `bunx astryx layout check`):
3
- // L > LC > V[g=8 a=center] > (V[g=1] > (H[g=2 a=center] > Ic + Tx"Hi, Vlad"[t=body]) + Hd"Where should we start?"[level=1 type=display-2]) + ChC"Ask anything" + (V[g=6] > (TgG"Category" > Tg"Writing"*4) + Hd"Suggested prompts"[level=2] + (G[c={min:280} g=3] > (CC[p=4] > V[g=0.5] > Hd"Draft"[level=3] + Tx"Compose"[t=body])*4))
3
+ // L > LC > V[g=8 a=center] > (V[g=1] > (H[g=2 a=center] > Ic + Tx"Hi, Vlad"[t=body]) + Hd"Where should we start?"[level=1 type=display-2]) + ChC"Ask anything" + (V[g=6] > (TgG"Category" > H[g=1 wrap] > Tg"Writing"*4) + Hd"Suggested prompts"[level=2] + (G[c={min:280} g=3] > (CC[p=4] > V[g=0.5] > Hd"Draft"[level=3] + Tx"Compose"[t=body])*4))
4
4
 
5
5
  import {useRef, useState, type CSSProperties} from 'react';
6
6
 
@@ -13,6 +13,7 @@ import {
13
13
  ChatDictationButton,
14
14
  useChatDictation,
15
15
  type ChatComposerInputHandle,
16
+ type ChatComposerToken,
16
17
  type ChatComposerTrigger,
17
18
  } from '@astryxdesign/core/Chat';
18
19
  import {
@@ -207,37 +208,54 @@ const COMMAND_ITEMS: SearchableItem<{description: string}>[] = [
207
208
  },
208
209
  ];
209
210
 
210
- const mentionTrigger: ChatComposerTrigger = {
211
+ // One factory for both composer triggers. Render (TypeaheadItem + auxiliary
212
+ // description) and select (token value/label/variant) differ per trigger;
213
+ // the trigger shape doesn't.
214
+ function makeTrigger<TAux>({
215
+ character,
216
+ items,
217
+ getDescription,
218
+ toToken,
219
+ }: {
220
+ character: string;
221
+ items: SearchableItem<TAux>[];
222
+ getDescription: (aux: TAux | undefined) => string | undefined;
223
+ toToken: (item: SearchableItem<TAux>) => string | ChatComposerToken;
224
+ }): ChatComposerTrigger {
225
+ return {
226
+ character,
227
+ searchSource: createStaticSource(items),
228
+ renderItem: item => (
229
+ <TypeaheadItem
230
+ item={item}
231
+ description={getDescription(item.auxiliaryData as TAux | undefined)}
232
+ />
233
+ ),
234
+ onSelect: item => toToken(item as SearchableItem<TAux>),
235
+ };
236
+ }
237
+
238
+ const mentionTrigger = makeTrigger({
211
239
  character: '@',
212
- searchSource: createStaticSource(MENTION_ITEMS),
213
- renderItem: item => (
214
- <TypeaheadItem
215
- item={item}
216
- description={(item.auxiliaryData as {role: string})?.role}
217
- />
218
- ),
219
- onSelect: item => ({
240
+ items: MENTION_ITEMS,
241
+ getDescription: aux => aux?.role,
242
+ toToken: item => ({
220
243
  value: `@${item.id}`,
221
244
  label: item.label,
222
245
  variant: 'cyan',
223
246
  }),
224
- };
247
+ });
225
248
 
226
- const commandTrigger: ChatComposerTrigger = {
249
+ const commandTrigger = makeTrigger({
227
250
  character: '/',
228
- searchSource: createStaticSource(COMMAND_ITEMS),
229
- renderItem: item => (
230
- <TypeaheadItem
231
- item={item}
232
- description={(item.auxiliaryData as {description: string})?.description}
233
- />
234
- ),
235
- onSelect: item => ({
251
+ items: COMMAND_ITEMS,
252
+ getDescription: aux => aux?.description,
253
+ toToken: item => ({
236
254
  value: `/${item.label}`,
237
255
  label: `/${item.label}`,
238
256
  variant: 'yellow',
239
257
  }),
240
- };
258
+ });
241
259
 
242
260
  const composerTriggers = [mentionTrigger, commandTrigger];
243
261
 
@@ -254,7 +272,7 @@ const INITIAL_ATTACHMENTS: Attachment[] = [
254
272
  'api_spec.yaml',
255
273
  'contrast_audit.csv',
256
274
  'dracula_spec.pdf',
257
- ].map((name, index) => ({id: `file-${index + 1}`, name}));
275
+ ].map(name => ({id: crypto.randomUUID(), name}));
258
276
 
259
277
  // The composer's imperative insert methods mutate the DOM without emitting a
260
278
  // change, so dispatch an input event to sync its value and clear the placeholder.
@@ -262,6 +280,35 @@ const syncComposerValue = () => {
262
280
  document.activeElement?.dispatchEvent(new Event('input', {bubbles: true}));
263
281
  };
264
282
 
283
+ // One inserter for every composer write. `insertText` replaces the selection
284
+ // (suggestion prompts); `insertToken` inserts a chip at the cursor without
285
+ // touching the selection (mentions, mode tokens).
286
+ function insertIntoComposer(
287
+ input: ChatComposerInputHandle | null,
288
+ write: {text: string} | {token: ChatComposerToken},
289
+ collapseToEnd = false,
290
+ ) {
291
+ if (!input) {
292
+ return;
293
+ }
294
+ input.focus();
295
+ if (document.activeElement) {
296
+ if (collapseToEnd) {
297
+ const sel = window.getSelection();
298
+ sel?.selectAllChildren(document.activeElement);
299
+ sel?.collapseToEnd();
300
+ } else {
301
+ window.getSelection()?.selectAllChildren(document.activeElement);
302
+ }
303
+ }
304
+ if ('text' in write) {
305
+ input.insertText(write.text);
306
+ } else {
307
+ input.insertToken(write.token);
308
+ }
309
+ syncComposerValue();
310
+ }
311
+
265
312
  // Main component
266
313
 
267
314
  export default function AiChatLanding() {
@@ -273,53 +320,36 @@ export default function AiChatLanding() {
273
320
  const [isModeMenuOpen, setIsModeMenuOpen] = useState(false);
274
321
  const composerInputRef = useRef<ChatComposerInputHandle>(null);
275
322
  const shouldFocusComposerRef = useRef(false);
276
- // Two dropped files can share a name, so attachments carry their own id.
277
- const nextAttachmentId = useRef(INITIAL_ATTACHMENTS.length);
323
+ // Dictation mic stays: the composer still renders ChatDictationButton below.
278
324
  const dictation = useChatDictation({inputRef: composerInputRef});
279
325
 
280
326
  const activeMode = MODE_OPTIONS.find(m => m.key === mode) ?? MODE_OPTIONS[0];
281
327
  const suggestions = category ? CATEGORY_SUGGESTIONS[category] : null;
282
328
 
283
329
  const applySuggestion = (prompt: string) => {
284
- const input = composerInputRef.current;
285
- if (!input) {
286
- return;
287
- }
288
- input.focus();
289
- if (document.activeElement) {
290
- window.getSelection()?.selectAllChildren(document.activeElement);
291
- }
292
- input.insertText(prompt);
293
- syncComposerValue();
330
+ insertIntoComposer(composerInputRef.current, {text: prompt});
294
331
  };
295
-
296
332
  const insertMention = (item: (typeof MENTION_ITEMS)[number]) => {
297
- const input = composerInputRef.current;
298
- if (!input) {
299
- return;
300
- }
301
- input.focus();
302
- if (document.activeElement) {
303
- const sel = window.getSelection();
304
- sel?.selectAllChildren(document.activeElement);
305
- sel?.collapseToEnd();
306
- }
307
- input.insertToken({
308
- value: `@${item.id}`,
309
- label: item.label,
310
- variant: 'cyan',
311
- });
312
- syncComposerValue();
333
+ insertIntoComposer(
334
+ composerInputRef.current,
335
+ {
336
+ token: {
337
+ value: `@${item.id}`,
338
+ label: item.label,
339
+ variant: 'cyan',
340
+ },
341
+ },
342
+ true,
343
+ );
313
344
  };
314
-
315
345
  const insertModeToken = (label: string) => {
316
- composerInputRef.current?.focus();
317
- composerInputRef.current?.insertToken({
318
- value: label,
319
- label,
320
- variant: 'orange',
346
+ insertIntoComposer(composerInputRef.current, {
347
+ token: {
348
+ value: label,
349
+ label,
350
+ variant: 'orange',
351
+ },
321
352
  });
322
- syncComposerValue();
323
353
  };
324
354
 
325
355
  return (
@@ -356,7 +386,7 @@ export default function AiChatLanding() {
356
386
  setAttachments(prev => [
357
387
  ...prev,
358
388
  ...files.map(file => ({
359
- id: `file-${++nextAttachmentId.current}`,
389
+ id: crypto.randomUUID(),
360
390
  name: file.name,
361
391
  })),
362
392
  ])
@@ -467,14 +497,22 @@ export default function AiChatLanding() {
467
497
  value={category}
468
498
  onChange={setCategory}
469
499
  size="lg">
470
- {CATEGORIES.map(cat => (
471
- <ToggleButton
472
- key={cat.key}
473
- value={cat.key}
474
- label={cat.label}
475
- icon={<Icon icon={cat.icon} size="sm" />}
476
- />
477
- ))}
500
+ {/* ToggleButtonGroup is a single nowrap flex row and the four
501
+ chips measure 488px together. In a 390px pane the fourth
502
+ chip was cut in half by the viewport edge, which reads as a
503
+ rendering fault rather than as overflow. The wrapping
504
+ HStack gives the row a second line instead; the group keeps
505
+ its own selection semantics either way. */}
506
+ <HStack gap={1} wrap="wrap">
507
+ {CATEGORIES.map(cat => (
508
+ <ToggleButton
509
+ key={cat.key}
510
+ value={cat.key}
511
+ label={cat.label}
512
+ icon={<Icon icon={cat.icon} size="sm" />}
513
+ />
514
+ ))}
515
+ </HStack>
478
516
  </ToggleButtonGroup>
479
517
 
480
518
  {suggestions && (