astryx-dracula 0.2.0 → 0.3.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 (83) hide show
  1. package/BRAND.md +30 -18
  2. package/README.md +22 -11
  3. package/USAGE.md +29 -10
  4. package/astryx-dracula.js +19 -13
  5. package/astryx-dracula.variants.d.ts +15 -0
  6. package/package.json +59 -17
  7. package/shared/auth-chrome-config.ts +18 -0
  8. package/shared/auth-chrome.tsx +25 -0
  9. package/shared/auth-copy.ts +22 -0
  10. package/shared/chaptered-doc-config.ts +92 -0
  11. package/shared/chaptered-doc.tsx +332 -0
  12. package/shared/chart-hues.ts +27 -0
  13. package/shared/chart-labels.tsx +44 -0
  14. package/shared/chart-legend.tsx +100 -0
  15. package/shared/chart-panel-style.ts +13 -0
  16. package/shared/data-bar.tsx +160 -0
  17. package/shared/gallery-image.ts +20 -0
  18. package/shared/login-demo.ts +7 -0
  19. package/shared/metric-delta.tsx +36 -0
  20. package/shared/order-desk.tsx +273 -0
  21. package/shared/revenue-chart.tsx +174 -0
  22. package/shared/scene-castle.tsx +322 -0
  23. package/shared/scene-frame.tsx +88 -0
  24. package/shared/scene-hues.ts +49 -0
  25. package/shared/scene-tile.tsx +128 -0
  26. package/shared/settings-data.ts +125 -0
  27. package/shared/settings-rows.tsx +179 -0
  28. package/shared/sparkline.tsx +94 -0
  29. package/shared/sso-icons.tsx +30 -0
  30. package/templates/ai-chat-landing.tsx +67 -32
  31. package/templates/ai-chat.tsx +41 -20
  32. package/templates/blank.tsx +5 -2
  33. package/templates/centered-hero.tsx +15 -124
  34. package/templates/classic-gallery.tsx +21 -65
  35. package/templates/contact-form.tsx +62 -40
  36. package/templates/dashboard-portfolio.tsx +146 -227
  37. package/templates/dashboard.tsx +123 -121
  38. package/templates/detail-page.tsx +101 -95
  39. package/templates/documentation-design.template.mjs +1 -1
  40. package/templates/documentation-design.tsx +85 -96
  41. package/templates/documentation-technical.template.mjs +1 -1
  42. package/templates/documentation-technical.tsx +33 -21
  43. package/templates/documentation.template.mjs +1 -1
  44. package/templates/documentation.tsx +54 -74
  45. package/templates/editor.tsx +119 -89
  46. package/templates/file-explorer.tsx +133 -84
  47. package/templates/form-two-column.tsx +62 -124
  48. package/templates/gallery-hero.tsx +17 -171
  49. package/templates/ide.tsx +27 -21
  50. package/templates/incident-console.tsx +105 -69
  51. package/templates/kanban-board.tsx +100 -41
  52. package/templates/library.template.mjs +2 -2
  53. package/templates/library.tsx +38 -41
  54. package/templates/login-card.tsx +59 -86
  55. package/templates/login-split.tsx +88 -128
  56. package/templates/login-sso.tsx +107 -68
  57. package/templates/login.tsx +108 -52
  58. package/templates/messaging-shell.tsx +172 -143
  59. package/templates/mixed-gallery.tsx +31 -130
  60. package/templates/payment-form.tsx +369 -289
  61. package/templates/product-detail.tsx +40 -57
  62. package/templates/product-gallery.tsx +31 -40
  63. package/templates/product-tour.template.mjs +6 -0
  64. package/templates/product-tour.tsx +916 -0
  65. package/templates/settings-dialog.tsx +145 -286
  66. package/templates/settings-sidebar.tsx +179 -267
  67. package/templates/settings.tsx +42 -13
  68. package/templates/shell-nav.tsx +32 -23
  69. package/templates/shell-side-nav.tsx +30 -19
  70. package/templates/shell-top-nav.tsx +81 -43
  71. package/templates/side-gallery.tsx +39 -93
  72. package/templates/table-grouped.tsx +63 -33
  73. package/templates/table-page-chart.tsx +24 -297
  74. package/templates/table-page-heatmap-status.tsx +157 -91
  75. package/templates/table-page-shoe-store-heatmap.tsx +19 -288
  76. package/templates/table-page.tsx +24 -6
  77. package/templates/table.tsx +24 -7
  78. package/templates/tech-report.template.mjs +6 -0
  79. package/templates/tech-report.tsx +1486 -0
  80. package/templates/theme-showcase.template.mjs +1 -1
  81. package/templates/theme-showcase.tsx +313 -249
  82. package/theme.css +76 -14
  83. package/tokens.css +425 -30
