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,5 +1,12 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ // XLE (canonical structure, validated with `bunx astryx layout check`):
3
+ // L > LC[p=6] > V[g=6] > (Hd"The night shift"[level=1] + V[g=6] > (H[j=between a=center] > Hd"Awake after dark"[level=2] + B.secondary"Reload") + (V[g=3] > C[p=4] + Tx"Hourly intervals"[t=supporting] + (H[g=6] > (H[g=2 a=center] > Ic + Tx"Desktop"[t=supporting])*2))) + (G[c={min:280} g=4] > (C > V[g=2] > Tx"Moonlit visitors"[t=supporting] + (H[g=2] > Tx"27.3 k"[t=display-3] + Tx"+18.2%"[t=body]) + Tx"Last 30 days vs. Previous"[t=supporting])*4) + D + (H[j=between a=center] > Hd"Night denizens"[level=2] + B.secondary"View more") + (G[c={min:280} g=4] > (C > V[g=4] > Hd"Territory"[level=3] + (H[g=4 wrap] > (V[g=0] > (H[g=2 a=center] > Ic + Tx[t=supporting]) + Tx[t=supporting])*5))*2) + D + (H[j=between a=center] > Hd"Engagement"[level=2] + B.secondary"View more") + (G[c={min:280} g=4] > (C > V[g=6] > (H[j=between a=center] > Hd"Top pages"[level=3] + Lk"All pages") + T)*2)
2
4
 
5
+ /**
6
+ * Analytics Dashboard — the night shift at a glance: live active users, four KPI tiles, audience breakdown strips, and engagement tables. (Frame/responsive/container: see XLE header above.)
7
+ */
8
+
9
+ import type {CSSProperties} from 'react';
3
10
  import {VStack, HStack, Layout, LayoutContent} from '@astryxdesign/core/Layout';
4
11
  import {Text, Heading} from '@astryxdesign/core/Text';
5
12
  import {Card} from '@astryxdesign/core/Card';
@@ -14,7 +21,12 @@ import {Icon} from '@astryxdesign/core/Icon';
14
21
 
15
22
  // ============= ICONS =============
16
23
 
17
- import {RefreshCw, ArrowUp, ArrowDown, Square} from 'lucide-react';
24
+ import {RefreshCw, Square} from 'lucide-react';
25
+ import {ChartLegend} from 'astryx-dracula/shared/chart-legend';
26
+ import {CHART_HUES} from 'astryx-dracula/shared/chart-hues';
27
+ import {MetricDelta} from 'astryx-dracula/shared/metric-delta';
28
+ import {Sparkline, type SparkPoint} from 'astryx-dracula/shared/sparkline';
29
+ import {CHART_PANEL_STYLE} from 'astryx-dracula/shared/chart-panel-style';
18
30
 
19
31
  // ============= DATA =============
20
32
 
@@ -171,20 +183,26 @@ const metrics = [
171
183
  },
172
184
  ];
173
185
 
