astryx-dracula 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/BRAND.md +54 -3
  2. package/README.md +11 -4
  3. package/USAGE.md +1 -1
  4. package/astryx-dracula.d.ts +2 -2
  5. package/astryx-dracula.js +26 -4
  6. package/astryx-dracula.variants.d.ts +2 -2
  7. package/package.json +11 -11
  8. package/shared/auth-chrome-config.ts +0 -4
  9. package/shared/auth-chrome.tsx +2 -3
  10. package/shared/chaptered-doc-config.ts +3 -2
  11. package/shared/order-desk.tsx +52 -54
  12. package/shared/revenue-chart.tsx +1 -1
  13. package/shared/scene-castle.tsx +21 -13
  14. package/templates/ai-chat-landing.tsx +6 -9
  15. package/templates/ai-chat.tsx +5 -1
  16. package/templates/centered-hero.tsx +2 -2
  17. package/templates/classic-gallery.tsx +5 -0
  18. package/templates/contact-form.tsx +9 -0
  19. package/templates/dashboard-portfolio.tsx +17 -4
  20. package/templates/dashboard.tsx +5 -5
  21. package/templates/detail-page.tsx +20 -28
  22. package/templates/documentation-design.tsx +3 -0
  23. package/templates/documentation-technical.tsx +8 -5
  24. package/templates/documentation.tsx +13 -6
  25. package/templates/form-two-column.tsx +11 -2
  26. package/templates/form-wizard.template.mjs +15 -0
  27. package/templates/form-wizard.tsx +878 -0
  28. package/templates/gallery-hero.tsx +10 -4
  29. package/templates/ide.tsx +177 -96
  30. package/templates/incident-console.tsx +15 -5
  31. package/templates/kanban-board.tsx +46 -4
  32. package/templates/library.tsx +12 -3
  33. package/templates/login-split.tsx +3 -3
  34. package/templates/login-sso.tsx +9 -17
  35. package/templates/messaging-shell.tsx +109 -46
  36. package/templates/mixed-gallery.tsx +6 -0
  37. package/templates/payment-form.tsx +29 -28
  38. package/templates/product-detail.tsx +12 -3
  39. package/templates/product-gallery.tsx +8 -3
  40. package/templates/product-tour.tsx +11 -7
  41. package/templates/settings-dialog.tsx +5 -0
  42. package/templates/settings-sidebar.tsx +603 -584
  43. package/templates/settings.tsx +5 -2
  44. package/templates/shell-nav.tsx +23 -3
  45. package/templates/shell-side-nav.tsx +94 -81
  46. package/templates/shell-top-nav.tsx +98 -86
  47. package/templates/table-grouped.tsx +146 -132
  48. package/templates/{table-page-shoe-store-heatmap.template.mjs → table-page-shoe-store.template.mjs} +1 -1
  49. package/templates/{table-page-shoe-store-heatmap.tsx → table-page-shoe-store.tsx} +1 -1
  50. package/templates/table-page.tsx +8 -0
  51. package/templates/table-tree.template.mjs +16 -0
  52. package/templates/table-tree.tsx +707 -0
  53. package/templates/tech-report.tsx +8 -5
  54. package/templates/theme-showcase.tsx +18 -11
  55. package/templates/work-item-detail.template.mjs +8 -0
  56. package/templates/work-item-detail.tsx +1049 -0
  57. package/theme.css +38 -3
  58. package/theme.css.d.ts +2 -2
  59. package/tokens.css +41 -4
@@ -14,10 +14,7 @@ import { HStack, VStack } from "@astryxdesign/core/Layout";
14
14
  import { Link } from "@astryxdesign/core/Link";
15
15
  import { Heading, Text } from "@astryxdesign/core/Text";
16
16
  import { TextInput } from "@astryxdesign/core/TextInput";
