astryx-dracula 0.3.0 → 0.4.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/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",
@@ -42,8 +43,8 @@
42
43
  "./shared/sparkline": "./shared/sparkline.tsx",
43
44
  "./shared/sso-icons": "./shared/sso-icons.tsx",
44
45
  ".": {
45
- "default": "./astryx-dracula.js",
46
- "types": "./astryx-dracula.d.ts"
46
+ "types": "./astryx-dracula.d.ts",
47
+ "default": "./astryx-dracula.js"
47
48
  },
48
49
  "./astryx.integration.mjs": "./astryx.integration.mjs",
49
50
  "./package.json": "./package.json",
@@ -60,6 +61,7 @@
60
61
  "astryx-dracula.js",
61
62
  "astryx-dracula.variants.d.ts",
62
63
  "fonts/",
64
+ "icons.mjs",
63
65
  "icons.tsx",
64
66
  "shared/",
65
67
  "theme.css",
@@ -80,7 +82,7 @@
80
82
  "license": "MIT",
81
83
  "name": "astryx-dracula",
82
84
  "peerDependencies": {
83
- "@astryxdesign/core": "^0.3.0",
85
+ "@astryxdesign/core": "^0.6.3",
84
86
  "lucide-react": "^1.45.0",
85
87
  "react": ">=19.0.0",
86
88
  "react-dom": ">=19.0.0"
@@ -94,9 +96,12 @@
94
96
  "build": "tsc --noEmit && vite build",
95
97
  "dev": "vite",
96
98
  "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"
99
+ "theme:build": "bun run icons:build && bunx astryx theme build astryx-theme.ts -o theme.css --icons-specifier ./icons.mjs",
100
+ "icons:build": "bun build icons.tsx --outfile icons.mjs --format esm --external react --external lucide-react",
101
+ "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",
102
+ "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; }",
103
+ "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)})\"",
104
+ "prepublishOnly": "bun run theme:check && bun run icons:check && bun run entry:check && bun run audit && tsc --noEmit && bunx react-doctor@latest --score"
100
105
  },
101
106
  "sideEffects": [
102
107
  "**/*.css"
@@ -106,5 +111,8 @@
106
111
  "@astryxdesign/cli"
107
112
  ],
108
113
  "type": "module",
109
- "version": "0.3.0"
114
+ "version": "0.4.0",
115
+ "patchedDependencies": {
116
+ "@astryxdesign/core@0.6.3": "patches/@astryxdesign%2Fcore@0.6.3.patch"
117
+ }
110
118
  }
@@ -6,12 +6,13 @@
6
6
 
7
7
  import type {CSSProperties} from 'react';
8
8
  import {CHART_PANEL_STYLE} from 'astryx-dracula/shared/chart-panel-style';
9
+ import {CHART_HUES} from 'astryx-dracula/shared/chart-hues';
9
10
  import {VStack, HStack} from '@astryxdesign/core/Layout';
10
11
  import {Text} from '@astryxdesign/core/Text';
11
12
  import {Card} from '@astryxdesign/core/Card';
12
13
  import {Icon} from '@astryxdesign/core/Icon';
13
14
  import {Square} from 'lucide-react';
14
- import {ChartLabel} from 'astryx-dracula/shared/chart-labels';
15
+ import {ChartLabel, CHART_LABEL_SIZE} from 'astryx-dracula/shared/chart-labels';
15
16
  import type {SceneHue} from 'astryx-dracula/shared/scene-hues';
16
17
 
17
18
  // ============= SHARED CHART-PANEL CARD STYLE =============