174
- // Sparkline data for each metric card (30 days, weekends at indices 5-6, 12-13, 19-20, 26-27)
175
- const sparklines = [
186
+ // Sparkline data for each metric card (30 days, weekends at indices 5-6, 12-13,
187
+ // 19-20, 26-27). Every sample carries its day, so the bars key off the series
188
+ // rather than off the array slot.
189
+ function toSparkSeries(values: number[]): SparkPoint[] {
190
+ return values.map((value, day) => ({id: `day-${day + 1}`, value}));
191
+ }
192
+
193
+ const sparklines: SparkPoint[][] = [
176
194
  // Monthly Visitors: +18.2% — declining first 2 weeks, hits bottom around day 14, then sharp recovery
177
195
  // prettier-ignore
178
- [48, 46, 44, 42, 40, 18, 16, 38, 36, 34, 32, 30, 12, 10, 28, 26, 28, 32, 36, 14, 12, 40, 44, 48, 52, 56, 28, 24, 58, 62],
196
+ toSparkSeries([48, 46, 44, 42, 40, 18, 16, 38, 36, 34, 32, 30, 12, 10, 28, 26, 28, 32, 36, 14, 12, 40, 44, 48, 52, 56, 28, 24, 58, 62]),
179
197
  // Monthly Page Views: +12.5% — flat/choppy first 3 weeks, then kicks up sharply in final week
180
198
  // prettier-ignore
181
- [36, 38, 35, 37, 36, 14, 12, 38, 36, 34, 37, 35, 12, 10, 36, 34, 36, 35, 38, 14, 12, 40, 44, 50, 54, 56, 26, 22, 58, 60],
199
+ toSparkSeries([36, 38, 35, 37, 36, 14, 12, 38, 36, 34, 37, 35, 12, 10, 36, 34, 36, 35, 38, 14, 12, 40, 44, 50, 54, 56, 26, 22, 58, 60]),
182
200
  // Avg. Session: -14.3% — strong start, holds through week 2, then clear drop-off week 3-4
183
201
  // prettier-ignore
184
- [58, 56, 60, 58, 62, 30, 26, 60, 58, 62, 60, 58, 28, 24, 56, 54, 50, 46, 42, 18, 14, 38, 36, 34, 32, 30, 10, 8, 28, 26],
202
+ toSparkSeries([58, 56, 60, 58, 62, 30, 26, 60, 58, 62, 60, 58, 28, 24, 56, 54, 50, 46, 42, 18, 14, 38, 36, 34, 32, 30, 10, 8, 28, 26]),
185
203
  // Bounce Rate: -8.7% — high and volatile first half, starts dropping around day 16, steady decline
186
204
  // prettier-ignore
187
- [52, 56, 50, 54, 58, 62, 60, 54, 52, 56, 50, 54, 60, 58, 50, 48, 46, 44, 40, 46, 44, 38, 36, 34, 36, 32, 38, 36, 30, 28],
205
+ toSparkSeries([52, 56, 50, 54, 58, 62, 60, 54, 52, 56, 50, 54, 60, 58, 50, 48, 46, 44, 40, 46, 44, 38, 36, 34, 36, 32, 38, 36, 30, 28]),
188
206
  ];
189
207
 
190
208
  // Demographics
@@ -192,48 +210,48 @@ const regionData = [
192
210
  {
193
211
  label: 'NORAM',
194
212
  value: 38,
195
- color: 'var(--dracula-comment)',
213
+ color: CHART_HUES.cyan,
196
214
  },
197
215
  {
198
216
  label: 'EMEA',
199
217
  value: 28,
200
- color: 'var(--dracula-orange)',
218
+ color: CHART_HUES.orange,
201
219
  },
202
220
  {
203
221
  label: 'APAC',
204
222
  value: 22,
205
- color: 'var(--dracula-green)',
223
+ color: CHART_HUES.green,
206
224
  },
207
225
  {
208
226
  label: 'LATAM',
209
227
  value: 8,
210
- color: 'var(--dracula-purple)',
228
+ color: CHART_HUES.pink,
211
229
  },
212
- {label: 'Other', value: 4, color: 'var(--dracula-selection)'},
230
+ {label: 'Other', value: 4, color: CHART_HUES.muted},
213
231
  ];
214
232
 
215
233
  const roleData = [
216
234
  {
217
235
  label: 'Engineer',
218
236
  value: 45,
219
- color: 'var(--dracula-comment)',
237
+ color: CHART_HUES.cyan,
220
238
  },
221
239
  {
222
240
  label: 'Manager',
223
241
  value: 20,
224
- color: 'var(--dracula-orange)',
242
+ color: CHART_HUES.orange,
225
243
  },
226
244
  {
227
245
  label: 'Designer',
228
246
  value: 15,
229
- color: 'var(--dracula-green)',
247
+ color: CHART_HUES.green,
230
248
  },
231
249
  {
232
250
  label: 'Data Scientist',
233
251
  value: 12,
234
- color: 'var(--dracula-purple)',
252
+ color: CHART_HUES.pink,
235
253
  },
236
- {label: 'Other', value: 8, color: 'var(--dracula-selection)'},
254
+ {label: 'Other', value: 8, color: CHART_HUES.muted},
237
255
  ];
238
256
 
239
257
  // Engagement — Top pages
@@ -318,10 +336,13 @@ const topPagesData: PageRow[] = [
318
336
  },
319
337
  ];
