astryx-dracula 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +6 -2
  2. package/shared/auth-chrome.tsx +156 -6
  3. package/shared/chaptered-doc.tsx +3 -4
  4. package/shared/chart-hues.ts +0 -5
  5. package/shared/chart-legend.tsx +2 -3
  6. package/shared/data-bar.tsx +5 -5
  7. package/shared/format-money.ts +3 -0
  8. package/shared/metric-card.tsx +127 -0
  9. package/shared/scene-hues.ts +0 -8
  10. package/shared/scene-tile.tsx +3 -11
  11. package/shared/settings-data.ts +28 -0
  12. package/shared/settings-rows.tsx +1 -6
  13. package/shared/stars.tsx +28 -0
  14. package/templates/ai-chat-landing.tsx +88 -58
  15. package/templates/ai-chat.tsx +65 -78
  16. package/templates/centered-hero.tsx +2 -2
  17. package/templates/dashboard-portfolio.tsx +77 -155
  18. package/templates/dashboard.tsx +17 -134
  19. package/templates/detail-page.tsx +14 -18
  20. package/templates/documentation-design.tsx +66 -519
  21. package/templates/documentation-technical.tsx +2 -22
  22. package/templates/editor.tsx +168 -185
  23. package/templates/file-explorer.tsx +10 -24
  24. package/templates/ide.tsx +12 -18
  25. package/templates/incident-console.tsx +48 -46
  26. package/templates/kanban-board.tsx +36 -85
  27. package/templates/library.tsx +42 -66
  28. package/templates/login-card.tsx +33 -126
  29. package/templates/login-split.tsx +91 -123
  30. package/templates/login-sso.tsx +12 -33
  31. package/templates/login.tsx +21 -121
  32. package/templates/messaging-shell.tsx +162 -168
  33. package/templates/payment-form.tsx +24 -26
  34. package/templates/product-detail.tsx +7 -40
  35. package/templates/product-tour.tsx +38 -60
  36. package/templates/settings-dialog.tsx +10 -32
  37. package/templates/settings-sidebar.tsx +44 -77
  38. package/templates/settings.tsx +208 -121
  39. package/templates/shell-nav.tsx +20 -75
  40. package/templates/shell-side-nav.tsx +16 -90
  41. package/templates/shell-top-nav.tsx +20 -53
  42. package/templates/side-gallery.tsx +19 -19
  43. package/templates/table-grouped.tsx +17 -164
  44. package/templates/table-page-chart.tsx +12 -11
  45. package/templates/table-page-heatmap-status.tsx +0 -17
  46. package/templates/table-page-shoe-store-heatmap.tsx +12 -12
  47. package/templates/table-page.tsx +4 -31
  48. package/templates/table.tsx +7 -10
  49. package/templates/tech-report.tsx +58 -72
  50. package/templates/theme-showcase.tsx +124 -415
@@ -13,6 +13,7 @@ import {
13
13
  ChatDictationButton,
14
14
  useChatDictation,
15
15
  type ChatComposerInputHandle,
16
+ type ChatComposerToken,
16
17
  type ChatComposerTrigger,
17
18
  } from '@astryxdesign/core/Chat';
18
19
  import {
@@ -207,37 +208,54 @@ const COMMAND_ITEMS: SearchableItem<{description: string}>[] = [
207
208
  },
208
209
  ];
209
210
 
