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
@@ -1,12 +1,14 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ // XLE (canonical structure, validated with `bunx astryx layout check`):
3
+ // S > L > (LH[divider] > Tbar > (Hd"Sprint Board"[level=1] + Bd.neutral"8") + (H[g=2] > SE + D.strong + IB"Sort" + IB"Filter" + IB"Search" + B.primary"Add task")) + (LC[p=0] > G[c={min:280,max:4} g=4] > (C.muted[p=0] > L > (LH[divider] > H[j=between a=center] > (H[g=2 a=center] > SD + Hd"To-do"[level=2] + IB) + Tx) + (LC[p=3] > V[g=2] > (C[p=3] > V[g=2] > (H[j=between] > (H[g=2] > Tk + Bd) + MM) + (V[g=1] > Hd[level=3] + Tx) + Tx)*2))*4)
2
4
 
3
5
  import {
4
6
  useEffect,
5
- useMemo,
6
7
  useRef,
7
8
  useState,
8
9
  type CSSProperties,
9
10
  type PointerEvent as ReactPointerEvent,
11
+ type KeyboardEvent as ReactKeyboardEvent,
10
12
  type ReactNode,
11
13
  } from 'react';
12
14
 
@@ -20,6 +22,7 @@ import {
20
22
  import {Text, Heading} from '@astryxdesign/core/Text';
21
23
  import {Card} from '@astryxdesign/core/Card';
22
24
  import {Badge} from '@astryxdesign/core/Badge';
25
+ import {Token} from '@astryxdesign/core/Token';
23
26
  import {Button} from '@astryxdesign/core/Button';
24
27
  import {IconButton} from '@astryxdesign/core/IconButton';
25
28
  import {Icon} from '@astryxdesign/core/Icon';
@@ -27,7 +30,7 @@ import {StatusDot} from '@astryxdesign/core/StatusDot';
27
30
  import {EmptyState} from '@astryxdesign/core/EmptyState';
28
31
  import {MoreMenu} from '@astryxdesign/core/MoreMenu';
29
32
  import {Selector} from '@astryxdesign/core/Selector';
30
- import {Tooltip} from '@astryxdesign/core/Tooltip';
33
+ import {Popover} from '@astryxdesign/core/Popover';
31
34
  import {Divider} from '@astryxdesign/core/Divider';
32
35
  import {Toolbar} from '@astryxdesign/core/Toolbar';
33
36
  import {Section} from '@astryxdesign/core/Section';
@@ -64,7 +67,7 @@ interface WorkItem {
64
67
  interface ColumnMeta {
65
68
  id: ColumnId;
66
69
  title: string;
67
- variant: 'neutral' | 'accent' | 'warning' | 'success';
70
+ variant: 'neutral' | 'info' | 'warning' | 'success';
68
71
  tooltip: string;
69
72
  emptyTitle: string;
70
73
  emptyDescription: string;
@@ -105,7 +108,7 @@ const COLUMNS: ColumnMeta[] = [
105
108
  {
106
109
  id: 'in-progress',
107
110
  title: 'In progress',
108
- variant: 'accent',
111
+ variant: 'info',
109
112
  tooltip: 'Theme work currently on the easel.',
110
113
  emptyTitle: 'Nothing in progress',
111
114
  emptyDescription: 'Tasks being worked on appear here.',
@@ -114,7 +117,7 @@ const COLUMNS: ColumnMeta[] = [
114
117
  {
115
118
  id: 'in-review',
116
119
  title: 'In review',
117
- variant: 'warning',
120
+ variant: 'info',
118
121
  tooltip: 'Shades waiting for your contrast review.',
119
122
  emptyTitle: 'Nothing in review',
120
123
  emptyDescription: 'Tasks awaiting your review appear here.',
@@ -133,13 +136,26 @@ const COLUMNS: ColumnMeta[] = [
133
136
 
134
137
  const PRIORITY_META: Record<
135
138
  Priority,
136
- {label: string; variant: 'red' | 'yellow' | 'cyan'}
139
+ {label: string; variant: 'red' | 'yellow' | 'neutral'}
137
140
  > = {
138
141
  high: {label: 'High', variant: 'red'},
139
142
  medium: {label: 'Medium', variant: 'yellow'},
140
- low: {label: 'Low', variant: 'cyan'},
143
+ low: {label: 'Low', variant: 'neutral'}, // neutral: low priority is not info
141
144
  };
142
145
 
146
+ function groupByColumn(items: WorkItem[]): Record<ColumnId, WorkItem[]> {
147
+ const map: Record<ColumnId, WorkItem[]> = {
148
+ todo: [],
149
+ 'in-progress': [],
150
+ 'in-review': [],
151
+ done: [],
152
+ };
153
+ for (const item of items) {
154
+ map[item.column].push(item);
155
+ }
156
+ return map;
157
+ }
158
+
143
159
  const INITIAL_ITEMS: WorkItem[] = [
144
160
  {
145
161
  id: 't1',
@@ -244,7 +260,7 @@ const boardColumnsStyle: CSSProperties = {
244
260
  overflowX: 'auto',
245
261
  overflowY: 'auto',
246
262
  height: '100%',
247
- padding: 'var(--space-gap)',
263
+ padding: 'var(--spacing-6)',
248
264
  };
249
265
  const columnShellStyle: CSSProperties = {
250
266
  minWidth: 0,
@@ -258,14 +274,17 @@ const cardStyle: CSSProperties = {
258
274
  touchAction: 'none',
259
275
  };
260
276
  // The dragged card is lifted out of flow and follows the pointer. It ignores
261
- // pointer events so hit-testing reads the columns underneath it.
277
+ // pointer events so hit-testing reads the columns underneath it. The clone
278
+ // rides above shell chrome (layered 0-3) but stays under overlay surfaces
279
+ // (menus, dialogs layer at 500+ plus the top layer), so an open menu wins.
280
+ const DRAG_CLONE_LAYER = 100;
262
281
  const floatingStyle: CSSProperties = {
263
282
  position: 'fixed',
264
283
  insetBlockStart: 0,
265
284
  insetInlineStart: 0,
266
285
  pointerEvents: 'none',
267
286
  cursor: 'grabbing',
268
- zIndex: 1000,
287
+ zIndex: DRAG_CLONE_LAYER,
269
288
  };
270
289
  // Placeholder marking the landing slot; matches the dragged card's height.
271
290
  const ghostStyle = (height: number): CSSProperties => ({
@@ -277,7 +296,13 @@ const toolbarDividerStyle: CSSProperties = {
277
296
  alignSelf: 'stretch',
278
297
  };
279
298
  const columnEmptyStateStyle: CSSProperties = {
280
- paddingBlock: 'var(--space-gap)',
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)',
281
306
  };
282
307
 
283
308
  // ============= CARD BODY =============
@@ -300,8 +325,8 @@ function BoardCardBody({
300
325
  return (
301
326
  <VStack gap={2}>
302
327
  <HStack hAlign="between" vAlign="start">
303
- <HStack gap={1} vAlign="center" wrap="wrap">
304
- <Badge label={item.ref} variant="neutral" />
328
+ <HStack gap={2} vAlign="center" wrap="wrap">
329
+ <Token label={item.ref} size="sm" />
305
330
  <Badge label={priority.label} variant={priority.variant} />
306
331
  </HStack>
307
332
  <MoreMenu
@@ -317,7 +342,7 @@ function BoardCardBody({
317
342
  </HStack>
318
343
 
319
344
  <VStack gap={1}>
320
- <Heading level={4}>{item.title}</Heading>
345
+ <Heading level={3}>{item.title}</Heading>
321
346
  <Text type="body" color="secondary" maxLines={2}>
322
347
  {item.description}
323
348
  </Text>
@@ -343,12 +368,33 @@ function BoardCard({
343
368
  onPointerDown: (e: ReactPointerEvent, id: string) => void;
344
369
  onMove: (id: string, to: ColumnId) => void;
345
370
  }) {
371
+ // Keyboard-move path for the core drag interaction: arrow keys move the
372
+ // focused card across columns. The pointer DnD below stays quarantined and
373
+ // untouched; this only reuses its moveItem commit.
374
+ const onKeyDown = (e: ReactKeyboardEvent) => {
375
+ const at = COLUMNS.findIndex(c => c.id === item.column);
376
+ const to =
377
+ e.key === 'ArrowRight'
378
+ ? COLUMNS[Math.min(COLUMNS.length - 1, at + 1)]
379
+ : e.key === 'ArrowLeft'
380
+ ? COLUMNS[Math.max(0, at - 1)]
381
+ : undefined;
382
+ if (!to || to.id === item.column) {
383
+ return;
384
+ }
385
+ e.preventDefault();
386
+ onMove(item.id, to.id);
387
+ };
346
388
  return (
347
389
  <Card
348
390
  ref={cardRef}
349
391
  padding={3}
350
392
  style={cardStyle}
351
- onPointerDown={e => onPointerDown(e, item.id)}>
393
+ tabIndex={0}
394
+ role="button"
395
+ aria-label={`${item.title}. In ${COLUMNS.find(c => c.id === item.column)?.title}. Press left or right arrow to move across columns.`}
396
+ onPointerDown={e => onPointerDown(e, item.id)}
397
+ onKeyDown={onKeyDown}>
352
398
  <BoardCardBody item={item} onMove={onMove} />
353
399
  </Card>
354
400
  );
@@ -374,15 +420,29 @@ function BoardColumn({
374
420
  header={
375
421
  <LayoutHeader hasDivider padding={3}>
376
422
  <HStack hAlign="between" vAlign="center">
377
- <HStack gap={2} vAlign="center">
423
+ <HStack gap={1} vAlign="center">
378
424
  <StatusDot
379
425
  variant={meta.variant}
380
426
  label={`${meta.title} status`}
427
+ isPulsing={meta.id === 'in-progress' || meta.id === 'in-review'}
381
428
  />
382
- <Heading level={4}>{meta.title}</Heading>
383
- <Tooltip content={meta.tooltip}>
384
- <Icon icon={Info} size="sm" color="secondary" />
385
- </Tooltip>
429
+ <Heading level={2}>{meta.title}</Heading>
430
+ <Popover
431
+ placement="below"
432
+ width="min(20rem, calc(100vw - 2 * var(--space-viewport)))"
433
+ label={`${meta.title} column help`}
434
+ content={
435
+ <Text type="body" color="secondary">
436
+ {meta.tooltip}
437
+ </Text>
438
+ }>
439
+ <IconButton
440
+ label={`About the ${meta.title} column`}
441
+ icon={<Icon icon={Info} size="sm" />}
442
+ variant="ghost"
443
+ size="sm"
444
+ />
445
+ </Popover>
386
446
  </HStack>
387
447
  <Text type="supporting" color="secondary" hasTabularNumbers>
388
448
  {count}
@@ -391,7 +451,7 @@ function BoardColumn({
391
451
  </LayoutHeader>
392
452
  }
393
453
  content={
394
- <LayoutContent ref={contentRef} padding={2}>
454
+ <LayoutContent ref={contentRef} padding={3}>
395
455
  {children ?? (
396
456
  <EmptyState
397
457
  isCompact
@@ -459,18 +519,7 @@ export default function KanbanBoard() {
459
519
  return cb;
460
520
  };
461
521
 
462
- const itemsByColumn = useMemo(() => {
463
- const map: Record<ColumnId, WorkItem[]> = {
464
- todo: [],
465
- 'in-progress': [],
466
- 'in-review': [],
467
- done: [],
468
- };
469
- for (const item of items) {
470
- map[item.column].push(item);
471
- }
472
- return map;
473
- }, [items]);
522
+ const itemsByColumn = groupByColumn(items);
474
523
 
475
524
  const moveItem = (id: string, to: ColumnId) => {
476
525
  setItems(prev =>
@@ -491,9 +540,12 @@ export default function KanbanBoard() {
491
540
  continue;
492
541
  }
493
542
 
494
- const ids = itemsByColumn[colId]
495
- .filter(it => it.id !== draggedId)
496
- .map(it => it.id);
543
+ const ids: string[] = [];
544
+ for (const it of itemsByColumn[colId]) {
545
+ if (it.id !== draggedId) {
546
+ ids.push(it.id);
547
+ }
548
+ }
497
549
 
498
550
  let index = ids.length;
499
551
  for (let i = 0; i < ids.length; i++) {
@@ -659,13 +711,13 @@ export default function KanbanBoard() {
659
711
  <Layout
660
712
  height="fill"
661
713
  header={
662
- <LayoutHeader hasDivider padding={4}>
714
+ <LayoutHeader hasDivider padding={6}>
663
715
  <Toolbar
664
716
  label="Board actions"
665
717
  gap={2}
666
718
  startContent={
667
719
  <>
668
- <Heading level={3}>Sprint Board</Heading>
720
+ <Heading level={1} type="display-2">Sprint Board</Heading>
669
721
  <Badge label={items.length} variant="neutral" />
670
722
  </>
671
723
  }
@@ -714,8 +766,14 @@ export default function KanbanBoard() {
714
766
  </LayoutHeader>
715
767
  }
716
768
  content={
717
- <LayoutContent padding={0}>
718
- <Grid columns={{ minWidth: 280, max: 4 }} gap={4} style={boardColumnsStyle}>
769
+ <LayoutContent padding={0} isScrollable={false}>
770
+ <Grid
771
+ columns={{ minWidth: 280, max: 4 }}
772
+ gap={4}
773
+ style={boardColumnsStyle}
774
+ tabIndex={0}
775
+ role="region"
776
+ aria-label="Sprint board columns">
719
777
  {COLUMNS.map(meta => (
720
778
  <BoardColumn
721
779
  key={meta.id}
@@ -732,10 +790,11 @@ export default function KanbanBoard() {
732
790
  {drag && draggedItem ? (
733
791
  <Card
734
792
  padding={3}
735
- elevation="high"
793
+ elevation="none"
736
794
  style={{
737
795
  ...floatingStyle,
738
796
  width: drag.width,
797
+ borderColor: 'var(--color-border-emphasized)',
739
798
  transform: `translate(${drag.pointerX - drag.offsetX}px, ${drag.pointerY - drag.offsetY}px)`,
740
799
  }}>
741
800
  <BoardCardBody item={draggedItem} onMove={() => {}} />
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  type: 'page',
3
- name: 'Card Grid',
3
+ name: 'Library',
4
4
  description: 'Browsable grid with tabs and filters',
5
- category: 'marketing',
5
+ category: 'docs',
6
6
  };
@@ -1,6 +1,12 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ // XLE (canonical structure, validated with `bunx astryx layout check`):
3
+ // L > (LH[divider] > (V[g=1] > Hd"Library"[level=1] + Tx"30 components across 5 categories"[t=body])) + (LC[p=6] > V[g=8] > (V[g=4] > TI"Search the stacks…" + (H[a=center g=3] > (SI[fill] > TgG"Filter" > OFL > Tg"All"! + Tg"Layout" + Tg"Forms" + Tg"Navigation" + Tg"Feedback" + Tg"Data") + DM"Sort")) + D + (V[g=8] > (H[j=between a=center] > Hd"Layout"[level=2] + Tx"6 items"[t=body]) + (G[c={min:280} g=4] > (C[p=0] > AR + (S.transparent[p=4] > V[g=1] > Hd"Card"[level=3] + Tx"Description"[t=supporting]))*4)))
2
4
 
3
- import {useState, useMemo, type CSSProperties} from 'react';
5
+ /**
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
+ */
8
+
9
+ import {useState, useMemo} from 'react';
4
10
  import {Layout, LayoutHeader, LayoutContent} from '@astryxdesign/core/Layout';
5
11
  import {Text, Heading} from '@astryxdesign/core/Text';
6
12
  import {Card} from '@astryxdesign/core/Card';
@@ -17,6 +23,8 @@ import {Button} from '@astryxdesign/core/Button';
17
23
  import {EmptyState} from '@astryxdesign/core/EmptyState';
18
24
  import {Icon} from '@astryxdesign/core/Icon';
19
25
  import {Search} from 'lucide-react';
26
+ import {SceneTile} from 'astryx-dracula/shared/scene-tile';
27
+ import type {SceneHue} from 'astryx-dracula/shared/scene-hues';
20
28
 
21
29
  interface LibraryItem {
22
30
  id: string;
@@ -26,9 +34,11 @@ interface LibraryItem {
26
34
  type: 'Component' | 'Pattern' | 'Utility';
27
35
  }
28
36
 
29
- // Dracula accent per category shelf, drawn from the fixed badge vocabulary.
30
- const CATEGORY_HUES: Record<string, string> = {
31
- Layout: 'var(--dracula-purple)',
37
+ // One Dracula accent per category shelf. Purple stays out of the map: it is
38
+ // reserved for interactive elements, and these hues only tint the thumbnail
39
+ // dot, so each shelf takes a non-purple accent instead.
40
+ const CATEGORY_HUES: Record<string, SceneHue> = {
41
+ Layout: 'var(--dracula-orange)',
32
42
  Forms: 'var(--dracula-cyan)',
33
43
  Navigation: 'var(--dracula-pink)',
34
44
  Feedback: 'var(--dracula-yellow)',
@@ -268,40 +278,16 @@ const ITEMS: LibraryItem[] = [
268
278
  },
269
279
  ];
270
280
 
271
- const thumbnailImage: CSSProperties = {
272
- width: '100%',
273
- height: '100%',
274
- display: 'block',
275
- };
276
-
277
281
  // =============================================================================
278
282
  // Side Nav
279
283
  // =============================================================================
280
284
 
281
285
  function LibraryCard({item}: {item: LibraryItem}) {
282
- const hue = CATEGORY_HUES[item.category] ?? 'var(--dracula-purple)';
286
+ const hue = CATEGORY_HUES[item.category] ?? 'var(--dracula-comment)';
283
287
  return (
284
288
  <Card padding={0}>
285
289
  <AspectRatio ratio={16 / 9}>
286
- <svg
287
- viewBox="0 0 400 300"
288
- preserveAspectRatio="xMidYMid slice"
289
- style={thumbnailImage}
290
- role="img"
291
- aria-label={`${item.name} thumbnail`}>
292
- <rect width="400" height="300" fill="var(--dracula-bg-light)" />
293
- <g
294
- transform="translate(200 150)"
295
- fill="none"
296
- stroke="var(--dracula-comment)"
297
- strokeWidth="5"
298
- strokeLinecap="round"
299
- strokeLinejoin="round">
300
- <rect x="-44" y="-44" width="88" height="88" rx="5" />
301
- <circle cx="18" cy="-18" r="2.5" fill={hue} stroke="none" />
302
- <path d="M-34 30 L-8 0 L10 18 L20 8 L34 24" />
303
- </g>
304
- </svg>
290
+ <SceneTile label={`${item.name} thumbnail`} hue={hue} size="lg" />
305
291
  </AspectRatio>
306
292
  <Section variant="transparent" padding={4}>
307
293
  <VStack gap={1}>
@@ -323,14 +309,14 @@ function LibrarySection({
323
309
  items: LibraryItem[];
324
310
  }) {
325
311
  return (
326
- <VStack gap={6}>
312
+ <VStack gap={8}>
327
313
  <HStack justify="between" vAlign="center">
328
314
  <Heading level={2}>{category}</Heading>
329
- <Text type="supporting" color="secondary" hasTabularNumbers>
315
+ <Text type="body" color="secondary" hasTabularNumbers>
330
316
  {items.length} {items.length === 1 ? 'item' : 'items'}
331
317
  </Text>
332
318
  </HStack>
333
- <Grid columns={{minWidth: 320}} gap={4}>
319
+ <Grid columns={{minWidth: 280}} gap={4}>
334
320
  {items.map(item => (
335
321
  <LibraryCard key={item.id} item={item} />
336
322
  ))}
@@ -380,32 +366,43 @@ export default function LibraryGrid() {
380
366
  }
381
367
  group.push(item);
382
368
  }
383
- return order
384
- .filter(cat => map.has(cat))
385
- .map(cat => ({category: cat, items: map.get(cat) ?? []}));
369
+ const result: Array<{category: string; items: LibraryItem[]}> = [];
370
+ for (const cat of order) {
371
+ const items = map.get(cat);
372
+ if (items) {
373
+ result.push({category: cat, items});
374
+ }
375
+ }
376
+ return result;
386
377
  }, [activeTab, filtered]);
387
378
 
388
379
  return (
389
380
  <Layout
381
+ height="fill"
390
382
  header={
391
383
  <LayoutHeader hasDivider padding={6}>
392
- <Heading level={1}>Library</Heading>
384
+ <VStack gap={1}>
385
+ <Heading level={1}>Library</Heading>
386
+ <Text type="body" color="secondary">
387
+ 30 components across 5 categories
388
+ </Text>
389
+ </VStack>
393
390
  </LayoutHeader>
394
391
  }
395
392
  content={
396
393
  <LayoutContent padding={6}>
397
- <VStack gap={6}>
394
+ <VStack gap={8}>
398
395
  <VStack gap={4}>
399
396
  <TextInput
400
397
  label="Search"
401
398
  isLabelHidden
402
- placeholder="Search the stacks..."
399
+ placeholder="Search the stacks…"
403
400
  value={search}
404
401
  onChange={setSearch}
405
402
  startIcon={Search}
406
403
  size="lg"
407
404
  />
408
- <HStack vAlign="center" gap={4}>
405
+ <HStack vAlign="center" gap={3}>
409
406
  <StackItem size="fill">
410
407
  <VStack>
411
408
  <ToggleButtonGroup
@@ -468,7 +465,7 @@ export default function LibraryGrid() {
468
465
  }
469
466
  />
470
467
  ) : (
471
- <VStack gap={6}>
468
+ <VStack gap={8}>
472
469
  {(
473
470
  groupedSections ?? [{category: activeTab, items: filtered}]
474
471
  ).flatMap(section => [