@@ -40,7 +41,10 @@ export function ProductSwatch({
40
41
  style={swatchStyle}
41
42
  role="img"
42
43
  aria-label={`${label} swatch`}>
43
- <rect width={36} height={36} rx={5} fill="var(--dracula-bg-light)" />
44
+ {/* A surface, not a data mark: the plate behind a product swatch wears the
45
+ card tier. --color-background-card is byte-identical to the old
46
+ --dracula-bg-light (#343746). */}
47
+ <rect width={36} height={36} rx={5} fill="var(--color-background-card)" />
44
48
  <rect
45
49
  x={1}
46
50
  y={1}
@@ -64,7 +68,10 @@ export function ProductSwatch({
64
68
 
65
69
  // ============= REVENUE CHART =============
66
70
 
67
- const REVENUE_LINE = 'var(--dracula-cyan)';
71
+ // Role token, not a --dracula-* primitive. Byte-identical (--color-data-
72
+ // categorical-cyan IS #8BE9FD) and routed through chart-hues.ts so the line
73
+ // colour is the sanctioned vocabulary rather than a literal decided here.
74
+ const REVENUE_LINE = CHART_HUES.cyan;
68
75
  const CHART_W = 540;
69
76
  const CHART_H = 200;
70
77
  const CHART_PAD_LEFT = 44;
@@ -73,6 +80,23 @@ const CHART_PAD_TOP = 12;
73
80
  const CHART_BASELINE = 164;
74
81
  const CHART_PLOT_W = CHART_W - CHART_PAD_LEFT - CHART_PAD_RIGHT;
75
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
+
76
100
  interface RevenuePoint {
77
101
  date: string;
78
102
  revenue: number;
@@ -83,6 +107,48 @@ function formatRevenueTick(tick: number): string {
83
107
  return tick >= 1000 ? `$${tick / 1000}k` : `$${tick}`;
84
108
  }
85
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
+
86
152
  export function RevenueChart({
87
153
  data,
88
154
  chartMax,
@@ -102,6 +168,7 @@ export function RevenueChart({
102
168
  x: CHART_PAD_LEFT + (i / (data.length - 1)) * CHART_PLOT_W,
103
169
  y: CHART_BASELINE - (d.revenue / chartMax) * plotH,
104
170
  }));
171
+ const dateTicks = new Set(xTickIndices(data.map(d => d.date)));
105
172
  const linePath = points
106
173
  .map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
107
174
  .join(' ');
@@ -144,14 +211,10 @@ export function RevenueChart({
144
211
  />
145
212
  {points.map(
146
213
  (p, i) =>
147
- (i % 3 === 0 || i === points.length - 1) && (
214
+ dateTicks.has(i) && (
148
215
  <ChartLabel
149
216
  key={p.date}
150
- x={
151
- i === points.length - 1
152
- ? CHART_W - CHART_PAD_RIGHT - 2
153
- : p.x
154
- }
217
+ x={i === points.length - 1 ? CHART_LAST_TICK_X : p.x}
155
218
  y={CHART_H - 8}
156
219
  textAnchor={i === points.length - 1 ? 'end' : 'middle'}>
157
220
  {p.date}
@@ -42,18 +42,60 @@ export const NAV_ITEMS: SettingsNavItem[] = [
42
42
  {label: 'Travel for work', icon: Briefcase},
43
43
  ];
44
44
 
45
+ /**
46
+ * How a row's trailing Button reads. Deliberately narrower than core's
47
+ * `ButtonVariant`: a trailing row control is not a place to choose freely, and
48
+ * two of core's four options can never be right here — `primary` would
49
+ * outrank the panel's own primary, `ghost` would leave an action with no
50
+ * affordance at all. Only the two that can be are offered.
51
+ */
52
+ export type RowActionVariant = 'secondary' | 'destructive';
45
53
  export interface InfoRow {
46
54
  label: string;
47
55
  value: string;
48
56
  action: string;
57
+ /**
58
+ * What `action` actually is. 'action' (the default) changes state in place
59
+ * and renders a Button; 'destination' navigates and renders a Link. Stated
60
+ * in the data rather than inferred from `href` or read off the label, so
61
+ * renaming "View" cannot silently turn a destination back into an action.
62
+ */
63
+ actionKind?: 'action' | 'destination';
64
+ /** Where a 'destination' action goes. Read only when actionKind is 'destination'. */
65
+ href?: string;
66
+ /**
67
+ * Visual weight of the trailing Button. `secondary` is the default and the
68
+ * right answer for almost every row. `destructive` is for a row whose action
69
+ * ends or destroys something — disconnecting an account, deleting data — so
70
+ * that control agrees with the templates' inline Deactivate/Delete buttons
71
+ * instead of rendering identically to "Create" sitting above it. Stated
72
+ * here for the same reason `actionKind` is: the renderer cannot recover it
73
+ * from the label without reading the English verb off the row, which is how
74
+ * "View" became a button in the first place. Ignored for a `'destination'`
75
+ * row, which renders a Link and takes no variant.
76
+ */
77
+ variant?: RowActionVariant;
78
+ /**
79
+ * What the Button does, when something wants it to. Optional on purpose: a
80
+ * row with no `onAction` renders an inert control, which is the honest
81
+ * default for a showcase with no backend — better than a toggle that flips
82
+ * "Connected" to "Disconnected" and pretends a call happened. A consumer
83
+ * that has a real handler passes one and the row goes live. Nothing here
84
+ * simulates a result or invents state to make the button look busy.
85
+ */
86
+ onAction?: () => void;
49
87
  }
50
88
 
51
89
  export const LOGIN_ROWS: InfoRow[] = [
52
90
  {label: 'Password', value: 'Not created', action: 'Create'},
53
91
  ];
54
92
 
93
+ // Disconnecting ends an account link the user set up on purpose, so it is the
94
+ // one destructive row in this file — marked here, from the verb, not by
95
+ // position. LOGIN_ROWS' "Create" is the opposite (it adds a password) and
96
+ // keeps the default `secondary`.
55
97
  export const SOCIAL_ROWS: InfoRow[] = [
56
- {label: 'Google', value: 'Connected', action: 'Disconnect'},
98
+ {label: 'Google', value: 'Connected', action: 'Disconnect', variant: 'destructive'},
57
99
  ];
58
100
 
59
101
  export interface DeviceRow {
@@ -26,32 +26,76 @@ import {
26
26
  SquarePen,
27
27
  Share2,
28
28
  } from 'lucide-react';
29
- import type {CSSProperties, MouseEvent, ReactNode} from 'react';
29
+ import type {CSSProperties, ReactNode} from 'react';
30
30
  import {VStack, HStack} from '@astryxdesign/core/Layout';
31
31
  import {Text} from '@astryxdesign/core/Text';
32
- import {Link} from '@astryxdesign/core/Link';
33
32
  import {Button} from '@astryxdesign/core/Button';
33
+ import {Link} from '@astryxdesign/core/Link';
34
34
  import {Divider} from '@astryxdesign/core/Divider';
35
35
 
36
36
  // ─── Row components ──────────────────────────────────────────────────────────
37
37
  // The row renderers themselves, moved here from both templates. The two copies
38
- // were byte-identical apart from two things that genuinely differ per
39
- // template, so those are props rather than forks: `href` (each template anchors
40
- // its Links to its own route) and `style` (the sidebar pads its rows, the
41
- // dialog does not). Verified by hash before the move — the row BODIES matched
42
- // exactly; only these two call-site differences remained.
38
+ // were byte-identical apart from things that genuinely differ per template, so
39
+ // those are props rather than forks: `style` (the sidebar pads its rows, the
40
+ // dialog does not) and `hasDivider` (each template draws its own group
41
+ // boundaries). Verified by hash before the move — the row BODIES matched
42
+ // exactly; only these call-site differences remained.
43
+ //
44
+ // FOUR RULES these rows are built around, from USAGE.md "Rules":
45
+ // - A link is a destination, a button is an action. The trailing control is a
46
+ // `Button` when the row acts in place and a `Link` when it navigates, and
47
+ // the row data says which: `actionKind` is `'action'` (the default) for
48
+ // "Create"/"Disconnect", `'destination'` for "View" on a list of past
49
+ // records, with `href` carrying the destination. It is stated in the data
50
+ // rather than guessed from the label, because reading the English verb off
51
+ // the row is what made "View" a button. The Link takes the kit's
52
+ // hover-only underline — it jumps out of the panel, which is the
53
+ // navigation case, not prose — and never a hand-rolled `textDecoration`.
54
+ // - The Button is configured from the row data, never from a constant here.
55
+ // `variant` defaults to `secondary` and a row opts into `destructive` when
56
+ // its action ends or destroys something, so a trailing "Disconnect" reads
57
+ // like the panels' own inline Deactivate/Delete controls instead of like
58
+ // the "Create" sitting above it. It is never `primary`: a row action must
59
+ // not outrank the panel's own primary, and a filled accent button in a
60
+ // trailing column is what made these panels read as a form.
61
+ // `onAction` becomes the Button's `onClick` and is optional — with no
62
+ // handler the Button renders inert, which is the honest default for a
63
+ // showcase with no backend. A consumer with a real handler passes one and
64
+ // the row goes live; nothing here fabricates state to make it look live.
65
+ // - A divider is a section boundary, not a row background. Rows therefore
66
+ // render one ONLY when the caller marks `hasDivider` at a real group
67
+ // boundary; without it the row leaves the rhythm to the caller's own
68
+ // `Stack gap` / row padding rather than adding a rule of its own.
69
+ // - Spacing comes from the scale. The label/value pair is spaced with
70
+ // `Stack gap`, never a margin, and `gap={1}` matches the rows each template
71
+ // builds itself (device history, Deactivate, the info tiles) so no row on
72
+ // the page reads tighter than its neighbours.
73
+ //
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.
43
79
 
44
80
  export function InfoRowItem({
45
81
  label,
46
82
  value,
47
83
  action,
84
+ actionKind,
48
85
  href,
86
+ variant = 'secondary',
87
+ onAction,
49
88
  style,
50
- }: InfoRow & {href: string; style?: CSSProperties}) {
89
+ hasDivider = true,
90
+ }: InfoRow & {
91
+ style?: CSSProperties;
92
+ /** Draw a Divider after the row. True only at a group boundary. */
93
+ hasDivider?: boolean;
94
+ }) {
51
95
  return (
52
96
  <>
53
97
  <HStack hAlign="between" vAlign="start" style={style}>
54
- <VStack gap={0}>
98
+ <VStack gap={1}>
55
99
  <Text type="body" weight="semibold" display="block">
56
100
  {label}
57
101
  </Text>
@@ -59,13 +103,22 @@ export function InfoRowItem({
59
103
  {value}
60
104
  </Text>
61
105
  </VStack>
62
- {action && (
63
- <Link href={href} style={actionNoWrap}>
64
- {action}
65
- </Link>
66
- )}
106
+ {action &&
107
+ (actionKind === 'destination' && href ? (
108
+ <Link href={href} style={actionNoWrap}>
109
+ {action}
110
+ </Link>
111
+ ) : (
112
+ <Button
113
+ label={action}
114
+ variant={variant}
115
+ size="sm"
116
+ style={actionNoWrap}
117
+ onClick={onAction}
118
+ />
119
+ ))}
67
120
  </HStack>
68
- <Divider />
121
+ {hasDivider && <Divider />}
69
122
  </>
70
123
  );
71
124
  }
@@ -101,18 +154,16 @@ function ExpandableRowViewing({
101
154
  label,
102
155
  value,
103
156
  onEdit,
104
- href,
105
157
  style,
106
158
  }: {
107
159
  label: string;
108
160
  value: string;
109
161
  onEdit: () => void;
110
- href: string;
111
162
  style?: CSSProperties;
112
163
  }) {
113
164
  return (
114
165
  <HStack hAlign="between" vAlign="start" style={style}>
115
- <VStack gap={0}>
166
+ <VStack gap={1}>
116
167
  <Text type="body" weight="semibold" display="block">
117
168
  {label}
118
169
  </Text>
@@ -120,15 +171,13 @@ function ExpandableRowViewing({
120
171
  {value}
121
172
  </Text>
122
173
  </VStack>
123
- <Link
124
- href={href}
174
+ <Button
175
+ label="Edit"
176
+ variant="secondary"
177
+ size="sm"
125
178
  style={actionNoWrap}
126
- onClick={(e: MouseEvent) => {
127
- e.preventDefault();
128
- onEdit();
129
- }}>
130
- Edit
131
- </Link>
179
+ onClick={onEdit}
180
+ />
132
181
  </HStack>
133
182
  );
134
183
  }
@@ -141,8 +190,8 @@ export function ExpandableRow({
141
190
  onEdit,
142
191
  onCancel,
143
192
  onSave,
144
- href,
145
193
  style,
194
+ hasDivider = true,
146
195
  }: {
147
196
  label: string;
148
197
  value: string;
@@ -151,8 +200,11 @@ export function ExpandableRow({
151
200
  onEdit: () => void;
152
201
  onCancel: () => void;
153
202
  onSave: () => void;
154
- href: string;
203
+ /** Accepted for call-site compatibility; unused — see the note above. */
204
+ href?: string;
155
205
  style?: CSSProperties;
206
+ /** Draw a Divider after the row. True only at a group boundary. */
207
+ hasDivider?: boolean;
156
208
  }) {
157
209
  return (
158
210
  <>
@@ -169,11 +221,10 @@ export function ExpandableRow({
169
221
  label={label}
170
222
  value={value}
171
223
  onEdit={onEdit}
172
- href={href}
173
224
  style={style}
174
225
  />
175
226
  )}
176
- <Divider />
227
+ {hasDivider && <Divider />}
177
228
  </>
178
229
  );
179
230
  }
@@ -2,6 +2,8 @@
2
2
  // geometry and the portfolio range-normalized step geometry behind one mode
3
3
  // switch. Purple stays out of the chart so it keeps meaning "interactive".
4
4
 
5
+ import {CHART_HUES} from 'astryx-dracula/shared/chart-hues';
6
+
5
7
  export interface SparkPoint {
6
8
  id: string;
7
9
  value: number;
@@ -28,7 +30,13 @@ export function Sparkline({
28
30
  mode = 'max',
29
31
  isCompact = false,
30
32
  }: SparklineProps) {
31
- const fill = positive ? 'var(--dracula-green)' : 'var(--dracula-red)';
33
+ // Role tokens, not --dracula-* primitives. Byte-identical values
34
+ // (--color-data-categorical-green IS #50FA7B), so this is a vocabulary
35
+ // change with no visual delta -- it just means the sparkline agrees with
36
+ // chart-hues.ts instead of being the one chart module that decided alone.
37
+ // Reported by a consumer whose test asserted their sparkline fill equalled
38
+ // their CHART_HUES.green and stopped agreeing across a version bump.
39
+ const fill = positive ? CHART_HUES.green : CHART_HUES.red;
32
40
  if (mode === 'range') {
33
41
  const values = data.map(point => point.value);
34
42
  const max = Math.max(...values);
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
  // XLE (canonical structure, validated with `bunx astryx layout check`):
3
- // L > LC > V[g=8 a=center] > (V[g=1] > (H[g=2 a=center] > Ic + Tx"Hi, Vlad"[t=body]) + Hd"Where should we start?"[level=1 type=display-2]) + ChC"Ask anything" + (V[g=6] > (TgG"Category" > Tg"Writing"*4) + Hd"Suggested prompts"[level=2] + (G[c={min:280} g=3] > (CC[p=4] > V[g=0.5] > Hd"Draft"[level=3] + Tx"Compose"[t=body])*4))
3
+ // L > LC > V[g=8 a=center] > (V[g=1] > (H[g=2 a=center] > Ic + Tx"Hi, Vlad"[t=body]) + Hd"Where should we start?"[level=1 type=display-2]) + ChC"Ask anything" + (V[g=6] > (TgG"Category" > H[g=1 wrap] > Tg"Writing"*4) + Hd"Suggested prompts"[level=2] + (G[c={min:280} g=3] > (CC[p=4] > V[g=0.5] > Hd"Draft"[level=3] + Tx"Compose"[t=body])*4))
4
4
 
5
5
  import {useRef, useState, type CSSProperties} from 'react';
6
6
 
@@ -467,14 +467,22 @@ export default function AiChatLanding() {
467
467
  value={category}
468
468
  onChange={setCategory}
469
469
  size="lg">
470
- {CATEGORIES.map(cat => (
471
- <ToggleButton
472
- key={cat.key}
473
- value={cat.key}
474
- label={cat.label}
475
- icon={<Icon icon={cat.icon} size="sm" />}
476
- />
477
- ))}
470
+ {/* ToggleButtonGroup is a single nowrap flex row and the four
471
+ chips measure 488px together. In a 390px pane the fourth
472
+ chip was cut in half by the viewport edge, which reads as a
473
+ rendering fault rather than as overflow. The wrapping
474
+ HStack gives the row a second line instead; the group keeps
475
+ its own selection semantics either way. */}
476
+ <HStack gap={1} wrap="wrap">
477
+ {CATEGORIES.map(cat => (
478
+ <ToggleButton
479
+ key={cat.key}
480
+ value={cat.key}
481
+ label={cat.label}
482
+ icon={<Icon icon={cat.icon} size="sm" />}
483
+ />
484
+ ))}
485
+ </HStack>
478
486
  </ToggleButtonGroup>
479
487
 
480
488
  {suggestions && (
@@ -301,8 +301,8 @@ export default function AiChat() {
301
301
  const rootRef = useRef<HTMLElement>(null);
302
302
  const artifactResize = useResizable({
303
303
  defaultSize: 640,
304
- minSizePx: 480,
305
- maxSizePx: 960,
304
+ minSize: 480,
305
+ maxSize: 960,
306
306
  autoSaveId: 'ai-chat-artifact-panel',
307
307
  });
308
308
 
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
  // XLE (canonical structure, validated with `bunx astryx layout check`):
3
- // L > LC[p=6] > V[g=10] > ((V[g=6 a=center] > (V[g=3 a=center] > Hd"Small joys for creatures of the night"[level=1 t=display-2] + Tx"Sometimes all it takes is one small ritual to turn the whole night around."[t=body] + Tx"astryx-dracula v0.3.0"[t=supporting]) + (H[j=center g=3] > B.primary"Embrace the night" + B.secondary"Read the lore")) + (S.transparent[p=0] > AR[ratio=16/9]))
3
+ // L > LC[p=6] > V[g=10] > ((V[g=6 a=center] > (V[g=3 a=center] > Hd"Small joys for creatures of the night"[level=1 t=display-2] + Tx"Sometimes all it takes is one small ritual to turn the whole night around."[t=body] + Tx"astryx-dracula v0.4.0"[t=supporting]) + (H[j=center g=3] > B.primary"Embrace the night" + B.secondary"Read the lore")) + (S.transparent[p=0] > AR[ratio=16/9]))
4
4
 
5
5
  import type {CSSProperties} from 'react';
6
6
  import {VStack, HStack, Layout, LayoutContent} from '@astryxdesign/core/Layout';
@@ -52,7 +52,7 @@ export default function CenteredHero() {
52
52
  color="secondary"
53
53
  justify="center"
54
54
  hasTabularNumbers>
55
- astryx-dracula v0.3.0
55
+ astryx-dracula v0.4.0
56
56
  </Text>
57
57
  </VStack>
58
58
  <HStack gap={3} wrap="wrap" justify="center">
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
  // XLE (canonical structure, validated with `bunx astryx layout check`):
3
- // Ctr > V[a=center] > S.transparent[p=6] > V[g=6] > (V[g=2 a=center] > Hd"Let's brew after dark"[level=1] + Tx"Tell us a bit"[t=body]) + (V[g=4] > Hd"Why work with us"[level=2] + (G[c={min:200} g=4] > (C > V[g=3] > Ic + (V[g=1] > Hd"Title"[level=3] + Tx"Desc"[t=body]))*3)) + (V[g=4] > Hd"Your details"[level=2] + (G[c={min:260} g=4] > TI"Full name" + TI"Email") + (G[c={min:260} g=4] > TI"Company" + TI"Phone")) + D + (V[g=4] > Hd"Your project"[level=2] + (Fd"Goals"[req] > (H[g=2] > Tk"Goal"*10)) + SE"Timeline" + SE"Budget" + (RL"Source" > RLI*5) + TA"Notes" + CB"Decision maker") + (V[g=3] > B.primary"Send it into the night" + (H[j=center g=1] > Tx[t=supporting] > Lk"Privacy Policy"))
3
+ // Ctr > V[a=center] > S.transparent[p=6] > V[g=6] > (V[g=2 a=center] > Hd"Let's brew after dark"[level=1] + Tx"Tell us a bit"[t=body]) + (V[g=4] > Hd"Why work with us"[level=2] + (G[c={min:200} g=4] > (C > V[g=3] > Ic + (V[g=1] > Hd"Title"[level=3] + Tx"Desc"[t=body]))*3)) + (V[g=4] > Hd"Your details"[level=2] + (G[c={min:260} g=4] > TI"Full name" + TI"Email") + (G[c={min:260} g=4] > TI"Company" + TI"Phone")) + D + (V[g=4] > Hd"Your project"[level=2] + (Fd"Goals"[req] > (H[g=2] > Tk"Goal"*10)) + SE"Timeline" + SE"Budget" + (RL"Source" > RLI*5) + TA"Notes" + CB"Decision maker") + (V[g=3] > B.primary"Send it into the night" + (Tx[t=supporting] > Lk"Privacy Policy"))
4
4
 
5
5
  import {useState} from 'react';
6
6
  import {VStack, HStack} from '@astryxdesign/core/Layout';
@@ -296,15 +296,16 @@ export default function ContactForm() {
296
296
  variant="primary"
297
297
  onClick={() => setSubmitted(true)}
298
298
  />
299
- <HStack gap={1} hAlign="center">
300
- <Text type="supporting" color="secondary">
301
- By submitting you agree to our{' '}
302
- <Link href="#/templates/contact-form" type="supporting">
303
- Privacy Policy
304
- </Link>
305
- .
306
- </Text>
307
- </HStack>
299
+ <Text type="supporting" color="secondary">
300
+ By submitting you agree to our{' '}
301
+ <Link
302
+ href="#/templates/contact-form"
303
+ type="supporting"
304
+ hasUnderline>
305
+ Privacy Policy
306
+ </Link>
307
+ .
308
+ </Text>
308
309
  </VStack>
309
310
  </VStack>
310
311
  </Section>
@@ -19,6 +19,10 @@ import {Divider} from '@astryxdesign/core/Divider';
19
19
  import {Link} from '@astryxdesign/core/Link';
20
20
  import {Icon} from '@astryxdesign/core/Icon';
21
21
 
22
+ // Same-route hash: demo links stay focusable anchors without escaping the
23
+ // template through the hash router (bare "#" would drop back to the home page).
24
+ const SELF_HASH = '#/templates/dashboard';
25
+
22
26
  // ============= ICONS =============
23
27
 
24
28
  import {RefreshCw, Square} from 'lucide-react';
@@ -632,7 +636,7 @@ function StackedBarCard({
632
636
  {/* Legend */}
633
637
  <HStack gap={4} wrap="wrap">
634
638
  {data.map(d => (
635
- <VStack key={d.label} gap={0}>
639
+ <VStack key={d.label} gap={1}>
636
640
  <HStack gap={2} vAlign="center">
637
641
  <Icon icon={Square} size="xsm" style={{color: d.color}} />
638
642
  <Text type="supporting" color="secondary">{d.label}</Text>
@@ -756,14 +760,14 @@ export default function DashboardTemplate() {
756
760
  <TableCard
757
761
  title="Top pages"
758
762
  linkLabel="All pages"
759
- linkHref="#"
763
+ linkHref={SELF_HASH}
760
764
  data={topPagesData}
761
765
  columns={topPagesColumns}
762
766
  />
763
767
  <TableCard
764
768
  title="Top events"
765
769
  linkLabel="All events"
766
- linkHref="#"
770
+ linkHref={SELF_HASH}
767
771
  data={topEventsData}
768
772
  columns={topEventsColumns}
769
773
  />