astryx-dracula 0.2.0 → 0.3.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/BRAND.md +30 -18
- package/README.md +22 -11
- package/USAGE.md +29 -10
- package/astryx-dracula.js +19 -13
- package/astryx-dracula.variants.d.ts +15 -0
- package/package.json +59 -17
- package/shared/auth-chrome-config.ts +18 -0
- package/shared/auth-chrome.tsx +25 -0
- package/shared/auth-copy.ts +22 -0
- package/shared/chaptered-doc-config.ts +92 -0
- package/shared/chaptered-doc.tsx +332 -0
- package/shared/chart-hues.ts +27 -0
- package/shared/chart-labels.tsx +44 -0
- package/shared/chart-legend.tsx +100 -0
- package/shared/chart-panel-style.ts +13 -0
- package/shared/data-bar.tsx +160 -0
- package/shared/gallery-image.ts +20 -0
- package/shared/login-demo.ts +7 -0
- package/shared/metric-delta.tsx +36 -0
- package/shared/order-desk.tsx +273 -0
- package/shared/revenue-chart.tsx +174 -0
- package/shared/scene-castle.tsx +322 -0
- package/shared/scene-frame.tsx +88 -0
- package/shared/scene-hues.ts +49 -0
- package/shared/scene-tile.tsx +128 -0
- package/shared/settings-data.ts +125 -0
- package/shared/settings-rows.tsx +179 -0
- package/shared/sparkline.tsx +94 -0
- package/shared/sso-icons.tsx +30 -0
- package/templates/ai-chat-landing.tsx +67 -32
- package/templates/ai-chat.tsx +41 -20
- package/templates/blank.tsx +5 -2
- package/templates/centered-hero.tsx +15 -124
- package/templates/classic-gallery.tsx +21 -65
- package/templates/contact-form.tsx +62 -40
- package/templates/dashboard-portfolio.tsx +146 -227
- package/templates/dashboard.tsx +123 -121
- package/templates/detail-page.tsx +101 -95
- package/templates/documentation-design.template.mjs +1 -1
- package/templates/documentation-design.tsx +85 -96
- package/templates/documentation-technical.template.mjs +1 -1
- package/templates/documentation-technical.tsx +33 -21
- package/templates/documentation.template.mjs +1 -1
- package/templates/documentation.tsx +54 -74
- package/templates/editor.tsx +119 -89
- package/templates/file-explorer.tsx +133 -84
- package/templates/form-two-column.tsx +62 -124
- package/templates/gallery-hero.tsx +17 -171
- package/templates/ide.tsx +27 -21
- package/templates/incident-console.tsx +105 -69
- package/templates/kanban-board.tsx +100 -41
- package/templates/library.template.mjs +2 -2
- package/templates/library.tsx +38 -41
- package/templates/login-card.tsx +59 -86
- package/templates/login-split.tsx +88 -128
- package/templates/login-sso.tsx +107 -68
- package/templates/login.tsx +108 -52
- package/templates/messaging-shell.tsx +172 -143
- package/templates/mixed-gallery.tsx +31 -130
- package/templates/payment-form.tsx +369 -289
- package/templates/product-detail.tsx +40 -57
- package/templates/product-gallery.tsx +31 -40
- package/templates/product-tour.template.mjs +6 -0
- package/templates/product-tour.tsx +916 -0
- package/templates/settings-dialog.tsx +145 -286
- package/templates/settings-sidebar.tsx +179 -267
- package/templates/settings.tsx +42 -13
- package/templates/shell-nav.tsx +32 -23
- package/templates/shell-side-nav.tsx +30 -19
- package/templates/shell-top-nav.tsx +81 -43
- package/templates/side-gallery.tsx +39 -93
- package/templates/table-grouped.tsx +63 -33
- package/templates/table-page-chart.tsx +24 -297
- package/templates/table-page-heatmap-status.tsx +157 -91
- package/templates/table-page-shoe-store-heatmap.tsx +19 -288
- package/templates/table-page.tsx +24 -6
- package/templates/table.tsx +24 -7
- package/templates/tech-report.template.mjs +6 -0
- package/templates/tech-report.tsx +1486 -0
- package/templates/theme-showcase.template.mjs +1 -1
- package/templates/theme-showcase.tsx +313 -249
- package/theme.css +76 -14
- package/tokens.css +425 -30
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Settings row DATA, split from the row renderers in settings-rows.tsx.
|
|
2
|
+
//
|
|
3
|
+
// WHY: settings-rows.tsx exports components, and a .tsx exporting non-components
|
|
4
|
+
// breaks React Fast Refresh -- editing NAV_ITEMS would remount every settings
|
|
5
|
+
// row instead of hot-updating. `react-doctor/only-export-components` catches it.
|
|
6
|
+
// Three templates (settings, settings-sidebar, settings-dialog) read from here,
|
|
7
|
+
// so this data is genuinely shared rather than co-located by accident.
|
|
8
|
+
import type {CSSProperties} from 'react';
|
|
9
|
+
import type {LucideIcon} from 'lucide-react';
|
|
10
|
+
import {
|
|
11
|
+
Bell,
|
|
12
|
+
Briefcase,
|
|
13
|
+
CreditCard,
|
|
14
|
+
FileText,
|
|
15
|
+
Fingerprint,
|
|
16
|
+
Globe,
|
|
17
|
+
KeyRound,
|
|
18
|
+
Laptop,
|
|
19
|
+
Lock,
|
|
20
|
+
Share2,
|
|
21
|
+
ShieldCheck,
|
|
22
|
+
Smartphone,
|
|
23
|
+
SquarePen,
|
|
24
|
+
Tablet,
|
|
25
|
+
User,
|
|
26
|
+
UserCog,
|
|
27
|
+
} from 'lucide-react';
|
|
28
|
+
|
|
29
|
+
export interface SettingsNavItem {
|
|
30
|
+
label: string;
|
|
31
|
+
icon: LucideIcon;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const NAV_ITEMS: SettingsNavItem[] = [
|
|
35
|
+
{label: 'Personal information', icon: User},
|
|
36
|
+
{label: 'Login & security', icon: Lock},
|
|
37
|
+
{label: 'Privacy', icon: ShieldCheck},
|
|
38
|
+
{label: 'Notifications', icon: Bell},
|
|
39
|
+
{label: 'Taxes', icon: FileText},
|
|
40
|
+
{label: 'Payments', icon: CreditCard},
|
|
41
|
+
{label: 'Languages & currency', icon: Globe},
|
|
42
|
+
{label: 'Travel for work', icon: Briefcase},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export interface InfoRow {
|
|
46
|
+
label: string;
|
|
47
|
+
value: string;
|
|
48
|
+
action: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const LOGIN_ROWS: InfoRow[] = [
|
|
52
|
+
{label: 'Password', value: 'Not created', action: 'Create'},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export const SOCIAL_ROWS: InfoRow[] = [
|
|
56
|
+
{label: 'Google', value: 'Connected', action: 'Disconnect'},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export interface DeviceRow {
|
|
60
|
+
label: string;
|
|
61
|
+
isCurrent?: boolean;
|
|
62
|
+
location: string;
|
|
63
|
+
action?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const DEVICE_ROWS: DeviceRow[] = [
|
|
67
|
+
{
|
|
68
|
+
label: 'OS X 10.15.7 · Chrome',
|
|
69
|
+
isCurrent: true,
|
|
70
|
+
location: 'Brașov, Transylvania · March 30, 2026 at 19:31',
|
|
71
|
+
},
|
|
72
|
+
{label: 'Session', location: 'August 9, 2023 at 04:19', action: 'Log out'},
|
|
73
|
+
{
|
|
74
|
+
label: 'OS X 10.15.7 · unknown',
|
|
75
|
+
location: 'Whitby, England · April 14, 2023 at 17:47',
|
|
76
|
+
action: 'Log out',
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export interface InfoTileData {
|
|
81
|
+
icon: LucideIcon;
|
|
82
|
+
title: string;
|
|
83
|
+
body: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const INFO_TILES: InfoTileData[] = [
|
|
87
|
+
{
|
|
88
|
+
icon: Lock,
|
|
89
|
+
title: "Why isn't my info shown here?",
|
|
90
|
+
body: "We're veiling some crypt details to protect your identity.",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
icon: SquarePen,
|
|
94
|
+
title: 'Which details can be edited?',
|
|
95
|
+
body: "Contact runes and personal details can be edited. If these were used to verify your identity, you'll need to be verified anew before your next stay, or to keep hosting your crypt.",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
icon: Share2,
|
|
99
|
+
title: 'What info is shared with others?',
|
|
100
|
+
body: 'We only release contact runes after a booking is sealed.',
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// Shared row chrome: the icon chip, nowrap action column, and sidebar heading
|
|
105
|
+
// offset duplicated across settings-dialog and settings-sidebar. Tokens only,
|
|
106
|
+
// no layout opinions — templates keep their own padding/dividers.
|
|
107
|
+
export const iconBox: CSSProperties = {
|
|
108
|
+
borderRadius: 'var(--radius-container)',
|
|
109
|
+
backgroundColor: 'var(--color-background-surface)',
|
|
110
|
+
flexShrink: 0,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Keeps row actions ("Log out", "Deactivate") on one line: without this the
|
|
114
|
+
// action column wraps mid-phrase at tablet widths while the info column still
|
|
115
|
+
// has room to wrap instead.
|
|
116
|
+
export const actionNoWrap: CSSProperties = {
|
|
117
|
+
flexShrink: 0,
|
|
118
|
+
whiteSpace: 'nowrap',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Aligns the sidebar heading with list-item label text. No heading margin prop.
|
|
122
|
+
export const sideNavHeading: CSSProperties = {
|
|
123
|
+
marginInline: 'var(--spacing-4)',
|
|
124
|
+
};
|
|
125
|
+
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Row RENDERERS for the settings dialog and settings sidebar templates.
|
|
2
|
+
// The data they render lives in shared/settings-data.ts, split out so this file
|
|
3
|
+
// exports only components -- a .tsx exporting non-components breaks React Fast
|
|
4
|
+
// Refresh, and three templates read that data, so it was never private to here.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEVICE_ROWS,
|
|
8
|
+
INFO_TILES,
|
|
9
|
+
LOGIN_ROWS,
|
|
10
|
+
SOCIAL_ROWS,
|
|
11
|
+
actionNoWrap,
|
|
12
|
+
iconBox,
|
|
13
|
+
type DeviceRow,
|
|
14
|
+
type InfoRow,
|
|
15
|
+
type InfoTileData,
|
|
16
|
+
type SettingsNavItem,
|
|
17
|
+
} from 'astryx-dracula/shared/settings-data';
|
|
18
|
+
import {
|
|
19
|
+
Lock,
|
|
20
|
+
ShieldCheck,
|
|
21
|
+
Bell,
|
|
22
|
+
FileText,
|
|
23
|
+
CreditCard,
|
|
24
|
+
Globe,
|
|
25
|
+
Briefcase,
|
|
26
|
+
SquarePen,
|
|
27
|
+
Share2,
|
|
28
|
+
} from 'lucide-react';
|
|
29
|
+
import type {CSSProperties, MouseEvent, ReactNode} from 'react';
|
|
30
|
+
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
|
31
|
+
import {Text} from '@astryxdesign/core/Text';
|
|
32
|
+
import {Link} from '@astryxdesign/core/Link';
|
|
33
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
34
|
+
import {Divider} from '@astryxdesign/core/Divider';
|
|
35
|
+
|
|
36
|
+
// ─── Row components ──────────────────────────────────────────────────────────
|
|
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.
|
|
43
|
+
|
|
44
|
+
export function InfoRowItem({
|
|
45
|
+
label,
|
|
46
|
+
value,
|
|
47
|
+
action,
|
|
48
|
+
href,
|
|
49
|
+
style,
|
|
50
|
+
}: InfoRow & {href: string; style?: CSSProperties}) {
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<HStack hAlign="between" vAlign="start" style={style}>
|
|
54
|
+
<VStack gap={0}>
|
|
55
|
+
<Text type="body" weight="semibold" display="block">
|
|
56
|
+
{label}
|
|
57
|
+
</Text>
|
|
58
|
+
<Text type="body" color="secondary" display="block">
|
|
59
|
+
{value}
|
|
60
|
+
</Text>
|
|
61
|
+
</VStack>
|
|
62
|
+
{action && (
|
|
63
|
+
<Link href={href} style={actionNoWrap}>
|
|
64
|
+
{action}
|
|
65
|
+
</Link>
|
|
66
|
+
)}
|
|
67
|
+
</HStack>
|
|
68
|
+
<Divider />
|
|
69
|
+
</>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ExpandableRowEditing({
|
|
74
|
+
label,
|
|
75
|
+
children,
|
|
76
|
+
onCancel,
|
|
77
|
+
onSave,
|
|
78
|
+
style,
|
|
79
|
+
}: {
|
|
80
|
+
label: string;
|
|
81
|
+
children: ReactNode;
|
|
82
|
+
onCancel: () => void;
|
|
83
|
+
onSave: () => void;
|
|
84
|
+
style?: CSSProperties;
|
|
85
|
+
}) {
|
|
86
|
+
return (
|
|
87
|
+
<VStack gap={4} style={style}>
|
|
88
|
+
<Text type="body" weight="semibold" display="block">
|
|
89
|
+
{label}
|
|
90
|
+
</Text>
|
|
91
|
+
{children}
|
|
92
|
+
<HStack gap={2}>
|
|
93
|
+
<Button label="Save" variant="primary" onClick={onSave} />
|
|
94
|
+
<Button label="Cancel" variant="ghost" onClick={onCancel} />
|
|
95
|
+
</HStack>
|
|
96
|
+
</VStack>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ExpandableRowViewing({
|
|
101
|
+
label,
|
|
102
|
+
value,
|
|
103
|
+
onEdit,
|
|
104
|
+
href,
|
|
105
|
+
style,
|
|
106
|
+
}: {
|
|
107
|
+
label: string;
|
|
108
|
+
value: string;
|
|
109
|
+
onEdit: () => void;
|
|
110
|
+
href: string;
|
|
111
|
+
style?: CSSProperties;
|
|
112
|
+
}) {
|
|
113
|
+
return (
|
|
114
|
+
<HStack hAlign="between" vAlign="start" style={style}>
|
|
115
|
+
<VStack gap={0}>
|
|
116
|
+
<Text type="body" weight="semibold" display="block">
|
|
117
|
+
{label}
|
|
118
|
+
</Text>
|
|
119
|
+
<Text type="body" color="secondary" display="block">
|
|
120
|
+
{value}
|
|
121
|
+
</Text>
|
|
122
|
+
</VStack>
|
|
123
|
+
<Link
|
|
124
|
+
href={href}
|
|
125
|
+
style={actionNoWrap}
|
|
126
|
+
onClick={(e: MouseEvent) => {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
onEdit();
|
|
129
|
+
}}>
|
|
130
|
+
Edit
|
|
131
|
+
</Link>
|
|
132
|
+
</HStack>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function ExpandableRow({
|
|
137
|
+
label,
|
|
138
|
+
value,
|
|
139
|
+
children,
|
|
140
|
+
isExpanded,
|
|
141
|
+
onEdit,
|
|
142
|
+
onCancel,
|
|
143
|
+
onSave,
|
|
144
|
+
href,
|
|
145
|
+
style,
|
|
146
|
+
}: {
|
|
147
|
+
label: string;
|
|
148
|
+
value: string;
|
|
149
|
+
children: ReactNode;
|
|
150
|
+
isExpanded: boolean;
|
|
151
|
+
onEdit: () => void;
|
|
152
|
+
onCancel: () => void;
|
|
153
|
+
onSave: () => void;
|
|
154
|
+
href: string;
|
|
155
|
+
style?: CSSProperties;
|
|
156
|
+
}) {
|
|
157
|
+
return (
|
|
158
|
+
<>
|
|
159
|
+
{isExpanded ? (
|
|
160
|
+
<ExpandableRowEditing
|
|
161
|
+
label={label}
|
|
162
|
+
onCancel={onCancel}
|
|
163
|
+
onSave={onSave}
|
|
164
|
+
style={style}>
|
|
165
|
+
{children}
|
|
166
|
+
</ExpandableRowEditing>
|
|
167
|
+
) : (
|
|
168
|
+
<ExpandableRowViewing
|
|
169
|
+
label={label}
|
|
170
|
+
value={value}
|
|
171
|
+
onEdit={onEdit}
|
|
172
|
+
href={href}
|
|
173
|
+
style={style}
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
<Divider />
|
|
177
|
+
</>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Merged trend-bar sparkline: absorbs the dashboard max-normalized fixed
|
|
2
|
+
// geometry and the portfolio range-normalized step geometry behind one mode
|
|
3
|
+
// switch. Purple stays out of the chart so it keeps meaning "interactive".
|
|
4
|
+
|
|
5
|
+
export interface SparkPoint {
|
|
6
|
+
id: string;
|
|
7
|
+
value: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface SparklineProps {
|
|
11
|
+
data: SparkPoint[];
|
|
12
|
+
/** REQUIRED and unique per instance: 4+ sparklines sharing one name is an
|
|
13
|
+
* a11y defect (the portfolio bug this module fixes). */
|
|
14
|
+
label: string;
|
|
15
|
+
positive: boolean;
|
|
16
|
+
/** max: fixed geometry normalized against the series max (dashboard tiles).
|
|
17
|
+
* range: step geometry normalized against the series range (market rows).
|
|
18
|
+
* @default 'max' */
|
|
19
|
+
mode?: 'max' | 'range';
|
|
20
|
+
/** @default false */
|
|
21
|
+
isCompact?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Sparkline({
|
|
25
|
+
data,
|
|
26
|
+
label,
|
|
27
|
+
positive,
|
|
28
|
+
mode = 'max',
|
|
29
|
+
isCompact = false,
|
|
30
|
+
}: SparklineProps) {
|
|
31
|
+
const fill = positive ? 'var(--dracula-green)' : 'var(--dracula-red)';
|
|
32
|
+
if (mode === 'range') {
|
|
33
|
+
const values = data.map(point => point.value);
|
|
34
|
+
const max = Math.max(...values);
|
|
35
|
+
const min = Math.min(...values);
|
|
36
|
+
const range = Math.max(1, max - min);
|
|
37
|
+
const step = 300 / data.length;
|
|
38
|
+
const height = isCompact ? 24 : 40;
|
|
39
|
+
const baseline = isCompact ? 21 : 36;
|
|
40
|
+
const plot = isCompact ? 18 : 32;
|
|
41
|
+
const floor = isCompact ? 2 : 3;
|
|
42
|
+
const barWidth = Math.max(1.5, step - 1);
|
|
43
|
+
return (
|
|
44
|
+
<svg
|
|
45
|
+
viewBox={`0 0 300 ${height}`}
|
|
46
|
+
width="100%"
|
|
47
|
+
height={height}
|
|
48
|
+
role="img"
|
|
49
|
+
aria-label={label}>
|
|
50
|
+
{data.map((point, slot) => {
|
|
51
|
+
const barHeight = Math.max(
|
|
52
|
+
floor,
|
|
53
|
+
((point.value - min) / range) * plot,
|
|
54
|
+
);
|
|
55
|
+
return (
|
|
56
|
+
<rect
|
|
57
|
+
key={point.id}
|
|
58
|
+
x={slot * step}
|
|
59
|
+
y={baseline - barHeight}
|
|
60
|
+
width={barWidth}
|
|
61
|
+
height={barHeight}
|
|
62
|
+
rx={Math.min(2, barWidth / 2)}
|
|
63
|
+
fill={fill}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</svg>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const max = Math.max(...data.map(point => point.value));
|
|
71
|
+
return (
|
|
72
|
+
<svg
|
|
73
|
+
viewBox="0 0 300 40"
|
|
74
|
+
width="100%"
|
|
75
|
+
height={40}
|
|
76
|
+
role="img"
|
|
77
|
+
aria-label={label}>
|
|
78
|
+
{data.map((point, day) => {
|
|
79
|
+
const barHeight = Math.max(3, (point.value / max) * 32);
|
|
80
|
+
return (
|
|
81
|
+
<rect
|
|
82
|
+
key={point.id}
|
|
83
|
+
x={day * 10}
|
|
84
|
+
y={36 - barHeight}
|
|
85
|
+
width={7}
|
|
86
|
+
height={barHeight}
|
|
87
|
+
rx={3.5}
|
|
88
|
+
fill={fill}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</svg>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Brand sign-in marks shared by login-card and login-split (byte-identical
|
|
2
|
+
// Apple/Google SVGs). Bespoke inline glyphs, no icon-library equivalent.
|
|
3
|
+
import type {SVGProps} from 'react';
|
|
4
|
+
|
|
5
|
+
export const AppleIcon = (props: SVGProps<SVGSVGElement>) => (
|
|
6
|
+
<svg
|
|
7
|
+
viewBox="0 0 24 24"
|
|
8
|
+
fill="currentColor"
|
|
9
|
+
width={16}
|
|
10
|
+
height={16}
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
{...props}>
|
|
13
|
+
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
|
14
|
+
</svg>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const GoogleIcon = (props: SVGProps<SVGSVGElement>) => (
|
|
18
|
+
<svg
|
|
19
|
+
viewBox="0 0 24 24"
|
|
20
|
+
fill="currentColor"
|
|
21
|
+
width={16}
|
|
22
|
+
height={16}
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
{...props}>
|
|
25
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
|
26
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
|
27
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
|
28
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
|
29
|
+
</svg>
|
|
30
|
+
);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
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))
|
|
2
4
|
|
|
3
5
|
import {useRef, useState, type CSSProperties} from 'react';
|
|
4
6
|
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
} from '@astryxdesign/core/Typeahead';
|
|
21
23
|
import {ToggleButton, ToggleButtonGroup} from '@astryxdesign/core/ToggleButton';
|
|
22
24
|
import {Token} from '@astryxdesign/core/Token';
|
|
25
|
+
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
|
|
23
26
|
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
|
24
27
|
import {Grid} from '@astryxdesign/core/Grid';
|
|
25
28
|
import {Icon} from '@astryxdesign/core/Icon';
|
|
@@ -37,9 +40,17 @@ import {
|
|
|
37
40
|
} from 'lucide-react';
|
|
38
41
|
|
|
39
42
|
// Fill the content area so the greeting and composer stay vertically centered.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
// `height`, not `minHeight`. A percentage min-height resolves against the
|
|
44
|
+
// containing block's DEFINITE height and computes to 0 when that is indefinite
|
|
45
|
+
// (CSS 2.1 10.5), so against a content-sized host it contributes nothing and
|
|
46
|
+
// the Layout's `height: 100%` still resolves to auto -- the edit is a no-op.
|
|
47
|
+
// Viewport units are absolute and need no ancestor, which is why every working
|
|
48
|
+
// instance in this repo uses them (editor.tsx:302, file-explorer.tsx:265,
|
|
49
|
+
// messaging-shell.tsx:67, ai-chat.tsx:64, kanban-board.tsx:704).
|
|
50
|
+
const pageStyle: CSSProperties = {height: '100dvh'};
|
|
51
|
+
// Five --spacing-4 steps: the box opens at ~80px so the empty composer reads
|
|
52
|
+
// as a writing surface, not a single-line field. No single token is 80px.
|
|
53
|
+
const composerInput: CSSProperties = {minHeight: 'calc(var(--spacing-4) * 5)'};
|
|
43
54
|
|
|
44
55
|
// Suggestion cards shown once a category is selected.
|
|
45
56
|
const CATEGORY_SUGGESTIONS: Record<
|
|
@@ -76,7 +87,7 @@ const CATEGORY_SUGGESTIONS: Record<
|
|
|
76
87
|
},
|
|
77
88
|
{
|
|
78
89
|
heading: 'Write a function',
|
|
79
|
-
body: '
|
|
90
|
+
body: 'Conjure a well-typed function warded against the dark',
|
|
80
91
|
prompt: 'Write a function that',
|
|
81
92
|
},
|
|
82
93
|
{
|
|
@@ -86,7 +97,7 @@ const CATEGORY_SUGGESTIONS: Record<
|
|
|
86
97
|
},
|
|
87
98
|
{
|
|
88
99
|
heading: 'Review my pull request',
|
|
89
|
-
body: '
|
|
100
|
+
body: 'Hunt bugs by moonlight, weigh contrast, bless the rite',
|
|
90
101
|
prompt: 'Review this code for bugs and improvements:',
|
|
91
102
|
},
|
|
92
103
|
],
|
|
@@ -98,7 +109,7 @@ const CATEGORY_SUGGESTIONS: Record<
|
|
|
98
109
|
},
|
|
99
110
|
{
|
|
100
111
|
heading: 'Explain a concept',
|
|
101
|
-
body: '
|
|
112
|
+
body: 'Unravel a tangled grimoire into plain night-tongue',
|
|
102
113
|
prompt: 'Explain the concept of',
|
|
103
114
|
},
|
|
104
115
|
{
|
|
@@ -120,12 +131,12 @@ const CATEGORY_SUGGESTIONS: Record<
|
|
|
120
131
|
},
|
|
121
132
|
{
|
|
122
133
|
heading: 'Write a dark tale',
|
|
123
|
-
body: '
|
|
134
|
+
body: 'Spin a moonlit tale crawling with night creatures',
|
|
124
135
|
prompt: 'Write a short story about',
|
|
125
136
|
},
|
|
126
137
|
{
|
|
127
138
|
heading: 'Design a concept',
|
|
128
|
-
body: '
|
|
139
|
+
body: 'Conjure nocturnal rites for product or visual haunts',
|
|
129
140
|
prompt: 'Help me design a concept for',
|
|
130
141
|
},
|
|
131
142
|
{
|
|
@@ -230,32 +241,45 @@ const commandTrigger: ChatComposerTrigger = {
|
|
|
230
241
|
|
|
231
242
|
const composerTriggers = [mentionTrigger, commandTrigger];
|
|
232
243
|
|
|
244
|
+
/** Composer attachment. The name is user-facing; the id is the React key, since
|
|
245
|
+
* two dropped files can share a name. */
|
|
246
|
+
interface Attachment {
|
|
247
|
+
id: string;
|
|
248
|
+
name: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const INITIAL_ATTACHMENTS: Attachment[] = [
|
|
252
|
+
'palette_brief.pdf',
|
|
253
|
+
'nocturne_v2.fig',
|
|
254
|
+
'api_spec.yaml',
|
|
255
|
+
'contrast_audit.csv',
|
|
256
|
+
'dracula_spec.pdf',
|
|
257
|
+
].map((name, index) => ({id: `file-${index + 1}`, name}));
|
|
258
|
+
|
|
259
|
+
// The composer's imperative insert methods mutate the DOM without emitting a
|
|
260
|
+
// change, so dispatch an input event to sync its value and clear the placeholder.
|
|
261
|
+
const syncComposerValue = () => {
|
|
262
|
+
document.activeElement?.dispatchEvent(new Event('input', {bubbles: true}));
|
|
263
|
+
};
|
|
264
|
+
|
|
233
265
|
// Main component
|
|
234
266
|
|
|
235
267
|
export default function AiChatLanding() {
|
|
236
268
|
const [mode, setMode] = useState<string | null>('auto');
|
|
237
269
|
const [category, setCategory] = useState<string | null>(null);
|
|
238
|
-
const [attachments, setAttachments] = useState<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
'api_spec.yaml',
|
|
242
|
-
'contrast_audit.csv',
|
|
243
|
-
'dracula_spec.pdf',
|
|
244
|
-
]);
|
|
270
|
+
const [attachments, setAttachments] = useState<Attachment[]>(
|
|
271
|
+
INITIAL_ATTACHMENTS,
|
|
272
|
+
);
|
|
245
273
|
const [isModeMenuOpen, setIsModeMenuOpen] = useState(false);
|
|
246
274
|
const composerInputRef = useRef<ChatComposerInputHandle>(null);
|
|
247
275
|
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);
|
|
248
278
|
const dictation = useChatDictation({inputRef: composerInputRef});
|
|
249
279
|
|
|
250
280
|
const activeMode = MODE_OPTIONS.find(m => m.key === mode) ?? MODE_OPTIONS[0];
|
|
251
281
|
const suggestions = category ? CATEGORY_SUGGESTIONS[category] : null;
|
|
252
282
|
|
|
253
|
-
// The composer's imperative insert methods mutate the DOM without emitting a
|
|
254
|
-
// change, so dispatch an input event to sync its value and clear the placeholder.
|
|
255
|
-
const syncComposerValue = () => {
|
|
256
|
-
document.activeElement?.dispatchEvent(new Event('input', {bubbles: true}));
|
|
257
|
-
};
|
|
258
|
-
|
|
259
283
|
const applySuggestion = (prompt: string) => {
|
|
260
284
|
const input = composerInputRef.current;
|
|
261
285
|
if (!input) {
|
|
@@ -309,8 +333,8 @@ export default function AiChatLanding() {
|
|
|
309
333
|
{/* Greeting */}
|
|
310
334
|
<VStack gap={1}>
|
|
311
335
|
<HStack gap={2} vAlign="center">
|
|
312
|
-
<Icon icon={Sparkles} size="md" color="
|
|
313
|
-
<Text type="
|
|
336
|
+
<Icon icon={Sparkles} size="md" color="secondary" />
|
|
337
|
+
<Text type="body">
|
|
314
338
|
Hi, Vlad
|
|
315
339
|
</Text>
|
|
316
340
|
</HStack>
|
|
@@ -329,19 +353,27 @@ export default function AiChatLanding() {
|
|
|
329
353
|
triggers={composerTriggers}
|
|
330
354
|
style={composerInput}
|
|
331
355
|
onFiles={files =>
|
|
332
|
-
setAttachments(prev => [
|
|
356
|
+
setAttachments(prev => [
|
|
357
|
+
...prev,
|
|
358
|
+
...files.map(file => ({
|
|
359
|
+
id: `file-${++nextAttachmentId.current}`,
|
|
360
|
+
name: file.name,
|
|
361
|
+
})),
|
|
362
|
+
])
|
|
333
363
|
}
|
|
334
364
|
/>
|
|
335
365
|
}
|
|
336
366
|
drawer={
|
|
337
367
|
attachments.length > 0 ? (
|
|
338
368
|
<ChatComposerDrawer count={attachments.length}>
|
|
339
|
-
{attachments.map(
|
|
369
|
+
{attachments.map(attachment => (
|
|
340
370
|
<Token
|
|
341
|
-
key={
|
|
342
|
-
label={name}
|
|
371
|
+
key={attachment.id}
|
|
372
|
+
label={attachment.name}
|
|
343
373
|
onRemove={() =>
|
|
344
|
-
setAttachments(prev =>
|
|
374
|
+
setAttachments(prev =>
|
|
375
|
+
prev.filter(item => item.id !== attachment.id),
|
|
376
|
+
)
|
|
345
377
|
}
|
|
346
378
|
/>
|
|
347
379
|
))}
|
|
@@ -429,7 +461,7 @@ export default function AiChatLanding() {
|
|
|
429
461
|
/>
|
|
430
462
|
|
|
431
463
|
{/* Category filters + suggestion cards */}
|
|
432
|
-
<VStack gap={6}
|
|
464
|
+
<VStack gap={6}>
|
|
433
465
|
<ToggleButtonGroup
|
|
434
466
|
label="Category"
|
|
435
467
|
value={category}
|
|
@@ -446,7 +478,9 @@ export default function AiChatLanding() {
|
|
|
446
478
|
</ToggleButtonGroup>
|
|
447
479
|
|
|
448
480
|
{suggestions && (
|
|
449
|
-
|
|
481
|
+
<>
|
|
482
|
+
<VisuallyHidden as="h2">Suggested prompts</VisuallyHidden>
|
|
483
|
+
<Grid columns={{minWidth: 280}} gap={3}>
|
|
450
484
|
{suggestions.map(suggestion => (
|
|
451
485
|
<ClickableCard
|
|
452
486
|
key={suggestion.heading}
|
|
@@ -458,14 +492,15 @@ export default function AiChatLanding() {
|
|
|
458
492
|
setMode(category);
|
|
459
493
|
}}>
|
|
460
494
|
<VStack gap={0.5}>
|
|
461
|
-
<Heading level={
|
|
495
|
+
<Heading level={3}>{suggestion.heading}</Heading>
|
|
462
496
|
<Text type="body" color="secondary">
|
|
463
497
|
{suggestion.body}
|
|
464
498
|
</Text>
|
|
465
499
|
</VStack>
|
|
466
500
|
</ClickableCard>
|
|
467
501
|
))}
|
|
468
|
-
|
|
502
|
+
</Grid>
|
|
503
|
+
</>
|
|
469
504
|
)}
|
|
470
505
|
</VStack>
|
|
471
506
|
</VStack>
|