astryx-dracula 0.4.0 → 0.5.1

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 (121) hide show
  1. package/astryx.integration.mjs +1 -1
  2. package/icons.mjs +17 -17
  3. package/icons.tsx +61 -62
  4. package/package.json +120 -116
  5. package/shared/auth-chrome-config.ts +6 -6
  6. package/shared/auth-chrome.tsx +168 -19
  7. package/shared/auth-copy.ts +14 -14
  8. package/shared/chaptered-doc-config.ts +48 -51
  9. package/shared/chaptered-doc.tsx +270 -292
  10. package/shared/chart-hues.ts +10 -15
  11. package/shared/chart-labels.tsx +31 -30
  12. package/shared/chart-legend.tsx +55 -57
  13. package/shared/chart-panel-style.ts +3 -3
  14. package/shared/data-bar.tsx +97 -102
  15. package/shared/format-money.ts +3 -0
  16. package/shared/gallery-image.ts +6 -6
  17. package/shared/login-demo.ts +1 -1
  18. package/shared/metric-card.tsx +123 -0
  19. package/shared/metric-delta.tsx +28 -27
  20. package/shared/order-desk.tsx +205 -216
  21. package/shared/revenue-chart.tsx +145 -165
  22. package/shared/scene-castle.tsx +206 -291
  23. package/shared/scene-frame.tsx +33 -32
  24. package/shared/scene-hues.ts +17 -25
  25. package/shared/scene-tile.tsx +88 -100
  26. package/shared/settings-data.ts +132 -104
  27. package/shared/settings-rows.tsx +143 -163
  28. package/shared/sparkline.tsx +75 -88
  29. package/shared/sso-icons.tsx +10 -22
  30. package/shared/stars.tsx +19 -0
  31. package/templates/ai-chat-landing.template.mjs +4 -4
  32. package/templates/ai-chat-landing.tsx +468 -447
  33. package/templates/ai-chat.template.mjs +4 -4
  34. package/templates/ai-chat.tsx +561 -645
  35. package/templates/blank.template.mjs +4 -4
  36. package/templates/blank.tsx +14 -12
  37. package/templates/centered-hero.template.mjs +4 -4
  38. package/templates/centered-hero.tsx +52 -67
  39. package/templates/classic-gallery.template.mjs +4 -4
  40. package/templates/classic-gallery.tsx +92 -101
  41. package/templates/contact-form.template.mjs +4 -4
  42. package/templates/contact-form.tsx +243 -280
  43. package/templates/dashboard-portfolio.template.mjs +4 -4
  44. package/templates/dashboard-portfolio.tsx +723 -634
  45. package/templates/dashboard.template.mjs +4 -4
  46. package/templates/dashboard.tsx +592 -682
  47. package/templates/detail-page.template.mjs +4 -4
  48. package/templates/detail-page.tsx +537 -598
  49. package/templates/documentation-design.template.mjs +4 -4
  50. package/templates/documentation-design.tsx +242 -709
  51. package/templates/documentation-technical.template.mjs +4 -4
  52. package/templates/documentation-technical.tsx +233 -258
  53. package/templates/documentation.template.mjs +4 -4
  54. package/templates/documentation.tsx +263 -277
  55. package/templates/editor.template.mjs +4 -4
  56. package/templates/editor.tsx +847 -899
  57. package/templates/file-explorer.template.mjs +4 -4
  58. package/templates/file-explorer.tsx +562 -602
  59. package/templates/form-two-column.template.mjs +4 -4
  60. package/templates/form-two-column.tsx +187 -208
  61. package/templates/gallery-hero.template.mjs +4 -4
  62. package/templates/gallery-hero.tsx +89 -112
  63. package/templates/ide.template.mjs +4 -4
  64. package/templates/ide.tsx +405 -462
  65. package/templates/incident-console.template.mjs +4 -4
  66. package/templates/incident-console.tsx +559 -581
  67. package/templates/kanban-board.template.mjs +4 -4
  68. package/templates/kanban-board.tsx +660 -729
  69. package/templates/library.template.mjs +4 -4
  70. package/templates/library.tsx +405 -452
  71. package/templates/login-card.template.mjs +4 -4
  72. package/templates/login-card.tsx +59 -163
  73. package/templates/login-split.template.mjs +4 -4
  74. package/templates/login-split.tsx +196 -232
  75. package/templates/login-sso.template.mjs +4 -4
  76. package/templates/login-sso.tsx +253 -277
  77. package/templates/login.template.mjs +4 -4
  78. package/templates/login.tsx +51 -149
  79. package/templates/messaging-shell.template.mjs +4 -4
  80. package/templates/messaging-shell.tsx +596 -624
  81. package/templates/mixed-gallery.template.mjs +4 -4
  82. package/templates/mixed-gallery.tsx +56 -56
  83. package/templates/payment-form.template.mjs +4 -4
  84. package/templates/payment-form.tsx +877 -990
  85. package/templates/product-detail.template.mjs +4 -4
  86. package/templates/product-detail.tsx +251 -302
  87. package/templates/product-gallery.template.mjs +4 -4
  88. package/templates/product-gallery.tsx +122 -135
  89. package/templates/product-tour.template.mjs +4 -4
  90. package/templates/product-tour.tsx +737 -794
  91. package/templates/settings-dialog.template.mjs +4 -4
  92. package/templates/settings-dialog.tsx +694 -747
  93. package/templates/settings-sidebar.template.mjs +4 -4
  94. package/templates/settings-sidebar.tsx +768 -848
  95. package/templates/settings.template.mjs +4 -4
  96. package/templates/settings.tsx +306 -230
  97. package/templates/shell-nav.template.mjs +4 -4
  98. package/templates/shell-nav.tsx +217 -277
  99. package/templates/shell-side-nav.template.mjs +4 -4
  100. package/templates/shell-side-nav.tsx +130 -231
  101. package/templates/shell-top-nav.template.mjs +4 -4
  102. package/templates/shell-top-nav.tsx +192 -248
  103. package/templates/side-gallery.template.mjs +4 -4
  104. package/templates/side-gallery.tsx +87 -94
  105. package/templates/table-grouped.template.mjs +4 -4
  106. package/templates/table-grouped.tsx +999 -1201
  107. package/templates/table-page-chart.template.mjs +4 -4
  108. package/templates/table-page-chart.tsx +398 -397
  109. package/templates/table-page-heatmap-status.template.mjs +4 -4
  110. package/templates/table-page-heatmap-status.tsx +479 -527
  111. package/templates/table-page-shoe-store-heatmap.template.mjs +4 -4
  112. package/templates/table-page-shoe-store-heatmap.tsx +746 -746
  113. package/templates/table-page.template.mjs +4 -4
  114. package/templates/table-page.tsx +393 -431
  115. package/templates/table.template.mjs +4 -4
  116. package/templates/table.tsx +93 -99
  117. package/templates/tech-report.template.mjs +4 -4
  118. package/templates/tech-report.tsx +1320 -1324
  119. package/templates/theme-showcase.template.mjs +4 -4
  120. package/templates/theme-showcase.tsx +869 -1246
  121. package/tokens.css +340 -343
