astryx-dracula 0.2.1 → 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 +17 -12
  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 +66 -33
  31. package/templates/ai-chat.tsx +36 -19
  32. package/templates/blank.tsx +4 -3
  33. package/templates/centered-hero.tsx +14 -125
  34. package/templates/classic-gallery.tsx +20 -66
  35. package/templates/contact-form.tsx +59 -39
  36. package/templates/dashboard-portfolio.tsx +143 -226
  37. package/templates/dashboard.tsx +122 -122
  38. package/templates/detail-page.tsx +95 -91
  39. package/templates/documentation-design.template.mjs +1 -1
  40. package/templates/documentation-design.tsx +83 -96
  41. package/templates/documentation-technical.template.mjs +1 -1
  42. package/templates/documentation-technical.tsx +29 -19
  43. package/templates/documentation.template.mjs +1 -1
  44. package/templates/documentation.tsx +52 -74
  45. package/templates/editor.tsx +112 -84
  46. package/templates/file-explorer.tsx +131 -84
  47. package/templates/form-two-column.tsx +61 -125
  48. package/templates/gallery-hero.tsx +15 -171
  49. package/templates/ide.tsx +22 -20
  50. package/templates/incident-console.tsx +104 -70
  51. package/templates/kanban-board.tsx +99 -42
  52. package/templates/library.template.mjs +2 -2
  53. package/templates/library.tsx +37 -42
  54. package/templates/login-card.tsx +58 -87
  55. package/templates/login-split.tsx +88 -126
  56. package/templates/login-sso.tsx +105 -64
  57. package/templates/login.tsx +107 -53
  58. package/templates/messaging-shell.tsx +171 -144
  59. package/templates/mixed-gallery.tsx +29 -130
  60. package/templates/payment-form.tsx +368 -290
  61. package/templates/product-detail.tsx +39 -58
  62. package/templates/product-gallery.tsx +30 -41
  63. package/templates/product-tour.template.mjs +6 -0
  64. package/templates/product-tour.tsx +916 -0
  65. package/templates/settings-dialog.tsx +135 -282
  66. package/templates/settings-sidebar.tsx +178 -268
  67. package/templates/settings.tsx +41 -14
  68. package/templates/shell-nav.tsx +31 -24
  69. package/templates/shell-side-nav.tsx +29 -20
  70. package/templates/shell-top-nav.tsx +80 -44
  71. package/templates/side-gallery.tsx +38 -94
  72. package/templates/table-grouped.tsx +62 -34
  73. package/templates/table-page-chart.tsx +23 -302
  74. package/templates/table-page-heatmap-status.tsx +156 -92
  75. package/templates/table-page-shoe-store-heatmap.tsx +18 -293
  76. package/templates/table-page.tsx +23 -7
  77. package/templates/table.tsx +23 -8
  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 +299 -237
  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,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.lg"Hi, Vlad") + Hd"Where should we start?"[level=1 type=display-2]) + ChC"Ask anything" + (V[g=6] > (TgG"Category" > Tg"Writing"*4) + (G[c={min:280} g=3] > (CC[p=4] > V[g=0.5] > Hd"Draft"[level=4] + 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" > 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
 
@@ -22,6 +22,7 @@ import {
22
22
  } from '@astryxdesign/core/Typeahead';
23
23
  import {ToggleButton, ToggleButtonGroup} from '@astryxdesign/core/ToggleButton';
24
24
  import {Token} from '@astryxdesign/core/Token';
25
+ import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
25
26
  import {ClickableCard} from '@astryxdesign/core/ClickableCard';
26
27
  import {Grid} from '@astryxdesign/core/Grid';
27
28
  import {Icon} from '@astryxdesign/core/Icon';
@@ -39,9 +40,17 @@ import {
39
40
  } from 'lucide-react';
40
41
 
41
42
  // Fill the content area so the greeting and composer stay vertically centered.
42
- const pageStyle: CSSProperties = {minHeight: '100%'};
43
- const composerInput: CSSProperties = {minHeight: 84};
44
- 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)'};
45
54
 
46
55
  // Suggestion cards shown once a category is selected.
47
56
  const CATEGORY_SUGGESTIONS: Record<
@@ -78,7 +87,7 @@ const CATEGORY_SUGGESTIONS: Record<
78
87
  },
79
88
  {
80
89
  heading: 'Write a function',
81
- body: 'Generate a well-typed function with error handling',
90
+ body: 'Conjure a well-typed function warded against the dark',
82
91
  prompt: 'Write a function that',
83
92
  },
84
93
  {
@@ -88,7 +97,7 @@ const CATEGORY_SUGGESTIONS: Record<
88
97
  },
89
98
  {
90
99
  heading: 'Review my pull request',
91
- body: 'Check for bugs, contrast, and best practices',
100
+ body: 'Hunt bugs by moonlight, weigh contrast, bless the rite',
92
101
  prompt: 'Review this code for bugs and improvements:',
93
102
  },
94
103
  ],
@@ -100,7 +109,7 @@ const CATEGORY_SUGGESTIONS: Record<
100
109
  },
101
110
  {
102
111
  heading: 'Explain a concept',
103
- body: 'Break down a complex topic in simple terms',
112
+ body: 'Unravel a tangled grimoire into plain night-tongue',
104
113
  prompt: 'Explain the concept of',
105
114
  },
