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.
Files changed (83) hide show
  1. package/BRAND.md +30 -18
  2. package/README.md +22 -11
  3. package/USAGE.md +29 -10
  4. package/astryx-dracula.js +19 -13
  5. package/astryx-dracula.variants.d.ts +15 -0
  6. package/package.json +59 -17
  7. package/shared/auth-chrome-config.ts +18 -0
  8. package/shared/auth-chrome.tsx +25 -0
  9. package/shared/auth-copy.ts +22 -0
  10. package/shared/chaptered-doc-config.ts +92 -0
  11. package/shared/chaptered-doc.tsx +332 -0
  12. package/shared/chart-hues.ts +27 -0
  13. package/shared/chart-labels.tsx +44 -0
  14. package/shared/chart-legend.tsx +100 -0
  15. package/shared/chart-panel-style.ts +13 -0
  16. package/shared/data-bar.tsx +160 -0
  17. package/shared/gallery-image.ts +20 -0
  18. package/shared/login-demo.ts +7 -0
  19. package/shared/metric-delta.tsx +36 -0
  20. package/shared/order-desk.tsx +273 -0
  21. package/shared/revenue-chart.tsx +174 -0
  22. package/shared/scene-castle.tsx +322 -0
  23. package/shared/scene-frame.tsx +88 -0
  24. package/shared/scene-hues.ts +49 -0
  25. package/shared/scene-tile.tsx +128 -0
  26. package/shared/settings-data.ts +125 -0
  27. package/shared/settings-rows.tsx +179 -0
  28. package/shared/sparkline.tsx +94 -0
  29. package/shared/sso-icons.tsx +30 -0
  30. package/templates/ai-chat-landing.tsx +67 -32
  31. package/templates/ai-chat.tsx +41 -20
  32. package/templates/blank.tsx +5 -2
  33. package/templates/centered-hero.tsx +15 -124
  34. package/templates/classic-gallery.tsx +21 -65
  35. package/templates/contact-form.tsx +62 -40
  36. package/templates/dashboard-portfolio.tsx +146 -227
  37. package/templates/dashboard.tsx +123 -121
  38. package/templates/detail-page.tsx +101 -95
  39. package/templates/documentation-design.template.mjs +1 -1
  40. package/templates/documentation-design.tsx +85 -96
  41. package/templates/documentation-technical.template.mjs +1 -1
  42. package/templates/documentation-technical.tsx +33 -21
  43. package/templates/documentation.template.mjs +1 -1
  44. package/templates/documentation.tsx +54 -74
  45. package/templates/editor.tsx +119 -89
  46. package/templates/file-explorer.tsx +133 -84
  47. package/templates/form-two-column.tsx +62 -124
  48. package/templates/gallery-hero.tsx +17 -171
  49. package/templates/ide.tsx +27 -21
  50. package/templates/incident-console.tsx +105 -69
  51. package/templates/kanban-board.tsx +100 -41
  52. package/templates/library.template.mjs +2 -2
  53. package/templates/library.tsx +38 -41
  54. package/templates/login-card.tsx +59 -86
  55. package/templates/login-split.tsx +88 -128
  56. package/templates/login-sso.tsx +107 -68
  57. package/templates/login.tsx +108 -52
  58. package/templates/messaging-shell.tsx +172 -143
  59. package/templates/mixed-gallery.tsx +31 -130
  60. package/templates/payment-form.tsx +369 -289
  61. package/templates/product-detail.tsx +40 -57
  62. package/templates/product-gallery.tsx +31 -40
  63. package/templates/product-tour.template.mjs +6 -0
  64. package/templates/product-tour.tsx +916 -0
  65. package/templates/settings-dialog.tsx +145 -286
  66. package/templates/settings-sidebar.tsx +179 -267
  67. package/templates/settings.tsx +42 -13
  68. package/templates/shell-nav.tsx +32 -23
  69. package/templates/shell-side-nav.tsx +30 -19
  70. package/templates/shell-top-nav.tsx +81 -43
  71. package/templates/side-gallery.tsx +39 -93
  72. package/templates/table-grouped.tsx +63 -33
  73. package/templates/table-page-chart.tsx +24 -297
  74. package/templates/table-page-heatmap-status.tsx +157 -91
  75. package/templates/table-page-shoe-store-heatmap.tsx +19 -288
  76. package/templates/table-page.tsx +24 -6
  77. package/templates/table.tsx +24 -7
  78. package/templates/tech-report.template.mjs +6 -0
  79. package/templates/tech-report.tsx +1486 -0
  80. package/templates/theme-showcase.template.mjs +1 -1
  81. package/templates/theme-showcase.tsx +313 -249
  82. package/theme.css +76 -14
  83. 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
- const pageStyle: CSSProperties = {minHeight: '100%'};
41
- const composerInput: CSSProperties = {minHeight: 84};
42
- const categories: CSSProperties = {paddingInline: 'var(--space-viewport)'};
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: 'Generate a well-typed function with error handling',
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: 'Check for bugs, contrast, and best practices',
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: 'Break down a complex topic in simple terms',
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: 'Create an engaging narrative with creatures',
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: 'Explore product or visual design ideas',
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<string[]>([
239
- 'palette_brief.pdf',
240
- 'nocturne_v2.fig',
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="accent" />
313
- <Text type="large">
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 => [...prev, ...files.map(f => f.name)])
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(name => (
369
+ {attachments.map(attachment => (
340
370
  <Token
341
- key={name}
342
- label={name}
371
+ key={attachment.id}
372
+ label={attachment.name}
343
373
  onRemove={() =>
344
- setAttachments(prev => prev.filter(n => n !== name))
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} style={categories}>
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
- <Grid columns={{minWidth: 280}} gap={3}>
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={4}>{suggestion.heading}</Heading>
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
- </Grid>
502
+ </Grid>
503
+ </>
469
504
  )}
470
505
  </VStack>
471
506
  </VStack>