@@ -3,42 +3,43 @@
3
3
  // (nothing meaningful below 12px, ever). The mono family resolves from the
4
4
  // `--font-family-mono` theme token, single-sourced with the body/code faces.
5
5
 
6
- import type {ReactNode} from 'react';
6
+ import type { ReactNode } from "react";
7
7
 
8
- export const CHART_LABEL_FILL = 'var(--color-text-paragraph)';
8
+ export const CHART_LABEL_FILL = "var(--color-text-paragraph)";
9
9
  export const CHART_LABEL_SIZE = 13;
10
10
 
11
11
  interface ChartLabelProps {
12
- x: number | string;
13
- y: number | string;
14
- textAnchor?: 'start' | 'middle' | 'end';
15
- /** Defaults to the paragraph token; pass an explicit fill for values drawn
16
- * on colored fills (e.g. in-cell heatmap counts).
17
- *
18
- * Deliberately NOT the chart-legend union: this paints TEXT, so its arms
19
- * are a text role and the on-fill role, and neither is a --color-data-*
20
- * var. Reusing the legend type here would reject the one use it exists
21
- * for. */
22
- fill?: `var(--color-text-${string})` | `var(--color-on-${string})`;
23
- children: ReactNode;
12
+ x: number | string;
13
+ y: number | string;
14
+ textAnchor?: "start" | "middle" | "end";
15
+ /** Defaults to the paragraph token; pass an explicit fill for values drawn
16
+ * on colored fills (e.g. in-cell heatmap counts).
17
+ *
18
+ * Deliberately NOT the chart-legend union: this paints TEXT, so its arms
19
+ * are a text role and the on-fill role, and neither is a --color-data-*
20
+ * var. Reusing the legend type here would reject the one use it exists
21
+ * for. */
22
+ fill?: `var(--color-text-${string})` | `var(--color-on-${string})`;
23
+ children: ReactNode;
24
24
  }
