astryx-dracula 0.4.0 → 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 (50) hide show
  1. package/package.json +6 -2
  2. package/shared/auth-chrome.tsx +156 -6
  3. package/shared/chaptered-doc.tsx +3 -4
  4. package/shared/chart-hues.ts +0 -5
  5. package/shared/chart-legend.tsx +2 -3
  6. package/shared/data-bar.tsx +5 -5
  7. package/shared/format-money.ts +3 -0
  8. package/shared/metric-card.tsx +127 -0
  9. package/shared/scene-hues.ts +0 -8
  10. package/shared/scene-tile.tsx +3 -11
  11. package/shared/settings-data.ts +28 -0
  12. package/shared/settings-rows.tsx +1 -6
  13. package/shared/stars.tsx +28 -0
  14. package/templates/ai-chat-landing.tsx +88 -58
  15. package/templates/ai-chat.tsx +65 -78
  16. package/templates/centered-hero.tsx +2 -2
  17. package/templates/dashboard-portfolio.tsx +77 -155
  18. package/templates/dashboard.tsx +17 -134
  19. package/templates/detail-page.tsx +14 -18
  20. package/templates/documentation-design.tsx +66 -519
  21. package/templates/documentation-technical.tsx +2 -22
  22. package/templates/editor.tsx +168 -185
  23. package/templates/file-explorer.tsx +10 -24
  24. package/templates/ide.tsx +12 -18
  25. package/templates/incident-console.tsx +48 -46
  26. package/templates/kanban-board.tsx +36 -85
  27. package/templates/library.tsx +42 -66
  28. package/templates/login-card.tsx +33 -126
  29. package/templates/login-split.tsx +91 -123
  30. package/templates/login-sso.tsx +12 -33
  31. package/templates/login.tsx +21 -121
  32. package/templates/messaging-shell.tsx +162 -168
  33. package/templates/payment-form.tsx +24 -26
  34. package/templates/product-detail.tsx +7 -40
  35. package/templates/product-tour.tsx +38 -60
  36. package/templates/settings-dialog.tsx +10 -32
  37. package/templates/settings-sidebar.tsx +44 -77
  38. package/templates/settings.tsx +208 -121
  39. package/templates/shell-nav.tsx +20 -75
  40. package/templates/shell-side-nav.tsx +16 -90
  41. package/templates/shell-top-nav.tsx +20 -53
  42. package/templates/side-gallery.tsx +19 -19
  43. package/templates/table-grouped.tsx +17 -164
  44. package/templates/table-page-chart.tsx +12 -11
  45. package/templates/table-page-heatmap-status.tsx +0 -17
  46. package/templates/table-page-shoe-store-heatmap.tsx +12 -12
  47. package/templates/table-page.tsx +4 -31
  48. package/templates/table.tsx +7 -10
  49. package/templates/tech-report.tsx +58 -72
  50. package/templates/theme-showcase.tsx +124 -415
package/package.json CHANGED
@@ -41,6 +41,9 @@
41
41
  "./shared/settings-data": "./shared/settings-data.ts",
42
42
  "./shared/settings-rows": "./shared/settings-rows.tsx",
43
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",
44
47
  "./shared/sso-icons": "./shared/sso-icons.tsx",
45
48
  ".": {
46
49
  "types": "./astryx-dracula.d.ts",
@@ -92,7 +95,8 @@
92
95
  "url": "git+https://github.com/yuzu-octopus/astryx-dracula.git"
93
96
  },
94
97
  "scripts": {
95
- "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/",
96
100
  "build": "tsc --noEmit && vite build",
97
101
  "dev": "vite",
98
102
  "preview": "vite preview",
@@ -111,7 +115,7 @@
111
115
  "@astryxdesign/cli"
112
116
  ],
113
117
  "type": "module",
114
- "version": "0.4.0",
118
+ "version": "0.5.0",
115
119
  "patchedDependencies": {
116
120
  "@astryxdesign/core@0.6.3": "patches/@astryxdesign%2Fcore@0.6.3.patch"
117
121
  }
@@ -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
+ }
@@ -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} />
@@ -165,3 +165,31 @@ export const sideNavHeading: CSSProperties = {
165
165
  marginInline: 'var(--spacing-4)',
166
166
  };
