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
@@ -269,17 +269,16 @@ const INCIDENTS: Incident[] = [
269
269
 
270
270
  const STATUS_ORDER: Status[] = ['investigating', 'mitigated', 'resolved'];
271
271
 
272
- const STATUS_LABEL: Record<Status, string> = {
273
- investigating: 'Investigating',
274
- mitigated: 'Mitigated',
275
- resolved: 'Resolved',
276
- };
277
-
278
- const STATUS_TOKEN_COLOR: Record<Status, 'cyan' | 'yellow' | 'green'> = {
279
- // Info vocab: an open investigation pulses cyan, never red.
280
- investigating: 'cyan',
281
- mitigated: 'yellow',
282
- resolved: 'green',
272
+ // One union: label for group headings + row tokens, color for row tokens.
273
+ // SEVERITY_DOT stays separate — it keys on severity (sev1/2/3), a different
274
+ // axis than status, so merging would conflate two domains in one map.
275
+ const STATUS: Record<
276
+ Status,
277
+ {label: string; tokenColor: 'cyan' | 'yellow' | 'green'}
278
+ > = {
279
+ investigating: {label: 'Investigating', tokenColor: 'cyan'},
280
+ mitigated: {label: 'Mitigated', tokenColor: 'yellow'},
281
+ resolved: {label: 'Resolved', tokenColor: 'green'},
283
282
  };
284
283
 
285
284
  const SEVERITY_DOT: Record<Severity, 'error' | 'warning' | 'neutral'> = {
@@ -288,6 +287,30 @@ const SEVERITY_DOT: Record<Severity, 'error' | 'warning' | 'neutral'> = {
288
287
  sev3: 'neutral',
289
288
  };
290
289
 
290
+ // SEV badge shared by rows and the inspector: dot + uppercase word travel
291
+ // together (a bare dot leaves SEV1/2/3 as hue alone; StatusDot's label is
292
+ // aria-only on a role="img" span and paints nothing).
293
+ function SeverityBadge({
294
+ severity,
295
+ isPulsing = false,
296
+ }: {
297
+ severity: Severity;
298
+ isPulsing?: boolean;
299
+ }) {
300
+ return (
301
+ <HStack gap={1} vAlign="center">
302
+ <StatusDot
303
+ variant={SEVERITY_DOT[severity]}
304
+ label={severity.toUpperCase()}
305
+ isPulsing={isPulsing}
306
+ />
307
+ <Text type="supporting" color="secondary">
308
+ {severity.toUpperCase()}
309
+ </Text>
310
+ </HStack>
311
+ );
312
+ }
313
+
291
314
  const SERVICE_VALUES = [
292
315
  {value: 'checkout-api', label: 'checkout-api'},
293
316
  {value: 'search-indexer', label: 'search-indexer'},
@@ -350,7 +373,7 @@ function IncidentRows({
350
373
  floor. The group heading is the string this sidebar is scanned
351
374
  for. Hierarchy comes from the two type roles (principle 6),
352
375
  not from dimming the count. */}
353
- <Text type="label">{STATUS_LABEL[group.status]}</Text>
376
+ <Text type="label">{STATUS[group.status].label}</Text>
354
377
  <Text type="supporting" hasTabularNumbers>
355
378
  {group.items.length}
356
379
  </Text>
@@ -362,27 +385,17 @@ function IncidentRows({
362
385
  label={incident.title}
363
386
  description={`${incident.id} · ${incident.service} · ${incident.impact}`}
364
387
  startContent={
365
- // StatusDot's label is aria-label on a role="img" span
366
- // (core StatusDot.js:107-120), so it reaches assistive tech
367
- // and paints nothing. A bare dot leaves SEV1/2/3 as hue
368
- // alone. Dot and word travel together, as at :751-757.
369
- <HStack gap={1} vAlign="center">
370
- <StatusDot
371
- variant={SEVERITY_DOT[incident.severity]}
372
- label={incident.severity.toUpperCase()}
373
- isPulsing={incident.status === 'investigating'}
374
- />
375
- <Text type="supporting" color="secondary">
376
- {incident.severity.toUpperCase()}
377
- </Text>
378
- </HStack>
388
+ <SeverityBadge
389
+ severity={incident.severity}
390
+ isPulsing={incident.status === 'investigating'}
391
+ />
379
392
  }
380
393
  endContent={
381
394
  <HStack gap={3} vAlign="center">
382
395
  <Token
383
396
  size="sm"
384
- color={STATUS_TOKEN_COLOR[incident.status]}
385
- label={STATUS_LABEL[incident.status]}
397
+ color={STATUS[incident.status].tokenColor}
398
+ label={STATUS[incident.status].label}
386
399
  />
387
400
  <Timestamp
388
401
  value={incident.startedAt}
@@ -414,27 +427,23 @@ function IncidentInspector({incident}: {incident: Incident}) {
414
427
  <VStack gap={4} style={styles.inspector}>
415
428
  <VStack gap={2}>
416
429
  <HStack gap={1} vAlign="center">
417
- <StatusDot
418
- variant={SEVERITY_DOT[incident.severity]}
419
- label={incident.severity.toUpperCase()}
430
+ <SeverityBadge
431
+ severity={incident.severity}
420
432
  isPulsing={incident.status === 'investigating'}
421
433
  />
422
- <Text type="supporting" color="secondary">
423
- {incident.severity.toUpperCase()}
424
- </Text>
425
434
  <Text type="supporting" color="secondary">
426
435
  {incident.id}
427
436
  </Text>
428
437
  <Token
429
438
  size="sm"
430
- color={STATUS_TOKEN_COLOR[incident.status]}
431
- label={STATUS_LABEL[incident.status]}
439
+ color={STATUS[incident.status].tokenColor}
440
+ label={STATUS[incident.status].label}
432
441
  />
433
442
  </HStack>
434
443
  <Heading level={2}>{incident.title}</Heading>
435
444
  </VStack>
436
445
 
437
- <HStack gap={2}>
446
+ <HStack gap={2} wrap="wrap">
438
447
  <Button label={nextAction} variant="primary" />
439
448
  <Button label="Escalate" variant="secondary" />
440
449
  </HStack>
@@ -535,21 +544,14 @@ export default function IncidentConsole() {
535
544
  // phone, and the counter beside it then wraps too and detaches into the
536
545
  // corner. Stacking keeps the counter on the title's own line at any width
537
546
  // the title needs more than one, and costs nothing at desktop.
538
- const titleGroup = isNarrow ? (
539
- <VStack gap={1}>
547
+ const titleGroup = (
548
+ <HStack gap={2} vAlign="center" wrap="wrap">
540
549
  <Heading level={1} type="display-2">
541
550
  Night watch
542
551
  </Heading>
543
552
  <Text type="supporting" color="secondary" hasTabularNumbers>
544
553
  {openCount} investigating
545
554
  </Text>
546
- </VStack>
547
- ) : (
548
- <HStack gap={2} vAlign="center">
549
- <Heading level={1} type="display-2">Night watch</Heading>
550
- <Text type="supporting" color="secondary" hasTabularNumbers>
551
- {openCount} investigating
552
- </Text>
553
555
  </HStack>
554
556
  );
555
557
 
@@ -6,7 +6,6 @@ import {
6
6
  useEffect,
7
7
  useRef,
8
8
  useState,
9
- type CSSProperties,
10
9
  type PointerEvent as ReactPointerEvent,
11
10
  type KeyboardEvent as ReactKeyboardEvent,
12
11
  type ReactNode,
@@ -253,58 +252,11 @@ const DRAG_THRESHOLD = 5;
253
252
 
254
253
  // ============= STYLES =============
255
254
 
256
- // Responsive board grid: columns collapse to fewer tracks, then a single
257
- // stack. The board keeps its own internal scroll as the only scroller;
258
- // grid children get room to shrink so nothing forces page-level scroll.
259
- const boardColumnsStyle: CSSProperties = {
260
- overflowX: 'auto',
261
- overflowY: 'auto',
262
- height: '100%',
263
- padding: 'var(--spacing-6)',
264
- };
265
- const columnShellStyle: CSSProperties = {
266
- minWidth: 0,
267
- height: '100%',
268
- };
269
- // Cards stay static per the Dracula brand (no hover lift); the floating drag
270
- // clone floats above the board with the high shadow token instead.
271
- const cardStyle: CSSProperties = {
272
- cursor: 'grab',
273
- userSelect: 'none',
274
- touchAction: 'none',
275
- };
276
255
  // The dragged card is lifted out of flow and follows the pointer. It ignores
277
256
  // pointer events so hit-testing reads the columns underneath it. The clone
278
257
  // rides above shell chrome (layered 0-3) but stays under overlay surfaces
279
258
  // (menus, dialogs layer at 500+ plus the top layer), so an open menu wins.
280
259
  const DRAG_CLONE_LAYER = 100;
281
- const floatingStyle: CSSProperties = {
282
- position: 'fixed',
283
- insetBlockStart: 0,
284
- insetInlineStart: 0,
285
- pointerEvents: 'none',
286
- cursor: 'grabbing',
287
- zIndex: DRAG_CLONE_LAYER,
288
- };
289
- // Placeholder marking the landing slot; matches the dragged card's height.
290
- const ghostStyle = (height: number): CSSProperties => ({
291
- height,
292
- borderRadius: 'var(--radius-element)',
293
- backgroundColor: 'var(--color-background-muted)',
294
- });
295
- const toolbarDividerStyle: CSSProperties = {
296
- alignSelf: 'stretch',
297
- };
298
- const columnEmptyStateStyle: CSSProperties = {
299
- // 12px, not 24. This applies to an EmptyState inside a LayoutContent
300
- // padding={3} (12px), so 24 rendered as 36px of block air on the empty
301
- // branch against 12px on the populated one -- a 3x difference between two
302
- // states of the same column, and the only nested inset in the kit that
303
- // exceeded its container. The block padding is the CONTAINER's job; this
304
- // one is the placeholder's room to breathe inside it.
305
- paddingBlock: 'var(--spacing-3)',
306
- };
307
-
308
260
  // ============= CARD BODY =============
309
261
 
310
262
  // Shared card contents, rendered both in the column list and inside the
@@ -332,12 +284,7 @@ function BoardCardBody({
332
284
  <MoreMenu
333
285
  label="Work item actions"
334
286
  size="sm"
335
- items={[
336
- {label: 'Open', onClick: () => {}},
337
- {label: 'Assign to me', onClick: () => {}},
338
- {type: 'divider'},
339
- ...moveTargets,
340
- ]}
287
+ items={[{label: 'Open'}, {label: 'Assign to me'}, {type: 'divider'}, ...moveTargets]}
341
288
  />
342
289
  </HStack>
343
290
 
@@ -389,7 +336,9 @@ function BoardCard({
389
336
  <Card
390
337
  ref={cardRef}
391
338
  padding={3}
392
- style={cardStyle}
339
+ // Cards stay static per the Dracula brand (no hover lift); the floating
340
+ // drag clone floats above the board with the high shadow token instead.
341
+ style={{cursor: 'grab', userSelect: 'none', touchAction: 'none'}}
393
342
  tabIndex={0}
394
343
  role="button"
395
344
  aria-label={`${item.title}. In ${COLUMNS.find(c => c.id === item.column)?.title}. Press left or right arrow to move across columns.`}
@@ -414,7 +363,7 @@ function BoardColumn({
414
363
  children: ReactNode;
415
364
  }) {
416
365
  return (
417
- <Card variant="muted" padding={0} style={columnShellStyle}>
366
+ <Card variant="muted" padding={0} style={{minWidth: 0, height: '100%'}}>
418
367
  <Layout
419
368
  height="fill"
420
369
  header={
@@ -455,7 +404,10 @@ function BoardColumn({
455
404
  {children ?? (
456
405
  <EmptyState
457
406
  isCompact
458
- style={columnEmptyStateStyle}
407
+ // 12px, not 24: this EmptyState sits inside a LayoutContent
408
+ // padding={3} (12px), so 24 would render as 36px of block air
409
+ // on the empty branch against 12px on the populated one.
410
+ style={{paddingBlock: 'var(--spacing-3)'}}
459
411
  icon={
460
412
  <Icon icon={meta.emptyIcon} size="lg" color="secondary" />
461
413
  }
@@ -488,36 +440,22 @@ export default function KanbanBoard() {
488
440
  );
489
441
  const teardownRef = useRef<(() => void) | null>(null);
490
442
 
491
- // Stable ref callbacks so registering an element never churns across renders.
492
- const getColumnRef = (id: ColumnId) => {
493
- let cb = columnRefCbs.current.get(id);
494
- if (!cb) {
495
- cb = el => {
496
- if (el) {
497
- columnEls.current.set(id, el);
498
- } else {
499
- columnEls.current.delete(id);
500
- }
501
- };
502
- columnRefCbs.current.set(id, cb);
503
- }
504
- return cb;
505
- };
506
-
507
- const getCardRef = (id: string) => {
508
- let cb = cardRefCbs.current.get(id);
443
+ // One helper for both registries: stable per-key ref callbacks so
444
+ // registering an element never churns across renders.
445
+ function refCallback<K>(cbs: Map<K, (el: HTMLDivElement | null) => void>, els: Map<K, HTMLElement>, key: K) {
446
+ let cb = cbs.get(key);
509
447
  if (!cb) {
510
448
  cb = el => {
511
449
  if (el) {
512
- cardEls.current.set(id, el);
450
+ els.set(key, el);
513
451
  } else {
514
- cardEls.current.delete(id);
452
+ els.delete(key);
515
453
  }
516
454
  };
517
- cardRefCbs.current.set(id, cb);
455
+ cbs.set(key, cb);
518
456
  }
519
457
  return cb;
520
- };
458
+ }
521
459
 
522
460
  const itemsByColumn = groupByColumn(items);
523
461
 
@@ -688,7 +626,7 @@ export default function KanbanBoard() {
688
626
  <BoardCard
689
627
  key={it.id}
690
628
  item={it}
691
- cardRef={getCardRef(it.id)}
629
+ cardRef={refCallback(cardRefCbs.current, cardEls.current, it.id)}
692
630
  onPointerDown={onCardPointerDown}
693
631
  onMove={moveItem}
694
632
  />
@@ -699,7 +637,15 @@ export default function KanbanBoard() {
699
637
  nodes.splice(
700
638
  index,
701
639
  0,
702
- <VStack key="drag-ghost" style={ghostStyle(ghostTarget.height)} />,
640
+ <VStack
641
+ key="drag-ghost"
642
+ // Placeholder marking the landing slot; matches the dragged card's height.
643
+ style={{
644
+ height: ghostTarget.height,
645
+ borderRadius: 'var(--radius-element)',
646
+ backgroundColor: 'var(--color-background-muted)',
647
+ }}
648
+ />,
703
649
  );
704
650
  }
705
651
 
@@ -739,7 +685,7 @@ export default function KanbanBoard() {
739
685
  <Divider
740
686
  variant="strong"
741
687
  orientation="vertical"
742
- style={toolbarDividerStyle}
688
+ style={{alignSelf: 'stretch'}}
743
689
  />
744
690
  <HStack gap={1} vAlign="center" wrap="wrap">
745
691
  <IconButton
@@ -770,7 +716,7 @@ export default function KanbanBoard() {
770
716
  <Grid
771
717
  columns={{ minWidth: 280, max: 4 }}
772
718
  gap={4}
773
- style={boardColumnsStyle}
719
+ style={{overflowX: 'auto', overflowY: 'auto', height: '100%', padding: 'var(--spacing-6)'}}
774
720
  tabIndex={0}
775
721
  role="region"
776
722
  aria-label="Sprint board columns">
@@ -779,7 +725,7 @@ export default function KanbanBoard() {
779
725
  key={meta.id}
780
726
  meta={meta}
781
727
  count={itemsByColumn[meta.id].length}
782
- contentRef={getColumnRef(meta.id)}>
728
+ contentRef={refCallback(columnRefCbs.current, columnEls.current, meta.id)}>
783
729
  {renderColumnCards(meta.id)}
784
730
  </BoardColumn>
785
731
  ))}
@@ -792,7 +738,12 @@ export default function KanbanBoard() {
792
738
  padding={3}
793
739
  elevation="none"
794
740
  style={{
795
- ...floatingStyle,
741
+ position: 'fixed',
742
+ insetBlockStart: 0,
743
+ insetInlineStart: 0,
744
+ pointerEvents: 'none',
745
+ cursor: 'grabbing',
746
+ zIndex: DRAG_CLONE_LAYER,
796
747
  width: drag.width,
797
748
  borderColor: 'var(--color-border-emphasized)',
798
749
  transform: `translate(${drag.pointerX - drag.offsetX}px, ${drag.pointerY - drag.offsetY}px)`,
@@ -6,7 +6,7 @@
6
6
  * Library — a browsable grid of design-system entries grouped by category. Frame: page header (title) | content column (search, filter row, s (Frame/responsive/container: see XLE header above.)
7
7
  */
8
8
 
9
- import {useState, useMemo} from 'react';
9
+ import {useState} from 'react';
10
10
  import {Layout, LayoutHeader, LayoutContent} from '@astryxdesign/core/Layout';
11
11
  import {Text, Heading} from '@astryxdesign/core/Text';
12
12
  import {Card} from '@astryxdesign/core/Card';
@@ -301,80 +301,48 @@ function LibraryCard({item}: {item: LibraryItem}) {
301
301
  );
302
302
  }
303
303
 
304
- function LibrarySection({
305
- category,
306
- items,
307
- }: {
308
- category: string;
309
- items: LibraryItem[];
310
- }) {
311
- return (
312
- <VStack gap={8}>
313
- <HStack justify="between" vAlign="center">
314
- <Heading level={2}>{category}</Heading>
315
- <Text type="body" color="secondary" hasTabularNumbers>
316
- {items.length} {items.length === 1 ? 'item' : 'items'}
317
- </Text>
318
- </HStack>
319
- <Grid columns={{minWidth: 280}} gap={4}>
320
- {items.map(item => (
321
- <LibraryCard key={item.id} item={item} />
322
- ))}
323
- </Grid>
324
- </VStack>
325
- );
326
- }
327
-
328
304
  export default function LibraryGrid() {
329
305
  const [activeTab, setActiveTab] = useState('All');
330
306
  const [search, setSearch] = useState('');
331
307
  const [sortOrder, setSortOrder] = useState('A-Z');
332
308
 
333
- const filtered = useMemo(() => {
334
- let items =
335
- activeTab === 'All' ? ITEMS : ITEMS.filter(i => i.category === activeTab);
336
- if (search.trim()) {
337
- const q = search.toLowerCase();
338
- items = items.filter(
309
+ // ITEMS is module-static (only .filter over it and .sort over copies below),
310
+ // so plain consts recompute cheaply per render with no memo needed.
311
+ const query = search.trim().toLowerCase();
312
+ const visible =
313
+ activeTab === 'All' ? ITEMS : ITEMS.filter(i => i.category === activeTab);
314
+ const searched = query
315
+ ? visible.filter(
339
316
  i =>
340
- i.name.toLowerCase().includes(q) ||
341
- i.description.toLowerCase().includes(q),
342
- );
343
- }
344
- const sorted = [...items];
345
- if (sortOrder === 'A-Z') {
346
- sorted.sort((a, b) => a.name.localeCompare(b.name));
347
- } else if (sortOrder === 'Z-A') {
348
- sorted.sort((a, b) => b.name.localeCompare(a.name));
349
- } else if (sortOrder === 'Newest') {
350
- sorted.sort((a, b) => Number(b.id) - Number(a.id));
351
- }
352
- return sorted;
353
- }, [activeTab, search, sortOrder]);
317
+ i.name.toLowerCase().includes(query) ||
318
+ i.description.toLowerCase().includes(query),
319
+ )
320
+ : visible;
321
+ const filtered = [...searched];
322
+ if (sortOrder === 'A-Z') {
323
+ filtered.sort((a, b) => a.name.localeCompare(b.name));
324
+ } else if (sortOrder === 'Z-A') {
325
+ filtered.sort((a, b) => b.name.localeCompare(a.name));
326
+ } else if (sortOrder === 'Newest') {
327
+ filtered.sort((a, b) => Number(b.id) - Number(a.id));
328
+ }
354
329
 
355
- const groupedSections = useMemo(() => {
356
- if (activeTab !== 'All') {
357
- return null;
358
- }
330
+ let groupedSections: Array<{category: string; items: LibraryItem[]}> | null =
331
+ null;
332
+ if (activeTab === 'All') {
359
333
  const order = CATEGORIES.filter(c => c !== 'All');
360
- const map = new Map<string, LibraryItem[]>();
334
+ const byCategory: Record<string, LibraryItem[]> = {};
361
335
  for (const item of filtered) {
362
- let group = map.get(item.category);
363
- if (!group) {
364
- group = [];
365
- map.set(item.category, group);
366
- }
367
- group.push(item);
336
+ (byCategory[item.category] ??= []).push(item);
368
337
  }
369
- const result: Array<{category: string; items: LibraryItem[]}> = [];
338
+ groupedSections = [];
370
339
  for (const cat of order) {
371
- const items = map.get(cat);
340
+ const items = byCategory[cat];
372
341
  if (items) {
373
- result.push({category: cat, items});
342
+ groupedSections.push({category: cat, items});
374
343
  }
375
344
  }
376
- return result;
377
- }, [activeTab, filtered]);
345
+ }
378
346
 
379
347
  return (
380
348
  <Layout
@@ -470,11 +438,19 @@ export default function LibraryGrid() {
470
438
  groupedSections ?? [{category: activeTab, items: filtered}]
471
439
  ).flatMap(section => [
472
440
  <Divider key={`d-${section.category}`} />,
473
- <LibrarySection
474
- key={section.category}
475
- category={section.category}
476
- items={section.items}
477
- />,
441
+ <VStack key={section.category} gap={8}>
442
+ <HStack justify="between" vAlign="center">
443
+ <Heading level={2}>{section.category}</Heading>
444
+ <Text type="body" color="secondary" hasTabularNumbers>
445
+ {section.items.length} {section.items.length === 1 ? 'item' : 'items'}
446
+ </Text>
447
+ </HStack>
448
+ <Grid columns={{minWidth: 280}} gap={4}>
449
+ {section.items.map(item => (
450
+ <LibraryCard key={item.id} item={item} />
451
+ ))}
452
+ </Grid>
453
+ </VStack>,
478
454
  ])}
479
455
  </VStack>
480
456
  )}