25
25
 
26
26
  export function ChartLabel({
27
- x,
28
- y,
29
- textAnchor = 'middle',
30
- fill = CHART_LABEL_FILL,
31
- children,
27
+ x,
28
+ y,
29
+ textAnchor = "middle",
30
+ fill = CHART_LABEL_FILL,
31
+ children,
32
32
  }: ChartLabelProps) {
33
- return (
34
- <text
35
- x={x}
36
- y={y}
37
- textAnchor={textAnchor}
38
- fontSize={CHART_LABEL_SIZE}
39
- fill={fill}
40
- fontFamily="var(--font-family-mono)">
41
- {children}
42
- </text>
43
- );
33
+ return (
34
+ <text
35
+ x={x}
36
+ y={y}
37
+ textAnchor={textAnchor}
38
+ fontSize={CHART_LABEL_SIZE}
39
+ fill={fill}
40
+ fontFamily="var(--font-family-mono)"
41
+ >
42
+ {children}
43
+ </text>
44
+ );
44
45
  }
@@ -21,21 +21,21 @@
21
21
  //
22
22
  // Not interactive. A legend is a key, not a control: no hover, no focus ring.
23
23
 
24
- import type {ReactNode} from 'react';
25
- import {HStack, StackItem, VStack} from '@astryxdesign/core/Stack';
26
- import {Text} from '@astryxdesign/core/Text';
27
- import {CHART_HUES, type ChartHue} from 'astryx-dracula/shared/chart-hues';
24
+ import { HStack, StackItem, VStack } from "@astryxdesign/core/Stack";
25
+ import { Text } from "@astryxdesign/core/Text";
26
+ import type { ChartHue } from "astryx-dracula/shared/chart-hues";
27
+ import type { ReactNode } from "react";
28
28
 
29
29
  export interface ChartLegendEntry {
30
- /** REQUIRED. The hue never travels alone — this is the 1.4.1 contract. */
31
- label: string;
32
- /** A --color-data-* var, or a --color-background-* surface. Never a raw
33
- * hex, never a --dracula-* primitive.
34
- *
35
- * The surface arm is not optional: a legend whose first row is a recessive
36
- * surface is normal (the heatmap's zero cell), so a data-only type would
37
- * reject the value the heatmap is supposed to paint. */
38
- color: ChartHue | `var(--color-data-${string})` | `var(--color-background-${string})`;
30
+ /** REQUIRED. The hue never travels alone — this is the 1.4.1 contract. */
31
+ label: string;
32
+ /** A --color-data-* var, or a --color-background-* surface. Never a raw
33
+ * hex, never a --dracula-* primitive.
34
+ *
35
+ * The surface arm is not optional: a legend whose first row is a recessive
36
+ * surface is normal (the heatmap's zero cell), so a data-only type would
37
+ * reject the value the heatmap is supposed to paint. */
38
+ color: ChartHue | `var(--color-data-${string})` | `var(--color-background-${string})`;
39
39
  }
40
40
 
41
41
  // Kept as a string-keyed fall-through on purpose, and DO NOT narrow it: call