106
115
  {
@@ -122,12 +131,12 @@ const CATEGORY_SUGGESTIONS: Record<
122
131
  },
123
132
  {
124
133
  heading: 'Write a dark tale',
125
- body: 'Create an engaging narrative with creatures',
134
+ body: 'Spin a moonlit tale crawling with night creatures',
126
135
  prompt: 'Write a short story about',
127
136
  },
128
137
  {
129
138
  heading: 'Design a concept',
130
- body: 'Explore product or visual design ideas',
139
+ body: 'Conjure nocturnal rites for product or visual haunts',
131
140
  prompt: 'Help me design a concept for',
132
141
  },
133
142
  {
@@ -232,32 +241,45 @@ const commandTrigger: ChatComposerTrigger = {
232
241
 
233
242
  const composerTriggers = [mentionTrigger, commandTrigger];
234
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
+
235
265
  // Main component
236
266
 
237
267
  export default function AiChatLanding() {
238
268
  const [mode, setMode] = useState<string | null>('auto');
239
269
  const [category, setCategory] = useState<string | null>(null);
240
- const [attachments, setAttachments] = useState<string[]>([
241
- 'palette_brief.pdf',
242
- 'nocturne_v2.fig',
243
- 'api_spec.yaml',
244
- 'contrast_audit.csv',
245
- 'dracula_spec.pdf',
246
- ]);
270
+ const [attachments, setAttachments] = useState<Attachment[]>(
271
+ INITIAL_ATTACHMENTS,
272
+ );
247
273
  const [isModeMenuOpen, setIsModeMenuOpen] = useState(false);
248
274
  const composerInputRef = useRef<ChatComposerInputHandle>(null);
249
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);
250
278
  const dictation = useChatDictation({inputRef: composerInputRef});
251
279
 
252
280
  const activeMode = MODE_OPTIONS.find(m => m.key === mode) ?? MODE_OPTIONS[0];
253
281
  const suggestions = category ? CATEGORY_SUGGESTIONS[category] : null;
254
282
 
255
- // The composer's imperative insert methods mutate the DOM without emitting a
256
- // change, so dispatch an input event to sync its value and clear the placeholder.
257
- const syncComposerValue = () => {
258
- document.activeElement?.dispatchEvent(new Event('input', {bubbles: true}));
259
- };
260
-
261
283
  const applySuggestion = (prompt: string) => {
262
284
  const input = composerInputRef.current;
263
285
  if (!input) {
@@ -311,8 +333,8 @@ export default function AiChatLanding() {
311
333
  {/* Greeting */}
312
334
  <VStack gap={1}>
313
335
  <HStack gap={2} vAlign="center">
314
- <Icon icon={Sparkles} size="md" color="accent" />
315
- <Text type="large">
336
+ <Icon icon={Sparkles} size="md" color="secondary" />
337
+ <Text type="body">
316
338
  Hi, Vlad
317
339
  </Text>
318
340
  </HStack>
@@ -331,19 +353,27 @@ export default function AiChatLanding() {
331
353
  triggers={composerTriggers}
332
354
  style={composerInput}
333
355
  onFiles={files =>
334
- 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
+ ])
335
363
  }
336
364
  />
337
365
  }
338
366
  drawer={
339
367
  attachments.length > 0 ? (
340
368
  <ChatComposerDrawer count={attachments.length}>
341
- {attachments.map(name => (
369
+ {attachments.map(attachment => (
342
370
  <Token
343
- key={name}
344
- label={name}
371
+ key={attachment.id}
372
+ label={attachment.name}
345
373
  onRemove={() =>
346
- setAttachments(prev => prev.filter(n => n !== name))
374
+ setAttachments(prev =>
375
+ prev.filter(item => item.id !== attachment.id),
376
+ )
347
377
  }
348
378
  />
349
379
  ))}
@@ -431,7 +461,7 @@ export default function AiChatLanding() {
431
461
  />
432
462
 
433
463
  {/* Category filters + suggestion cards */}
434
- <VStack gap={6} style={categories}>
464
+ <VStack gap={6}>
435
465
  <ToggleButtonGroup
436
466
  label="Category"
437
467
  value={category}
@@ -448,7 +478,9 @@ export default function AiChatLanding() {
448
478
  </ToggleButtonGroup>
449
479
 
450
480
  {suggestions && (
451
- <Grid columns={{minWidth: 280}} gap={3}>
481
+ <>
482
+ <VisuallyHidden as="h2">Suggested prompts</VisuallyHidden>
483
+ <Grid columns={{minWidth: 280}} gap={3}>
452
484
  {suggestions.map(suggestion => (
453
485
  <ClickableCard
454
486
  key={suggestion.heading}
@@ -460,14 +492,15 @@ export default function AiChatLanding() {
460
492
  setMode(category);
461
493
  }}>
462
494
  <VStack gap={0.5}>
463
- <Heading level={4}>{suggestion.heading}</Heading>
495
+ <Heading level={3}>{suggestion.heading}</Heading>
464
496
  <Text type="body" color="secondary">
465
497
  {suggestion.body}
466
498
  </Text>
467
499
  </VStack>
468
500
  </ClickableCard>
469
501
  ))}
470
- </Grid>
502
+ </Grid>
503
+ </>
471
504
  )}
472
505
  </VStack>
473
506
  </VStack>