astryx-dracula 0.3.1 → 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.
@@ -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}
@@ -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
  }
@@ -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.1"[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.1
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
  />
@@ -216,7 +216,9 @@ function PageHeader({
216
216
  <VStack gap={3}>
217
217
  <HStack gap={4} vAlign="start">
218
218
  <StackItem size="fill">
219
- <VStack gap={0}>
219
+ {/* gap={2}: the breadcrumb is a control, not a text-baseline
220
+ pairing, so it needs separation from the H1 rather than 0. */}
221
+ <VStack gap={2}>
220
222
  <Link href="#/templates/detail-page">
221
223
  <HStack gap={1} vAlign="center">
222
224
  <Icon icon={ArrowLeft} size="sm" color="inherit" />
@@ -502,8 +504,8 @@ function TimelineSection() {
502
504
  return (
503
505
  <Section>
504
506
  <VStack gap={4}>
505
- {/* gap={2}, matching the byte-identical header rows at :338 and
506
- :407. Core's Stack has no default gap (Stack.tsx:211, pass-through
507
+ {/* gap={2}, matching the byte-identical header rows at :340 and
508
+ :408. Core's Stack has no default gap (Stack.tsx:211, pass-through
507
509
  at :255/:285), so this rendered 0px -- the heading and the Filters
508
510
  button touched -- while both siblings render 8px. */}
509
511
  <HStack gap={2} vAlign="center">
@@ -582,11 +584,16 @@ function PanelContent() {
582
584
  return (
583
585
  <VStack gap={4}>
584
586
  <Collapsible trigger={<Heading level={3}>Notes</Heading>}>
587
+ {/* "Show more" closes the sentence inside the Text below, so it is
588
+ prose and asks for `hasUnderline` — colour alone is not a
589
+ sufficient cue inline (1.4.1 / F73). `type="inherit"` adopts the
590
+ paragraph's size and leading. The two links in the header are
591
+ navigation, so they pass neither. */}
585
592
  <Text type="body">
586
593
  Customer is a repeat buyer, 3rd order this quarter. Prefers
587
594
  moonlight and sage glazes. Requested gift wrapping for the mug set.
588
595
  Ships to a residential address in CA.{' '}
589
- <Link href="#/templates/detail-page">
596
+ <Link href="#/templates/detail-page" hasUnderline type="inherit">
590
597
  Show more
591
598
  </Link>
592
599
  </Text>
@@ -29,7 +29,7 @@ import {useMediaQuery} from '@astryxdesign/core/hooks';
29
29
  import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
30
30
  import {Divider} from '@astryxdesign/core/Divider';
31
31
  import {Tooltip} from '@astryxdesign/core/Tooltip';
32
- import {Table, pixel} from '@astryxdesign/core/Table';
32
+ import {Table, pixel, proportional} from '@astryxdesign/core/Table';
33
33
  import {Icon} from '@astryxdesign/core/Icon';
34
34
  import {Section} from '@astryxdesign/core/Section';
35
35
  import {Center} from '@astryxdesign/core/Center';
@@ -42,6 +42,17 @@ const tabListFlush: CSSProperties = {
42
42
  marginInlineStart: 'calc(var(--spacing-3) * -1)',
43
43
  };
44
44
 
45
+ // The best-practices table lives in the prose column, where the eye tracks a
46
+ // single measure. Table's scroll wrapper bleeds edge-to-edge by reading the
47
+ // container padding vars LayoutContent publishes (32px here), which pushes
48
+ // its dividers 32px past the measure on both sides at every width. This table
49
+ // never overflows, so the bleed buys nothing — zero the vars for this subtree
50
+ // so it sits on the measure with the headings above it.
51
+ const practicesOnMeasure: CSSProperties = ({
52
+ '--container-padding-inline-start': '0px',
53
+ '--container-padding-inline-end': '0px',
54
+ }) as CSSProperties;
55
+
45
56
  const COMPONENT_OUTLINE_ITEMS: OutlineItem[] = [
46
57
  {id: 'usage', label: 'Usage', level: 2},
47
58
  {id: 'best-practices', label: 'Best practices', level: 3},
@@ -635,34 +646,37 @@ function ComponentDetailView({activeNav}: {activeNav: string}) {
635
646
  <Heading id="best-practices" level={3}>
636
647
  Best practices
637
648
  </Heading>
638
- <Table
639
- data={docs.bestPractices as Record<string, unknown>[]}
640
- dividers="none"
641
- hasHover
642
- columns={[
643
- {
644
- key: 'type',
645
- header: 'Guidance',
646
- width: pixel(125),
647
- renderCell: (item: Record<string, unknown>) => (
648
- <Badge
649
- label={item.type === 'do' ? 'Do' : "Don't"}
650
- variant={item.type === 'do' ? 'green' : 'red'}
651
- />
652
- ),
653
- },
654
- {
655
- key: 'text',
656
- header: 'Practices',
657
- renderCell: (item: Record<string, unknown>) => (
658
- <Text type="body" textWrap="wrap">
659
- {item.text as string}
660
- </Text>
661
- ),
662
- },
663
- ]}
664
- density="spacious"
665
- />
649
+ <div style={practicesOnMeasure}>
650
+ <Table
651
+ data={docs.bestPractices as Record<string, unknown>[]}
652
+ dividers="none"
653
+ hasHover
654
+ columns={[
655
+ {
656
+ key: 'type',
657
+ header: 'Guidance',
658
+ width: pixel(125),
659
+ renderCell: (item: Record<string, unknown>) => (
660
+ <Badge
661
+ label={item.type === 'do' ? 'Do' : "Don't"}
662
+ variant={item.type === 'do' ? 'green' : 'red'}
663
+ />
664
+ ),
665
+ },
666
+ {
667
+ key: 'text',
668
+ header: 'Practices',
669
+ width: proportional(1),
670
+ renderCell: (item: Record<string, unknown>) => (
671
+ <Text type="body" textWrap="wrap">
672
+ {item.text as string}
673
+ </Text>
674
+ ),
675
+ },
676
+ ]}
677
+ density="spacious"
678
+ />
679
+ </div>
666
680
  </VStack>
667
681
 
668
682
  <Divider />