210
- const mentionTrigger: ChatComposerTrigger = {
211
+ // One factory for both composer triggers. Render (TypeaheadItem + auxiliary
212
+ // description) and select (token value/label/variant) differ per trigger;
213
+ // the trigger shape doesn't.
214
+ function makeTrigger<TAux>({
215
+ character,
216
+ items,
217
+ getDescription,
218
+ toToken,
219
+ }: {
220
+ character: string;
221
+ items: SearchableItem<TAux>[];
222
+ getDescription: (aux: TAux | undefined) => string | undefined;
223
+ toToken: (item: SearchableItem<TAux>) => string | ChatComposerToken;
224
+ }): ChatComposerTrigger {
225
+ return {
226
+ character,
227
+ searchSource: createStaticSource(items),
228
+ renderItem: item => (
229
+ <TypeaheadItem
230
+ item={item}
231
+ description={getDescription(item.auxiliaryData as TAux | undefined)}
232
+ />
233
+ ),
234
+ onSelect: item => toToken(item as SearchableItem<TAux>),
235
+ };
236
+ }
237
+
238
+ const mentionTrigger = makeTrigger({
211
239
  character: '@',
212
- searchSource: createStaticSource(MENTION_ITEMS),
213
- renderItem: item => (
214
- <TypeaheadItem
215
- item={item}
216
- description={(item.auxiliaryData as {role: string})?.role}
217
- />
218
- ),
219
- onSelect: item => ({
240
+ items: MENTION_ITEMS,
241
+ getDescription: aux => aux?.role,
242
+ toToken: item => ({
220
243
  value: `@${item.id}`,
221
244
  label: item.label,
222
245
  variant: 'cyan',
223
246
  }),
224
- };
247
+ });
225
248
 
226
- const commandTrigger: ChatComposerTrigger = {
249
+ const commandTrigger = makeTrigger({
227
250
  character: '/',
228
- searchSource: createStaticSource(COMMAND_ITEMS),
229
- renderItem: item => (
230
- <TypeaheadItem
231
- item={item}
232
- description={(item.auxiliaryData as {description: string})?.description}
233
- />
234
- ),
235
- onSelect: item => ({
251
+ items: COMMAND_ITEMS,
252
+ getDescription: aux => aux?.description,
253
+ toToken: item => ({
236
254
  value: `/${item.label}`,
237
255
  label: `/${item.label}`,
238
256
  variant: 'yellow',
239
257
  }),
240
- };
258
+ });
241
259
 
242
260
  const composerTriggers = [mentionTrigger, commandTrigger];
243
261
 
@@ -254,7 +272,7 @@ const INITIAL_ATTACHMENTS: Attachment[] = [
254
272
  'api_spec.yaml',
255
273
  'contrast_audit.csv',
256
274
  'dracula_spec.pdf',
257
- ].map((name, index) => ({id: `file-${index + 1}`, name}));
275
+ ].map(name => ({id: crypto.randomUUID(), name}));
258
276
 
259
277
  // The composer's imperative insert methods mutate the DOM without emitting a
260
278
  // change, so dispatch an input event to sync its value and clear the placeholder.
@@ -262,6 +280,35 @@ const syncComposerValue = () => {
262
280
  document.activeElement?.dispatchEvent(new Event('input', {bubbles: true}));
263
281
  };
264
282
 
283
+ // One inserter for every composer write. `insertText` replaces the selection
284
+ // (suggestion prompts); `insertToken` inserts a chip at the cursor without
285
+ // touching the selection (mentions, mode tokens).
286
+ function insertIntoComposer(
287
+ input: ChatComposerInputHandle | null,
288
+ write: {text: string} | {token: ChatComposerToken},
289
+ collapseToEnd = false,
290
+ ) {
291
+ if (!input) {
292
+ return;
293
+ }
294
+ input.focus();
295
+ if (document.activeElement) {
296
+ if (collapseToEnd) {
297
+ const sel = window.getSelection();
298
+ sel?.selectAllChildren(document.activeElement);
299
+ sel?.collapseToEnd();
300
+ } else {
301
+ window.getSelection()?.selectAllChildren(document.activeElement);
302
+ }
303
+ }
304
+ if ('text' in write) {
305
+ input.insertText(write.text);
306
+ } else {
307
+ input.insertToken(write.token);
308
+ }
309
+ syncComposerValue();
310
+ }
311
+
265
312
  // Main component
266
313
 
267
314
  export default function AiChatLanding() {
@@ -273,53 +320,36 @@ export default function AiChatLanding() {
273
320
  const [isModeMenuOpen, setIsModeMenuOpen] = useState(false);
274
321
  const composerInputRef = useRef<ChatComposerInputHandle>(null);
275
322
  const shouldFocusComposerRef = useRef(false);
276
- // Two dropped files can share a name, so attachments carry their own id.
277
- const nextAttachmentId = useRef(INITIAL_ATTACHMENTS.length);
323
+ // Dictation mic stays: the composer still renders ChatDictationButton below.
278
324
  const dictation = useChatDictation({inputRef: composerInputRef});
279
325
 
280
326
  const activeMode = MODE_OPTIONS.find(m => m.key === mode) ?? MODE_OPTIONS[0];
281
327
  const suggestions = category ? CATEGORY_SUGGESTIONS[category] : null;
282
328
 
283
329
  const applySuggestion = (prompt: string) => {
284
- const input = composerInputRef.current;
285
- if (!input) {
286
- return;
287
- }
288
- input.focus();
289
- if (document.activeElement) {
290
- window.getSelection()?.selectAllChildren(document.activeElement);
291
- }
292
- input.insertText(prompt);
293
- syncComposerValue();
330
+ insertIntoComposer(composerInputRef.current, {text: prompt});
294
331
  };
295
-
296
332
  const insertMention = (item: (typeof MENTION_ITEMS)[number]) => {
297
- const input = composerInputRef.current;
298
- if (!input) {
299
- return;
300
- }
301
- input.focus();
302
- if (document.activeElement) {
303
- const sel = window.getSelection();
304
- sel?.selectAllChildren(document.activeElement);
305
- sel?.collapseToEnd();
306
- }
307
- input.insertToken({
308
- value: `@${item.id}`,
309
- label: item.label,
310
- variant: 'cyan',
311
- });
312
- syncComposerValue();
333
+ insertIntoComposer(
334
+ composerInputRef.current,
335
+ {
336
+ token: {
337
+ value: `@${item.id}`,
338
+ label: item.label,
339
+ variant: 'cyan',
340
+ },
341
+ },
342
+ true,
343
+ );
313
344
  };
314
-
315
345
  const insertModeToken = (label: string) => {
316
- composerInputRef.current?.focus();
317
- composerInputRef.current?.insertToken({
318
- value: label,
319
- label,
320
- variant: 'orange',
346
+ insertIntoComposer(composerInputRef.current, {
347
+ token: {
348
+ value: label,
349
+ label,
350
+ variant: 'orange',
351
+ },
321
352
  });
322
- syncComposerValue();
323
353
  };
324
354
 
325
355
  return (
@@ -356,7 +386,7 @@ export default function AiChatLanding() {
356
386
  setAttachments(prev => [
357
387
  ...prev,
358
388
  ...files.map(file => ({
359
- id: `file-${++nextAttachmentId.current}`,
389
+ id: crypto.randomUUID(),
360
390
  name: file.name,
361
391
  })),
362
392
  ])
@@ -2,7 +2,7 @@
2
2
  // XLE (canonical structure, validated with `bunx astryx layout check`):
3
3
  // L > LC[p=0] > H > (SI[fill] > ChL > ChML > (ChM > ChB)*4 + ChC"Ask a follow up...") + (C.transparent > Tbar + S.section > MD) ;; Dlg#artifact[variant=fullscreen] > L > (DH"JWT Token Refresh" + (LC[p=0] > S.section > MD))
4
4
 
5
- import {useRef, useState, type CSSProperties} from 'react';
5
+ import {useState, type CSSProperties} from 'react';
6
6
 
7
7
  import {
8
8
  HStack,
@@ -40,6 +40,7 @@ import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
40
40
  import {MoreMenu} from '@astryxdesign/core/MoreMenu';
41
41
  import {Toolbar} from '@astryxdesign/core/Toolbar';
42
42
  import {useResizable, ResizeHandle} from '@astryxdesign/core/Resizable';
43
+ import {useMediaQuery} from '@astryxdesign/core/hooks';
43
44
 
44
45
  import {
45
46
  FileText,
@@ -52,19 +53,17 @@ import {
52
53
  } from 'lucide-react';
53
54
 
54
55
  // Below this container width the split-pane collapses to a single chat column
55
- // and the artifact opens as a full-screen dialog instead. Shared by the CSS
56
- // container query and the JS check in openArtifact so they can't drift. 1023
57
- // keeps tablet widths from squeezing the chat into a ~100px rail beside the
58
- // 640px default artifact panel.
56
+ // and the artifact opens as a full-screen dialog instead. Single source:
57
+ // MOBILE_MAX_WIDTH feeds useMediaQuery below, which both gates the panel and
58
+ // routes openArtifact — no container query, no manual width read to drift.
59
+ // 1023 keeps tablet widths from squeezing the chat into a ~100px rail beside
60
+ // the 640px default artifact panel.
59
61
  const MOBILE_MAX_WIDTH = 1023;
60
-
61
62
  const root: CSSProperties = {
62
63
  // The viewer owns its chrome: the root fills the viewport and the message
63
64
  // list plus artifact body scroll inside it (editor pattern).
64
65
  height: '100dvh',
65
66
  width: '100%',
66
- containerType: 'inline-size',
67
- containerName: 'artifact',
68
67
  };
69
68
  const chatColumn: CSSProperties = {
70
69
  flex: 1,
@@ -91,38 +90,16 @@ const articleBody: CSSProperties = {
91
90
  marginInline: 'auto',
92
91
  };
93
92
 
94
- // Runtime width for the artifact panel, passed in via the --artifact-panel-width
95
- // custom property so the MOBILE container query can still override it to 100%
96
- // (an inline `width` would beat the class rule). The container query lives in a
97
- // plain <style> tag below so it needs NO CSS compiler.
93
+ // Width for the artifact panel. Plain style object — the panel unmounts below
94
+ // the breakpoint (see useMediaQuery in the component), so no container query
95
+ // or <style> tag is needed to hide it.
98
96
  const artifactPanelWidthVar = (size: number | string): CSSProperties =>
99
97
  ({
100
- '--artifact-panel-width': typeof size === 'number' ? `${size}px` : size,
98
+ width: typeof size === 'number' ? `${size}px` : size,
99
+ flexShrink: 0,
100
+ overflow: 'hidden',
101
101
  }) as CSSProperties;
102
102
 
103
- const AI_CHAT_CSS = `
104
- .ai-chat-resize-handle {
105
- display: flex;
106
- }
107
- .ai-chat-artifact-panel {
108
- overflow: hidden;
109
- display: flex;
110
- flex-direction: column;
111
- width: var(--artifact-panel-width);
112
- flex-shrink: 0;
113
- }
114
- @container artifact (max-width: ${MOBILE_MAX_WIDTH}px) {
115
- .ai-chat-resize-handle {
116
- display: none;
117
- }
118
- .ai-chat-artifact-panel {
119
- display: none;
120
- width: 100%;
121
- flex-shrink: 1;
122
- }
123
- }
124
- `;
125
-
126
103
  // Artifact content
127
104
 
128
105
  const MENTION_TOKENS = [
@@ -175,6 +152,13 @@ The refresh path is covered end to end:
175
152
 
176
153
  // Artifact subviews
177
154
 
155
+ // Copy/Share live in both surfaces: desktop toolbar buttons and the mobile
156
+ // overflow menu. One items array so the two can't drift.
157
+ const ARTIFACT_ACTION_ITEMS = [
158
+ {label: 'Copy', icon: Copy},
159
+ {label: 'Share', icon: Share2},
160
+ ] as const;
161
+
178
162
  // Header actions: version menu, copy, share. Pass `onClose` for the desktop
179
163
  // close button. A fragment so each control is a direct child of the toolbar.
180
164
  function ArtifactActions({onClose}: {onClose?: () => void}) {
@@ -188,20 +172,16 @@ function ArtifactActions({onClose}: {onClose?: () => void}) {
188
172
  }}
189
173
  items={[{label: 'v2 (current)'}, {label: 'v1'}]}
190
174
  />
191
- <Button
192
- label="Copy"
193
- variant="ghost"
194
- size="sm"
195
- icon={<Icon icon={Copy} size="sm" />}
196
- isIconOnly
197
- />
198
- <Button
199
- label="Share"
200
- variant="ghost"
201
- size="sm"
202
- icon={<Icon icon={Share2} size="sm" />}
203
- isIconOnly
204
- />
175
+ {ARTIFACT_ACTION_ITEMS.map(item => (
176
+ <Button
177
+ key={item.label}
178
+ label={item.label}
179
+ variant="ghost"
180
+ size="sm"
181
+ icon={<Icon icon={item.icon} size="sm" />}
182
+ isIconOnly
183
+ />
184
+ ))}
205
185
  {onClose != null && (
206
186
  <Button
207
187
  label="Close document"
@@ -226,23 +206,23 @@ function MobileArtifactActions() {
226
206
  {
227
207
  type: 'section',
228
208
  title: 'Version',
229
- items: [
230
- {label: 'v2 (current)', onClick: () => {}},
231
- {label: 'v1', onClick: () => {}},
232
- ],
209
+ items: [{label: 'v2 (current)'}, {label: 'v1'}],
233
210
  },
234
211
  {type: 'divider'},
235
- {label: 'Copy', icon: Copy},
236
- {label: 'Share', icon: Share2},
212
+ ...ARTIFACT_ACTION_ITEMS,
237
213
  ]}
238
214
  />
239
215
  );
240
216
  }
241
217
 
242
- // Scrollable artifact body — the formatted document. The mobile dialog renders
243
- // the title in its DialogHeader, so it drops the in-body heading rather than
244
- // announcing the same h1 twice.
245
- function ArtifactBody({hasOwnTitle = true}: {hasOwnTitle?: boolean}) {
218
+ // Scrollable artifact content — the formatted document, no heading. The
219
+ // desktop panel renders ArtifactBody above it; the mobile dialog renders its
220
+ // title in DialogHeader instead, so both share this rather than a flag prop.
221
+ function ArtifactContent() {
222
+ return <Markdown>{ARTIFACT_CONTENT}</Markdown>;
223
+ }
224
+ // Desktop body: in-body heading plus the shared content.
225
+ function ArtifactBody() {
246
226
  return (
247
227
  <Section
248
228
  variant="transparent"
@@ -251,13 +231,10 @@ function ArtifactBody({hasOwnTitle = true}: {hasOwnTitle?: boolean}) {
251
231
  aria-label={ARTIFACT_TITLE}
252
232
  tabIndex={0}>
253
233
  <VStack gap={2} style={articleBody}>
254
- {/* Page owns the h1 (VisuallyHidden "Night thread"); the panel title is h2. */}
255
- {hasOwnTitle && (
256
- <Heading level={2} type="display-2">
257
- {ARTIFACT_TITLE}
258
- </Heading>
259
- )}
260
- <Markdown>{ARTIFACT_CONTENT}</Markdown>
234
+ <Heading level={2} type="display-2">
235
+ {ARTIFACT_TITLE}
236
+ </Heading>
237
+ <ArtifactContent />
261
238
  </VStack>
262
239
  </Section>
263
240
  );
@@ -298,7 +275,6 @@ export default function AiChat() {
298
275
  // Mobile shows the artifact as a full-screen dialog; desktop as a side panel.
299
276
  const [isArtifactDialogOpen, setIsArtifactDialogOpen] = useState(false);
300
277
  const [isArtifactOpen, setIsArtifactOpen] = useState(true);
301
- const rootRef = useRef<HTMLElement>(null);
302
278
  const artifactResize = useResizable({
303
279
  defaultSize: 640,
304
280
  minSize: 480,
@@ -306,10 +282,12 @@ export default function AiChat() {
306
282
  autoSaveId: 'ai-chat-artifact-panel',
307
283
  });
308
284
 
309
- // Match the container-query breakpoint by measuring the root, not the viewport.
285
+ // Single source for the breakpoint: the media query both gates the panel
286
+ // below and routes openArtifact, so the split-pane and the dialog target
287
+ // can't disagree about which surface is live.
288
+ const isMobileWidth = useMediaQuery(`(max-width: ${MOBILE_MAX_WIDTH}px)`);
310
289
  const openArtifact = () => {
311
- const width = rootRef.current?.offsetWidth ?? Infinity;
312
- if (width <= MOBILE_MAX_WIDTH) {
290
+ if (isMobileWidth) {
313
291
  setIsArtifactDialogOpen(true);
314
292
  } else {
315
293
  setIsArtifactOpen(true);
@@ -317,8 +295,7 @@ export default function AiChat() {
317
295
  };
318
296
 
319
297
  return (
320
- <VStack ref={rootRef} style={root}>
321
- <style>{AI_CHAT_CSS}</style>
298
+ <VStack style={root}>
322
299
  <Layout
323
300
  height="fill"
324
301
  content={
@@ -700,8 +677,11 @@ The fix is to catch \`TokenExpiredError\` specifically and attempt a refresh bef
700
677
  </ChatLayout>
701
678
  </VStack>
702
679
 
703
- {/* Desktop split-pane: resize handle + artifact panel */}
704
- {isArtifactOpen && (
680
+ {/* Desktop split-pane: resize handle + artifact panel. Gated on
681
+ the same media query that routes openArtifact, so shrinking
682
+ the window while open moves to the dialog instead of
683
+ squeezing the chat into a rail. */}
684
+ {isArtifactOpen && !isMobileWidth && (
705
685
  <>
706
686
  <ResizeHandle
707
687
  direction="horizontal"
@@ -710,14 +690,12 @@ The fix is to catch \`TokenExpiredError\` specifically and attempt a refresh bef
710
690
  pillPlacement="start"
711
691
  hasDivider
712
692
  label="Resize artifact panel"
713
- className="ai-chat-resize-handle"
714
693
  />
715
694
 
716
695
  {/* Toolbar as the card header, body below */}
717
696
  <Card
718
697
  variant="transparent"
719
698
  height="100%"
720
- className="ai-chat-artifact-panel"
721
699
  style={artifactPanelWidthVar(artifactResize.size)}>
722
700
  <Toolbar
723
701
  label="Artifact actions"
@@ -773,7 +751,16 @@ The fix is to catch \`TokenExpiredError\` specifically and attempt a refresh bef
773
751
  }
774
752
  content={
775
753
  <LayoutContent padding={0}>
776
- <ArtifactBody hasOwnTitle={false} />
754
+ <Section
755
+ variant="transparent"
756
+ style={artifactScroll}
757
+ role="region"
758
+ aria-label={ARTIFACT_TITLE}
759
+ tabIndex={0}>
760
+ <VStack gap={2} style={articleBody}>
761
+ <ArtifactContent />
762
+ </VStack>
763
+ </Section>
777
764
  </LayoutContent>
778
765
  }
779
766
  />
@@ -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[p=6] > V[g=10] > ((V[g=6 a=center] > (V[g=3 a=center] > Hd"Small joys for creatures of the night"[level=1 t=display-2] + Tx"Sometimes all it takes is one small ritual to turn the whole night around."[t=body] + Tx"astryx-dracula v0.4.0"[t=supporting]) + (H[j=center g=3] > B.primary"Embrace the night" + B.secondary"Read the lore")) + (S.transparent[p=0] > AR[ratio=16/9]))
3
+ // L > LC[p=6] > V[g=10] > ((V[g=6 a=center] > (V[g=3 a=center] > Hd"Small joys for creatures of the night"[level=1 t=display-2] + Tx"Sometimes all it takes is one small ritual to turn the whole night around."[t=body] + Tx"astryx-dracula v0.5.0"[t=supporting]) + (H[j=center g=3] > B.primary"Embrace the night" + B.secondary"Read the lore")) + (S.transparent[p=0] > AR[ratio=16/9]))
4
4
 
5
5
  import type {CSSProperties} from 'react';
6
6
  import {VStack, HStack, Layout, LayoutContent} from '@astryxdesign/core/Layout';
@@ -52,7 +52,7 @@ export default function CenteredHero() {
52
52
  color="secondary"
53
53
  justify="center"
54
54
  hasTabularNumbers>
55
- astryx-dracula v0.4.0
55
+ astryx-dracula v0.5.0
56
56
  </Text>
57
57
  </VStack>
58
58
  <HStack gap={3} wrap="wrap" justify="center">