@@ -45,56 +45,54 @@ export interface ChartLegendEntry {
45
45
  // would render `background: undefined` at every call site, compile clean, and
46
46
  // break at runtime. The old comment here claimed call sites may pass a KEY;
47
47
  // none does. The behaviour is correct; only the comment was wrong.
48
- const HUE_LOOKUP: Record<string, string> = CHART_HUES;
49
48
 
50
49
  export interface ChartLegendProps {
51
- entries: readonly ChartLegendEntry[];
52
- /** Spacing step between entries. @default 2 */
53
- gap?: 1 | 2 | 3 | 4 | 6;
54
- /** Accessible name for the legend as a group. */
55
- label?: string;
56
- /** Swatch size in px. @default 10 */
57
- swatchSize?: number;
58
- /** Caption under the legend, e.g. "Hourly intervals · trailing 24 hours". */
59
- caption?: ReactNode;
60
- /** Orientation. @default 'row' */
61
- direction?: 'row' | 'column';
50
+ entries: readonly ChartLegendEntry[];
51
+ /** Spacing step between entries. @default 2 */
52
+ gap?: 1 | 2 | 3 | 4 | 6;
53
+ /** Accessible name for the legend as a group. */
54
+ label?: string;
55
+ /** Swatch size in px. @default 10 */
56
+ swatchSize?: number;
57
+ /** Caption under the legend, e.g. "Hourly intervals · trailing 24 hours". */
58
+ caption?: ReactNode;
59
+ /** Orientation. @default 'row' */
60
+ direction?: "row" | "column";
62
61
  }
63
62
 
64
-
65
63
  export function ChartLegend({
66
- entries,
67
- gap = 2,
68
- swatchSize = 10,
69
- label,
70
- caption,
71
- direction = 'row',
64
+ entries,
65
+ gap = 2,
66
+ swatchSize = 10,
67
+ label,
68
+ caption,
69
+ direction = "row",
72
70
  }: ChartLegendProps) {
73
- const Stack = direction === 'row' ? HStack : VStack;
74
- return (
75
- <VStack gap={1}>
76
- <Stack gap={gap} vAlign="center" role="list" aria-label={label}>
77
- {entries.map(entry => (
78
- <HStack key={entry.label} gap={1} vAlign="center" role="listitem">
79
- <StackItem
80
- style={{
81
- width: `${swatchSize}px`,
82
- height: `${swatchSize}px`,
83
- flexShrink: 0,
84
- background: HUE_LOOKUP[entry.color] ?? entry.color,
85
- borderRadius: 'var(--radius-inner)',
86
- }}
87
- />
88
- {/* supporting, not body: a legend label is metadata by the
71
+ const Stack = direction === "row" ? HStack : VStack;
72
+ return (
73
+ <VStack gap={1}>
74
+ <Stack gap={gap} vAlign="center" role="list" aria-label={label}>
75
+ {entries.map((entry) => (
76
+ <HStack key={entry.label} gap={1} vAlign="center" role="listitem">
77
+ <StackItem
78
+ style={{
79
+ width: `${swatchSize}px`,
80
+ height: `${swatchSize}px`,
81
+ flexShrink: 0,
82
+ background: entry.color,
83
+ borderRadius: "var(--radius-inner)",
84
+ }}
85
+ />
86
+ {/* supporting, not body: a legend label is metadata by the
89
87
  typography doctrine, and it never carries a sentence the page
90
88
  needs the reader to have. */}
91
- <Text type="supporting" color="secondary" hasTabularNumbers>
92
- {entry.label}
93
- </Text>
94
- </HStack>
95
- ))}
96
- </Stack>
97
- {caption}
98
- </VStack>
99
- );
89
+ <Text type="supporting" color="secondary" hasTabularNumbers>
90
+ {entry.label}
91
+ </Text>
92
+ </HStack>
93
+ ))}
94
+ </Stack>
95
+ {caption}
96
+ </VStack>
97
+ );
100
98
  }
@@ -2,12 +2,12 @@
2
2
  // kit (dashboard, portfolio, chart, heatmap-status, shoe-store). Split from
3
3
  // shared/revenue-chart.tsx so that file exports only components and React Fast