167
167
 
168
+ // Locale pickers shared by the sidebar and dialog templates. The two copies
169
+ // were byte-identical walls; a single source keeps them in sync.
170
+ export const LANGUAGES: {label: string; value: string}[] = [
171
+ {label: 'English (Canada)', value: 'en-CA'},
172
+ {label: 'English (US)', value: 'en-US'},
173
+ {label: 'French', value: 'fr'},
174
+ {label: 'Spanish', value: 'es'},
175
+ {label: 'German', value: 'de'},
176
+ {label: 'Japanese', value: 'ja'},
177
+ ];
178
+
179
+ export const CURRENCIES: {label: string; value: string}[] = [
180
+ {label: 'Canadian dollar (CAD)', value: 'CAD'},
181
+ {label: 'US dollar (USD)', value: 'USD'},
182
+ {label: 'Euro (EUR)', value: 'EUR'},
183
+ {label: 'British pound (GBP)', value: 'GBP'},
184
+ {label: 'Japanese yen (JPY)', value: 'JPY'},
185
+ ];
186
+
187
+ export const TIMEZONES: {label: string; value: string}[] = [
188
+ {label: '(GMT-05:00) Eastern Time (US & Canada)', value: 'ET'},
189
+ {label: '(GMT-06:00) Central Time (US & Canada)', value: 'CT'},
190
+ {label: '(GMT-07:00) Mountain Time (US & Canada)', value: 'MT'},
191
+ {label: '(GMT-08:00) Pacific Time (US & Canada)', value: 'PT'},
192
+ {label: '(GMT+00:00) UTC', value: 'UTC'},
193
+ {label: '(GMT+01:00) London', value: 'GMT+1'},
194
+ ];
195
+
@@ -72,10 +72,7 @@ import {Divider} from '@astryxdesign/core/Divider';
72
72
  // the page reads tighter than its neighbours.
73
73
  //
74
74
  // `href` is a real field on `InfoRow`: where a `'destination'` action goes,
75
- // read only when `actionKind` says the row navigates. The inert `href` prop on
76
- // `ExpandableRow` stays for call-site compatibility — it anchored Links this
77
- // file no longer renders, nothing reads it now, and removing it would break
78
- // any caller that still passes it.
75
+ // read only when `actionKind` says the row navigates.
79
76
 
80
77
  export function InfoRowItem({
81
78
  label,
@@ -200,8 +197,6 @@ export function ExpandableRow({
200
197
  onEdit: () => void;
201
198
  onCancel: () => void;
202
199
  onSave: () => void;
203
- /** Accepted for call-site compatibility; unused — see the note above. */
204
- href?: string;
205
200
  style?: CSSProperties;
206
201
  /** Draw a Divider after the row. True only at a group boundary. */
207
202
  hasDivider?: boolean;
@@ -0,0 +1,28 @@
1
+ import {HStack} from '@astryxdesign/core/Stack';
2
+ import {Text} from '@astryxdesign/core/Text';
3
+ import {Icon} from '@astryxdesign/core/Icon';
4
+ import {Star} from 'lucide-react';
5
+
6
+ export function StarRating({rating, count}: {rating: number; count: number}) {
7
+ const filled = Math.round(rating);
8
+ const empty = 5 - filled;
9
+
10
+ return (
11
+ <HStack gap={1} vAlign="center">
12
+ {Array.from({length: filled}, (_, i) => (
13
+ <Icon key={`full-${i}`} icon={Star} size="sm" color="yellow" />
14
+ ))}
15
+ {/* secondary, not disabled: --color-icon-disabled #6272A4 is 2.51:1 on
16
+ the card #343746, under the 3:1 non-text floor, and reads as "this
17
+ control is unavailable" — but a star is a data mark, not a control.
18
+ --color-icon-secondary #9AA1BC is 4.60:1, one step under the filled
19
+ yellow (7.4:1), so the two halves still read as one rating. */}
20
+ {Array.from({length: empty}, (_, i) => (
21
+ <Icon key={`empty-${i}`} icon={Star} size="sm" color="secondary" />
22
+ ))}
23
+ <Text type="body" color="secondary" hasTabularNumbers>
24
+ {rating} ({count})
25
+ </Text>
26
+ </HStack>
27
+ );
28
+ }