astryx-dracula 0.3.1 → 0.5.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.
- package/USAGE.md +25 -0
- package/astryx-dracula.d.ts +2 -2
- package/astryx-dracula.js +384 -3
- package/astryx-dracula.variants.d.ts +2 -14
- package/icons.mjs +125 -0
- package/package.json +21 -9
- package/shared/auth-chrome.tsx +156 -6
- package/shared/chaptered-doc.tsx +3 -4
- package/shared/chart-hues.ts +0 -5
- package/shared/chart-legend.tsx +2 -3
- package/shared/data-bar.tsx +5 -5
- package/shared/format-money.ts +3 -0
- package/shared/metric-card.tsx +127 -0
- package/shared/revenue-chart.tsx +63 -7
- package/shared/scene-hues.ts +0 -8
- package/shared/scene-tile.tsx +3 -11
- package/shared/settings-data.ts +71 -1
- package/shared/settings-rows.tsx +76 -30
- package/shared/stars.tsx +28 -0
- package/templates/ai-chat-landing.tsx +105 -67
- package/templates/ai-chat.tsx +67 -80
- package/templates/centered-hero.tsx +2 -2
- package/templates/contact-form.tsx +11 -10
- package/templates/dashboard-portfolio.tsx +77 -155
- package/templates/dashboard.tsx +24 -137
- package/templates/detail-page.tsx +25 -22
- package/templates/documentation-design.tsx +39 -478
- package/templates/documentation-technical.tsx +13 -31
- package/templates/editor.tsx +168 -185
- package/templates/file-explorer.tsx +91 -93
- package/templates/form-two-column.tsx +1 -1
- package/templates/ide.tsx +18 -24
- package/templates/incident-console.tsx +67 -42
- package/templates/kanban-board.tsx +36 -85
- package/templates/library.tsx +42 -66
- package/templates/login-card.tsx +33 -123
- package/templates/login-split.tsx +96 -119
- package/templates/login-sso.tsx +69 -74
- package/templates/login.tsx +21 -118
- package/templates/messaging-shell.tsx +162 -168
- package/templates/payment-form.tsx +63 -71
- package/templates/product-detail.tsx +11 -46
- package/templates/product-tour.tsx +38 -60
- package/templates/settings-dialog.tsx +93 -118
- package/templates/settings-sidebar.tsx +122 -143
- package/templates/settings.tsx +208 -121
- package/templates/shell-nav.tsx +20 -75
- package/templates/shell-side-nav.tsx +16 -90
- package/templates/shell-top-nav.tsx +20 -53
- package/templates/side-gallery.tsx +19 -19
- package/templates/table-grouped.tsx +86 -136
- package/templates/table-page-chart.tsx +12 -11
- package/templates/table-page-heatmap-status.tsx +0 -17
- package/templates/table-page-shoe-store-heatmap.tsx +12 -12
- package/templates/table-page.tsx +4 -31
- package/templates/table.tsx +18 -2
- package/templates/tech-report.tsx +59 -73
- package/templates/theme-showcase.tsx +124 -415
- package/theme.css +211 -119
package/package.json
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"description": "Pure Dracula brand theme for Astryx React sites. Dark-only tokens, syntax highlighting, and chart colors with prebuilt CSS.",
|
|
10
10
|
"devDependencies": {
|
|
11
|
-
"@astryxdesign/cli": "
|
|
11
|
+
"@astryxdesign/cli": "0.6.3",
|
|
12
|
+
"@astryxdesign/core": "0.6.3",
|
|
12
13
|
"@types/bun": "^1.4.2",
|
|
13
14
|
"@types/node": "^26.5.1",
|
|
14
15
|
"@types/react": "^19.3.0",
|
|
@@ -40,10 +41,13 @@
|
|
|
40
41
|
"./shared/settings-data": "./shared/settings-data.ts",
|
|
41
42
|
"./shared/settings-rows": "./shared/settings-rows.tsx",
|
|
42
43
|
"./shared/sparkline": "./shared/sparkline.tsx",
|
|
44
|
+
"./shared/format-money": "./shared/format-money.ts",
|
|
45
|
+
"./shared/metric-card": "./shared/metric-card.tsx",
|
|
46
|
+
"./shared/stars": "./shared/stars.tsx",
|
|
43
47
|
"./shared/sso-icons": "./shared/sso-icons.tsx",
|
|
44
48
|
".": {
|
|
45
|
-
"
|
|
46
|
-
"
|
|
49
|
+
"types": "./astryx-dracula.d.ts",
|
|
50
|
+
"default": "./astryx-dracula.js"
|
|
47
51
|
},
|
|
48
52
|
"./astryx.integration.mjs": "./astryx.integration.mjs",
|
|
49
53
|
"./package.json": "./package.json",
|
|
@@ -60,6 +64,7 @@
|
|
|
60
64
|
"astryx-dracula.js",
|
|
61
65
|
"astryx-dracula.variants.d.ts",
|
|
62
66
|
"fonts/",
|
|
67
|
+
"icons.mjs",
|
|
63
68
|
"icons.tsx",
|
|
64
69
|
"shared/",
|
|
65
70
|
"theme.css",
|
|
@@ -80,7 +85,7 @@
|
|
|
80
85
|
"license": "MIT",
|
|
81
86
|
"name": "astryx-dracula",
|
|
82
87
|
"peerDependencies": {
|
|
83
|
-
"@astryxdesign/core": "^0.3
|
|
88
|
+
"@astryxdesign/core": "^0.6.3",
|
|
84
89
|
"lucide-react": "^1.45.0",
|
|
85
90
|
"react": ">=19.0.0",
|
|
86
91
|
"react-dom": ">=19.0.0"
|
|
@@ -90,13 +95,17 @@
|
|
|
90
95
|
"url": "git+https://github.com/yuzu-octopus/astryx-dracula.git"
|
|
91
96
|
},
|
|
92
97
|
"scripts": {
|
|
93
|
-
"audit": "bun scripts/check.ts",
|
|
98
|
+
"audit": "bun run fonts:sync && bun scripts/check.ts",
|
|
99
|
+
"fonts:sync": "mkdir -p public/fonts && cp fonts/*.woff2 public/fonts/",
|
|
94
100
|
"build": "tsc --noEmit && vite build",
|
|
95
101
|
"dev": "vite",
|
|
96
102
|
"preview": "vite preview",
|
|
97
|
-
"theme:build": "bunx astryx theme build astryx-theme.ts -o theme.css",
|
|
98
|
-
"
|
|
99
|
-
"
|
|
103
|
+
"theme:build": "bun run icons:build && bunx astryx theme build astryx-theme.ts -o theme.css --icons-specifier ./icons.mjs",
|
|
104
|
+
"icons:build": "bun build icons.tsx --outfile icons.mjs --format esm --external react --external lucide-react",
|
|
105
|
+
"theme:check": "bun run icons:check && bunx astryx theme build astryx-theme.ts -o theme.css --icons-specifier ./icons.mjs --check --json | bun -e 'const r = JSON.parse(await Bun.stdin.text()); if (r.type !== \"theme.build.check\") { console.error(\"theme:check: unexpected CLI response \" + JSON.stringify(r).slice(0, 200)); process.exit(1); } const stale = r.data.stale.filter(s => s.path !== \"astryx-dracula.js\"); for (const s of stale) console.error(\"stale: \" + s.path + \" (\" + s.reason + \")\"); if (stale.length) process.exit(1); const entry = await import(\"./astryx-dracula.js\"); if (!entry.astryxDraculaTheme || !entry.draculaIconRegistry) { console.error(\"astryx-dracula.js is missing its theme or icon registry export\"); process.exit(1); } console.log(\"theme outputs up to date; astryx-dracula.js (hand-maintained) loads and exports\");' && bun run entry:check",
|
|
106
|
+
"icons:check": "bun build icons.tsx --outfile .tmp-icons.mjs --format esm --external react --external lucide-react; s=$?; cmp -s .tmp-icons.mjs icons.mjs; c=$?; rm -f .tmp-icons.mjs; [ $s -eq 0 ] && [ $c -eq 0 ] || { echo 'stale: icons.mjs (rerun bun run icons:build)'; exit 1; }",
|
|
107
|
+
"entry:check": "node -e \"const{pathToFileURL}=require('url');const{resolve}=require('path');import(pathToFileURL(resolve('astryx-dracula.js')).href).then(m=>{if(!m.astryxDraculaTheme||!m.draculaIconRegistry){console.error('astryx-dracula.js is missing its theme or icon registry export');process.exit(1)}console.log('astryx-dracula.js loads under plain Node ESM')}).catch(e=>{console.error('astryx-dracula.js is not importable by Node ESM: '+e.message);process.exit(1)})\"",
|
|
108
|
+
"prepublishOnly": "bun run theme:check && bun run icons:check && bun run entry:check && bun run audit && tsc --noEmit && bunx react-doctor@latest --score"
|
|
100
109
|
},
|
|
101
110
|
"sideEffects": [
|
|
102
111
|
"**/*.css"
|
|
@@ -106,5 +115,8 @@
|
|
|
106
115
|
"@astryxdesign/cli"
|
|
107
116
|
],
|
|
108
117
|
"type": "module",
|
|
109
|
-
"version": "0.
|
|
118
|
+
"version": "0.5.0",
|
|
119
|
+
"patchedDependencies": {
|
|
120
|
+
"@astryxdesign/core@0.6.3": "patches/@astryxdesign%2Fcore@0.6.3.patch"
|
|
121
|
+
}
|
|
110
122
|
}
|
package/shared/auth-chrome.tsx
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
// Shared login chrome: page wash + 400px column + Moon brand row + the
|
|
2
2
|
// autocomplete widen-record TextInput needs (prop type omits input attrs).
|
|
3
|
-
import type {
|
|
3
|
+
import type {ReactNode} from 'react';
|
|
4
4
|
import {Moon} from 'lucide-react';
|
|
5
5
|
import {VStack} from '@astryxdesign/core/Layout';
|
|
6
6
|
import {Text} from '@astryxdesign/core/Text';
|
|
7
7
|
import {Icon} from '@astryxdesign/core/Icon';
|
|
8
|
-
import {
|
|
8
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
9
|
+
import {Link} from '@astryxdesign/core/Link';
|
|
10
|
+
import {TextInput} from '@astryxdesign/core/TextInput';
|
|
11
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
12
|
+
import {Heading} from '@astryxdesign/core/Text';
|
|
9
13
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
AUTH_BRAND_NAME,
|
|
15
|
+
AUTH_HEADING,
|
|
16
|
+
AUTH_SUBTITLE,
|
|
17
|
+
AUTH_PRIMARY_CTA,
|
|
18
|
+
AUTH_SIGNUP_PROMPT,
|
|
19
|
+
AUTH_SIGNUP_LINK,
|
|
20
|
+
AUTH_FORGOT_PASSWORD,
|
|
21
|
+
AUTH_EMAIL_PLACEHOLDER,
|
|
22
|
+
AUTH_PASSWORD_PLACEHOLDER,
|
|
23
|
+
AUTH_TERMS_PREFIX,
|
|
24
|
+
AUTH_TERMS_SERVICE,
|
|
25
|
+
AUTH_TERMS_PRIVACY,
|
|
26
|
+
} from 'astryx-dracula/shared/auth-copy';
|
|
27
|
+
import {inputAutoComplete} from 'astryx-dracula/shared/auth-chrome-config';
|
|
14
28
|
|
|
15
29
|
|
|
16
30
|
export function LoginBrand() {
|
|
@@ -23,3 +37,139 @@ export function LoginBrand() {
|
|
|
23
37
|
</VStack>
|
|
24
38
|
);
|
|
25
39
|
}
|
|
40
|
+
|
|
41
|
+
// Shared password-login core: the centered header, credential fields, primary
|
|
42
|
+
// CTA, signup line and terms line every password page in the family carries.
|
|
43
|
+
// The three pages differed only in what sits between the CTA and the signup
|
|
44
|
+
// line (nothing, or the SSO divider + provider buttons) and in their outer
|
|
45
|
+
// shell — login-split's two-column grid and login-sso's step machine keep
|
|
46
|
+
// their own markup and take the pieces below instead of this whole card.
|
|
47
|
+
export function AuthCard({
|
|
48
|
+
selfHash,
|
|
49
|
+
signupLink = AUTH_SIGNUP_LINK,
|
|
50
|
+
children,
|
|
51
|
+
}: {
|
|
52
|
+
selfHash: string;
|
|
53
|
+
signupLink?: string;
|
|
54
|
+
children: ReactNode;
|
|
55
|
+
}) {
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
<Card padding={4} width="100%">
|
|
59
|
+
<VStack gap={4} hAlign="stretch">
|
|
60
|
+
<VStack gap={1} hAlign="center">
|
|
61
|
+
<Heading level={1} type="display-2" justify="center">
|
|
62
|
+
{AUTH_HEADING}
|
|
63
|
+
</Heading>
|
|
64
|
+
<Text type="body" color="secondary">
|
|
65
|
+
{AUTH_SUBTITLE}
|
|
66
|
+
</Text>
|
|
67
|
+
</VStack>
|
|
68
|
+
{children}
|
|
69
|
+
{/* Prose link: colour alone is not a cue inside a sentence
|
|
70
|
+
(WCAG 1.4.1 / F73), so it asks for the underline the kit
|
|
71
|
+
only gives on hover. */}
|
|
72
|
+
<VStack hAlign="center">
|
|
73
|
+
<Text type="supporting" color="secondary">
|
|
74
|
+
{AUTH_SIGNUP_PROMPT}{' '}
|
|
75
|
+
<Link href={selfHash} type="supporting" hasUnderline>
|
|
76
|
+
{signupLink}
|
|
77
|
+
</Link>
|
|
78
|
+
</Text>
|
|
79
|
+
</VStack>
|
|
80
|
+
</VStack>
|
|
81
|
+
</Card>
|
|
82
|
+
<AuthTerms selfHash={selfHash} />
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Email + password fields with the shared error/forgot-password rhythm, plus
|
|
88
|
+
// the primary CTA that always follows them. The field gap is 8px (gap={2}),
|
|
89
|
+
// not the outer 16px: without the group the two inputs sat twice as far
|
|
90
|
+
// apart as the fields below them.
|
|
91
|
+
export function AuthLoginFields({
|
|
92
|
+
email,
|
|
93
|
+
password,
|
|
94
|
+
error,
|
|
95
|
+
selfHash,
|
|
96
|
+
isLoading,
|
|
97
|
+
onEmailChange,
|
|
98
|
+
onPasswordChange,
|
|
99
|
+
onSubmit,
|
|
100
|
+
}: {
|
|
101
|
+
email: string;
|
|
102
|
+
password: string;
|
|
103
|
+
error: string | null;
|
|
104
|
+
selfHash: string;
|
|
105
|
+
isLoading: boolean;
|
|
106
|
+
onEmailChange: (value: string) => void;
|
|
107
|
+
onPasswordChange: (value: string) => void;
|
|
108
|
+
onSubmit: () => void;
|
|
109
|
+
}) {
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
<VStack gap={2}>
|
|
113
|
+
<TextInput
|
|
114
|
+
label="Email"
|
|
115
|
+
isLabelHidden
|
|
116
|
+
value={email}
|
|
117
|
+
onChange={onEmailChange}
|
|
118
|
+
placeholder={AUTH_EMAIL_PLACEHOLDER}
|
|
119
|
+
type="email"
|
|
120
|
+
{...inputAutoComplete('email')}
|
|
121
|
+
size="lg"
|
|
122
|
+
onEnter={onSubmit}
|
|
123
|
+
status={error ? {type: 'error', message: error} : undefined}
|
|
124
|
+
/>
|
|
125
|
+
<VStack gap={1}>
|
|
126
|
+
<TextInput
|
|
127
|
+
label="Password"
|
|
128
|
+
isLabelHidden
|
|
129
|
+
value={password}
|
|
130
|
+
onChange={onPasswordChange}
|
|
131
|
+
placeholder={AUTH_PASSWORD_PLACEHOLDER}
|
|
132
|
+
type="password"
|
|
133
|
+
{...inputAutoComplete('current-password')}
|
|
134
|
+
size="lg"
|
|
135
|
+
onEnter={onSubmit}
|
|
136
|
+
status={error ? {type: 'error', message: error} : undefined}
|
|
137
|
+
/>
|
|
138
|
+
{error && (
|
|
139
|
+
<VStack hAlign="end">
|
|
140
|
+
<Link href={selfHash} color="secondary" isStandalone>
|
|
141
|
+
{AUTH_FORGOT_PASSWORD}
|
|
142
|
+
</Link>
|
|
143
|
+
</VStack>
|
|
144
|
+
)}
|
|
145
|
+
</VStack>
|
|
146
|
+
</VStack>
|
|
147
|
+
<Button
|
|
148
|
+
label={AUTH_PRIMARY_CTA}
|
|
149
|
+
variant="primary"
|
|
150
|
+
size="lg"
|
|
151
|
+
isLoading={isLoading}
|
|
152
|
+
onClick={onSubmit}
|
|
153
|
+
/>
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The family terms line, rendered below the card in the page column.
|
|
159
|
+
export function AuthTerms({selfHash}: {selfHash: string}) {
|
|
160
|
+
return (
|
|
161
|
+
<VStack hAlign="center" width="100%">
|
|
162
|
+
<Text type="supporting" color="secondary" justify="center">
|
|
163
|
+
{AUTH_TERMS_PREFIX}{' '}
|
|
164
|
+
<Link href={selfHash} type="supporting" hasUnderline>
|
|
165
|
+
{AUTH_TERMS_SERVICE}
|
|
166
|
+
</Link>{' '}
|
|
167
|
+
and{' '}
|
|
168
|
+
<Link href={selfHash} type="supporting" hasUnderline>
|
|
169
|
+
{AUTH_TERMS_PRIVACY}
|
|
170
|
+
</Link>
|
|
171
|
+
.
|
|
172
|
+
</Text>
|
|
173
|
+
</VStack>
|
|
174
|
+
);
|
|
175
|
+
}
|
package/shared/chaptered-doc.tsx
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
buildOutlineByChapter,
|
|
10
10
|
outlinePanel,
|
|
11
11
|
sceneClip,
|
|
12
|
-
sceneFill,
|
|
13
12
|
scrollToSection,
|
|
14
13
|
sectionId,
|
|
15
14
|
type ChapterGroup,
|
|
@@ -49,7 +48,7 @@ import {useMediaQuery} from '@astryxdesign/core/hooks';
|
|
|
49
48
|
|
|
50
49
|
import {ChevronLeft, ChevronRight} from 'lucide-react';
|
|
51
50
|
|
|
52
|
-
|
|
51
|
+
function ChapterRail({
|
|
53
52
|
groups,
|
|
54
53
|
activeId,
|
|
55
54
|
onSelect,
|
|
@@ -100,7 +99,7 @@ export function ChapterRail({
|
|
|
100
99
|
|
|
101
100
|
// ─── Chapter body ────────────────────────────────────────────────────────────
|
|
102
101
|
|
|
103
|
-
|
|
102
|
+
function SectionBlock({
|
|
104
103
|
chapterId,
|
|
105
104
|
section,
|
|
106
105
|
}: {
|
|
@@ -134,7 +133,7 @@ export function SectionBlock({
|
|
|
134
133
|
);
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
|
|
136
|
+
function ChapterNav({
|
|
138
137
|
chapters,
|
|
139
138
|
chapter,
|
|
140
139
|
onSelect,
|
package/shared/chart-hues.ts
CHANGED
|
@@ -20,8 +20,3 @@ export const CHART_HUES = {
|
|
|
20
20
|
} as const;
|
|
21
21
|
|
|
22
22
|
export type ChartHue = (typeof CHART_HUES)[keyof typeof CHART_HUES];
|
|
23
|
-
|
|
24
|
-
// The value list exists for the gate: it reads the object's VALUES, never a
|
|
25
|
-
// grep across shared/. A text-level filter cannot separate the sanctioned rows
|
|
26
|
-
// from a drifting neighbour, which is why the check is here and not in a lint.
|
|
27
|
-
export const CHART_HUE_VALUES: readonly ChartHue[] = Object.values(CHART_HUES);
|
package/shared/chart-legend.tsx
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import type {ReactNode} from 'react';
|
|
25
25
|
import {HStack, StackItem, VStack} from '@astryxdesign/core/Stack';
|
|
26
26
|
import {Text} from '@astryxdesign/core/Text';
|
|
27
|
-
import
|
|
27
|
+
import type {ChartHue} from 'astryx-dracula/shared/chart-hues';
|
|
28
28
|
|
|
29
29
|
export interface ChartLegendEntry {
|
|
30
30
|
/** REQUIRED. The hue never travels alone — this is the 1.4.1 contract. */
|
|
@@ -45,7 +45,6 @@ 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
50
|
entries: readonly ChartLegendEntry[];
|
|
@@ -81,7 +80,7 @@ export function ChartLegend({
|
|
|
81
80
|
width: `${swatchSize}px`,
|
|
82
81
|
height: `${swatchSize}px`,
|
|
83
82
|
flexShrink: 0,
|
|
84
|
-
background:
|
|
83
|
+
background: entry.color,
|
|
85
84
|
borderRadius: 'var(--radius-inner)',
|
|
86
85
|
}}
|
|
87
86
|
/>
|
package/shared/data-bar.tsx
CHANGED
|
@@ -66,9 +66,6 @@ export interface DataBarProps {
|
|
|
66
66
|
formatValue?: (total: number) => string;
|
|
67
67
|
/** Gap between segments as a spacing-scale step. @default 0 */
|
|
68
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
69
|
}
|
|
73
70
|
|
|
74
71
|
const segmentStyle: CSSProperties = {
|
|
@@ -82,7 +79,6 @@ export function DataBar({
|
|
|
82
79
|
hasValueLabel = false,
|
|
83
80
|
formatValue = total => total.toLocaleString(),
|
|
84
81
|
gap = 0,
|
|
85
|
-
trackColor = 'var(--color-progress-value)',
|
|
86
82
|
}: DataBarProps) {
|
|
87
83
|
// Negative values are not a magnitude and are clamped out before the ratio is
|
|
88
84
|
// taken: summing a negative into `total` inflates every other segment's
|
|
@@ -136,7 +132,11 @@ export function DataBar({
|
|
|
136
132
|
style={
|
|
137
133
|
total > 0
|
|
138
134
|
? undefined
|
|
139
|
-
: {
|
|
135
|
+
: {
|
|
136
|
+
// Deliberately --color-progress-value, NOT a text token — see the header.
|
|
137
|
+
background: 'var(--color-progress-value)',
|
|
138
|
+
borderRadius: 'var(--radius-inner)',
|
|
139
|
+
}
|
|
140
140
|
}>
|
|
141
141
|
{drawn.map(segment => (
|
|
142
142
|
<StackItem
|
|
@@ -0,0 +1,127 @@
|
|
|
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 type {ReactNode} from 'react';
|
|
8
|
+
import {VStack, HStack} from '@astryxdesign/core/Stack';
|
|
9
|
+
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
10
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
11
|
+
import {Link} from '@astryxdesign/core/Link';
|
|
12
|
+
import {Table} from '@astryxdesign/core/Table';
|
|
13
|
+
import type {TableColumn} from '@astryxdesign/core/Table';
|
|
14
|
+
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
|
15
|
+
import {MetricDelta} from 'astryx-dracula/shared/metric-delta';
|
|
16
|
+
import {Sparkline, type SparkPoint} from 'astryx-dracula/shared/sparkline';
|
|
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
|
|
53
|
+
data={sparkline}
|
|
54
|
+
label={`${label} thirty-day trend`}
|
|
55
|
+
positive={isPositive}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
</VStack>
|
|
59
|
+
</Card>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function TableCard<T extends {id: string}>({
|
|
64
|
+
title,
|
|
65
|
+
linkLabel,
|
|
66
|
+
linkHref,
|
|
67
|
+
data,
|
|
68
|
+
columns,
|
|
69
|
+
}: {
|
|
70
|
+
title: string;
|
|
71
|
+
/** Omit both for a linkless panel (e.g. portfolio trending stocks). */
|
|
72
|
+
linkLabel?: string;
|
|
73
|
+
linkHref?: string;
|
|
74
|
+
data: T[];
|
|
75
|
+
columns: TableColumn<T>[];
|
|
76
|
+
}) {
|
|
77
|
+
return (
|
|
78
|
+
<Card>
|
|
79
|
+
<VStack gap={6}>
|
|
80
|
+
<HStack hAlign="between" vAlign="center">
|
|
81
|
+
<Heading level={3}>{title}</Heading>
|
|
82
|
+
{linkLabel && linkHref && <Link href={linkHref}>{linkLabel}</Link>}
|
|
83
|
+
</HStack>
|
|
84
|
+
<Table<T>
|
|
85
|
+
data={data}
|
|
86
|
+
columns={columns}
|
|
87
|
+
idKey="id"
|
|
88
|
+
density="compact"
|
|
89
|
+
dividers="rows"
|
|
90
|
+
textOverflow="truncate"
|
|
91
|
+
hasHover
|
|
92
|
+
/>
|
|
93
|
+
</VStack>
|
|
94
|
+
</Card>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function BarCell({
|
|
99
|
+
label,
|
|
100
|
+
value,
|
|
101
|
+
max,
|
|
102
|
+
children,
|
|
103
|
+
}: {
|
|
104
|
+
/** Accessible bar label, e.g. "/home views". */
|
|
105
|
+
label: string;
|
|
106
|
+
value: number;
|
|
107
|
+
max: number;
|
|
108
|
+
/** Formatted count rendered under the bar. */
|
|
109
|
+
children: ReactNode;
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<VStack gap={1}>
|
|
113
|
+
<ProgressBar value={value} max={max} label={label} isLabelHidden />
|
|
114
|
+
<Text type="body" hasTabularNumbers maxLines={1}>
|
|
115
|
+
{children}
|
|
116
|
+
</Text>
|
|
117
|
+
</VStack>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function CountCell({children}: {children: ReactNode}) {
|
|
122
|
+
return (
|
|
123
|
+
<Text hasTabularNumbers maxLines={1}>
|
|
124
|
+
{children}
|
|
125
|
+
</Text>
|
|
126
|
+
);
|
|
127
|
+
}
|
package/shared/revenue-chart.tsx
CHANGED
|
@@ -12,7 +12,7 @@ import {Text} from '@astryxdesign/core/Text';
|
|
|
12
12
|
import {Card} from '@astryxdesign/core/Card';
|
|
13
13
|
import {Icon} from '@astryxdesign/core/Icon';
|
|
14
14
|
import {Square} from 'lucide-react';
|
|
15
|
-
import {ChartLabel} from 'astryx-dracula/shared/chart-labels';
|
|
15
|
+
import {ChartLabel, CHART_LABEL_SIZE} from 'astryx-dracula/shared/chart-labels';
|
|
16
16
|
import type {SceneHue} from 'astryx-dracula/shared/scene-hues';
|
|
17
17
|
|
|
18
18
|
// ============= SHARED CHART-PANEL CARD STYLE =============
|
|
@@ -80,6 +80,23 @@ const CHART_PAD_TOP = 12;
|
|
|
80
80
|
const CHART_BASELINE = 164;
|
|
81
81
|
const CHART_PLOT_W = CHART_W - CHART_PAD_LEFT - CHART_PAD_RIGHT;
|
|
82
82
|
|
|
83
|
+
// X-axis label budget, counted in ticks rather than baked into a stride: five
|
|
84
|
+
// intervals is the most the 484-unit plot carries with a tick-width of air
|
|
85
|
+
// still between neighbours — a sixth drops the tightest pair to ~20px on a
|
|
86
|
+
// 390px viewport, and the final pair needs room on top of that. Six ticks is
|
|
87
|
+
// the ceiling, and the stride comes from the point count.
|
|
88
|
+
const CHART_MAX_X_TICKS = 6;
|
|
89
|
+
// A tick is as wide as its own text — the mono face advances 0.6em a glyph, so
|
|
90
|
+
// `Jan 1` and `Jan 13` are different widths, and the last pair is decided by
|
|
91
|
+
// that difference. Measured, not assumed.
|
|
92
|
+
const CHART_TICK_ADVANCE = 0.6 * CHART_LABEL_SIZE;
|
|
93
|
+
// The final tick is end-anchored here instead of centred on its own point,
|
|
94
|
+
// which is what buys it room inside the right pad. The price is the
|
|
95
|
+
// `CHART_TICK_PIN` it hangs back plus half a tick box of approach from the
|
|
96
|
+
// left, and that is the air the grid below has to leave it.
|
|
97
|
+
const CHART_LAST_TICK_X = CHART_W - CHART_PAD_RIGHT - 2;
|
|
98
|
+
const CHART_TICK_PIN = CHART_PAD_LEFT + CHART_PLOT_W - CHART_LAST_TICK_X;
|
|
99
|
+
|
|
83
100
|
interface RevenuePoint {
|
|
84
101
|
date: string;
|
|
85
102
|
revenue: number;
|
|
@@ -90,6 +107,48 @@ function formatRevenueTick(tick: number): string {
|
|
|
90
107
|
return tick >= 1000 ? `$${tick / 1000}k` : `$${tick}`;
|
|
91
108
|
}
|
|
92
109
|
|
|
110
|
+
// Which points get a date tick. A fixed stride cannot answer this: `i % 3`
|
|
111
|
+
// plus a forced final tick puts that final tick 0–1 slots from its neighbour
|
|
112
|
+
// for two point counts in three, and one slot is `Jan 13` and `Jan 15` set
|
|
113
|
+
// solid. So the stride comes from the point count, and the final point only
|
|
114
|
+
// joins the axis once the pin has left it its air; when it has not, the
|
|
115
|
+
// crowded grid tick gives way instead, so the axis ends on a gap wider than
|
|
116
|
+
// every other one rather than on the narrowest one.
|
|
117
|
+
function xTickIndices(dates: string[]): number[] {
|
|
118
|
+
const last = dates.length - 1;
|
|
119
|
+
if (last < 1) return [last];
|
|
120
|
+
const step = CHART_PLOT_W / last;
|
|
121
|
+
const width = (i: number) => dates[i].length * CHART_TICK_ADVANCE;
|
|
122
|
+
// Clear space between two ticks: `slots` apart, less the half-box each side
|
|
123
|
+
// shows. The final tick is the exception — it shows its whole box to the left
|
|
124
|
+
// of its anchor, so its pair is the one that has to clear the most.
|
|
125
|
+
const gap = (a: number, b: number) =>
|
|
126
|
+
(b - a) * step -
|
|
127
|
+
(width(a) + width(b)) / 2 -
|
|
128
|
+
(b === last ? CHART_TICK_PIN + width(b) / 2 : 0);
|
|
129
|
+
const stride = Math.max(1, Math.ceil(last / (CHART_MAX_X_TICKS - 1)));
|
|
130
|
+
// The grid, minus whatever the pinned final tick has crowded out. Every
|
|
131
|
+
// step of `end` down leaves the grid spacing even and gives the last pair
|
|
132
|
+
// one stride more room, so the first grid that clears is the one to keep.
|
|
133
|
+
let ticks = [0, last];
|
|
134
|
+
for (let end = Math.floor(last / stride) * stride; end >= 0; end -= stride) {
|
|
135
|
+
const grid: number[] = [];
|
|
136
|
+
for (let i = 0; i <= end; i += stride) grid.push(i);
|
|
137
|
+
if (grid[grid.length - 1] !== last) grid.push(last);
|
|
138
|
+
// Every pair, in order, so the last one can be held to the tightest of
|
|
139
|
+
// the rest instead of to the average.
|
|
140
|
+
const gaps = grid
|
|
141
|
+
.slice(0, -1)
|
|
142
|
+
.map((t, k) => gap(t, grid[k + 1]));
|
|
143
|
+
const interior = gaps.slice(0, -1);
|
|
144
|
+
if (interior.length === 0 || gaps[gaps.length - 1] >= Math.min(...interior)) {
|
|
145
|
+
ticks = grid;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return ticks;
|
|
150
|
+
}
|
|
151
|
+
|
|
93
152
|
export function RevenueChart({
|
|
94
153
|
data,
|
|
95
154
|
chartMax,
|
|
@@ -109,6 +168,7 @@ export function RevenueChart({
|
|
|
109
168
|
x: CHART_PAD_LEFT + (i / (data.length - 1)) * CHART_PLOT_W,
|
|
110
169
|
y: CHART_BASELINE - (d.revenue / chartMax) * plotH,
|
|
111
170
|
}));
|
|
171
|
+
const dateTicks = new Set(xTickIndices(data.map(d => d.date)));
|
|
112
172
|
const linePath = points
|
|
113
173
|
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
|
|
114
174
|
.join(' ');
|
|
@@ -151,14 +211,10 @@ export function RevenueChart({
|
|
|
151
211
|
/>
|
|
152
212
|
{points.map(
|
|
153
213
|
(p, i) =>
|
|
154
|
-
(i
|
|
214
|
+
dateTicks.has(i) && (
|
|
155
215
|
<ChartLabel
|
|
156
216
|
key={p.date}
|
|
157
|
-
x={
|
|
158
|
-
i === points.length - 1
|
|
159
|
-
? CHART_W - CHART_PAD_RIGHT - 2
|
|
160
|
-
: p.x
|
|
161
|
-
}
|
|
217
|
+
x={i === points.length - 1 ? CHART_LAST_TICK_X : p.x}
|
|
162
218
|
y={CHART_H - 8}
|
|
163
219
|
textAnchor={i === points.length - 1 ? 'end' : 'middle'}>
|
|
164
220
|
{p.date}
|
package/shared/scene-hues.ts
CHANGED
|
@@ -26,14 +26,6 @@ export const SCENE_HUES = {
|
|
|
26
26
|
|
|
27
27
|
export type SceneHue = (typeof SCENE_HUES)[keyof typeof SCENE_HUES];
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
* The union above constrains the KEY, not the value it resolves to. An entry
|
|
31
|
-
* rewritten to `var(--dracula-purple)` still typechecks. `scripts/check.ts`
|
|
32
|
-
* gates the values of this object; add the gate there if it is ever removed.
|
|
33
|
-
* A prose rule does not stop a caller — see the remit note above.
|
|
34
|
-
*/
|
|
35
|
-
export const SCENE_HUE_VALUES: readonly SceneHue[] = Object.values(SCENE_HUES);
|
|
36
|
-
|
|
37
29
|
// Shared alt list for the nine-tile side/classic wall (folded into one
|
|
38
30
|
// module so the two surfaces cannot drift apart again).
|
|
39
31
|
export const SCENE_TILE_ALTS = [
|
package/shared/scene-tile.tsx
CHANGED
|
@@ -22,11 +22,7 @@
|
|
|
22
22
|
// site with the exact offending value named.
|
|
23
23
|
|
|
24
24
|
import {galleryImage} from 'astryx-dracula/shared/gallery-image';
|
|
25
|
-
import {
|
|
26
|
-
SCENE_HUES,
|
|
27
|
-
type SceneHue,
|
|
28
|
-
type SceneTileSize,
|
|
29
|
-
} from 'astryx-dracula/shared/scene-hues';
|
|
25
|
+
import type {SceneHue, SceneTileSize} from 'astryx-dracula/shared/scene-hues';
|
|
30
26
|
|
|
31
27
|
|
|
32
28
|
interface SceneTileProps {
|
|
@@ -43,9 +39,6 @@ interface SceneTileProps {
|
|
|
43
39
|
label: string;
|
|
44
40
|
/** `lg` for grid thumbs, `sm` for the classic/side landscape tiles. */
|
|
45
41
|
size?: SceneTileSize;
|
|
46
|
-
/** Star wash for the sm landscape. Decorative, and purple-free by the same
|
|
47
|
-
* remit as `hue`, so it takes the same union. */
|
|
48
|
-
stars?: SceneHue;
|
|
49
42
|
/** Positional seed so repeated sm tiles vary moon, hills, and star field.
|
|
50
43
|
* Ignored by the lg fork (one centered glyph, no variance). */
|
|
51
44
|
index?: number;
|
|
@@ -56,7 +49,6 @@ export function SceneTile({
|
|
|
56
49
|
hue,
|
|
57
50
|
label,
|
|
58
51
|
size = 'lg',
|
|
59
|
-
stars = 'var(--dracula-comment)',
|
|
60
52
|
index = 0,
|
|
61
53
|
}: SceneTileProps) {
|
|
62
54
|
const moonX = 90 + ((index * 53) % 220);
|
|
@@ -73,8 +65,8 @@ export function SceneTile({
|
|
|
73
65
|
<rect width="400" height="300" fill="var(--dracula-bg-light)" />
|
|
74
66
|
{size === 'sm' && (
|
|
75
67
|
<>
|
|
76
|
-
{/* Stars */}
|
|
77
|
-
<g fill=
|
|
68
|
+
{/* Stars: decorative wash, comment so purple stays reserved for tappables. */}
|
|
69
|
+
<g fill="var(--dracula-comment)" opacity={0.55}>
|
|
78
70
|
<circle cx={40 + ((index * 37) % 320)} cy={30} r={2} />
|
|
79
71
|
<circle cx={120 + ((index * 23) % 200)} cy={52} r={1.6} />
|
|
80
72
|
<circle cx={260 + ((index * 11) % 110)} cy={26} r={2.2} />
|