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.
Files changed (59) hide show
  1. package/USAGE.md +25 -0
  2. package/astryx-dracula.d.ts +2 -2
  3. package/astryx-dracula.js +384 -3
  4. package/astryx-dracula.variants.d.ts +2 -14
  5. package/icons.mjs +125 -0
  6. package/package.json +21 -9
  7. package/shared/auth-chrome.tsx +156 -6
  8. package/shared/chaptered-doc.tsx +3 -4
  9. package/shared/chart-hues.ts +0 -5
  10. package/shared/chart-legend.tsx +2 -3
  11. package/shared/data-bar.tsx +5 -5
  12. package/shared/format-money.ts +3 -0
  13. package/shared/metric-card.tsx +127 -0
  14. package/shared/revenue-chart.tsx +63 -7
  15. package/shared/scene-hues.ts +0 -8
  16. package/shared/scene-tile.tsx +3 -11
  17. package/shared/settings-data.ts +71 -1
  18. package/shared/settings-rows.tsx +76 -30
  19. package/shared/stars.tsx +28 -0
  20. package/templates/ai-chat-landing.tsx +105 -67
  21. package/templates/ai-chat.tsx +67 -80
  22. package/templates/centered-hero.tsx +2 -2
  23. package/templates/contact-form.tsx +11 -10
  24. package/templates/dashboard-portfolio.tsx +77 -155
  25. package/templates/dashboard.tsx +24 -137
  26. package/templates/detail-page.tsx +25 -22
  27. package/templates/documentation-design.tsx +39 -478
  28. package/templates/documentation-technical.tsx +13 -31
  29. package/templates/editor.tsx +168 -185
  30. package/templates/file-explorer.tsx +91 -93
  31. package/templates/form-two-column.tsx +1 -1
  32. package/templates/ide.tsx +18 -24
  33. package/templates/incident-console.tsx +67 -42
  34. package/templates/kanban-board.tsx +36 -85
  35. package/templates/library.tsx +42 -66
  36. package/templates/login-card.tsx +33 -123
  37. package/templates/login-split.tsx +96 -119
  38. package/templates/login-sso.tsx +69 -74
  39. package/templates/login.tsx +21 -118
  40. package/templates/messaging-shell.tsx +162 -168
  41. package/templates/payment-form.tsx +63 -71
  42. package/templates/product-detail.tsx +11 -46
  43. package/templates/product-tour.tsx +38 -60
  44. package/templates/settings-dialog.tsx +93 -118
  45. package/templates/settings-sidebar.tsx +122 -143
  46. package/templates/settings.tsx +208 -121
  47. package/templates/shell-nav.tsx +20 -75
  48. package/templates/shell-side-nav.tsx +16 -90
  49. package/templates/shell-top-nav.tsx +20 -53
  50. package/templates/side-gallery.tsx +19 -19
  51. package/templates/table-grouped.tsx +86 -136
  52. package/templates/table-page-chart.tsx +12 -11
  53. package/templates/table-page-heatmap-status.tsx +0 -17
  54. package/templates/table-page-shoe-store-heatmap.tsx +12 -12
  55. package/templates/table-page.tsx +4 -31
  56. package/templates/table.tsx +18 -2
  57. package/templates/tech-report.tsx +59 -73
  58. package/templates/theme-showcase.tsx +124 -415
  59. 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": "^0.3.0",
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
- "default": "./astryx-dracula.js",
46
- "types": "./astryx-dracula.d.ts"
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.0",
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
- "theme:check": "bunx astryx theme build astryx-theme.ts -o theme.css --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\");'",
99
- "prepublishOnly": "bun run theme:check && bun run audit && tsc --noEmit && bunx react-doctor@latest --score"
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.3.1"
118
+ "version": "0.5.0",
119
+ "patchedDependencies": {
120
+ "@astryxdesign/core@0.6.3": "patches/@astryxdesign%2Fcore@0.6.3.patch"
121
+ }
110
122
  }
@@ -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 {CSSProperties} from 'react';
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 {AUTH_BRAND_NAME} from 'astryx-dracula/shared/auth-copy';
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
- authContentStyle,
11
- authPageStyle,
12
- inputAutoComplete,
13
- } from 'astryx-dracula/shared/auth-chrome-config';
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
+ }
@@ -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
- export function ChapterRail({
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
- export function SectionBlock({
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
- export function ChapterNav({
136
+ function ChapterNav({
138
137
  chapters,
139
138
  chapter,
140
139
  onSelect,
@@ -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);
@@ -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 {CHART_HUES, type ChartHue} from 'astryx-dracula/shared/chart-hues';
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: HUE_LOOKUP[entry.color] ?? entry.color,
83
+ background: entry.color,
85
84
  borderRadius: 'var(--radius-inner)',
86
85
  }}
87
86
  />
@@ -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
- : {background: trackColor, borderRadius: 'var(--radius-inner)'}
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,3 @@
1
+ export function formatMoney(n: number): string {
2
+ return `$${n.toFixed(2)}`;
3
+ }
@@ -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
+ }
@@ -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 % 3 === 0 || i === points.length - 1) && (
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}
@@ -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 = [
@@ -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={stars} opacity={0.55}>
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} />