17
- import {
18
- inputAutoComplete,
19
- authPageStyle as pageStyle,
20
- } from "astryx-dracula/shared/auth-chrome-config";
17
+ import { authPageStyle as pageStyle } from "astryx-dracula/shared/auth-chrome-config";
21
18
  import {
22
19
  AUTH_EMAIL_PLACEHOLDER,
23
20
  AUTH_ERROR_MESSAGE,
@@ -41,14 +38,13 @@ import { useState, useTransition } from "react";
41
38
 
42
39
  type SSOProvider = {
43
40
  name: string;
44
- abbr: string;
45
41
  };
46
42
  const SSO_PROVIDERS: Record<string, SSOProvider> = {
47
- "google.com": { name: "Google Workspace", abbr: "G" },
48
- "microsoft.com": { name: "Microsoft Entra ID", abbr: "M" },
49
- "okta.com": { name: "Okta", abbr: "O" },
50
- "meta.com": { name: "Meta SSO", abbr: "M" },
51
- "apple.com": { name: "Apple Business", abbr: "A" },
43
+ "google.com": { name: "Google Workspace" },
44
+ "microsoft.com": { name: "Microsoft Entra ID" },
45
+ "okta.com": { name: "Okta" },
46
+ "meta.com": { name: "Meta SSO" },
47
+ "apple.com": { name: "Apple Business" },
52
48
  };
53
49
 
54
50
  function getProvider(email: string) {
@@ -136,7 +132,7 @@ export default function LoginSso() {
136
132
  label="Work email"
137
133
  isLabelHidden
138
134
  type="email"
139
- {...inputAutoComplete("email")}
135
+ autoComplete="email"
140
136
  placeholder={AUTH_EMAIL_PLACEHOLDER}
141
137
  value={email}
142
138
  onChange={(v: string) => {
@@ -144,11 +140,7 @@ export default function LoginSso() {
144
140
  setError(null);
145
141
  }}
146
142
  size="lg"
147
- onKeyDown={(e: React.KeyboardEvent) => {
148
- if (e.key === "Enter") {
149
- handleContinue();
150
- }
151
- }}
143
+ onEnter={handleContinue}
152
144
  status={error ? { type: "error", message: error } : undefined}
153
145
  />
154
146
 
@@ -246,7 +238,7 @@ export default function LoginSso() {
246
238
  <TextInput
247
239
  label="Password"
248
240
  type="password"
249
- {...inputAutoComplete("current-password")}
241
+ autoComplete="current-password"
250
242
  value={password}
251
243
  size="lg"
252
244
  onChange={(v: string) => {
@@ -2,7 +2,7 @@
2
2
  // XLE (canonical structure of the tree below; the CLI had no XLE validator
3
3
  // after `astryx layout` was removed in 0.7.0, so this is a reading aid and a
4
4
  // human drift check — update it with the file, never alone):
5
- // L > (LP[w=68] > V[g=2] > Av + IB*4 + SI[fill] + IB"Settings") + (LP[w=260] > V > (H[g=2] > (SI[fill] > Hd"Messages"[level=1]) + IB"New message") + (V[g=0] > TI"Jump to…") + (SI[fill] > (UL[density=compact edge=flush !hasDividers] > (Tx"Channels"[t=label] + LI*4)) + (UL[density=compact edge=flush !hasDividers] > (Tx"Direct messages"[t=label] + LI*3)))) + (LC[p=0] > V > (H[g=3] > Ic + Hd"design-systems"[level=2] + (SI[fill] > Tx"Component APIs, tokens, and release coordination"[t=body]) + SD"12 online" + IB"Members") + D + (SI[fill] > ChL > (ChML[density=balanced] > ChSM"Tuesday, June 30" + ChSM"Sasha Ortiz joined #design-systems" + (ChM > ChBu) + (ChM > ChBu*2) + (ChM > ChBu*2) + (ChM > ChBu) + (ChM > ChBu)) + ChC"Message #design-systems" + EmptyState"No messages yet")) + (LP[w=340] > V > (H[g=2] > (SI[fill] > (H[g=2] > Tx"Thread"[weight=semibold] + Tx"#design-systems"[t=supporting])) + IB"Close thread") + D + (SI[fill] > ChML[density=compact] > (ChM > ChBu) + ChSM"2 replies" + (ChM > ChBu)*2) + (V[g=0] > ChC"Reply in thread…")))
5
+ // L > (LP[w=68] > V[g=2] > Av + IB*4 + SI[fill] + IB"Settings") + (LP[w=260] > V > (H[g=2] > (SI[fill] > Hd"Messages"[level=1]) + IB"New message") + (V[g=0] > TI"Jump to…") + (SI[fill] > (UL[density=compact edge=flush !hasDividers] > (Tx"Channels"[t=label] + LI*4)) + (UL[density=compact edge=flush !hasDividers] > (Tx"Direct messages"[t=label] + LI*3)))) + (LC[p=0] > V > (H[g=3] > Ic + Hd"design-systems"[level=2] + (SI[fill] > Tx"Component APIs, tokens, and release coordination"[t=body]) + SD"12 online" + IB"Open thread" + IB"Members") + D + (SI[fill] > ChL > (ChML[density=balanced] > ChSM"Tuesday, June 30" + ChSM"Sasha Ortiz joined #design-systems" + (ChM > ChBu) + (ChM > ChBu*2) + (ChM > ChBu*2) + (ChM > ChBu) + (ChM > ChBu)) + ChC"Message #design-systems" + EmptyState"No messages yet")) + (LP[w=340] > V > (H[g=2] > (SI[fill] > (H[g=2] > Tx"Thread"[weight=semibold] + Tx"#design-systems"[t=supporting])) + IB"Close thread") + D + (SI[fill] > ChML[density=compact] > (ChM > ChBu) + ChSM"2 replies" + (ChM > ChBu)*2) + (V[g=0] > ChC"Reply in thread…")) ;; Drawer"Thread"[side=end w=340] > (DrawerHeader"Thread" + (V > (SI[fill] > ChML[density=compact] > (ChM > ChBu) + ChSM"2 replies" + (ChM > ChBu)*2) + (V[g=0] > ChC"Reply in thread…")))
6
6
 
7
7
  /**
8
8
  * Messaging Shell — Slack-style column frame for team messaging tools. Frame (desktop, left to right): (Frame/responsive/container: see XLE header above.)
@@ -20,6 +20,7 @@ import {
20
20
  ChatSystemMessage,
21
21
  } from "@astryxdesign/core/Chat";
22
22
  import { Divider } from "@astryxdesign/core/Divider";
23
+ import { Drawer, DrawerHeader } from "@astryxdesign/core/Drawer";
23
24
  import { EmptyState } from "@astryxdesign/core/EmptyState";
24
25
  import { useMediaQuery } from "@astryxdesign/core/hooks";
25
26
  import { Icon } from "@astryxdesign/core/Icon";
@@ -38,6 +39,7 @@ import { StatusDot } from "@astryxdesign/core/StatusDot";
38
39
  import { Heading, Text } from "@astryxdesign/core/Text";
39
40
  import { TextInput } from "@astryxdesign/core/TextInput";
40
41
  import { Timestamp } from "@astryxdesign/core/Timestamp";
42
+ import { VisuallyHidden } from "@astryxdesign/core/VisuallyHidden";
41
43
  // ============= ICONS (verified lucide-react exports) =============
42
44
  // Bell ← BellIcon, Bookmark ← BookmarkIcon, MessagesSquare ← ChatBubbleLeftRightIcon,
43
45
  // Settings ← Cog6ToothIcon, Hash ← HashtagIcon, House ← HomeIcon, Inbox ← InboxIcon,
@@ -420,6 +422,10 @@ export default function MessagingShell() {
420
422
  const [selectedChannelId, setSelectedChannelId] = useState("design-systems");
421
423
  const [selectedDmId, setSelectedDmId] = useState<string | null>(null);
422
424
  const [isThreadOpen, setIsThreadOpen] = useState(true);
425
+ // Below 1024 the thread is a Drawer instead of a column, and that overlay
426
+ // owns its own open state: loaded narrow, it starts closed and the message
427
+ // header is what opens it.
428
+ const [isThreadDrawerOpen, setIsThreadDrawerOpen] = useState(false);
423
429
  const [searchQuery, setSearchQuery] = useState("");
424
430
 
425
431
  // Responsive contract (see file header).
@@ -439,6 +445,16 @@ export default function MessagingShell() {
439
445
  );
440
446
 
441
447
  const showThreadPanel = isThreadOpen && !isThreadHidden;
448
+ // One control drives both presentations — the docked column at 1024 and
449
+ // above, the Drawer below it — so closing the thread is never one-way.
450
+ const isThreadVisible = isThreadHidden ? isThreadDrawerOpen : showThreadPanel;
451
+ const toggleThread = () => {
452
+ if (isThreadHidden) {
453
+ setIsThreadDrawerOpen((isOpen) => !isOpen);
454
+ } else {
455
+ setIsThreadOpen((isOpen) => !isOpen);
456
+ }
457
+ };
442
458
 
443
459
  const workspaceRail = (
444
460
  <VStack gap={2} style={railStyle}>
@@ -450,7 +466,6 @@ export default function MessagingShell() {
450
466
  tooltip={item.label}
451
467
  icon={<Icon icon={item.icon} size="sm" color="inherit" />}
452
468
  variant={item.id === "home" ? "secondary" : "ghost"}
453
- onClick={() => {}}
454
469
  />
455
470
  ))}
456
471
  <StackItem size="fill" />
@@ -459,7 +474,6 @@ export default function MessagingShell() {
459
474
  tooltip="Settings"
460
475
  icon={<Icon icon={Settings} size="sm" color="inherit" />}
461
476
  variant="ghost"
462
- onClick={() => {}}
463
477
  />
464
478
  </VStack>
465
479
  );
@@ -476,7 +490,6 @@ export default function MessagingShell() {
476
490
  icon={<Icon icon={SquarePen} size="sm" color="inherit" />}
477
491
  variant="ghost"
478
492
  size="sm"
479
- onClick={() => {}}
480
493
  />
481
494
  </HStack>
482
495
  <VStack gap={0} style={sidebarSearchStyle}>
@@ -563,6 +576,14 @@ export default function MessagingShell() {
563
576
  const messageStream = (
564
577
  <Stack direction="vertical" style={streamColumnStyle}>
565
578
  <HStack gap={3} style={streamHeaderStyle}>
579
+ {/* The sidebar carries the page h1 and is hidden at 768 and below; the
580
+ stream keeps the outline intact so a phone still has a top-level
581
+ heading to navigate by. */}
582
+ {isSidebarHidden && (
583
+ <VisuallyHidden>
584
+ <Heading level={1}>Messages</Heading>
585
+ </VisuallyHidden>
586
+ )}
566
587
  <Icon icon={Hash} size="sm" color="secondary" />
567
588
  <Heading level={2}>{selectedChannel.name}</Heading>
568
589
  <StackItem size="fill" style={streamTopicStyle}>
@@ -571,13 +592,20 @@ export default function MessagingShell() {
571
592
  </Text>
572
593
  </StackItem>
573
594
  <StatusDot variant="success" label="12 online" />
595
+ <IconButton
596
+ label={isThreadVisible ? "Close thread" : "Open thread"}
597
+ tooltip={isThreadVisible ? "Close thread" : "Open thread"}
598
+ icon={<Icon icon={isThreadVisible ? X : MessagesSquare} size="sm" color="inherit" />}
599
+ variant="ghost"
600
+ size="sm"
601
+ onClick={toggleThread}
602
+ />
574
603
  <IconButton
575
604
  label="Members"
576
605
  tooltip="Members"
577
606
  icon={<Icon icon={Users} size="sm" color="inherit" />}
578
607
  variant="ghost"
579
608
  size="sm"
580
- onClick={() => {}}
581
609
  />
582
610
  </HStack>
583
611
  <Divider />
@@ -609,6 +637,31 @@ export default function MessagingShell() {
609
637
  </Stack>
610
638
  );
611
639
 
640
+ // The thread body is shared by the docked column and the narrow Drawer, so
641
+ // the two presentations of one thread cannot drift apart.
642
+ const threadBody = (
643
+ <>
644
+ <StackItem
645
+ size="fill"
646
+ style={threadScrollStyle}
647
+ role="region"
648
+ aria-label="Thread messages"
649
+ tabIndex={0}
650
+ >
651
+ <ChatMessageList density="compact">
652
+ <ThreadMessage reply={THREAD[0]} />
653
+ <ChatSystemMessage variant="divider">{THREAD.length - 1} replies</ChatSystemMessage>
654
+ {THREAD.slice(1).map((reply) => (
655
+ <ThreadMessage key={reply.id} reply={reply} />
656
+ ))}
657
+ </ChatMessageList>
658
+ </StackItem>
659
+ <VStack gap={0} style={threadComposerStyle}>
660
+ <ChatComposer density="compact" placeholder="Reply in thread…" onSubmit={() => {}} />
661
+ </VStack>
662
+ </>
663
+ );
664
+
612
665
  const threadPanel = (
613
666
  <Stack direction="vertical" style={threadColumnStyle}>
614
667
  <HStack gap={2} style={threadHeaderStyle}>
@@ -630,51 +683,61 @@ export default function MessagingShell() {
630
683
  />
631
684
  </HStack>
632
685
  <Divider />
633
- <StackItem
634
- size="fill"
635
- style={threadScrollStyle}
636
- role="region"
637
- aria-label="Thread messages"
638
- tabIndex={0}
639
- >
640
- <ChatMessageList density="compact">
641
- <ThreadMessage reply={THREAD[0]} />
642
- <ChatSystemMessage variant="divider">{THREAD.length - 1} replies</ChatSystemMessage>
643
- {THREAD.slice(1).map((reply) => (
644
- <ThreadMessage key={reply.id} reply={reply} />
645
- ))}
646
- </ChatMessageList>
647
- </StackItem>
648
- <VStack gap={0} style={threadComposerStyle}>
649
- <ChatComposer density="compact" placeholder="Reply in thread…" onSubmit={() => {}} />
650
- </VStack>
686
+ {threadBody}
651
687
  </Stack>
652
688
  );
653
689
 
654
690
  return (
655
- <Layout
656
- height="fill"
657
- style={rootStyle}
658
- start={
659
- <>
660
- <LayoutPanel width={68} padding={0}>
661
- {workspaceRail}
662
- </LayoutPanel>
663
- {!isSidebarHidden && (
664
- <LayoutPanel width={260} padding={0}>
665
- {channelSidebar}
691
+ <>
692
+ <Layout
693
+ height="fill"
694
+ style={rootStyle}
695
+ start={
696
+ <>
697
+ <LayoutPanel width={68} padding={0}>
698
+ {workspaceRail}
666
699
  </LayoutPanel>
667
- )}
668
- </>
669
- }
670
- end={
671
- showThreadPanel ? (
672
- <LayoutPanel width={340} padding={0}>
673
- {threadPanel}
674
- </LayoutPanel>
675
- ) : undefined
676
- }
677
- content={<LayoutContent padding={0}>{messageStream}</LayoutContent>}
678
- />
700
+ {!isSidebarHidden && (
701
+ <LayoutPanel width={260} padding={0}>
702
+ {channelSidebar}
703
+ </LayoutPanel>
704
+ )}
705
+ </>
706
+ }
707
+ end={
708
+ showThreadPanel ? (
709
+ <LayoutPanel width={340} padding={0}>
710
+ {threadPanel}
711
+ </LayoutPanel>
712
+ ) : undefined
713
+ }
714
+ content={<LayoutContent padding={0}>{messageStream}</LayoutContent>}
715
+ />
716
+ {/* Below 1024 the thread is a Drawer, not a column: it overlays the
717
+ stream rather than compressing it. Non-modal (hasScrim={false}) so the
718
+ conversation behind stays selectable — the point of a thread.
719
+ DrawerHeader renders the close button because it is given
720
+ onOpenChange. `padding={0}` because the thread body brings its own
721
+ gutters, exactly as the docked column does. */}
722
+ <Drawer
723
+ side="end"
724
+ label="Thread"
725
+ isOpen={isThreadHidden && isThreadDrawerOpen}
726
+ onOpenChange={setIsThreadDrawerOpen}
727
+ hasScrim={false}
728
+ width={340}
729
+ padding={0}
730
+ >
731
+ <Stack direction="vertical" style={threadColumnStyle}>
732
+ <DrawerHeader
733
+ title="Thread"
734
+ subtitle={`#${selectedChannel.name}`}
735
+ onOpenChange={setIsThreadDrawerOpen}
736
+ hasDivider
737
+ />
738
+ {threadBody}
739
+ </Stack>
740
+ </Drawer>
741
+ </>
679
742
  );
680
743
  }
@@ -11,10 +11,15 @@ import { Heading, Text } from "@astryxdesign/core/Text";
11
11
  import { galleryImageClip } from "astryx-dracula/shared/gallery-image";
12
12
  import type { SceneHue } from "astryx-dracula/shared/scene-hues";
13
13
  import { SceneTile } from "astryx-dracula/shared/scene-tile";
14
+ import type { CSSProperties } from "react";
14
15
 
15
16
  // ─── Styles ────────────────────────────────────────────────────────────────
16
17
  // Image fill + radius live in shared/gallery-image (no Image primitive in
17
18
  // Astryx, #2582). Tile art lives in shared/scene-tile (large fork).
19
+ // Definite ancestor for `Layout height="fill"`: 100dvh, not minHeight: '100%'
20
+ // (a percentage min-height against an indefinite block computes to 0 — CSS 2.1
21
+ // 10.5). Same shape as side-gallery.tsx.
22
+ const pageStyle: CSSProperties = { height: "100dvh" };
18
23
 
19
24
  // ─── Gallery Data ───────────────────────────────────────────────────────────
20
25
 
@@ -57,6 +62,7 @@ function GalleryCard({ image, ratio }: { image: GalleryImage; ratio: number }) {
57
62
  export default function MixedGallery() {
58
63
  return (
59
64
  <Layout
65
+ style={pageStyle}
60
66
  height="fill"
61
67
  contentWidth={1400}
62
68
  content={
@@ -24,6 +24,7 @@ import { StatusDot } from "@astryxdesign/core/StatusDot";
24
24
  import { Heading, Text } from "@astryxdesign/core/Text";
25
25
  import { TextArea } from "@astryxdesign/core/TextArea";
26
26
  import { TextInput } from "@astryxdesign/core/TextInput";
27
+ import { formatMoney } from "astryx-dracula/shared/format-money";
27
28
  import type { SceneHue } from "astryx-dracula/shared/scene-hues";
28
29
  import { SceneTile } from "astryx-dracula/shared/scene-tile";
29
30
  import { CircleCheck, Lock, ShieldCheck, Truck } from "lucide-react";
@@ -126,13 +127,20 @@ const ORDER_ITEMS = [
126
127
 
127
128
  const TAX_RATE = 0.08;
128
129
  const FREE_SHIPPING_THRESHOLD = 300;
129
- const fmt = (n: number) => `$${n.toFixed(2)}`;
130
+ // The rates the summary and the RadioList labels both read.
131
+ const SHIPPING_STANDARD = 4.95;
132
+ const SHIPPING_EXPEDITED = 9.95;
130
133
 
131
134
  // ── Styles ────────────────────────────────────────────────────────────────────
132
135
  // Plain inline styles using Astryx design-token CSS variables (declared at
133
136
  // :root by `@astryxdesign/core/astryx.css`). No StyleX compiler required.
134
137
 
135
138
  const fullWidth: CSSProperties = { width: "100%" };
139
+ // Definite ancestor for `Layout height="fill"`: 100dvh, not minHeight: '100%'
140
+ // (a percentage min-height against an indefinite block computes to 0 — CSS 2.1
141
+ // 10.5). LayoutContent is overflow:auto, so the height lands as a content-pane
142
+ // scroller. Same shape as side-gallery.tsx.
143
+ const pageStyle: CSSProperties = { height: "100dvh" };
136
144
  // Form column flex-basis so the two checkout columns share width evenly.
137
145
  const formColBasis: CSSProperties = { flexBasis: 0 };
138
146
  // Space the Order Summary content below its collapsible trigger title.
@@ -203,7 +211,7 @@ function OrderLineItem({
203
211
  )}
204
212
  </HStack>
205
213
  <Text type="body" hasTabularNumbers>
206
- {fmt(item.price)}
214
+ {formatMoney(item.price)}
207
215
  </Text>
208
216
  </HStack>
209
217
  <Text type="supporting" color="secondary">
@@ -219,8 +227,8 @@ function OrderLineItem({
219
227
  isIntegerOnly
220
228
  />
221
229
  {/* Editing the order in place — an action, never a destination. */}
222
- <Button label="Remove" variant="ghost" onClick={() => {}} />
223
- <Button label="Save" variant="ghost" onClick={() => {}} />
230
+ <Button label="Remove" variant="ghost" />
231
+ <Button label="Save" variant="ghost" />
224
232
  </HStack>
225
233
  </VStack>
226
234
  </StackItem>
@@ -285,7 +293,7 @@ function OrderTotalSection({
285
293
  Subtotal
286
294
  </Text>
287
295
  <Text type="body" hasTabularNumbers>
288
- {fmt(subtotal)}
296
+ {formatMoney(subtotal)}
289
297
  </Text>
290
298
  </HStack>
291
299
  <HStack hAlign="between" vAlign="center">
@@ -293,7 +301,7 @@ function OrderTotalSection({
293
301
  Shipping
294
302
  </Text>
295
303
  <Text type="body" hasTabularNumbers>
296
- {fmt(shipping)}
304
+ {formatMoney(shipping)}
297
305
  </Text>
298
306
  </HStack>
299
307
  <HStack hAlign="between" vAlign="center">
@@ -301,7 +309,7 @@ function OrderTotalSection({
301
309
  Tax
302
310
  </Text>
303
311
  <Text type="body" hasTabularNumbers>
304
- {fmt(tax)}
312
+ {formatMoney(tax)}
305
313
  </Text>
306
314
  </HStack>
307
315
  </VStack>
@@ -309,13 +317,13 @@ function OrderTotalSection({
309
317
  <HStack hAlign="between" vAlign="center">
310
318
  <Text type="large">Total</Text>
311
319
  <Text type="large" hasTabularNumbers>
312
- {fmt(total)}
320
+ {formatMoney(total)}
313
321
  </Text>
314
322
  </HStack>
315
323
  <Banner
316
324
  status="info"
317
325
  icon={<Icon icon={Truck} size="sm" />}
318
- title="Free shipping on orders over $300"
326
+ title={`Free shipping on orders over $${FREE_SHIPPING_THRESHOLD}`}
319
327
  />
320
328
  </VStack>
321
329
  );
@@ -410,8 +418,11 @@ export default function PaymentForm() {
410
418
  (sum, item) => sum + item.price * (quantities[item.id] ?? item.qty),
411
419
  0,
412
420
  );
413
- const shipping =
414
- subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : deliveryMethod === "expedited" ? 9.95 : 4.95;
421
+ // The summary and the RadioList labels read the same rate constants, so a
422
+ // price change cannot leave the radio advertising a value the order does
423
+ // not charge.
424
+ const shippingRate = deliveryMethod === "expedited" ? SHIPPING_EXPEDITED : SHIPPING_STANDARD;
425
+ const shipping = subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : shippingRate;
415
426
  const tax = Math.round(subtotal * TAX_RATE * 100) / 100;
416
427
  const total = subtotal + shipping + tax;
417
428
 
@@ -444,6 +455,7 @@ export default function PaymentForm() {
444
455
 
445
456
  return (
446
457
  <Layout
458
+ style={pageStyle}
447
459
  height="fill"
448
460
  content={
449
461
  <LayoutContent padding={0}>
@@ -471,7 +483,7 @@ export default function PaymentForm() {
471
483
  <VStack gap={1}>
472
484
  <HStack gap={2} hAlign="between" vAlign="center">
473
485
  <Heading level={2}>Sign in to check out</Heading>
474
- <Button label="Sign in" variant="secondary" onClick={() => {}} />
486
+ <Button label="Sign in" variant="secondary" />
475
487
  </HStack>
476
488
  <Text type="body" color="secondary">
477
489
  Sign in to track your order and save your information for faster checkout.
@@ -612,7 +624,7 @@ export default function PaymentForm() {
612
624
  label="Standard (3–7 business days)"
613
625
  endContent={
614
626
  <Text type="body" weight="semibold" hasTabularNumbers>
615
- $4.95
627
+ {formatMoney(SHIPPING_STANDARD)}
616
628
  </Text>
617
629
  }
618
630
  />
@@ -621,7 +633,7 @@ export default function PaymentForm() {
621
633
  label="Expedited (1–2 business days)"
622
634
  endContent={
623
635
  <Text type="body" weight="semibold" hasTabularNumbers>
624
- $9.95
636
+ {formatMoney(SHIPPING_EXPEDITED)}
625
637
  </Text>
626
638
  }
627
639
  />
@@ -641,19 +653,9 @@ export default function PaymentForm() {
641
653
  <VStack gap={3}>
642
654
  <Grid columns={isMobile ? 1 : 2} gap={3}>
643
655
  {/* PayPal */}
644
- <Button
645
- label="PayPal"
646
- variant="secondary"
647
- size="lg"
648
- onClick={() => {}}
649
- />
656
+ <Button label="PayPal" variant="secondary" size="lg" />
650
657
  {/* Google Pay */}
651
- <Button
652
- label="Google Pay"
653
- variant="secondary"
654
- size="lg"
655
- onClick={() => {}}
656
- />
658
+ <Button label="Google Pay" variant="secondary" size="lg" />
657
659
  </Grid>
658
660
  </VStack>
659
661
 
@@ -867,7 +869,7 @@ export default function PaymentForm() {
867
869
  controlStyle={fullWidth}
868
870
  />
869
871
  </StackItem>
870
- <Button label="Apply" variant="secondary" size="lg" onClick={() => {}} />
872
+ <Button label="Apply" variant="secondary" size="lg" />
871
873
  </HStack>
872
874
  </VStack>
873
875
 
@@ -923,7 +925,6 @@ export default function PaymentForm() {
923
925
  variant="secondary"
924
926
  size="lg"
925
927
  style={fullWidth}
926
- onClick={() => {}}
927
928
  />
928
929
  </VStack>
929
930
  <Divider />
@@ -34,6 +34,11 @@ const stickyInfo: CSSProperties = {
34
34
  top: "var(--spacing-8)",
35
35
  alignSelf: "start",
36
36
  };
37
+ // Definite ancestor for `Layout height="fill"`: 100dvh, not minHeight: '100%'
38
+ // (a percentage min-height against an indefinite block computes to 0 — CSS 2.1
39
+ // 10.5). LayoutContent is overflow:auto, so the height lands as a content-pane
40
+ // scroller. Same shape as side-gallery.tsx and dashboard.tsx.
41
+ const pageStyle: CSSProperties = { height: "100dvh" };
37
42
 
38
43
  import type { SceneHue } from "astryx-dracula/shared/scene-hues";
39
44
  import { Minus, Plus } from "lucide-react";
@@ -62,6 +67,10 @@ const PRODUCT = {
62
67
  name: "Midnight Ceremony Mug & Plate Set",
63
68
  price: 89.0,
64
69
  originalPrice: 119.0,
70
+ // The review count is data, so it lives with the product rather than as a
71
+ // numeral at the render site.
72
+ rating: 4.3,
73
+ reviewCount: 128,
65
74
  description:
66
75
  "A hand-thrown mug and plate set that brings quiet warmth to every midnight meal. The mug sits easy in the hand with a generous 12 oz capacity, while the 8-inch plate works for everything from toast to tapas. Each piece is kiln-fired at 2,300\u00B0F for a finish that resists chips and stains. Subtle variations in the reactive glaze pool like night mist, so no two sets are exactly alike. Dishwasher and microwave safe.",
67
76
  composition:
@@ -87,7 +96,6 @@ const FINISHES = [
87
96
  // ─── Image Gallery ──────────────────────────────────────────────────────────
88
97
  function ImageGallery({ selected, onSelect }: { selected: number; onSelect: (i: number) => void }) {
89
98
  const heroHue: SceneHue = IMAGES[selected];
90
- const thumbnails = IMAGES;
91
99
 
92
100
  return (
93
101
  <VStack gap={3}>
@@ -95,7 +103,7 @@ function ImageGallery({ selected, onSelect }: { selected: number; onSelect: (i:
95
103
  <SceneTile label={PRODUCT.name} hue={heroHue} size="lg" />
96
104
  </AspectRatio>
97
105
  <Grid columns={3} gap={2}>
98
- {thumbnails.map((hue, i) => (
106
+ {IMAGES.map((hue, i) => (
99
107
  <AspectRatio key={hue} ratio={1}>
100
108
  <SelectableCard
101
109
  label={`Product image ${i + 1}`}
@@ -134,7 +142,7 @@ function ProductInfo() {
134
142
  <Heading level={1} type="display-2">
135
143
  {PRODUCT.name}
136
144
  </Heading>
137
- <StarRating rating={4.3} count={128} />
145
+ <StarRating rating={PRODUCT.rating} count={PRODUCT.reviewCount} />
138
146
  <HStack gap={2} vAlign="center">
139
147
  <Text type="large" hasTabularNumbers>
140
148
  {formatMoney(PRODUCT.price)}
@@ -286,6 +294,7 @@ export default function ProductDetail() {
286
294
 
287
295
  return (
288
296
  <Layout
297
+ style={pageStyle}
289
298
  height="fill"
290
299
  contentWidth={1200}
291
300
  content={
@@ -11,13 +11,19 @@ import { Grid } from "@astryxdesign/core/Grid";
11
11
  import { Icon } from "@astryxdesign/core/Icon";
12
12
  import { Layout, LayoutContent, VStack } from "@astryxdesign/core/Layout";
13
13
  import { Heading, Text } from "@astryxdesign/core/Text";
14
+ import { formatMoney } from "astryx-dracula/shared/format-money";
14
15
  import type { SceneHue } from "astryx-dracula/shared/scene-hues";
15
16
  import { SceneTile } from "astryx-dracula/shared/scene-tile";
16
17
  import { ArrowRight } from "lucide-react";
18
+ import type { CSSProperties } from "react";
17
19
 
18
20
  // ─── Styles ─────────────────────────────────────────────────────────────────
19
21
  // Product tile art lives in shared/scene-tile (large fork); there is no Image
20
22
  // primitive to fill the AspectRatio box with `object-fit` (#2582).
23
+ // Definite ancestor for `Layout height="fill"`: 100dvh, not minHeight: '100%'
24
+ // (a percentage min-height against an indefinite block computes to 0 — CSS 2.1
25
+ // 10.5). Same shape as side-gallery.tsx.
26
+ const pageStyle: CSSProperties = { height: "100dvh" };
21
27
 
22
28
  // ─── Product Data ───────────────────────────────────────────────────────────
23
29
 
@@ -75,8 +81,6 @@ const PRODUCTS: Product[] = [
75
81
  },
76
82
  ];
77
83
 
78
- const fmt = (n: number) => `$${n.toFixed(2)}`;
79
-
80
84
  // ─── Product Card ───────────────────────────────────────────────────────────
81
85
 
82
86
  function ProductCard({ product }: { product: Product }) {
@@ -93,7 +97,7 @@ function ProductCard({ product }: { product: Product }) {
93
97
  {product.description}
94
98
  </Text>
95
99
  <Text type="large" hasTabularNumbers>
96
- {fmt(product.price)}
100
+ {formatMoney(product.price)}
97
101
  </Text>
98
102
  </VStack>
99
103
  </VStack>
@@ -105,6 +109,7 @@ function ProductCard({ product }: { product: Product }) {
105
109
  export default function ProductGallery() {
106
110
  return (
107
111
  <Layout
112
+ style={pageStyle}
108
113
  height="fill"
109
114
  contentWidth={1200}
110
115
  content={