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.
- package/BRAND.md +30 -18
- package/README.md +17 -12
- package/USAGE.md +30 -10
- package/astryx-dracula.js +19 -13
- package/astryx-dracula.variants.d.ts +15 -0
- package/package.json +59 -17
- package/shared/auth-chrome-config.ts +18 -0
- package/shared/auth-chrome.tsx +25 -0
- package/shared/auth-copy.ts +22 -0
- package/shared/chaptered-doc-config.ts +92 -0
- package/shared/chaptered-doc.tsx +332 -0
- package/shared/chart-hues.ts +27 -0
- package/shared/chart-labels.tsx +44 -0
- package/shared/chart-legend.tsx +100 -0
- package/shared/chart-panel-style.ts +13 -0
- package/shared/data-bar.tsx +160 -0
- package/shared/gallery-image.ts +20 -0
- package/shared/login-demo.ts +7 -0
- package/shared/metric-delta.tsx +36 -0
- package/shared/order-desk.tsx +273 -0
- package/shared/revenue-chart.tsx +181 -0
- package/shared/scene-castle.tsx +322 -0
- package/shared/scene-frame.tsx +88 -0
- package/shared/scene-hues.ts +49 -0
- package/shared/scene-tile.tsx +128 -0
- package/shared/settings-data.ts +125 -0
- package/shared/settings-rows.tsx +179 -0
- package/shared/sparkline.tsx +102 -0
- package/shared/sso-icons.tsx +30 -0
- package/templates/ai-chat-landing.tsx +66 -33
- package/templates/ai-chat.tsx +36 -19
- package/templates/blank.tsx +4 -3
- package/templates/centered-hero.tsx +14 -125
- package/templates/classic-gallery.tsx +20 -66
- package/templates/contact-form.tsx +59 -39
- package/templates/dashboard-portfolio.tsx +143 -226
- package/templates/dashboard.tsx +122 -122
- package/templates/detail-page.tsx +95 -91
- package/templates/documentation-design.template.mjs +1 -1
- package/templates/documentation-design.tsx +83 -96
- package/templates/documentation-technical.template.mjs +1 -1
- package/templates/documentation-technical.tsx +29 -19
- package/templates/documentation.template.mjs +1 -1
- package/templates/documentation.tsx +52 -74
- package/templates/editor.tsx +112 -84
- package/templates/file-explorer.tsx +131 -84
- package/templates/form-two-column.tsx +61 -125
- package/templates/gallery-hero.tsx +15 -171
- package/templates/ide.tsx +22 -20
- package/templates/incident-console.tsx +104 -70
- package/templates/kanban-board.tsx +99 -42
- package/templates/library.template.mjs +2 -2
- package/templates/library.tsx +37 -42
- package/templates/login-card.tsx +58 -87
- package/templates/login-split.tsx +88 -126
- package/templates/login-sso.tsx +105 -64
- package/templates/login.tsx +107 -53
- package/templates/messaging-shell.tsx +171 -144
- package/templates/mixed-gallery.tsx +29 -130
- package/templates/payment-form.tsx +368 -290
- package/templates/product-detail.tsx +39 -58
- package/templates/product-gallery.tsx +30 -41
- package/templates/product-tour.template.mjs +6 -0
- package/templates/product-tour.tsx +916 -0
- package/templates/settings-dialog.tsx +135 -282
- package/templates/settings-sidebar.tsx +178 -268
- package/templates/settings.tsx +41 -14
- package/templates/shell-nav.tsx +31 -24
- package/templates/shell-side-nav.tsx +29 -20
- package/templates/shell-top-nav.tsx +80 -44
- package/templates/side-gallery.tsx +38 -94
- package/templates/table-grouped.tsx +62 -34
- package/templates/table-page-chart.tsx +23 -302
- package/templates/table-page-heatmap-status.tsx +156 -92
- package/templates/table-page-shoe-store-heatmap.tsx +18 -293
- package/templates/table-page.tsx +23 -7
- package/templates/table.tsx +23 -8
- package/templates/tech-report.template.mjs +6 -0
- package/templates/tech-report.tsx +1486 -0
- package/templates/theme-showcase.template.mjs +1 -1
- package/templates/theme-showcase.tsx +299 -237
- package/theme.css +76 -14
- 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
|
+
}
|