astryx-dracula 0.2.1 → 0.3.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 (83) hide show
  1. package/BRAND.md +30 -18
  2. package/README.md +17 -12
  3. package/USAGE.md +30 -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 +181 -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 +102 -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 +433 -30
@@ -0,0 +1,160 @@
1
+ // Shared data bar: a magnitude against a domain (quota, budget, allocation,
2
+ // bandwidth headroom). This is NOT core's ProgressBar, and the difference is
3
+ // the whole point of this file.
4
+ //
5
+ // WHY THIS EXISTS: core's ProgressBar is a TASK-progress component. Its own
6
+ // JSDoc frames it as completion of known-duration work and says "Don't place
7
+ // icons or labels inside the bar" and "Don't use a progress bar for instant
8
+ // actions". Nine demo sites were using it for quota/headroom/budget meters — a
9
+ // quantity that is not a percentage of anything and never completes. Those nine
10
+ // now use this. If you are about to write "Risk level" value={15} with a
11
+ // success colour and a hidden number, you are repeating the bug this module
12
+ // exists to end: 15 is not 15% of anything, the number is invisible, and
13
+ // success-green tells the reader the opposite of what a risk bar means.
14
+ //
15
+ // COLOUR CONTRACT (UIAuditColorMotion, measured against the surface each
16
+ // element paints on):
17
+ // - A mark resolves to --color-data-categorical-* or a --color-data-<family>-N
18
+ // step. It does NOT resolve to a raw --dracula-* primitive. That discipline
19
+ // is what makes the 56 --color-data-* tokens load-bearing instead of
20
+ // decorative; before this module, zero of them had a single consumer.
21
+ // - On-fill text is dark. No Dracula fill carries #F8F8F2 at 4.5:1 (best case
22
+ // Functional Purple #815CD6 at 4.44:1), so dark on-fill is forced. The value
23
+ // is already pinned at tokens.css:208-211 and clears AA on all 7 accents.
24
+ // Do not let a contrast-solving heuristic reintroduce #000000 — raw black is
25
+ // a colour the spec never uses.
26
+ //
27
+ // INHERITED-FROM-CORE WARNING, verified against @astryxdesign/core 0.3.0:
28
+ // core paints its neutral/disabled fill with a TEXT token —
29
+ // ProgressBar.tsx:303,306 -> dist/astryx.css:586 (class x16fr6go) is
30
+ // `background-color: var(--color-text-disabled)`. In this theme
31
+ // --color-text-disabled and --color-progress-value are BOTH pin(DRA.comment)
32
+ // #6272A4 (astryx-theme.ts:147 and :279), so the role substitution is invisible
33
+ // by coincidence — zero delta is exactly why it survived. The neutral fill below
34
+ // uses --color-progress-value, the token that means what it says.
35
+ //
36
+ // Not interactive. A data bar is not a control, so it carries no hover channel
37
+ // and no focus ring; the inset-ring decision belongs to interactive surfaces.
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';
43
+
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})`;
52
+ }
53
+
54
+ /** Minimum segment share, so a tiny non-zero value stays visible. */
55
+ const MIN_SEGMENT_PCT = 2;
56
+
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;
72
+ }
73
+
74
+ const segmentStyle: CSSProperties = {
75
+ borderRadius: 'var(--radius-inner)',
76
+ };
77
+
78
+ 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)',
86
+ }: 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);
105
+
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);
127
+
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
+ );
149
+
150
+ if (!hasValueLabel) return bar;
151
+
152
+ return (
153
+ <VStack gap={1}>
154
+ {bar}
155
+ <Text type="supporting" color="secondary" hasTabularNumbers>
156
+ {formatValue(total)}
157
+ </Text>
158
+ </VStack>
159
+ );
160
+ }
@@ -0,0 +1,20 @@
1
+ // Shared gallery image fill + radius-clip (pending core Image #2582).
2
+ // AspectRatio exposes no objectFit or radius props and Astryx ships no Image
3
+ // primitive, so gallery SVG scenes fill their box and clip to rounded corners
4
+ // through these two styles. Replace with component props once #2582 lands.
5
+
6
+ import type {CSSProperties} from 'react';
7
+
8
+ // Fills the AspectRatio box. No objectFit prop on AspectRatio (#2582).
9
+ export const galleryImage: CSSProperties = {
10
+ width: '100%',
11
+ height: '100%',
12
+ display: 'block',
13
+ };
14
+
15
+ // Rounds the gallery corners (element radius; chapter art uses container — see
16
+ // shared/chaptered-doc sceneClip). No radius prop on AspectRatio (#2582);
17
+ export const galleryImageClip: CSSProperties = {
18
+ borderRadius: 'var(--radius-element)',
19
+ overflow: 'clip',
20
+ };
@@ -0,0 +1,7 @@
1
+ // Fake-auth pause shared by the four login templates (login, login-card,
2
+ // login-split, login-sso). Fixed 2s: plain Promise + setTimeout (no ES2024
3
+ // Promise.withResolvers — the ES2022 target breaks strict consumers), awaited
4
+ // inside each template's own startTransition.
5
+ export function demoLogin(): Promise<void> {
6
+ return new Promise(resolve => setTimeout(resolve, 2000));
7
+ }
@@ -0,0 +1,36 @@
1
+ // The one KPI-delta pattern: semibold body text tinted positive/negative with
2
+ // the direction arrow carrying the same tone. The sign and the arrow carry the
3
+ // direction; the tone only reinforces it — never rely on color alone.
4
+
5
+ import {HStack} from '@astryxdesign/core/Stack';
6
+ import {Text} from '@astryxdesign/core/Text';
7
+ import {Icon} from '@astryxdesign/core/Icon';
8
+ import {ArrowUp, ArrowDown} from 'lucide-react';
9
+
10
+ export function MetricDelta({
11
+ value,
12
+ positive,
13
+ }: {
14
+ /** Signed figure, e.g. "+12.4%" or "-3.1%". */
15
+ value: string;
16
+ positive: boolean;
17
+ }) {
18
+ return (
19
+ <HStack gap={1} vAlign="center">
20
+ <Icon
21
+ icon={positive ? ArrowUp : ArrowDown}
22
+ size="xsm"
23
+ color={positive ? 'success' : 'error'}
24
+ />
25
+ <Text
26
+ type="body"
27
+ weight="semibold"
28
+ hasTabularNumbers
29
+ style={{
30
+ color: positive ? 'var(--color-positive)' : 'var(--color-negative)',
31
+ }}>
32
+ {value}
33
+ </Text>
34
+ </HStack>
35
+ );
36
+ }
@@ -0,0 +1,273 @@
1
+ // Parametric order desk shared by the table-page-chart (Matcha Bar) and
2
+ // table-page-shoe-store-heatmap (Midnight Kicks) templates. The two pages are
3
+ // one archetype — revenue line over the order log — differing only in catalogue,
4
+ // rows, and chart scale, so the frame/columns live here once.
5
+ import type {CSSProperties} from 'react';
6
+ import {
7
+ VStack,
8
+ HStack,
9
+ StackItem,
10
+ Layout,
11
+ LayoutContent,
12
+ LayoutHeader,
13
+ } from '@astryxdesign/core/Layout';
14
+ import {Text, Heading} from '@astryxdesign/core/Text';
15
+ import {Button} from '@astryxdesign/core/Button';
16
+ import {IconButton} from '@astryxdesign/core/IconButton';
17
+ import {Icon} from '@astryxdesign/core/Icon';
18
+ import {Token} from '@astryxdesign/core/Token';
19
+ import {Link} from '@astryxdesign/core/Link';
20
+ import {Table, proportional, pixel} from '@astryxdesign/core/Table';
21
+ import type {TableColumn} from '@astryxdesign/core/Table';
22
+ import {Filter, Download, Plus} from 'lucide-react';
23
+ import {RevenueChart, ProductSwatch} from 'astryx-dracula/shared/revenue-chart';
24
+ import type {SceneHue} from 'astryx-dracula/shared/scene-hues';
25
+
26
+ // Anchor for the page wrapper.
27
+ //
28
+ // `height: '100dvh'`, NOT `minHeight: '100%'`. A percentage min-height
29
+ // resolves against the containing block's DEFINITE height and computes to 0
30
+ // when that is indefinite (CSS 2.1 10.5), so against a content-sized host it
31
+ // contributes nothing at all: the Layout's `height: 100%` still resolves to
32
+ // auto and the document keeps the scroll, which is the failure this exists to
33
+ // remove. Viewport units are absolute and need no ancestor, which is why every
34
+ // working instance in this repo uses them -- editor.tsx:302, file-explorer.tsx:
35
+ // 265, messaging-shell.tsx:67, ai-chat.tsx:64, kanban-board.tsx:704 -- and why
36
+ // minHeight appears in none of them.
37
+ //
38
+ // The wrapper is shared by all three callers (table-page-chart.tsx:410,
39
+ // table-page-shoe-store-heatmap.tsx:759, and this module's own default), so
40
+ // anchoring it here covers them together. Anchoring inside each caller instead
41
+ // would put a 100dvh box inside this one and overflow by the header height.
42
+ //
43
+ // The wrapper exists to give the Layout's `height: 100%` (Layout.tsx:58-63)
44
+ // something definite to resolve against when the host is content-sized, which
45
+ // is the case whenever a template ships standalone.
46
+ const pageStyle: CSSProperties = {height: '100dvh'};
47
+
48
+ export interface OrderDeskProduct {
49
+ name: string;
50
+ category: string;
51
+ /**
52
+ * Per-product art tint, painted by `ProductSwatch`. Same remit as
53
+ * `SceneTile.hue` — an art treatment, not a data series — so it takes the
54
+ * same `SceneHue` union rather than a free-form string. Typing the boundary
55
+ * HERE is what makes the compile-error list the real list: typed only at the
56
+ * consumer, a raw string flows through untyped and the error never fires.
57
+ */
58
+ accent: SceneHue;
59
+ price: number;
60
+ }
61
+
62
+ export interface OrderDeskRow extends Record<string, unknown> {
63
+ id: string;
64
+ customer: string;
65
+ email: string;
66
+ product: string;
67
+ category: string;
68
+ imageIndex: number;
69
+ amount: number;
70
+ status: 'completed' | 'processing' | 'shipped' | 'refunded';
71
+ date: string;
72
+ }
73
+
74
+ export interface RevenueDatum {
75
+ date: string;
76
+ revenue: number;
77
+ }
78
+
79
+ // Vocabulary, not a hue choice: orange is attention and constants only
80
+ // (principle 3), and an order being picked is routine in-flight work, not
81
+ // attention. In-progress/activity is cyan (info). refunded stays red — that
82
+ // is a real negative.
83
+ const STATUS_TOKEN_COLOR: Record<
84
+ OrderDeskRow['status'],
85
+ 'green' | 'cyan' | 'red'
86
+ > = {
87
+ completed: 'green',
88
+ shipped: 'cyan',
89
+ processing: 'cyan',
90
+ refunded: 'red',
91
+ };
92
+
93
+ const STATUS_LABEL: Record<OrderDeskRow['status'], string> = {
94
+ completed: 'Completed',
95
+ shipped: 'Shipped',
96
+ processing: 'Processing',
97
+ refunded: 'Refunded',
98
+ };
99
+
100
+ function columnsFor(selfHash: string, products: OrderDeskProduct[]): TableColumn<OrderDeskRow>[] {
101
+ return [
102
+ {
103
+ key: 'id',
104
+ header: 'Order',
105
+ width: pixel(96),
106
+ renderCell: (item: OrderDeskRow) => (
107
+ <Link href={selfHash} isStandalone>
108
+ {item.id}
109
+ </Link>
110
+ ),
111
+ },
112
+ {
113
+ key: 'product',
114
+ header: 'Product',
115
+ width: proportional(3, {minWidth: 160}),
116
+ renderCell: (item: OrderDeskRow) => (
117
+ <HStack gap={3} vAlign="center">
118
+ <ProductSwatch
119
+ accent={products[item.imageIndex].accent}
120
+ label={item.product}
121
+ />
122
+ <StackItem size="fill">
123
+ <VStack gap={0}>
124
+ <Text type="body" maxLines={1}>
125
+ {item.product}
126
+ </Text>
127
+ <Text type="supporting" color="secondary" maxLines={1}>
128
+ {item.category}
129
+ </Text>
130
+ </VStack>
131
+ </StackItem>
132
+ </HStack>
133
+ ),
134
+ },
135
+ {
136
+ key: 'amount',
137
+ header: 'Amount',
138
+ width: pixel(88),
139
+ renderCell: (item: OrderDeskRow) => (
140
+ <Text type="body" hasTabularNumbers maxLines={1}>
141
+ ${item.amount}
142
+ </Text>
143
+ ),
144
+ },
145
+ {
146
+ key: 'customer',
147
+ header: 'Customer',
148
+ width: proportional(2, {minWidth: 120}),
149
+ renderCell: (item: OrderDeskRow) => (
150
+ <Text type="body" maxLines={1}>
151
+ {item.customer}
152
+ </Text>
153
+ ),
154
+ },
155
+ {
156
+ key: 'email',
157
+ header: 'Email',
158
+ width: proportional(2, {minWidth: 120}),
159
+ renderCell: (item: OrderDeskRow) => (
160
+ <Text type="body" maxLines={1}>
161
+ {item.email}
162
+ </Text>
163
+ ),
164
+ },
165
+ {
166
+ key: 'status',
167
+ header: 'Status',
168
+ width: pixel(120),
169
+ renderCell: (item: OrderDeskRow) => (
170
+ <Token
171
+ size="sm"
172
+ color={STATUS_TOKEN_COLOR[item.status]}
173
+ label={STATUS_LABEL[item.status]}
174
+ />
175
+ ),
176
+ },
177
+ {
178
+ key: 'date',
179
+ header: 'Date',
180
+ width: pixel(112),
181
+ renderCell: (item: OrderDeskRow) => (
182
+ <Text type="body" hasTabularNumbers maxLines={1}>
183
+ {item.date}
184
+ </Text>
185
+ ),
186
+ },
187
+ ];
188
+ }
189
+
190
+ export function OrderDesk({
191
+ title,
192
+ selfHash,
193
+ products,
194
+ orders,
195
+ revenueData,
196
+ chartMax,
197
+ gridTicks,
198
+ }: {
199
+ title: string;
200
+ selfHash: string;
201
+ products: OrderDeskProduct[];
202
+ orders: OrderDeskRow[];
203
+ revenueData: RevenueDatum[];
204
+ chartMax: number;
205
+ gridTicks: number[];
206
+ }) {
207
+ return (
208
+ // The anchor lives on a page-owned wrapper, never on the Layout. A bare
209
+ // `height="fill"` resolves against `min-height: 100%`, which collapses when
210
+ // the host is content-sized — and templates ship standalone, so that is the
211
+ // common case. Putting the height on the Layout instead would trade
212
+ // a soft failure (the document scrolls) for a hard one (content clipped,
213
+ // because `LayoutContent` is `overflow: auto` with a definite height).
214
+ <div style={pageStyle}>
215
+ <Layout
216
+ height="fill"
217
+ header={
218
+ <LayoutHeader hasDivider padding={6}>
219
+ <HStack gap={2} vAlign="center">
220
+ <StackItem size="fill">
221
+ {/* maxLines={1}, not wrap="wrap": at 390 the 35px display-2 title
222
+ wraps 2-3 lines in a 342px header (measured: 136px tall for
223
+ "Midnight Kicks", 180px for an 18-char title). Wrapping the
224
+ row measured IDENTICAL header heights in both configurations
225
+ — the controls already fit, the title is what is tall. And
226
+ maxLines wires core's useTruncation, so the full value stays
227
+ reachable in a Tooltip (SC 1.4.10, "truncation must reveal"). */}
228
+ <Heading level={1} type="display-2" maxLines={1}>{title}</Heading>
229
+ </StackItem>
230
+ <IconButton
231
+ label="Filter"
232
+ icon={<Icon icon={Filter} size="sm" />}
233
+ variant="ghost"
234
+ tooltip="Filter"
235
+ />
236
+ <IconButton
237
+ label="Export"
238
+ icon={<Icon icon={Download} size="sm" />}
239
+ variant="ghost"
240
+ tooltip="Export"
241
+ />
242
+ <Button
243
+ label="New order"
244
+ variant="primary"
245
+ icon={<Icon icon={Plus} size="sm" />}
246
+ />
247
+ </HStack>
248
+ </LayoutHeader>
249
+ }
250
+ content={
251
+ <LayoutContent padding={3}>
252
+ <VStack gap={4}>
253
+ <RevenueChart
254
+ data={revenueData}
255
+ chartMax={chartMax}
256
+ gridTicks={gridTicks}
257
+ />
258
+ <Table<OrderDeskRow>
259
+ data={orders}
260
+ columns={columnsFor(selfHash, products)}
261
+ idKey="id"
262
+ density="balanced"
263
+ dividers="rows"
264
+ textOverflow="truncate"
265
+ hasHover
266
+ />
267
+ </VStack>
268
+ </LayoutContent>
269
+ }
270
+ />
271
+ </div>
272
+ );
273
+ }
@@ -0,0 +1,181 @@
1
+ // Shared daily-revenue line chart plus the rounded product swatch. Absorbs the
2
+ // table-page-chart / shoe-store clones (only CHART_MAX and ticks differ) and
3
+ // the theme-showcase ThumbSwatch as a sized variant. Chart panel Card keeps
4
+ // the one shared chart-panel style until the theme pins a Card variant
5
+ // (wave 2): import CHART_PANEL_STYLE rather than restating it.
6
+
7
+ import type {CSSProperties} from 'react';
8
+ import {CHART_PANEL_STYLE} from 'astryx-dracula/shared/chart-panel-style';
9
+ import {CHART_HUES} from 'astryx-dracula/shared/chart-hues';
10
+ import {VStack, HStack} from '@astryxdesign/core/Layout';
11
+ import {Text} from '@astryxdesign/core/Text';
12
+ import {Card} from '@astryxdesign/core/Card';
13
+ import {Icon} from '@astryxdesign/core/Icon';
14
+ import {Square} from 'lucide-react';
15
+ import {ChartLabel} from 'astryx-dracula/shared/chart-labels';
16
+ import type {SceneHue} from 'astryx-dracula/shared/scene-hues';
17
+
18
+ // ============= SHARED CHART-PANEL CARD STYLE =============
19
+
20
+
21
+ // ============= PRODUCT SWATCH =============
22
+
23
+ const swatchStyle: CSSProperties = {flexShrink: 0};
24
+
25
+ // Rounded product swatch: Dracula surface with a per-product accent glyph (one 36px size).
26
+ // `accent` is an art treatment, so it takes the same `SceneHue` union as
27
+ // `SceneTile.hue` — one art-treatment vocabulary, two consumers. A free-form
28
+ // `string` here is what let `--dracula-purple` reach a product swatch.
29
+ export function ProductSwatch({
30
+ accent,
31
+ label,
32
+ }: {
33
+ accent: SceneHue;
34
+ label: string;
35
+ }) {
36
+ return (
37
+ <svg
38
+ viewBox="0 0 36 36"
39
+ width={36}
40
+ height={36}
41
+ style={swatchStyle}
42
+ role="img"
43
+ aria-label={`${label} swatch`}>
44
+ {/* A surface, not a data mark: the plate behind a product swatch wears the
45
+ card tier. --color-background-card is byte-identical to the old
46
+ --dracula-bg-light (#343746). */}
47
+ <rect width={36} height={36} rx={5} fill="var(--color-background-card)" />
48
+ <rect
49
+ x={1}
50
+ y={1}
51
+ width={34}
52
+ height={34}
53
+ rx={4}
54
+ fill="none"
55
+ stroke="var(--color-widget-content-border)"
56
+ />
57
+ <circle
58
+ cx={18}
59
+ cy={18}
60
+ r={7}
61
+ fill="none"
62
+ stroke={accent}
63
+ strokeWidth={3}
64
+ />
65
+ </svg>
66
+ );
67
+ }
68
+
69
+ // ============= REVENUE CHART =============
70
+
71
+ // Role token, not a --dracula-* primitive. Byte-identical (--color-data-
72
+ // categorical-cyan IS #8BE9FD) and routed through chart-hues.ts so the line
73
+ // colour is the sanctioned vocabulary rather than a literal decided here.
74
+ const REVENUE_LINE = CHART_HUES.cyan;
75
+ const CHART_W = 540;
76
+ const CHART_H = 200;
77
+ const CHART_PAD_LEFT = 44;
78
+ const CHART_PAD_RIGHT = 12;
79
+ const CHART_PAD_TOP = 12;
80
+ const CHART_BASELINE = 164;
81
+ const CHART_PLOT_W = CHART_W - CHART_PAD_LEFT - CHART_PAD_RIGHT;
82
+
83
+ interface RevenuePoint {
84
+ date: string;
85
+ revenue: number;
86
+ }
87
+
88
+ // Axis ticks switch to thousands once the scale leaves the hundreds.
89
+ function formatRevenueTick(tick: number): string {
90
+ return tick >= 1000 ? `$${tick / 1000}k` : `$${tick}`;
91
+ }
92
+
93
+ export function RevenueChart({
94
+ data,
95
+ chartMax,
96
+ gridTicks,
97
+ ariaLabel = 'Daily revenue, January 1 to 15',
98
+ caption = 'Daily revenue · Jan 1–15',
99
+ }: {
100
+ data: RevenuePoint[];
101
+ chartMax: number;
102
+ gridTicks: number[];
103
+ ariaLabel?: string;
104
+ caption?: string;
105
+ }) {
106
+ const plotH = CHART_BASELINE - CHART_PAD_TOP;
107
+ const points = data.map((d, i) => ({
108
+ ...d,
109
+ x: CHART_PAD_LEFT + (i / (data.length - 1)) * CHART_PLOT_W,
110
+ y: CHART_BASELINE - (d.revenue / chartMax) * plotH,
111
+ }));
112
+ const linePath = points
113
+ .map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
114
+ .join(' ');
115
+ const areaPath = `${linePath} L${points[points.length - 1].x.toFixed(1)},${CHART_BASELINE} L${points[0].x.toFixed(1)},${CHART_BASELINE} Z`;
116
+ return (
117
+ <VStack gap={3}>
118
+ <Card padding={4} style={CHART_PANEL_STYLE}>
119
+ <svg
120
+ viewBox={`0 0 ${CHART_W} ${CHART_H}`}
121
+ width="100%"
122
+ role="img"
123
+ aria-label={ariaLabel}>
124
+ {gridTicks.map(tick => {
125
+ const y = CHART_BASELINE - (tick / chartMax) * plotH;
126
+ return (
127
+ <g key={tick}>
128
+ <line
129
+ x1={CHART_PAD_LEFT}
130
+ y1={y}
131
+ x2={CHART_W - CHART_PAD_RIGHT}
132
+ y2={y}
133
+ stroke="var(--color-separator)"
134
+ strokeDasharray={tick === 0 ? undefined : '3 3'}
135
+ opacity={tick === 0 ? 1 : 0.5}
136
+ />
137
+ <ChartLabel x={CHART_PAD_LEFT - 6} y={y + 3} textAnchor="end">
138
+ {formatRevenueTick(tick)}
139
+ </ChartLabel>
140
+ </g>
141
+ );
142
+ })}
143
+ <path d={areaPath} fill={REVENUE_LINE} opacity={0.25} />
144
+ <path
145
+ d={linePath}
146
+ fill="none"
147
+ stroke={REVENUE_LINE}
148
+ strokeWidth={2}
149
+ strokeLinejoin="round"
150
+ strokeLinecap="round"
151
+ />
152
+ {points.map(
153
+ (p, i) =>
154
+ (i % 3 === 0 || i === points.length - 1) && (
155
+ <ChartLabel
156
+ key={p.date}
157
+ x={
158
+ i === points.length - 1
159
+ ? CHART_W - CHART_PAD_RIGHT - 2
160
+ : p.x
161
+ }
162
+ y={CHART_H - 8}
163
+ textAnchor={i === points.length - 1 ? 'end' : 'middle'}>
164
+ {p.date}
165
+ </ChartLabel>
166
+ ),
167
+ )}
168
+ </svg>
169
+ </Card>
170
+ <Text type="supporting" color="secondary">
171
+ {caption}
172
+ </Text>
173
+ <HStack gap={2} vAlign="center">
174
+ <Icon icon={Square} size="xsm" style={{color: REVENUE_LINE}} />
175
+ <Text type="supporting" color="secondary">
176
+ Revenue
177
+ </Text>
178
+ </HStack>
179
+ </VStack>
180
+ );
181
+ }