4
4
  // Refresh stays intact; see shared/scene-hues.ts for the same reason.
5
- import type {CSSProperties} from 'react';
5
+ import type { CSSProperties } from "react";
6
6
 
7
7
  // One chart-panel look for every hand-drawn SVG chart panel in this wave's
8
8
  // templates (dashboard, portfolio, chart, heatmap-status, shoe-store). Restate
9
9
  // nowhere: theme Card variant work lands in wave 2.
10
10
  export const CHART_PANEL_STYLE: CSSProperties = {
11
- backgroundColor: 'var(--color-background)',
12
- border: 'var(--border-width) solid var(--color-separator)',
11
+ backgroundColor: "var(--color-background)",
12
+ border: "var(--border-width) solid var(--color-separator)",
13
13
  };
@@ -36,125 +36,120 @@
36
36
  // Not interactive. A data bar is not a control, so it carries no hover channel
37
37
  // and no focus ring; the inset-ring decision belongs to interactive surfaces.
38
38
 
39
- import type {CSSProperties} from 'react';
40
- import {HStack, StackItem, VStack} from '@astryxdesign/core/Stack';
41
- import {Text} from '@astryxdesign/core/Text';
42
- import type {ChartHue} from './chart-hues';
39
+ import { HStack, StackItem, VStack } from "@astryxdesign/core/Stack";
40
+ import { Text } from "@astryxdesign/core/Text";
41
+ import type { CSSProperties } from "react";
42
+ import type { ChartHue } from "./chart-hues";
43
43
 
44
44
  export interface DataBarSegment {
45
- /** Stable key for the segment. */
46
- id: string;
47
- value: number;
48
- /** MUST be a --color-data-categorical-* or --color-data-<family>-N var.
49
- * Never a raw hex, never a --dracula-* primitive. Typed rather than
50
- * documented: `string` made the rule a comment nobody could break. */
51
- color: ChartHue | `var(--color-data-${string})`;
45
+ /** Stable key for the segment. */
46
+ id: string;
47
+ value: number;
48
+ /** MUST be a --color-data-categorical-* or --color-data-<family>-N var.
49
+ * Never a raw hex, never a --dracula-* primitive. Typed rather than
50
+ * documented: `string` made the rule a comment nobody could break. */
51
+ color: ChartHue | `var(--color-data-${string})`;
52
52
  }
53
53
 
54
54
  /** Minimum segment share, so a tiny non-zero value stays visible. */
55
55
  const MIN_SEGMENT_PCT = 2;
56
56
 
57
57
  export interface DataBarProps {
58
- /** Accessible name. Required — a bar with no name is an unlabelled graphic. */
59
- label: string;
60
- segments: readonly DataBarSegment[];
61
- /** Bar thickness in px. @default 12 */
62
- height?: number;
63
- /** Show the summed value under the bar. @default false */
64
- hasValueLabel?: boolean;
65
- /** Formats the summed value. @default toLocaleString() */
66
- formatValue?: (total: number) => string;
67
- /** Gap between segments as a spacing-scale step. @default 0 */
68
- gap?: 0 | 0.5 | 1 | 1.5 | 2 | 3;
69
- /** Neutral fill shown when every segment is zero. Deliberately
70
- * --color-progress-value, NOT a text token — see the header. */
71
- trackColor?: string;
58
+ /** Accessible name. Required — a bar with no name is an unlabelled graphic. */
59
+ label: string;
60
+ segments: readonly DataBarSegment[];
61
+ /** Bar thickness in px. @default 12 */
62
+ height?: number;
63
+ /** Show the summed value under the bar. @default false */
64
+ hasValueLabel?: boolean;
65
+ /** Formats the summed value. @default toLocaleString() */
66
+ formatValue?: (total: number) => string;
67
+ /** Gap between segments as a spacing-scale step. @default 0 */
68
+ gap?: 0 | 0.5 | 1 | 1.5 | 2 | 3;
72
69
  }
73
70
 
74
71
  const segmentStyle: CSSProperties = {
75
- borderRadius: 'var(--radius-inner)',
72
+ borderRadius: "var(--radius-inner)",
76
73
  };