320
338
 
339
+ // Counts render with thousands separators in every table cell.
340
+ const formatCount = (value: number) => value.toLocaleString();
341
+
321
342
  const topPagesMaxViews = Math.max(...topPagesData.map(d => d.views));
322
343
 
323
344
  const topPagesColumns: TableColumn<PageRow>[] = [
324
- {key: 'page', header: 'Page', width: pixel(160)},
345
+ {key: 'page', header: 'Page', width: pixel(112)},
325
346
  {
326
347
  key: 'views',
327
348
  header: 'Views',
@@ -334,8 +355,8 @@ const topPagesColumns: TableColumn<PageRow>[] = [
334
355
  label={`${item.page} views`}
335
356
  isLabelHidden
336
357
  />
337
- <Text type="supporting" hasTabularNumbers>
338
- {item.views.toLocaleString()}
358
+ <Text type="body" hasTabularNumbers maxLines={1}>
359
+ {formatCount(item.views)}
339
360
  </Text>
340
361
  </VStack>
341
362
  ),
@@ -343,17 +364,21 @@ const topPagesColumns: TableColumn<PageRow>[] = [
343
364
  {
344
365
  key: 'newUsers',
345
366
  header: 'New Users',
346
- width: pixel(120),
367
+ width: pixel(104),
347
368
  renderCell: (item: PageRow) => (
348
- <Text hasTabularNumbers>{item.newUsers}</Text>
369
+ <Text hasTabularNumbers maxLines={1}>
370
+ {item.newUsers}
371
+ </Text>
349
372
  ),
350
373
  },
351
374
  {
352
375
  key: 'avgTime',
353
376
  header: 'Avg. Time',
354
- width: pixel(120),
377
+ width: pixel(104),
355
378
  renderCell: (item: PageRow) => (
356
- <Text hasTabularNumbers>{item.avgTime}</Text>
379
+ <Text hasTabularNumbers maxLines={1}>
380
+ {item.avgTime}
381
+ </Text>
357
382
  ),
358
383
  },
359
384
  ];
@@ -383,7 +408,7 @@ const topEventsData: EventRow[] = [
383
408
  const topEventsMaxCount = Math.max(...topEventsData.map(d => d.count));
384
409
 
385
410
  const topEventsColumns: TableColumn<EventRow>[] = [
386
- {key: 'event', header: 'Event', width: pixel(160)},
411
+ {key: 'event', header: 'Event', width: pixel(112)},
387
412
  {
388
413
  key: 'count',
389
414
  header: 'Count',
@@ -396,8 +421,8 @@ const topEventsColumns: TableColumn<EventRow>[] = [
396
421
  label={`${item.count}`}
397
422
  isLabelHidden
398
423
  />
399
- <Text type="supporting" hasTabularNumbers>
400
- {item.count.toLocaleString()}
424
+ <Text type="body" hasTabularNumbers maxLines={1}>
425
+ {formatCount(item.count)}
401
426
  </Text>
402
427
  </VStack>
403
428
  ),
@@ -405,39 +430,36 @@ const topEventsColumns: TableColumn<EventRow>[] = [
405
430
  {
406
431
  key: 'users',
407
432
  header: 'Users',
408
- width: pixel(120),
433
+ width: pixel(72),
409
434
  renderCell: (item: EventRow) => (
410
- <Text hasTabularNumbers>{item.users.toLocaleString()}</Text>
435
+ <Text hasTabularNumbers maxLines={1}>
436
+ {formatCount(item.users)}
437
+ </Text>
411
438
  ),
412
439
  },
413
440
  {
414
441
  key: 'newUsers',
415
442
  header: 'New Users',
416
- width: pixel(120),
443
+ width: pixel(104),
417
444
  renderCell: (item: EventRow) => (
418
- <Text hasTabularNumbers>{item.newUsers.toLocaleString()}</Text>
445
+ <Text hasTabularNumbers maxLines={1}>
446
+ {formatCount(item.newUsers)}
447
+ </Text>
419
448
  ),
420
449
  },
421
450
  ];
422
451
 
423
452
  // ============= CHART COMPONENTS =============
424
453
 
425
- // Chart series colors: desktop glows orange, mobile purple
454
+ // Chart series colors: desktop glows orange, mobile glows cyan. Sourced from
455
+ // the shared categorical hues so data encoding can never reach for purple.
426
456
  const chartColors = {
427
- desktop: 'var(--dracula-orange)',
428
- mobile: 'var(--dracula-purple)',
457
+ desktop: CHART_HUES.orange,
458
+ mobile: CHART_HUES.cyan,
429
459
  };
430
460
 
431
- function ChartLegendItem({color, label}: {color: string; label: string}) {
432
- return (
433
- <HStack gap={2} vAlign="center">
434
- <Icon icon={Square} size="xsm" style={{color}} />
435
- <Text type="supporting" color="secondary">
436
- {label}
437
- </Text>
438
- </HStack>
439
- );
440
- }
461
+ // Legend rows go through shared/chart-legend.tsx, which makes `label` a
462
+ // REQUIRED field so a hue can never ship without the word that names it.
441
463
 
442
464
  function ActiveUsersChart() {
443
465
  // Downsample the 96 quarter-hour points to one bar per hour, stacking
@@ -445,15 +467,11 @@ function ActiveUsersChart() {
445
467
  const bars = activeUsersData.filter((_, i) => i % 4 === 0);
446
468
  const max = 130;
447
469
  const tickHours = [0, 32, 64, 92];
448
- // SVG type scales up with the viewBox, so unit sizes stay small
470
+ // SVG type scales up with the viewBox, so unit sizes stay small: raw <text>
471
+ // (not ChartLabel) — 13px labels would render ~2.4x oversize in this 540-wide viewBox.
449
472
  return (
450
473
  <VStack gap={3}>
451
- <Card
452
- padding={3}
453
- style={{
454
- backgroundColor: 'var(--color-background)',
455
- border: 'var(--border-width) solid var(--color-separator)',
456
- }}>
474
+ <Card padding={4} style={CHART_PANEL_STYLE}>
457
475
  <svg
458
476
  viewBox="0 0 540 180"
459
477
  width="100%"
@@ -511,44 +529,22 @@ function ActiveUsersChart() {
511
529
  })}
512
530
  </svg>
513
531
  </Card>
514
- <Text type="supporting" color="secondary">
515
- Hourly intervals · trailing 24 hours
516
- </Text>
517
- <HStack gap={6} vAlign="center">
518
- <ChartLegendItem color={chartColors.desktop} label="Desktop" />
519
- <ChartLegendItem color={chartColors.mobile} label="Mobile" />
520
- </HStack>
532
+ <ChartLegend
533
+ entries={[
534
+ {label: 'Desktop', color: chartColors.desktop},
535
+ {label: 'Mobile', color: chartColors.mobile},
536
+ ]}
537
+ gap={4}
538
+ caption={
539
+ <Text type="supporting" color="secondary">
540
+ Hourly intervals · trailing 24 hours
541
+ </Text>
542
+ }
543
+ />
521
544
  </VStack>
522
545
  );
523
546
  }
524
547
 
525
- function Sparkline({data, label}: {data: number[]; label: string}) {
526
- const max = Math.max(...data);
527
- return (
528
- <svg
529
- viewBox="0 0 300 40"
530
- width="100%"
531
- height={40}
532
- role="img"
533
- aria-label={`${label} thirty-day trend`}>
534
- {data.map((v, i) => {
535
- const h = Math.max(3, (v / max) * 32);
536
- return (
537
- <rect
538
- key={i}
539
- x={i * 10}
540
- y={36 - h}
541
- width={7}
542
- height={h}
543
- rx={4}
544
- fill="var(--dracula-purple)"
545
- />
546
- );
547
- })}
548
- </svg>
549
- );
550
- }
551
-
552
548
  // ============= CARD COMPONENTS =============
553
549
 
554
550
  function MetricCard({
@@ -562,29 +558,28 @@ function MetricCard({
562
558
  value: string;
563
559
  change: string;
564
560
  positive: boolean;
565
- sparkline: number[];
561
+ sparkline: SparkPoint[];
566
562
  }) {
567
563
  return (
568
564
  <Card>
569
565
  <VStack gap={2}>
570
- <Heading level={4}>{label}</Heading>
566
+ <Text type="supporting" color="secondary">
567
+ {label}
568
+ </Text>
571
569
  <HStack gap={2} vAlign="center">
572
- <Heading level={2}>{value}</Heading>
573
- <HStack gap={1} vAlign="center">
574
- <Icon
575
- icon={change.trim().startsWith('-') ? ArrowDown : ArrowUp}
576
- size="xsm"
577
- color={positive ? 'success' : 'error'}
578
- />
579
- <Text type="body" color="secondary" hasTabularNumbers>
580
- {change}
581
- </Text>
582
- </HStack>
570
+ <Text type="display-3" weight="semibold" hasTabularNumbers>
571
+ {value}
572
+ </Text>
573
+ <MetricDelta value={change} positive={positive} />
583
574
  </HStack>
584
575
  <Text type="supporting" color="secondary">
585
576
  Last 30 days vs. Previous
586
577
  </Text>
587
- <Sparkline data={sparkline} label={label} />
578
+ <Sparkline
579
+ data={sparkline}
580
+ label={`${label} thirty-day trend`}
581
+ positive={positive}
582
+ />
588
583
  </VStack>
589
584
  </Card>
590
585
  );
@@ -609,7 +604,7 @@ function StackedBarCard({
609
604
  return (
610
605
  <Card>
611
606
  <VStack gap={4}>
612
- <Heading level={4}>{title}</Heading>
607
+ <Heading level={3}>{title}</Heading>
613
608
  <svg
614
609
  viewBox="0 0 540 24"
615
610
  width="100%"
@@ -640,7 +635,7 @@ function StackedBarCard({
640
635
  <VStack key={d.label} gap={0}>
641
636
  <HStack gap={2} vAlign="center">
642
637
  <Icon icon={Square} size="xsm" style={{color: d.color}} />
643
- <Text type="supporting">{d.label}</Text>
638
+ <Text type="supporting" color="secondary">{d.label}</Text>
644
639
  </HStack>
645
640
  <Text type="supporting" color="secondary" hasTabularNumbers>
646
641
  {((d.value / total) * 100).toFixed(0)}%
@@ -672,7 +667,7 @@ function TableCard<T extends {id: string}>({
672
667
  <Card>
673
668
  <VStack gap={6}>
674
669
  <HStack hAlign="between" vAlign="center">
675
- <Heading level={4}>{title}</Heading>
670
+ <Heading level={3}>{title}</Heading>
676
671
  <Link href={linkHref}>{linkLabel}</Link>
677
672
  </HStack>
678
673
  <Table<T>
@@ -681,6 +676,7 @@ function TableCard<T extends {id: string}>({
681
676
  idKey="id"
682
677
  density="compact"
683
678
  dividers="rows"
679
+ textOverflow="truncate"
684
680
  hasHover
685
681
  />
686
682
  </VStack>
@@ -692,17 +688,34 @@ function TableCard<T extends {id: string}>({
692
688
 
693
689
  // ============= MAIN COMPONENT =============
694
690
 
691
+ // A definite ancestor, so `Layout height="fill"` (Layout.tsx:58-63, height:
692
+ // calc(100% + ...)) has something definite to resolve against instead of
693
+ // collapsing to auto and handing the scroll to the document. 100dvh rather
694
+ // than minHeight: '100%' -- a percentage min-height against an indefinite
695
+ // containing block computes to 0, so the soft failure would survive. The
696
+ // anchor is on the Layout, not a wrapper: LayoutContent is overflow:auto
697
+ // (LayoutContent.tsx:36-39), so a definite height is what makes the content
698
+ // pane SCROLL rather than clip. Same shape as editor.tsx:302 and
699
+ // kanban-board.tsx:704. Not applied to shell-nav / shell-side-nav, which
700
+ // already sit inside an AppShell's own 100dvh and would overflow by the
701
+ // header height.
702
+ const pageStyle: CSSProperties = {height: '100dvh'};
703
+
695
704
  export default function DashboardTemplate() {
696
705
  return (
697
706
  <Layout
707
+ style={pageStyle}
698
708
  height="fill"
699
709
  content={
700
710
  <LayoutContent padding={6}>
701
711
  <VStack gap={6}>
712
+ {/* Page header — the only h1; section titles sit at level 2 */}
713
+ <Heading level={1}>The night shift</Heading>
714
+
702
715
  {/* Active Users Chart */}
703
716
  <VStack gap={6}>
704
717
  <HStack hAlign="between" vAlign="center">
705
- <Heading level={3}>Awake after dark</Heading>
718
+ <Heading level={2}>Awake after dark</Heading>
706
719
  <Button
707
720
  label="Reload"
708
721
  variant="secondary"
@@ -714,20 +727,9 @@ export default function DashboardTemplate() {
714
727
  </VStack>
715
728
 
716
729
  {/* Metric Cards */}
717
- <Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
718
- {[0, 2].map(start => (
719
- <Grid
720
- key={start}
721
- columns={{minWidth: 240, repeat: 'fit'}}
722
- gap={4}>
723
- {metrics.slice(start, start + 2).map((m, i) => (
724
- <MetricCard
725
- key={m.label}
726
- {...m}
727
- sparkline={sparklines[start + i]}
728
- />
729
- ))}
730
- </Grid>
730
+ <Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
731
+ {metrics.map((m, index) => (
732
+ <MetricCard key={m.label} {...m} sparkline={sparklines[index]} />
731
733
  ))}
732
734
  </Grid>
733
735
 
@@ -735,10 +737,10 @@ export default function DashboardTemplate() {
735
737
 
736
738
  {/* Demographics */}
737
739
  <HStack hAlign="between" vAlign="center">
738
- <Heading level={3}>Night denizens</Heading>
740
+ <Heading level={2}>Night denizens</Heading>
739
741
  <Button label="View more" variant="secondary" size="md" />
740
742
  </HStack>
741
- <Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
743
+ <Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
742
744
  <StackedBarCard title="Territory" data={regionData} />
743
745
  <StackedBarCard title="Role" data={roleData} />
744
746
  </Grid>
@@ -747,10 +749,10 @@ export default function DashboardTemplate() {
747
749
 
748
750
  {/* Engagement */}
749
751
  <HStack hAlign="between" vAlign="center">
750
- <Heading level={3}>Engagement</Heading>
752
+ <Heading level={2}>Engagement</Heading>
751
753
  <Button label="View more" variant="secondary" size="md" />
752
754
  </HStack>
753
- <Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
755
+ <Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
754
756
  <TableCard
755
757
  title="Top pages"
756
758
  linkLabel="All pages"