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.
- package/USAGE.md +25 -0
- package/astryx-dracula.d.ts +2 -2
- package/astryx-dracula.js +384 -3
- package/astryx-dracula.variants.d.ts +2 -14
- package/icons.mjs +125 -0
- package/package.json +21 -9
- package/shared/auth-chrome.tsx +156 -6
- package/shared/chaptered-doc.tsx +3 -4
- package/shared/chart-hues.ts +0 -5
- package/shared/chart-legend.tsx +2 -3
- package/shared/data-bar.tsx +5 -5
- package/shared/format-money.ts +3 -0
- package/shared/metric-card.tsx +127 -0
- package/shared/revenue-chart.tsx +63 -7
- package/shared/scene-hues.ts +0 -8
- package/shared/scene-tile.tsx +3 -11
- package/shared/settings-data.ts +71 -1
- package/shared/settings-rows.tsx +76 -30
- package/shared/stars.tsx +28 -0
- package/templates/ai-chat-landing.tsx +105 -67
- package/templates/ai-chat.tsx +67 -80
- package/templates/centered-hero.tsx +2 -2
- package/templates/contact-form.tsx +11 -10
- package/templates/dashboard-portfolio.tsx +77 -155
- package/templates/dashboard.tsx +24 -137
- package/templates/detail-page.tsx +25 -22
- package/templates/documentation-design.tsx +39 -478
- package/templates/documentation-technical.tsx +13 -31
- package/templates/editor.tsx +168 -185
- package/templates/file-explorer.tsx +91 -93
- package/templates/form-two-column.tsx +1 -1
- package/templates/ide.tsx +18 -24
- package/templates/incident-console.tsx +67 -42
- package/templates/kanban-board.tsx +36 -85
- package/templates/library.tsx +42 -66
- package/templates/login-card.tsx +33 -123
- package/templates/login-split.tsx +96 -119
- package/templates/login-sso.tsx +69 -74
- package/templates/login.tsx +21 -118
- package/templates/messaging-shell.tsx +162 -168
- package/templates/payment-form.tsx +63 -71
- package/templates/product-detail.tsx +11 -46
- package/templates/product-tour.tsx +38 -60
- package/templates/settings-dialog.tsx +93 -118
- package/templates/settings-sidebar.tsx +122 -143
- package/templates/settings.tsx +208 -121
- package/templates/shell-nav.tsx +20 -75
- package/templates/shell-side-nav.tsx +16 -90
- package/templates/shell-top-nav.tsx +20 -53
- package/templates/side-gallery.tsx +19 -19
- package/templates/table-grouped.tsx +86 -136
- package/templates/table-page-chart.tsx +12 -11
- package/templates/table-page-heatmap-status.tsx +0 -17
- package/templates/table-page-shoe-store-heatmap.tsx +12 -12
- package/templates/table-page.tsx +4 -31
- package/templates/table.tsx +18 -2
- package/templates/tech-report.tsx +59 -73
- package/templates/theme-showcase.tsx +124 -415
- package/theme.css +211 -119
package/shared/settings-data.ts
CHANGED
|
@@ -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
|
+
|
package/shared/settings-rows.tsx
CHANGED
|
@@ -26,32 +26,73 @@ import {
|
|
|
26
26
|
SquarePen,
|
|
27
27
|
Share2,
|
|
28
28
|
} from 'lucide-react';
|
|
29
|
-
import type {CSSProperties,
|
|
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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// exactly; only these
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
64
|
-
{
|
|
65
|
-
|
|
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={
|
|
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
|
-
<
|
|
124
|
-
|
|
171
|
+
<Button
|
|
172
|
+
label="Edit"
|
|
173
|
+
variant="secondary"
|
|
174
|
+
size="sm"
|
|
125
175
|
style={actionNoWrap}
|
|
126
|
-
onClick={
|
|
127
|
-
|
|
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
|
}
|
package/shared/stars.tsx
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
249
|
+
const commandTrigger = makeTrigger({
|
|
227
250
|
character: '/',
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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:
|
|
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
|
-
{
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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 && (
|