77
74
 
78
75
  export function DataBar({
79
- label,
80
- segments,
81
- height = 12,
82
- hasValueLabel = false,
83
- formatValue = total => total.toLocaleString(),
84
- gap = 0,
85
- trackColor = 'var(--color-progress-value)',
76
+ label,
77
+ segments,
78
+ height = 12,
79
+ hasValueLabel = false,
80
+ formatValue = (total) => total.toLocaleString(),
81
+ gap = 0,
86
82
  }: DataBarProps) {
87
- // Negative values are not a magnitude and are clamped out before the ratio is
88
- // taken: summing a negative into `total` inflates every other segment's
89
- // share and can push the bar past 100%. An empty or all-zero bar falls
90
- // through to the track fill below.
91
- const total = segments.reduce(
92
- (sum, segment) => sum + Math.max(0, segment.value),
93
- 0,
94
- );
95
- const visible = segments
96
- .map(segment => {
97
- if (segment.value <= 0) return null;
98
- return {
99
- id: segment.id,
100
- color: segment.color,
101
- share: (segment.value / total) * 100,
102
- };
103
- })
104
- .filter((segment): segment is NonNullable<typeof segment> => segment !== null);
83
+ // Negative values are not a magnitude and are clamped out before the ratio is
84
+ // taken: summing a negative into `total` inflates every other segment's
85
+ // share and can push the bar past 100%. An empty or all-zero bar falls
86
+ // through to the track fill below.
87
+ const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
88
+ const visible = segments
89
+ .map((segment) => {
90
+ if (segment.value <= 0) return null;
91
+ return {
92
+ id: segment.id,
93
+ color: segment.color,
94
+ share: (segment.value / total) * 100,
95
+ };
96
+ })
97
+ .filter((segment): segment is NonNullable<typeof segment> => segment !== null);
105
98
 
106
- // A tiny non-zero value must stay visible, so each segment gets a floor —
107
- // but the floors are taken OUT of the segments that can afford it. Flooring
108
- // every segment independently overflows the bar (3 tiny + 1 huge measured
109
- // 106%), and a bar wider than its track is worse than a missing speck.
110
- const floorTotal = visible.length * MIN_SEGMENT_PCT;
111
- const surplus = Math.max(0, 100 - floorTotal);
112
- const slack = Math.max(
113
- 0,
114
- visible.reduce(
115
- (sum, segment) => sum + Math.max(0, segment.share - MIN_SEGMENT_PCT),
116
- 0,
117
- ),
118
- );
119
- const widths = visible.map(segment => {
120
- const room = Math.max(0, segment.share - MIN_SEGMENT_PCT);
121
- const claim = slack > 0 ? (room / slack) * surplus : 0;
122
- return Math.min(100, MIN_SEGMENT_PCT + claim);
123
- });
124
- const drawn = visible
125
- .map((segment, index) => ({...segment, width: widths[index]}))
126
- .filter(segment => segment.width > 0);
99
+ // A tiny non-zero value must stay visible, so each segment gets a floor —
100
+ // but the floors are taken OUT of the segments that can afford it. Flooring
101
+ // every segment independently overflows the bar (3 tiny + 1 huge measured
102
+ // 106%), and a bar wider than its track is worse than a missing speck.
103
+ const floorTotal = visible.length * MIN_SEGMENT_PCT;
104
+ const surplus = Math.max(0, 100 - floorTotal);
105
+ const slack = Math.max(
106
+ 0,
107
+ visible.reduce((sum, segment) => sum + Math.max(0, segment.share - MIN_SEGMENT_PCT), 0),
108
+ );
109
+ const widths = visible.map((segment) => {
110
+ const room = Math.max(0, segment.share - MIN_SEGMENT_PCT);
111
+ const claim = slack > 0 ? (room / slack) * surplus : 0;
112
+ return Math.min(100, MIN_SEGMENT_PCT + claim);
113
+ });
114
+ const drawn = visible
115
+ .map((segment, index) => ({ ...segment, width: widths[index] }))
116
+ .filter((segment) => segment.width > 0);
127
117
 
128
- const bar = (
129
- <HStack
130
- gap={gap}
131
- height={height}
132
- width="100%"
133
- vAlign="stretch"
134
- role="img"
135
- aria-label={label}
136
- style={
137
- total > 0
138
- ? undefined
139
- : {background: trackColor, borderRadius: 'var(--radius-inner)'}
140
- }>
141
- {drawn.map(segment => (
142
- <StackItem
143
- key={segment.id}
144
- style={{...segmentStyle, flexBasis: `${segment.width}%`, background: segment.color}}
145
- />
146
- ))}
147
- </HStack>
148
- );
118
+ const bar = (
119
+ <HStack
120
+ gap={gap}
121
+ height={height}
122
+ width="100%"
123
+ vAlign="stretch"
124
+ role="img"
125
+ aria-label={label}
126
+ style={
127
+ total > 0
128
+ ? undefined
129
+ : {
130
+ // Deliberately --color-progress-value, NOT a text token — see the header.
131
+ background: "var(--color-progress-value)",
132
+ borderRadius: "var(--radius-inner)",
133
+ }
134
+ }
135
+ >
136
+ {drawn.map((segment) => (
137
+ <StackItem
138
+ key={segment.id}
139
+ style={{ ...segmentStyle, flexBasis: `${segment.width}%`, background: segment.color }}
140
+ />
141
+ ))}
142
+ </HStack>
143
+ );
149
144
 
150
- if (!hasValueLabel) return bar;
145
+ if (!hasValueLabel) return bar;
151
146
 
152
- return (
153
- <VStack gap={1}>
154
- {bar}
155
- <Text type="supporting" color="secondary" hasTabularNumbers>
156
- {formatValue(total)}
157
- </Text>
158
- </VStack>
159
- );
147
+ return (
148
+ <VStack gap={1}>
149
+ {bar}
150
+ <Text type="supporting" color="secondary" hasTabularNumbers>
151
+ {formatValue(total)}
152
+ </Text>
153
+ </VStack>
154
+ );
160
155
  }
