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
@@ -1,7 +1,12 @@
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=6] > (V[g=6] > (H[j=between a=center] > Hd"Awake after dark"[level=3] + B.secondary"Reload") + C[p=3]) + (G[c=4 g=4] > C*4) + D + (H[j=between a=center] > Hd"Night denizens"[level=3] + B.secondary"View more") + (G[c=2 g=4] > C*2) + D + (H[j=between a=center] > Hd"Engagement"[level=3] + B.secondary"View more") + (G[c=2 g=4] > (C > V[g=6] > (H[j=between a=center] > Hd"Top pages"[level=4] + Lk"All pages") + T)*2)
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)
4
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';
5
10
  import {VStack, HStack, Layout, LayoutContent} from '@astryxdesign/core/Layout';
6
11
  import {Text, Heading} from '@astryxdesign/core/Text';
7
12
  import {Card} from '@astryxdesign/core/Card';
@@ -16,7 +21,12 @@ import {Icon} from '@astryxdesign/core/Icon';
16
21
 
17
22
  // ============= ICONS =============
18
23
 
19
- 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';
20
30
 
21
31
  // ============= DATA =============
22
32
 
@@ -173,20 +183,26 @@ const metrics = [
173
183
  },
174
184
  ];
175
185
 
176
- // Sparkline data for each metric card (30 days, weekends at indices 5-6, 12-13, 19-20, 26-27)
177
- 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[][] = [
178
194
  // Monthly Visitors: +18.2% — declining first 2 weeks, hits bottom around day 14, then sharp recovery
179
195
  // prettier-ignore
180
- [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]),
181
197
  // Monthly Page Views: +12.5% — flat/choppy first 3 weeks, then kicks up sharply in final week
182
198
  // prettier-ignore
183
- [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]),
184
200
  // Avg. Session: -14.3% — strong start, holds through week 2, then clear drop-off week 3-4
185
201
  // prettier-ignore
186
- [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]),
187
203
  // Bounce Rate: -8.7% — high and volatile first half, starts dropping around day 16, steady decline
188
204
  // prettier-ignore
189
- [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]),
190
206
  ];
191
207
 
192
208
  // Demographics
@@ -194,48 +210,48 @@ const regionData = [
194
210
  {
195
211
  label: 'NORAM',
196
212
  value: 38,
197
- color: 'var(--dracula-cyan)',
213
+ color: CHART_HUES.cyan,
198
214
  },
199
215
  {
200
216
  label: 'EMEA',
201
217
  value: 28,
202
- color: 'var(--dracula-orange)',
218
+ color: CHART_HUES.orange,
203
219
  },
204
220
  {
205
221
  label: 'APAC',
206
222
  value: 22,
207
- color: 'var(--dracula-green)',
223
+ color: CHART_HUES.green,
208
224
  },
209
225
  {
210
226
  label: 'LATAM',
211
227
  value: 8,
212
- color: 'var(--dracula-purple)',
228
+ color: CHART_HUES.pink,
213
229
  },
214
- {label: 'Other', value: 4, color: 'var(--dracula-comment)'},
230
+ {label: 'Other', value: 4, color: CHART_HUES.muted},
215
231
  ];
216
232
 
217
233
  const roleData = [
218
234
  {
219
235
  label: 'Engineer',
220
236
  value: 45,
221
- color: 'var(--dracula-cyan)',
237
+ color: CHART_HUES.cyan,
222
238
  },
223
239
  {
224
240
  label: 'Manager',
225
241
  value: 20,
226
- color: 'var(--dracula-orange)',
242
+ color: CHART_HUES.orange,
227
243
  },
228
244
  {
229
245
  label: 'Designer',
230
246
  value: 15,
231
- color: 'var(--dracula-green)',
247
+ color: CHART_HUES.green,
232
248
  },
233
249
  {
234
250
  label: 'Data Scientist',
235
251
  value: 12,
236
- color: 'var(--dracula-purple)',
252
+ color: CHART_HUES.pink,
237
253
  },
238
- {label: 'Other', value: 8, color: 'var(--dracula-comment)'},
254
+ {label: 'Other', value: 8, color: CHART_HUES.muted},
239
255
  ];
240
256
 
241
257
  // Engagement — Top pages
@@ -320,10 +336,13 @@ const topPagesData: PageRow[] = [
320
336
  },