@@ -0,0 +1,92 @@
1
+ // Chaptered-doc TYPES and FRAME STYLES, split from the components.
2
+ //
3
+ // WHY: chaptered-doc.tsx exports components, and a .tsx exporting
4
+ // non-components breaks React Fast Refresh.
5
+ // `react-doctor/only-export-components` catches it. See shared/scene-hues.ts
6
+ // for the same split, same reason.
7
+ //
8
+ // Everything the four callers (chaptered-doc, documentation-design,
9
+ // documentation-technical, product-tour, tech-report) share lives here; the
10
+ // outline-building and scroll helpers that only chaptered-doc itself calls stay
11
+ // unexported in that file.
12
+ import type {CSSProperties} from 'react';
13
+ import type {IconType} from '@astryxdesign/core/Icon';
14
+ import type {OutlineItem} from '@astryxdesign/core/Outline';
15
+
16
+ // ─── Types ───────────────────────────────────────────────────────────────────
17
+
18
+ export interface DocSection {
19
+ // Unique within the chapter; the DOM id is derived from it so the outline
20
+ // links and the headings can never drift apart.
21
+ key: string;
22
+ heading: string;
23
+ body: string;
24
+ bullets?: string[];
25
+ code?: {language: string; title: string; source: string};
26
+ }
27
+
28
+ export interface DocChapter {
29
+ id: string;
30
+ title: string;
31
+ icon: IconType;
32
+ intro: string;
33
+ hasArt?: boolean;
34
+ sections: DocSection[];
35
+ }
36
+
37
+ export type ChapterGroup = {title: string; chapters: DocChapter[]};
38
+
39
+ export const sectionId = (chapterId: string, key: string) =>
40
+ `${chapterId}--${key}`;
41
+
42
+ // Astryx has no image primitive: AspectRatio exposes no objectFit or radius
43
+ // props, so the scene fill and the corner clip live in these two styles.
44
+ export const sceneFill: CSSProperties = {
45
+ width: '100%',
46
+ height: '100%',
47
+ display: 'block',
48
+ };
49
+ export const sceneClip: CSSProperties = {
50
+ borderRadius: 'var(--radius-container)',
51
+ overflow: 'clip',
52
+ };
53
+
54
+ // The outline is sticky so it tracks the chapter as the document scrolls.
55
+ export const outlinePanel: CSSProperties = {
56
+ position: 'sticky',
57
+ top: 'var(--spacing-6)',
58
+ alignSelf: 'start',
59
+ // No paddingBlockStart. This object is applied via `style=` to the outline
60
+ // LayoutPanels (chaptered-doc.tsx:316, documentation-design.tsx:592,
61
+ // documentation-technical.tsx:82), and an inline style prop BEATS the stylex
62
+ // class -- so this 8px silently overrode the panel's own 16px block padding
63
+ // at the top edge only, leaving every "On this page" rail in the kit
64
+ // asymmetric by 8px. The panel already owns its block padding.
65
+ };
66
+
67
+ // Derived once per page so the Outline receives a stable array identity
68
+ // and does not re-register its scroll spy on every render.
69
+ export function buildOutlineByChapter(
70
+ chapters: DocChapter[],
71
+ ): Record<string, OutlineItem[]> {
72
+ return Object.fromEntries(
73
+ chapters.map(chapter => [
74
+ chapter.id,
75
+ chapter.sections.map(section => ({
76
+ id: sectionId(chapter.id, section.key),
77
+ label: section.heading,
78
+ level: 2,
79
+ })),
80
+ ]),
81
+ );
82
+ }
83
+
84
+ export function scrollToSection(id: string) {
85
+ const target = document.getElementById(id);
86
+ if (target != null) {
87
+ target.scrollIntoView({behavior: 'smooth', block: 'start'});
88
+ }
89
+ }
90
+
91
+ // ─── Rail ────────────────────────────────────────────────────────────────────
92
+
@@ -0,0 +1,332 @@
1
+ // Identical scaffold shared by the product-tour and tech-report templates:
2
+ // chapter rail (SideNav) + Layout content + sticky Outline that collapses to
3
+ // an "On this page" Selector below 1024px (the AppShell drawer owns the rail
4
+ // there behind the MobileNavToggle). Chapter data stays in each template.
5
+ // Scene systems stay local too: product-tour keeps BAT_WING/BAT_BODY/FIR +
6
+ // NightScene/PaletteScene/TypeScene/PagesScene, tech-report keeps its own.
7
+
8
+ import {
9
+ buildOutlineByChapter,
10
+ outlinePanel,
11
+ sceneClip,
12
+ sceneFill,
13
+ scrollToSection,
14
+ sectionId,
15
+ type ChapterGroup,
16
+ type DocChapter,
17
+ type DocSection,
18
+ } from 'astryx-dracula/shared/chaptered-doc-config';
19
+ import {useState, type CSSProperties, type ReactNode} from 'react';
20
+
21
+ import {AppShell} from '@astryxdesign/core/AppShell';
22
+ import {MobileNavToggle} from '@astryxdesign/core/MobileNav';
23
+ import {
24
+ SideNav,
25
+ SideNavHeading,
26
+ SideNavItem,
27
+ SideNavSection,
28
+ } from '@astryxdesign/core/SideNav';
29
+ import {NavIcon} from '@astryxdesign/core/NavIcon';
30
+ import {
31
+ HStack,
32
+ Layout,
33
+ LayoutContent,
34
+ LayoutPanel,
35
+ StackItem,
36
+ VStack,
37
+ } from '@astryxdesign/core/Layout';
38
+ import {Heading, Text} from '@astryxdesign/core/Text';
39
+ import {Button} from '@astryxdesign/core/Button';
40
+ import {Icon} from '@astryxdesign/core/Icon';
41
+ import type {IconType} from '@astryxdesign/core/Icon';
42
+ import {AspectRatio} from '@astryxdesign/core/AspectRatio';
43
+ import {CodeBlock} from '@astryxdesign/core/CodeBlock';
44
+ import {Divider} from '@astryxdesign/core/Divider';
45
+ import {List, ListItem} from '@astryxdesign/core/List';
46
+ import {Outline} from '@astryxdesign/core/Outline';
47
+ import {Selector} from '@astryxdesign/core/Selector';
48
+ import {useMediaQuery} from '@astryxdesign/core/hooks';
49
+
50
+ import {ChevronLeft, ChevronRight} from 'lucide-react';
51
+
52
+ export function ChapterRail({
53
+ groups,
54
+ activeId,
55
+ onSelect,
56
+ heading,
57
+ subheading,
58
+ headingHref,
59
+ headingIcon,
60
+ }: {
61
+ groups: ChapterGroup[];
62
+ activeId: string;
63
+ onSelect: (id: string) => void;
64
+ heading: string;
65
+ subheading: string;
66
+ headingHref: string;
67
+ headingIcon: ReactNode;
68
+ }) {
69
+ // Resizable like the shell-side-nav template: 264 default, 220-400 range.
70
+ // The AppShell drawer owns the rail below 1024px (see MobileNavToggle),
71
+ // so the handle only matters at desktop widths.
72
+ return (
73
+ <SideNav
74
+ collapsible
75
+ resizable={{defaultWidth: 264, minWidth: 220, maxWidth: 400}}
76
+ header={
77
+ <SideNavHeading
78
+ icon={<NavIcon icon={headingIcon} />}
79
+ heading={heading}
80
+ subheading={subheading}
81
+ headingHref={headingHref}
82
+ />
83
+ }>
84
+ {groups.map(group => (
85
+ <SideNavSection key={group.title} title={group.title}>
86
+ {group.chapters.map(chapter => (
87
+ <SideNavItem
88
+ key={chapter.id}
89
+ label={chapter.title}
90
+ icon={chapter.icon}
91
+ isSelected={chapter.id === activeId}
92
+ onClick={() => onSelect(chapter.id)}
93
+ />
94
+ ))}
95
+ </SideNavSection>
96
+ ))}
97
+ </SideNav>
98
+ );
99
+ }
100
+
101
+ // ─── Chapter body ────────────────────────────────────────────────────────────
102
+
103
+ export function SectionBlock({
104
+ chapterId,
105
+ section,
106
+ }: {
107
+ chapterId: string;
108
+ section: DocSection;
109
+ }) {
110
+ return (
111
+ <VStack gap={3}>
112
+ <Heading level={2} id={sectionId(chapterId, section.key)}>
113
+ {section.heading}
114
+ </Heading>
115
+ <Text type="body" color="secondary" display="block">
116
+ {section.body}
117
+ </Text>
118
+ {section.bullets != null && (
119
+ <List listStyle="disc">
120
+ {section.bullets.map(bullet => (
121
+ <ListItem key={bullet} label={bullet} />
122
+ ))}
123
+ </List>
124
+ )}
125
+ {section.code != null && (
126
+ <CodeBlock
127
+ code={section.code.source}
128
+ language={section.code.language}
129
+ title={section.code.title}
130
+ width="100%"
131
+ />
132
+ )}
133
+ </VStack>
134
+ );
135
+ }
136
+
137
+ export function ChapterNav({
138
+ chapters,
139
+ chapter,
140
+ onSelect,
141
+ }: {
142
+ chapters: DocChapter[];
143
+ chapter: DocChapter;
144
+ onSelect: (id: string) => void;
145
+ }) {
146
+ const index = chapters.findIndex(entry => entry.id === chapter.id);
147
+ const previous = index > 0 ? chapters[index - 1] : undefined;
148
+ const next = index < chapters.length - 1 ? chapters[index + 1] : undefined;
149
+ return (
150
+ <HStack gap={3} hAlign="between" vAlign="center">
151
+ {previous != null ? (
152
+ <Button
153
+ label={previous.title}
154
+ variant="secondary"
155
+ icon={<Icon icon={ChevronLeft} size="sm" />}
156
+ onClick={() => onSelect(previous.id)}
157
+ />
158
+ ) : (
159
+ <StackItem size="fill" />
160
+ )}
161
+ {next != null ? (
162
+ <Button
163
+ label={next.title}
164
+ variant="secondary"
165
+ endContent={<Icon icon={ChevronRight} size="sm" />}
166
+ onClick={() => onSelect(next.id)}
167
+ />
168
+ ) : (
169
+ <StackItem size="fill" />
170
+ )}
171
+ </HStack>
172
+ );
173
+ }
174
+
175
+ // ─── Page ────────────────────────────────────────────────────────────────────
176
+
177
+ export function ChapterArtFrame({children}: {children: ReactNode}) {
178
+ return (
179
+ <AspectRatio ratio={16 / 9} style={sceneClip}>
180
+ {children}
181
+ </AspectRatio>
182
+ );
183
+ }
184
+
185
+ export default function ChapteredDoc({
186
+ groups,
187
+ defaultChapter,
188
+ railHeading,
189
+ railSubheading,
190
+ railHref,
191
+ railIcon,
192
+ eyebrow,
193
+ badge,
194
+ art,
195
+ footer,
196
+ }: {
197
+ groups: ChapterGroup[];
198
+ defaultChapter: string;
199
+ railHeading: string;
200
+ railSubheading: string;
201
+ railHref: string;
202
+ railIcon: ReactNode;
203
+ eyebrow: string;
204
+ badge: (chapter: DocChapter, index: number, total: number) => ReactNode;
205
+ art: (chapterId: string) => ReactNode;
206
+ footer?: ReactNode;
207
+ }) {
208
+ const chapters = groups.flatMap(group => group.chapters);
209
+ const [outlineByChapter] = useState(() => buildOutlineByChapter(chapters));
210
+ const [chapterId, setChapterId] = useState(defaultChapter);
211
+ const [activeSection, setActiveSection] = useState(
212
+ outlineByChapter[defaultChapter]?.[0]?.id ?? '',
213
+ );
214
+
215
+ // Responsive contract: below 1024px the outline stops being a column, since
216
+ // a narrow viewport has nothing to outline against.
217
+ const isNarrow = useMediaQuery('(max-width: 1024px)');
218
+
219
+ const chapter = chapters.find(entry => entry.id === chapterId) ?? chapters[0];
220
+ const outlineItems = outlineByChapter[chapter.id] ?? [];
221
+
222
+ const openChapter = (id: string) => {
223
+ setChapterId(id);
224
+ setActiveSection(outlineByChapter[id]?.[0]?.id ?? '');
225
+ // A chapter reads as a new page, so the document goes back to the top
226
+ // instead of keeping the previous chapter's scroll offset.
227
+ window.scrollTo({top: 0});
228
+ };
229
+
230
+ return (
231
+ <AppShell
232
+ height="auto"
233
+ contentPadding={0}
234
+ mobileNav={{hasToggle: false}}
235
+ sideNav={
236
+ <ChapterRail
237
+ groups={groups}
238
+ activeId={chapter.id}
239
+ onSelect={openChapter}
240
+ heading={railHeading}
241
+ subheading={railSubheading}
242
+ headingHref={railHref}
243
+ headingIcon={railIcon}
244
+ />
245
+ }>
246
+ <Layout
247
+ height="auto"
248
+ contentWidth={960}
249
+ end={
250
+ isNarrow ? undefined : (
251
+ <LayoutPanel
252
+ isScrollable={false}
253
+ label="On this page"
254
+ role="complementary"
255
+ style={outlinePanel}>
256
+ <Outline
257
+ items={outlineItems}
258
+ onActiveIdChange={setActiveSection}
259
+ />
260
+ </LayoutPanel>
261
+ )
262
+ }
263
+ content={
264
+ <LayoutContent isScrollable={false} padding={8}>
265
+ <VStack gap={8}>
266
+ <VStack gap={2}>
267
+ <HStack gap={2} vAlign="center">
268
+ <MobileNavToggle />
269
+ <Text type="supporting" color="secondary">
270
+ {eyebrow}
271
+ </Text>
272
+ </HStack>
273
+ <Heading level={1} type="display-2">
274
+ {chapter.title}
275
+ </Heading>
276
+ <Text type="supporting" color="secondary" hasTabularNumbers>
277
+ {badge(
278
+ chapter,
279
+ chapters.findIndex(entry => entry.id === chapter.id),
280
+ chapters.length,
281
+ )}
282
+ </Text>
283
+ {isNarrow && (
284
+ <Selector
285
+ label="On this page"
286
+ isLabelHidden
287
+ options={outlineItems.map(item => ({
288
+ value: item.id,
289
+ label: item.label,
290
+ }))}
291
+ value={activeSection}
292
+ onChange={(id: string) => {
293
+ setActiveSection(id);
294
+ scrollToSection(id);
295
+ }}
296
+ width="100%"
297
+ />
298
+ )}
299
+ </VStack>
300
+
301
+ <Text type="large" color="secondary" display="block">
302
+ {chapter.intro}
303
+ </Text>
304
+
305
+ {art(chapter.id)}
306
+
307
+ <VStack gap={8}>
308
+ {chapter.sections.map(section => (
309
+ <SectionBlock
310
+ key={section.key}
311
+ chapterId={chapter.id}
312
+ section={section}
313
+ />
314
+ ))}
315
+ </VStack>
316
+
317
+ <Divider />
318
+
319
+ <ChapterNav
320
+ chapters={chapters}
321
+ chapter={chapter}
322
+ onSelect={openChapter}
323
+ />
324
+
325
+ {footer}
326
+ </VStack>
327
+ </LayoutContent>
328
+ }
329
+ />
330
+ </AppShell>
331
+ );
332
+ }
@@ -0,0 +1,27 @@
1
+ // Shared categorical chart hues: the visual.md categorical ramp as importable
2
+ // constants. Every value is a role token var -- never a raw --dracula-* primitive
3
+ // and never a raw hex -- so the vocabulary is the sanctioned one by construction
4
+ // rather than by convention, and a gate can read the object and enforce it.
5
+ // Purple is deliberately absent: purple means tappable, so it must never encode
6
+ // data. `muted` is a COMMENT-blue grey because it backs the `{label: 'Other'}`
7
+ // legend row (dashboard.tsx:229, :253) -- a labelled data category, not chrome.
8
+
9
+ export const CHART_HUES = {
10
+ cyan: 'var(--color-data-categorical-cyan)',
11
+ orange: 'var(--color-data-categorical-orange)',
12
+ green: 'var(--color-data-categorical-green)',
13
+ pink: 'var(--color-data-categorical-pink)',
14
+ muted: 'var(--color-data-categorical-blue)',
15
+ // Added because --color-data-categorical-red exists in the palette
16
+ // (tokens.css:276) and heat maps need a severity colour, but no module
17
+ // exposed it. Without this, a chart could not reach red through the
18
+ // sanctioned source at all.
19
+ red: 'var(--color-data-categorical-red)',
20
+ } as const;
21
+
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);
@@ -0,0 +1,44 @@
1
+ // Shared SVG chart-axis/label primitives. Every hand-drawn chart label goes
2
+ // through ChartLabel: token fill plus the 13px mono floor from visual.md
3
+ // (nothing meaningful below 12px, ever). The mono family resolves from the
4
+ // `--font-family-mono` theme token, single-sourced with the body/code faces.
5
+
6
+ import type {ReactNode} from 'react';
7
+
8
+ export const CHART_LABEL_FILL = 'var(--color-text-paragraph)';
9
+ export const CHART_LABEL_SIZE = 13;
10
+
11
+ interface ChartLabelProps {
12
+ x: number | string;
13
+ y: number | string;
14
+ textAnchor?: 'start' | 'middle' | 'end';
15
+ /** Defaults to the paragraph token; pass an explicit fill for values drawn
16
+ * on colored fills (e.g. in-cell heatmap counts).
17
+ *
18
+ * Deliberately NOT the chart-legend union: this paints TEXT, so its arms
19
+ * are a text role and the on-fill role, and neither is a --color-data-*
20
+ * var. Reusing the legend type here would reject the one use it exists
21
+ * for. */
22
+ fill?: `var(--color-text-${string})` | `var(--color-on-${string})`;
23
+ children: ReactNode;
24
+ }
25
+
26
+ export function ChartLabel({
27
+ x,
28
+ y,
29
+ textAnchor = 'middle',
30
+ fill = CHART_LABEL_FILL,
31
+ children,
32
+ }: ChartLabelProps) {
33
+ return (
34
+ <text
35
+ x={x}
36
+ y={y}
37
+ textAnchor={textAnchor}
38
+ fontSize={CHART_LABEL_SIZE}
39
+ fill={fill}
40
+ fontFamily="var(--font-family-mono)">
41
+ {children}
42
+ </text>
43
+ );
44
+ }
@@ -0,0 +1,100 @@
1
+ // Shared chart legend: swatch + label, always paired.
2
+ //
3
+ // WHY THIS EXISTS: the legend was hand-rolled three incompatible ways before
4
+ // this module — dashboard.tsx used an Icon swatch, table-page-heatmap-status
5
+ // used a 12px rx=4 rect, tech-report used an 8px rx=1 rect — so three files
6
+ // disagreed on what a legend swatch even looks like. Meanwhile demo/Bento.tsx
7
+ // and demo/Dashboard.tsx render seven bars in seven hues with no legend at all,
8
+ // so those hues encoded nothing. This module makes that state unrepresentable.
9
+ //
10
+ // COLOUR CONTRACT (UIAuditColorMotion, from references/visual.md): "Legends pair
11
+ // spectral badges with dots; never rely on color alone, labels travel with
12
+ // hues." So `label` is REQUIRED and not optional — a dot without a label is
13
+ // colour-alone encoding, and WCAG 1.4.1 plus 1.4.11 both fail on that. Making
14
+ // the label required closes both in one shape instead of leaving every call site
15
+ // to remember.
16
+ //
17
+ // Swatch colour MUST be a --color-data-categorical-* or --color-data-<family>-N
18
+ // var. Never a raw hex, never a --dracula-* primitive: that discipline is what
19
+ // makes the 56 --color-data-* tokens load-bearing rather than decorative, and
20
+ // shared/chart-hues.ts is the sanctioned categorical source.
21
+ //
22
+ // Not interactive. A legend is a key, not a control: no hover, no focus ring.
23
+
24
+ import type {ReactNode} from 'react';
25
+ import {HStack, StackItem, VStack} from '@astryxdesign/core/Stack';
26
+ import {Text} from '@astryxdesign/core/Text';
27
+ import {CHART_HUES, type ChartHue} from 'astryx-dracula/shared/chart-hues';
28
+
29
+ export interface ChartLegendEntry {
30
+ /** REQUIRED. The hue never travels alone — this is the 1.4.1 contract. */
31
+ label: string;
32
+ /** A --color-data-* var, or a --color-background-* surface. Never a raw
33
+ * hex, never a --dracula-* primitive.
34
+ *
35
+ * The surface arm is not optional: a legend whose first row is a recessive
36
+ * surface is normal (the heatmap's zero cell), so a data-only type would
37
+ * reject the value the heatmap is supposed to paint. */
38
+ color: ChartHue | `var(--color-data-${string})` | `var(--color-background-${string})`;
39
+ }
40
+
41
+ // Kept as a string-keyed fall-through on purpose, and DO NOT narrow it: call
42
+ // sites pass an already-RESOLVED value (CHART_HUES.x is a property access, so
43
+ // it yields the value, never the key), so the lookup misses at every site and
44
+ // the `?? entry.color` is the only path that paints. Narrowing the key type
45
+ // would render `background: undefined` at every call site, compile clean, and
46
+ // break at runtime. The old comment here claimed call sites may pass a KEY;
47
+ // none does. The behaviour is correct; only the comment was wrong.
48
+ const HUE_LOOKUP: Record<string, string> = CHART_HUES;
49
+
50
+ export interface ChartLegendProps {
51
+ entries: readonly ChartLegendEntry[];
52
+ /** Spacing step between entries. @default 2 */
53
+ gap?: 1 | 2 | 3 | 4 | 6;
54
+ /** Accessible name for the legend as a group. */
55
+ label?: string;
56
+ /** Swatch size in px. @default 10 */
57
+ swatchSize?: number;
58
+ /** Caption under the legend, e.g. "Hourly intervals · trailing 24 hours". */
59
+ caption?: ReactNode;
60
+ /** Orientation. @default 'row' */
61
+ direction?: 'row' | 'column';
62
+ }
63
+
64
+
65
+ export function ChartLegend({
66
+ entries,
67
+ gap = 2,
68
+ swatchSize = 10,
69
+ label,
70
+ caption,
71
+ direction = 'row',
72
+ }: ChartLegendProps) {
73
+ const Stack = direction === 'row' ? HStack : VStack;
74
+ return (
75
+ <VStack gap={1}>
76
+ <Stack gap={gap} vAlign="center" role="list" aria-label={label}>
77
+ {entries.map(entry => (
78
+ <HStack key={entry.label} gap={1} vAlign="center" role="listitem">
79
+ <StackItem
80
+ style={{
81
+ width: `${swatchSize}px`,
82
+ height: `${swatchSize}px`,
83
+ flexShrink: 0,
84
+ background: HUE_LOOKUP[entry.color] ?? entry.color,
85
+ borderRadius: 'var(--radius-inner)',
86
+ }}
87
+ />
88
+ {/* supporting, not body: a legend label is metadata by the
89
+ typography doctrine, and it never carries a sentence the page
90
+ needs the reader to have. */}
91
+ <Text type="supporting" color="secondary" hasTabularNumbers>
92
+ {entry.label}
93
+ </Text>
94
+ </HStack>
95
+ ))}
96
+ </Stack>
97
+ {caption}
98
+ </VStack>
99
+ );
100
+ }
@@ -0,0 +1,13 @@
1
+ // The one chart-panel look, shared by every hand-drawn SVG chart panel in the
2
+ // kit (dashboard, portfolio, chart, heatmap-status, shoe-store). Split from
3
+ // shared/revenue-chart.tsx so that file exports only components and React Fast
4
+ // Refresh stays intact; see shared/scene-hues.ts for the same reason.
5
+ import type {CSSProperties} from 'react';
6
+
7
+ // One chart-panel look for every hand-drawn SVG chart panel in this wave's
8
+ // templates (dashboard, portfolio, chart, heatmap-status, shoe-store). Restate
9
+ // nowhere: theme Card variant work lands in wave 2.
10
+ export const CHART_PANEL_STYLE: CSSProperties = {
11
+ backgroundColor: 'var(--color-background)',
12
+ border: 'var(--border-width) solid var(--color-separator)',
13
+ };