@@ -0,0 +1,3 @@
1
+ export function formatMoney(n: number): string {
2
+ return `$${n.toFixed(2)}`;
3
+ }
@@ -3,18 +3,18 @@
3
3
  // primitive, so gallery SVG scenes fill their box and clip to rounded corners
4
4
  // through these two styles. Replace with component props once #2582 lands.
5
5
 
6
- import type {CSSProperties} from 'react';
6
+ import type { CSSProperties } from "react";
7
7
 
8
8
  // Fills the AspectRatio box. No objectFit prop on AspectRatio (#2582).
9
9
  export const galleryImage: CSSProperties = {
10
- width: '100%',
11
- height: '100%',
12
- display: 'block',
10
+ width: "100%",
11
+ height: "100%",
12
+ display: "block",
13
13
  };
14
14
 
15
15
  // Rounds the gallery corners (element radius; chapter art uses container — see
16
16
  // shared/chaptered-doc sceneClip). No radius prop on AspectRatio (#2582);
17
17
  export const galleryImageClip: CSSProperties = {
18
- borderRadius: 'var(--radius-element)',
19
- overflow: 'clip',
18
+ borderRadius: "var(--radius-element)",
19
+ overflow: "clip",
20
20
  };
@@ -3,5 +3,5 @@
3
3
  // Promise.withResolvers — the ES2022 target breaks strict consumers), awaited
4
4
  // inside each template's own startTransition.
5
5
  export function demoLogin(): Promise<void> {
6
- return new Promise(resolve => setTimeout(resolve, 2000));
6
+ return new Promise((resolve) => setTimeout(resolve, 2000));
7
7
  }