321
337
  ];
322
338
 
339
+ // Counts render with thousands separators in every table cell.
340
+ const formatCount = (value: number) => value.toLocaleString();
341
+
323
342
  const topPagesMaxViews = Math.max(...topPagesData.map(d => d.views));
324
343
 
325
344
  const topPagesColumns: TableColumn<PageRow>[] = [
326
- {key: 'page', header: 'Page', width: pixel(160)},
345
+ {key: 'page', header: 'Page', width: pixel(112)},
327
346
  {
328
347
  key: 'views',
329
348
  header: 'Views',
@@ -336,8 +355,8 @@ const topPagesColumns: TableColumn<PageRow>[] = [
336
355
  label={`${item.page} views`}
337
356
  isLabelHidden
338
357
  />
339
- <Text type="supporting" hasTabularNumbers>
340
- {item.views.toLocaleString()}
358
+ <Text type="body" hasTabularNumbers maxLines={1}>
359
+ {formatCount(item.views)}
341
360
  </Text>
342
361
  </VStack>
343
362
  ),
@@ -345,17 +364,21 @@ const topPagesColumns: TableColumn<PageRow>[] = [
345
364
  {
346
365
  key: 'newUsers',
347
366
  header: 'New Users',
348
- width: pixel(120),
367
+ width: pixel(104),
349
368
  renderCell: (item: PageRow) => (
350
- <Text hasTabularNumbers>{item.newUsers}</Text>
369
+ <Text hasTabularNumbers maxLines={1}>
370
+ {item.newUsers}
371
+ </Text>
351
372
  ),
352
373
  },
353
374
  {
354
375
  key: 'avgTime',
355
376
  header: 'Avg. Time',
356
- width: pixel(120),
377
+ width: pixel(104),
357
378
  renderCell: (item: PageRow) => (
358
- <Text hasTabularNumbers>{item.avgTime}</Text>
379
+ <Text hasTabularNumbers maxLines={1}>
380
+ {item.avgTime}
381
+ </Text>
359
382
  ),
360
383
  },
361
384
  ];
@@ -385,7 +408,7 @@ const topEventsData: EventRow[] = [
385
408
  const topEventsMaxCount = Math.max(...topEventsData.map(d => d.count));
386
409
 
387
410
  const topEventsColumns: TableColumn<EventRow>[] = [
388
- {key: 'event', header: 'Event', width: pixel(160)},
411
+ {key: 'event', header: 'Event', width: pixel(112)},
389
412
  {
390
413
  key: 'count',
391
414
  header: 'Count',
@@ -398,8 +421,8 @@ const topEventsColumns: TableColumn<EventRow>[] = [
398
421
  label={`${item.count}`}
399
422
  isLabelHidden
400
423
  />
401
- <Text type="supporting" hasTabularNumbers>
402
- {item.count.toLocaleString()}
424
+ <Text type="body" hasTabularNumbers maxLines={1}>
425
+ {formatCount(item.count)}
403
426
  </Text>
404
427
  </VStack>
405
428
  ),
@@ -407,39 +430,36 @@ const topEventsColumns: TableColumn<EventRow>[] = [
407
430
  {
408
431
  key: 'users',
409
432
  header: 'Users',
410
- width: pixel(120),
433
+ width: pixel(72),
411
434
  renderCell: (item: EventRow) => (
412
- <Text hasTabularNumbers>{item.users.toLocaleString()}</Text>
435
+ <Text hasTabularNumbers maxLines={1}>
436
+ {formatCount(item.users)}
437
+ </Text>
413
438
  ),
414
439
  },
415
440
  {
416
441
  key: 'newUsers',
417
442
  header: 'New Users',
418
- width: pixel(120),
443
+ width: pixel(104),
419
444
  renderCell: (item: EventRow) => (
420
- <Text hasTabularNumbers>{item.newUsers.toLocaleString()}</Text>
445
+ <Text hasTabularNumbers maxLines={1}>
446
+ {formatCount(item.newUsers)}
447
+ </Text>
421
448
  ),
422
449
  },
423
450
  ];
424
451
 
425
452
  // ============= CHART COMPONENTS =============
426
453
 
427
- // 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.
428
456
  const chartColors = {
429
- desktop: 'var(--dracula-orange)',
430
- mobile: 'var(--dracula-purple)',
457
+ desktop: CHART_HUES.orange,
458
+ mobile: CHART_HUES.cyan,
431
459
  };
432
460
 
433
- function ChartLegendItem({color, label}: {color: string; label: string}) {
434
- return (
435
- <HStack gap={2} vAlign="center">
436
- <Icon icon={Square} size="xsm" style={{color}} />
437
- <Text type="supporting" color="secondary">
438
- {label}
439
- </Text>
440
- </HStack>
441
- );
442
- }
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.
443
463
 
444
464
  function ActiveUsersChart() {
445
465
  // Downsample the 96 quarter-hour points to one bar per hour, stacking
@@ -447,15 +467,11 @@ function ActiveUsersChart() {
447
467
  const bars = activeUsersData.filter((_, i) => i % 4 === 0);
448
468
  const max = 130;
449
469
  const tickHours = [0, 32, 64, 92];
450
- // 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.
451
472
  return (
452
473
  <VStack gap={3}>
453
- <Card
454
- padding={3}
455
- style={{
456
- backgroundColor: 'var(--color-background)',
457
- border: 'var(--border-width) solid var(--color-separator)',
458
- }}>
474
+ <Card padding={4} style={CHART_PANEL_STYLE}>
459
475
  <svg
460
476
  viewBox="0 0 540 180"
461
477
  width="100%"
@@ -513,44 +529,22 @@ function ActiveUsersChart() {
513
529
  })}
514
530
  </svg>
515
531
  </Card>
516
- <Text type="supporting" color="secondary">
517
- Hourly intervals · trailing 24 hours
518
- </Text>
519
- <HStack gap={6} vAlign="center">
520
- <ChartLegendItem color={chartColors.desktop} label="Desktop" />
521
- <ChartLegendItem color={chartColors.mobile} label="Mobile" />
522
- </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
+ />
523
544
  </VStack>
524
545
  );
525
546
  }
526
547
 
527
- function Sparkline({data, label}: {data: number[]; label: string}) {
528
- const max = Math.max(...data);
529
- return (
530
- <svg
531
- viewBox="0 0 300 40"
532
- width="100%"
533
- height={40}
534
- role="img"
535
- aria-label={`${label} thirty-day trend`}>
536
- {data.map((v, i) => {
537
- const h = Math.max(3, (v / max) * 32);
538
- return (
539
- <rect
540
- key={i}
541
- x={i * 10}
542
- y={36 - h}
543
- width={7}
544
- height={h}
545
- rx={4}
546
- fill="var(--dracula-purple)"
547
- />
548
- );
549
- })}
550
- </svg>
551
- );
552
- }
553
-
554
548
  // ============= CARD COMPONENTS =============
555
549
 
556
550
  function MetricCard({
@@ -564,29 +558,28 @@ function MetricCard({
564
558
  value: string;
565
559
  change: string;
566
560
  positive: boolean;
567
- sparkline: number[];
561
+ sparkline: SparkPoint[];
568
562
  }) {
569
563
  return (
570
564
  <Card>
571
565
  <VStack gap={2}>
572
- <Heading level={4}>{label}</Heading>
566
+ <Text type="supporting" color="secondary">
567
+ {label}
568
+ </Text>
573
569
  <HStack gap={2} vAlign="center">
574
- <Heading level={2}>{value}</Heading>
575
- <HStack gap={1} vAlign="center">
576
- <Icon
577
- icon={change.trim().startsWith('-') ? ArrowDown : ArrowUp}
578
- size="xsm"
579
- color={positive ? 'success' : 'error'}
580
- />
581
- <Text type="body" color="secondary" hasTabularNumbers>
582
- {change}
583
- </Text>
584
- </HStack>
570
+ <Text type="display-3" weight="semibold" hasTabularNumbers>
571
+ {value}
572
+ </Text>
573
+ <MetricDelta value={change} positive={positive} />
585
574
  </HStack>
586
575
  <Text type="supporting" color="secondary">
587
576
  Last 30 days vs. Previous
588
577
  </Text>
589
- <Sparkline data={sparkline} label={label} />
578
+ <Sparkline
579
+ data={sparkline}
580
+ label={`${label} thirty-day trend`}
581
+ positive={positive}
582
+ />
590
583
  </VStack>
591
584
  </Card>
592
585
  );
@@ -611,7 +604,7 @@ function StackedBarCard({
611
604
  return (
612
605
  <Card>
613
606
  <VStack gap={4}>
614
- <Heading level={4}>{title}</Heading>
607
+ <Heading level={3}>{title}</Heading>
615
608
  <svg
616
609
  viewBox="0 0 540 24"
617
610
  width="100%"
@@ -642,7 +635,7 @@ function StackedBarCard({
642
635
  <VStack key={d.label} gap={0}>
643
636
  <HStack gap={2} vAlign="center">
644
637
  <Icon icon={Square} size="xsm" style={{color: d.color}} />
645
- <Text type="supporting">{d.label}</Text>
638
+ <Text type="supporting" color="secondary">{d.label}</Text>
646
639
  </HStack>
647
640
  <Text type="supporting" color="secondary" hasTabularNumbers>
648
641
  {((d.value / total) * 100).toFixed(0)}%
@@ -674,7 +667,7 @@ function TableCard<T extends {id: string}>({
674
667
  <Card>
675
668
  <VStack gap={6}>
676
669
  <HStack hAlign="between" vAlign="center">
677
- <Heading level={4}>{title}</Heading>
670
+ <Heading level={3}>{title}</Heading>
678
671
  <Link href={linkHref}>{linkLabel}</Link>
679
672
  </HStack>
680
673
  <Table<T>
@@ -683,6 +676,7 @@ function TableCard<T extends {id: string}>({
683
676
  idKey="id"
684
677
  density="compact"
685
678
  dividers="rows"
679
+ textOverflow="truncate"
686
680
  hasHover
687
681
  />
688
682
  </VStack>
@@ -694,17 +688,34 @@ function TableCard<T extends {id: string}>({
694
688
 
695
689
  // ============= MAIN COMPONENT =============
696
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
+
697
704
  export default function DashboardTemplate() {
698
705
  return (
699
706
  <Layout
707
+ style={pageStyle}
700
708
  height="fill"
701
709
  content={
702
710
  <LayoutContent padding={6}>
703
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
+
704
715
  {/* Active Users Chart */}
705
716
  <VStack gap={6}>
706
717
  <HStack hAlign="between" vAlign="center">
707
- <Heading level={3}>Awake after dark</Heading>
718
+ <Heading level={2}>Awake after dark</Heading>
708
719
  <Button
709
720
  label="Reload"
710
721
  variant="secondary"
@@ -716,20 +727,9 @@ export default function DashboardTemplate() {
716
727
  </VStack>
717
728
 
718
729
  {/* Metric Cards */}
719
- <Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
720
- {[0, 2].map(start => (
721
- <Grid
722
- key={start}
723
- columns={{minWidth: 240, repeat: 'fit'}}
724
- gap={4}>
725
- {metrics.slice(start, start + 2).map((m, i) => (
726
- <MetricCard
727
- key={m.label}
728
- {...m}
729
- sparkline={sparklines[start + i]}
730
- />
731
- ))}
732
- </Grid>
730
+ <Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
731
+ {metrics.map((m, index) => (
732
+ <MetricCard key={m.label} {...m} sparkline={sparklines[index]} />
733
733
  ))}
734
734
  </Grid>
735
735
 
@@ -737,10 +737,10 @@ export default function DashboardTemplate() {
737
737
 
738
738
  {/* Demographics */}
739
739
  <HStack hAlign="between" vAlign="center">
740
- <Heading level={3}>Night denizens</Heading>
740
+ <Heading level={2}>Night denizens</Heading>
741
741
  <Button label="View more" variant="secondary" size="md" />
742
742
  </HStack>
743
- <Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
743
+ <Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
744
744
  <StackedBarCard title="Territory" data={regionData} />
745
745
  <StackedBarCard title="Role" data={roleData} />
746
746
  </Grid>
@@ -749,10 +749,10 @@ export default function DashboardTemplate() {
749
749
 
750
750
  {/* Engagement */}
751
751
  <HStack hAlign="between" vAlign="center">
752
- <Heading level={3}>Engagement</Heading>
752
+ <Heading level={2}>Engagement</Heading>
753
753
  <Button label="View more" variant="secondary" size="md" />
754
754
  </HStack>
755
- <Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
755
+ <Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
756
756
  <TableCard
757
757
  title="Top pages"
758
758
  linkLabel="All pages"