@@ -0,0 +1,123 @@
1
+ // Shared dashboard cards: the KPI tile (MetricCard), the linked table panel
2
+ // (TableCard), and the two table-cell renderers the engagement tables share
3
+ // (BarCell = magnitude bar + formatted count, CountCell = plain tabular
4
+ // count). One home so the analytics and portfolio templates can't drift into
5
+ // a second KPI/table vocabulary.
6
+
7
+ import { Card } from "@astryxdesign/core/Card";
8
+ import { Link } from "@astryxdesign/core/Link";
9
+ import { ProgressBar } from "@astryxdesign/core/ProgressBar";
10
+ import { HStack, VStack } from "@astryxdesign/core/Stack";
11
+ import type { TableColumn } from "@astryxdesign/core/Table";
12
+ import { Table } from "@astryxdesign/core/Table";
13
+ import { Heading, Text } from "@astryxdesign/core/Text";
14
+ import { MetricDelta } from "astryx-dracula/shared/metric-delta";
15
+ import { Sparkline, type SparkPoint } from "astryx-dracula/shared/sparkline";
16
+ import type { ReactNode } from "react";
17
+
18
+ export function MetricCard({
19
+ label,
20
+ value,
21
+ change,
22
+ positive,
23
+ caption = "Last 30 days vs. Previous",
24
+ sparkline,
25
+ }: {
26
+ label: string;
27
+ value: string;
28
+ change: string;
29
+ /** Defaults to "no leading minus means positive". */
30
+ positive?: boolean;
31
+ caption?: string;
32
+ /** Rendered as a thirty-day trend sparkline when provided. */
33
+ sparkline?: SparkPoint[];
34
+ }) {
35
+ const isPositive = positive ?? !change.startsWith("-");
36
+ return (
37
+ <Card>
38
+ <VStack gap={2}>
39
+ <Text type="supporting" color="secondary">
40
+ {label}
41
+ </Text>
42
+ <HStack gap={2} vAlign="center">
43
+ <Text type="display-3" weight="semibold" hasTabularNumbers>
44
+ {value}
45
+ </Text>
46
+ <MetricDelta value={change} positive={isPositive} />
47
+ </HStack>
48
+ <Text type="supporting" color="secondary">
49
+ {caption}
50
+ </Text>
51
+ {sparkline && (
52
+ <Sparkline data={sparkline} label={`${label} thirty-day trend`} positive={isPositive} />
53
+ )}
54
+ </VStack>
55
+ </Card>
56
+ );
57
+ }
58
+
59
+ export function TableCard<T extends { id: string }>({
60
+ title,
61
+ linkLabel,
62
+ linkHref,
63
+ data,
64
+ columns,
65
+ }: {
66
+ title: string;
67
+ /** Omit both for a linkless panel (e.g. portfolio trending stocks). */
68
+ linkLabel?: string;
69
+ linkHref?: string;
70
+ data: T[];
71
+ columns: TableColumn<T>[];
72
+ }) {
73
+ return (
74
+ <Card>
75
+ <VStack gap={6}>
76
+ <HStack hAlign="between" vAlign="center">
77
+ <Heading level={3}>{title}</Heading>
78
+ {linkLabel && linkHref && <Link href={linkHref}>{linkLabel}</Link>}
79
+ </HStack>
80
+ <Table<T>
81
+ data={data}
82
+ columns={columns}
83
+ idKey="id"
84
+ density="compact"
85
+ dividers="rows"
86
+ textOverflow="truncate"
87
+ hasHover
88
+ />
89
+ </VStack>
90
+ </Card>
91
+ );
92
+ }
93
+
94
+ export function BarCell({
95
+ label,
96
+ value,
97
+ max,
98
+ children,
99
+ }: {
100
+ /** Accessible bar label, e.g. "/home views". */
101
+ label: string;
102
+ value: number;
103
+ max: number;
104
+ /** Formatted count rendered under the bar. */
105
+ children: ReactNode;
106
+ }) {
107
+ return (
108
+ <VStack gap={1}>
109
+ <ProgressBar value={value} max={max} label={label} isLabelHidden />
110
+ <Text type="body" hasTabularNumbers maxLines={1}>
111
+ {children}
112
+ </Text>
113
+ </VStack>
114
+ );
115
+ }
116
+
117
+ export function CountCell({ children }: { children: ReactNode }) {
118
+ return (
119
+ <Text hasTabularNumbers maxLines={1}>
120
+ {children}
121
+ </Text>
122
+ );
123
+ }