astryx-dracula 0.5.0 → 0.6.2

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 (129) hide show
  1. package/BRAND.md +3 -3
  2. package/README.md +7 -4
  3. package/USAGE.md +49 -49
  4. package/astryx-dracula.d.ts +2 -2
  5. package/astryx-dracula.js +25 -9
  6. package/astryx-dracula.variants.d.ts +2 -2
  7. package/astryx.integration.mjs +1 -1
  8. package/icons.mjs +21 -17
  9. package/icons.tsx +69 -62
  10. package/package.json +125 -120
  11. package/shared/auth-chrome-config.ts +6 -6
  12. package/shared/auth-chrome.tsx +143 -144
  13. package/shared/auth-copy.ts +14 -14
  14. package/shared/chaptered-doc-config.ts +48 -51
  15. package/shared/chaptered-doc.tsx +267 -289
  16. package/shared/chart-hues.ts +25 -10
  17. package/shared/chart-labels.tsx +31 -30
  18. package/shared/chart-legend.tsx +55 -56
  19. package/shared/chart-panel-style.ts +3 -3
  20. package/shared/data-bar.tsx +97 -102
  21. package/shared/format-money.ts +1 -1
  22. package/shared/gallery-image.ts +6 -6
  23. package/shared/login-demo.ts +1 -1
  24. package/shared/metric-card.tsx +101 -105
  25. package/shared/metric-delta.tsx +28 -27
  26. package/shared/order-desk.tsx +205 -216
  27. package/shared/revenue-chart.tsx +145 -165
  28. package/shared/scene-castle.tsx +206 -291
  29. package/shared/scene-frame.tsx +33 -32
  30. package/shared/scene-hues.ts +17 -17
  31. package/shared/scene-tile.tsx +88 -92
  32. package/shared/settings-data.ts +119 -125
  33. package/shared/settings-rows.tsx +122 -159
  34. package/shared/sparkline.tsx +75 -88
  35. package/shared/sso-icons.tsx +10 -22
  36. package/shared/stars.tsx +16 -25
  37. package/templates/ai-chat-landing.template.mjs +5 -4
  38. package/templates/ai-chat-landing.tsx +473 -460
  39. package/templates/ai-chat.template.mjs +5 -4
  40. package/templates/ai-chat.tsx +543 -612
  41. package/templates/blank.template.mjs +5 -4
  42. package/templates/blank.tsx +18 -14
  43. package/templates/centered-hero.template.mjs +5 -4
  44. package/templates/centered-hero.tsx +55 -68
  45. package/templates/classic-gallery.template.mjs +5 -4
  46. package/templates/classic-gallery.tsx +97 -104
  47. package/templates/contact-form.template.mjs +5 -4
  48. package/templates/contact-form.tsx +247 -282
  49. package/templates/dashboard-portfolio.template.mjs +5 -4
  50. package/templates/dashboard-portfolio.tsx +717 -548
  51. package/templates/dashboard.template.mjs +5 -4
  52. package/templates/dashboard.tsx +596 -567
  53. package/templates/detail-page.template.mjs +5 -4
  54. package/templates/detail-page.tsx +546 -603
  55. package/templates/documentation-design.template.mjs +5 -4
  56. package/templates/documentation-design.tsx +231 -243
  57. package/templates/documentation-technical.template.mjs +5 -4
  58. package/templates/documentation-technical.tsx +236 -239
  59. package/templates/documentation.template.mjs +5 -4
  60. package/templates/documentation.tsx +267 -279
  61. package/templates/editor.template.mjs +5 -4
  62. package/templates/editor.tsx +844 -877
  63. package/templates/file-explorer.template.mjs +5 -4
  64. package/templates/file-explorer.tsx +566 -590
  65. package/templates/form-two-column.template.mjs +5 -4
  66. package/templates/form-two-column.tsx +195 -210
  67. package/templates/gallery-hero.template.mjs +5 -4
  68. package/templates/gallery-hero.tsx +93 -114
  69. package/templates/ide.template.mjs +5 -4
  70. package/templates/ide.tsx +407 -453
  71. package/templates/incident-console.template.mjs +5 -4
  72. package/templates/incident-console.tsx +593 -575
  73. package/templates/kanban-board.template.mjs +5 -4
  74. package/templates/kanban-board.tsx +665 -683
  75. package/templates/library.template.mjs +5 -4
  76. package/templates/library.tsx +409 -430
  77. package/templates/login-card.template.mjs +5 -4
  78. package/templates/login-card.tsx +63 -72
  79. package/templates/login-split.template.mjs +5 -4
  80. package/templates/login-split.tsx +192 -193
  81. package/templates/login-sso.template.mjs +5 -4
  82. package/templates/login-sso.tsx +257 -258
  83. package/templates/login.template.mjs +5 -4
  84. package/templates/login.tsx +53 -49
  85. package/templates/messaging-shell.template.mjs +5 -4
  86. package/templates/messaging-shell.tsx +556 -574
  87. package/templates/mixed-gallery.template.mjs +5 -4
  88. package/templates/mixed-gallery.tsx +60 -58
  89. package/templates/payment-form.template.mjs +5 -4
  90. package/templates/payment-form.tsx +882 -991
  91. package/templates/product-detail.template.mjs +5 -4
  92. package/templates/product-detail.tsx +261 -272
  93. package/templates/product-gallery.template.mjs +5 -4
  94. package/templates/product-gallery.tsx +126 -137
  95. package/templates/product-tour.template.mjs +5 -4
  96. package/templates/product-tour.tsx +741 -773
  97. package/templates/settings-dialog.template.mjs +5 -4
  98. package/templates/settings-dialog.tsx +688 -717
  99. package/templates/settings-sidebar.template.mjs +5 -4
  100. package/templates/settings-sidebar.tsx +762 -807
  101. package/templates/settings.template.mjs +5 -4
  102. package/templates/settings.tsx +289 -304
  103. package/templates/shell-nav.template.mjs +5 -4
  104. package/templates/shell-nav.tsx +212 -219
  105. package/templates/shell-side-nav.template.mjs +5 -4
  106. package/templates/shell-side-nav.tsx +135 -157
  107. package/templates/shell-top-nav.template.mjs +5 -4
  108. package/templates/shell-top-nav.tsx +182 -203
  109. package/templates/side-gallery.template.mjs +5 -4
  110. package/templates/side-gallery.tsx +85 -90
  111. package/templates/table-grouped.template.mjs +5 -4
  112. package/templates/table-grouped.tsx +1000 -1053
  113. package/templates/table-page-chart.template.mjs +5 -4
  114. package/templates/table-page-chart.tsx +400 -398
  115. package/templates/table-page-heatmap-status.template.mjs +5 -4
  116. package/templates/table-page-heatmap-status.tsx +483 -512
  117. package/templates/table-page-shoe-store-heatmap.template.mjs +5 -4
  118. package/templates/table-page-shoe-store-heatmap.tsx +748 -746
  119. package/templates/table-page.template.mjs +5 -4
  120. package/templates/table-page.tsx +396 -405
  121. package/templates/table.template.mjs +5 -4
  122. package/templates/table.tsx +92 -93
  123. package/templates/tech-report.template.mjs +5 -4
  124. package/templates/tech-report.tsx +1316 -1304
  125. package/templates/theme-showcase.template.mjs +5 -4
  126. package/templates/theme-showcase.tsx +871 -951
  127. package/theme.css +35 -77
  128. package/theme.css.d.ts +9 -0
  129. package/tokens.css +373 -380
@@ -1,287 +1,282 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
- // XLE (canonical structure, validated with `bunx astryx layout check`):
3
- // L > (LP[w=320] > V[g=4] > (S[p=4] > V[g=4] > Hd"Page Editor"[level=1] + Tbar) + (V[g=4] > (TL > Tab"Blocks"! + Tab"Properties") + D + (S[p=4] > V[g=2] > Hd"Add Block"[level=3] + UL + Hd"Layers"[level=3] + UL))) + (LC > V[g=4] > C*3)
4
-
5
- import {
6
- useState,
7
- useCallback,
8
- type ComponentType,
9
- type CSSProperties,
10
- type KeyboardEvent,
11
- type MouseEvent,
12
- type ReactNode,
13
- type SVGProps,
14
- } from 'react';
15
- import {useMediaQuery} from '@astryxdesign/core/hooks';
16
- import {Button} from '@astryxdesign/core/Button';
17
- import {SelectableCard} from '@astryxdesign/core/SelectableCard';
18
- import {Center} from '@astryxdesign/core/Center';
19
- import {Dialog} from '@astryxdesign/core/Dialog';
20
- import {Divider} from '@astryxdesign/core/Divider';
21
- import {EmptyState} from '@astryxdesign/core/EmptyState';
2
+ // XLE (canonical structure of the tree below; the CLI had no XLE validator
3
+ // after `astryx layout` was removed in 0.7.0, so this is a reading aid and a
4
+ // human drift check — update it with the file, never alone):
5
+ // L[h=fill] > (LP[w=320 p=0 divider] > V[g=4] > (S.transparent[p=4] > V[g=4] > (TI"Page title" + Hd"Page Editor"[level=1 t=display-2]) + Tbar) + (V[g=4] > (V[g=0] > (TL > Tab"Blocks"! + Tab"Properties") + D) + (S.transparent[p=4] > (V[g=2] > (V[g=1] > Hd"Add Block"[level=3] + UL) + (V[g=1] > Hd"Layers"[level=3] + UL)) + (V[g=4] > TI"Heading" + TA"Description")))) + (LC[p=8] > V[g=4] > SC*3) ;; Dlg[variant=fullscreen p=0] > L[h=fill] > (LH[divider] > (H[g=3 j=between a=center] > Hd"Customize"[level=3] + IB"Close")) + (LC[p=0] > (V[g=4] > (V[g=0] > (TL > Tab"Blocks"! + Tab"Properties") + D) + (S.transparent[p=4] > (V[g=2] > (V[g=1] > Hd"Add Block"[level=3] + UL) + (V[g=1] > Hd"Layers"[level=3] + UL)) + (V[g=4] > TI"Heading" + TA"Description")))))
6
+
7
+ import { Button } from "@astryxdesign/core/Button";
8
+ import { Center } from "@astryxdesign/core/Center";
9
+ import { Dialog } from "@astryxdesign/core/Dialog";
10
+ import { Divider } from "@astryxdesign/core/Divider";
11
+ import { EmptyState } from "@astryxdesign/core/EmptyState";
12
+ import { useMediaQuery } from "@astryxdesign/core/hooks";
13
+ import { Icon } from "@astryxdesign/core/Icon";
22
14
  import {
23
- HStack,
24
- VStack,
25
- Layout,
26
- LayoutContent,
27
- LayoutHeader,
28
- LayoutPanel,
29
- } from '@astryxdesign/core/Layout';
30
- import {Icon} from '@astryxdesign/core/Icon';
31
- import {List, ListItem} from '@astryxdesign/core/List';
32
- import {Table, proportional} from '@astryxdesign/core/Table';
33
- import type {TableColumn} from '@astryxdesign/core/Table';
34
- import {Section} from '@astryxdesign/core/Section';
15
+ HStack,
16
+ Layout,
17
+ LayoutContent,
18
+ LayoutHeader,
19
+ LayoutPanel,
20
+ VStack,
21
+ } from "@astryxdesign/core/Layout";
22
+ import { List, ListItem } from "@astryxdesign/core/List";
23
+ import { Section } from "@astryxdesign/core/Section";
24
+ import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl";
25
+ import { SelectableCard } from "@astryxdesign/core/SelectableCard";
26
+ import { Selector } from "@astryxdesign/core/Selector";
27
+ import { Tab, TabList } from "@astryxdesign/core/TabList";
28
+ import type { TableColumn } from "@astryxdesign/core/Table";
29
+ import { proportional, Table } from "@astryxdesign/core/Table";
30
+ import { Heading, Text } from "@astryxdesign/core/Text";
31
+ import { TextArea } from "@astryxdesign/core/TextArea";
32
+ import { TextInput } from "@astryxdesign/core/TextInput";
33
+ import { Toolbar } from "@astryxdesign/core/Toolbar";
35
34
  import {
36
- SegmentedControl,
37
- SegmentedControlItem,
38
- } from '@astryxdesign/core/SegmentedControl';
39
- import {Selector} from '@astryxdesign/core/Selector';
40
- import {TabList, Tab} from '@astryxdesign/core/TabList';
41
- import {Text, Heading} from '@astryxdesign/core/Text';
42
- import {TextArea} from '@astryxdesign/core/TextArea';
43
- import {TextInput} from '@astryxdesign/core/TextInput';
44
- import {Toolbar} from '@astryxdesign/core/Toolbar';
35
+ Banknote,
36
+ CarFront,
37
+ ChevronDown,
38
+ ChevronUp,
39
+ CirclePlay,
40
+ CirclePlus,
41
+ Columns,
42
+ Eye,
43
+ FileText,
44
+ Image,
45
+ LayoutGrid,
46
+ Lock,
47
+ Megaphone,
48
+ Monitor,
49
+ MousePointerClick,
50
+ ShoppingBag,
51
+ ShoppingCart,
52
+ SlidersHorizontal,
53
+ Smartphone,
54
+ Sparkles,
55
+ Tablet,
56
+ Trash2,
57
+ X,
58
+ } from "lucide-react";
45
59
  import {
46
- LayoutGrid,
47
- FileText,
48
- Image,
49
- MousePointerClick,
50
- Columns,
51
- Sparkles,
52
- Megaphone,
53
- Trash2,
54
- ChevronUp,
55
- ChevronDown,
56
- Monitor,
57
- Tablet,
58
- Smartphone,
59
- Eye,
60
- SlidersHorizontal,
61
- X,
62
- CirclePlus,
63
- ShoppingBag,
64
- ShoppingCart,
65
- Banknote,
66
- CarFront,
67
- CirclePlay,
68
- Lock,
69
- } from 'lucide-react';
70
-
71
- type BlockType =
72
- 'hero' | 'text' | 'image' | 'button' | 'cards' | 'features' | 'cta';
60
+ type ComponentType,
61
+ type CSSProperties,
62
+ type KeyboardEvent,
63
+ type MouseEvent,
64
+ type ReactNode,
65
+ type SVGProps,
66
+ useCallback,
67
+ useState,
68
+ } from "react";
69
+
70
+ type BlockType = "hero" | "text" | "image" | "button" | "cards" | "features" | "cta";
73
71
 
74
72
  interface Block {
75
- id: string;
76
- type: BlockType;
77
- label: string;
78
- props: Record<string, unknown>;
73
+ id: string;
74
+ type: BlockType;
75
+ label: string;
76
+ props: Record<string, unknown>;
79
77
  }
80
78
 
81
- type ViewportSize = 'desktop' | 'tablet' | 'phone';
82
- type SidebarTab = 'blocks' | 'properties';
79
+ type ViewportSize = "desktop" | "tablet" | "phone";
80
+ type SidebarTab = "blocks" | "properties";
83
81
  type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
84
82
 
85
- const BLOCK_META: Record<BlockType, {label: string; icon: IconComponent}> = {
86
- hero: {label: 'Hero', icon: LayoutGrid},
87
- text: {label: 'Text', icon: FileText},
88
- image: {label: 'Image', icon: Image},
89
- button: {label: 'Button', icon: MousePointerClick},
90
- cards: {label: 'Cards', icon: Columns},
91
- features: {label: 'Features', icon: Sparkles},
92
- cta: {label: 'CTA', icon: Megaphone},
83
+ const BLOCK_META: Record<BlockType, { label: string; icon: IconComponent }> = {
84
+ hero: { label: "Hero", icon: LayoutGrid },
85
+ text: { label: "Text", icon: FileText },
86
+ image: { label: "Image", icon: Image },
87
+ button: { label: "Button", icon: MousePointerClick },
88
+ cards: { label: "Cards", icon: Columns },
89
+ features: { label: "Features", icon: Sparkles },
90
+ cta: { label: "CTA", icon: Megaphone },
93
91
  };
94
92
 
95
93
  type Transaction = {
96
- id: string;
97
- name: string;
98
- category: string;
99
- date: string;
100
- amount: string;
101
- isPositive?: boolean;
94
+ id: string;
95
+ name: string;
96
+ category: string;
97
+ date: string;
98
+ amount: string;
99
+ isPositive?: boolean;
102
100
  };
103
101
 
104
102
  const CATEGORY_ICONS: Record<string, IconComponent> = {
105
- 'Food & Drink': ShoppingBag,
106
- Groceries: ShoppingCart,
107
- Income: Banknote,
108
- Transport: CarFront,
109
- Entertainment: CirclePlay,
103
+ "Food & Drink": ShoppingBag,
104
+ Groceries: ShoppingCart,
105
+ Income: Banknote,
106
+ Transport: CarFront,
107
+ Entertainment: CirclePlay,
110
108
  };
111
109
 
112
110
  // The minimums clear the 375px phone canvas once the preview card's 24px
113
111
  // padding is taken out; the widest column carries the slack and every cell
114
112
  // truncates rather than pushing the table past the card.
115
113
  const TRANSACTION_COLUMNS: TableColumn<Transaction>[] = [
116
- {
117
- key: 'name',
118
- header: 'Transaction',
119
- width: proportional(2, {minWidth: 104}),
120
- renderCell: (item: Transaction) => (
121
- <HStack gap={3} vAlign="center">
122
- <Icon icon={CATEGORY_ICONS[item.category] || Sparkles} />
123
- <VStack gap={0}>
124
- <Text type="body" weight="semibold">
125
- {item.name}
126
- </Text>
127
- <Text type="supporting" color="secondary">
128
- {item.category}
129
- </Text>
130
- </VStack>
131
- </HStack>
132
- ),
133
- },
134
- {
135
- key: 'date',
136
- header: 'Date',
137
- width: proportional(1, {minWidth: 64}),
138
- renderCell: (item: Transaction) => (
139
- <Text type="body" color="secondary" hasTabularNumbers>
140
- {item.date}
141
- </Text>
142
- ),
143
- },
144
- {
145
- key: 'amount',
146
- header: 'Amount',
147
- width: proportional(1, {minWidth: 88}),
148
- renderCell: (item: Transaction) => (
149
- <Text type="body" weight="semibold" hasTabularNumbers>
150
- {item.amount}
151
- </Text>
152
- ),
153
- },
114
+ {
115
+ key: "name",
116
+ header: "Transaction",
117
+ width: proportional(2, { minWidth: 104 }),
118
+ renderCell: (item: Transaction) => (
119
+ <HStack gap={3} vAlign="center">
120
+ <Icon icon={CATEGORY_ICONS[item.category] || Sparkles} />
121
+ <VStack gap={0}>
122
+ <Text type="body" weight="semibold">
123
+ {item.name}
124
+ </Text>
125
+ <Text type="supporting" color="secondary">
126
+ {item.category}
127
+ </Text>
128
+ </VStack>
129
+ </HStack>
130
+ ),
131
+ },
132
+ {
133
+ key: "date",
134
+ header: "Date",
135
+ width: proportional(1, { minWidth: 64 }),
136
+ renderCell: (item: Transaction) => (
137
+ <Text type="body" color="secondary" hasTabularNumbers>
138
+ {item.date}
139
+ </Text>
140
+ ),
141
+ },
142
+ {
143
+ key: "amount",
144
+ header: "Amount",
145
+ width: proportional(1, { minWidth: 88 }),
146
+ renderCell: (item: Transaction) => (
147
+ <Text type="body" weight="semibold" hasTabularNumbers>
148
+ {item.amount}
149
+ </Text>
150
+ ),
151
+ },
154
152
  ];
155
153
 
156
154
  const VIEWPORT_MAX: Record<ViewportSize, number> = {
157
- desktop: 960,
158
- tablet: 768,
159
- phone: 375,
155
+ desktop: 960,
156
+ tablet: 768,
157
+ phone: 375,
160
158
  };
161
159
 
162
-
163
160
  const DEFAULT_BLOCKS: Block[] = [
164
- {
165
- id: '2',
166
- type: 'features',
167
- label: 'Recent Transactions',
168
- props: {
169
- heading: 'Recent Transactions',
170
- description: 'Your latest account activity.',
171
- items: [
172
- {
173
- id: 't1',
174
- name: 'Midnight Oil Coffee',
175
- category: 'Food & Drink',
176
- date: 'Today, 10:24 AM',
177
- amount: '-$6.50',
178
- },
179
- {
180
- id: 't2',
181
- name: 'Moonlit Market',
182
- category: 'Groceries',
183
- date: 'Yesterday',
184
- amount: '-$142.30',
185
- },
186
- {
187
- id: 't3',
188
- name: 'Night Shift Payout',
189
- category: 'Income',
190
- date: 'Oct 12',
191
- amount: '+$4,200.00',
192
- isPositive: true,
193
- },
194
- {
195
- id: 't4',
196
- name: 'Night Owl Rides',
197
- category: 'Transport',
198
- date: 'Oct 11',
199
- amount: '-$24.10',
200
- },
201
- {
202
- id: 't5',
203
- name: 'Cryptflix Subscription',
204
- category: 'Entertainment',
205
- date: 'Oct 10',
206
- amount: '-$19.99',
207
- },
208
- ],
209
- },
210
- },
211
- {
212
- id: '3',
213
- type: 'text',
214
- label: 'Syncing State',
215
- props: {
216
- heading: 'Syncing your accounts',
217
- description:
218
- "We're pulling in your latest transactions.\nThis usually takes a few seconds.",
219
- buttonLabel: 'Cancel',
220
- },
221
- },
222
- {
223
- id: '4',
224
- type: 'cta',
225
- label: 'Trust Notice',
226
- props: {
227
- heading: 'Adding devices from people you trust',
228
- description:
229
- "When you approve a request, you grant someone full access to your account. They'll be able to change reservations and send messages on your behalf.",
230
- },
231
- },
161
+ {
162
+ id: "2",
163
+ type: "features",
164
+ label: "Recent Transactions",
165
+ props: {
166
+ heading: "Recent Transactions",
167
+ description: "Your latest account activity.",
168
+ items: [
169
+ {
170
+ id: "t1",
171
+ name: "Midnight Oil Coffee",
172
+ category: "Food & Drink",
173
+ date: "Today, 10:24 AM",
174
+ amount: "-$6.50",
175
+ },
176
+ {
177
+ id: "t2",
178
+ name: "Moonlit Market",
179
+ category: "Groceries",
180
+ date: "Yesterday",
181
+ amount: "-$142.30",
182
+ },
183
+ {
184
+ id: "t3",
185
+ name: "Night Shift Payout",
186
+ category: "Income",
187
+ date: "Oct 12",
188
+ amount: "+$4,200.00",
189
+ isPositive: true,
190
+ },
191
+ {
192
+ id: "t4",
193
+ name: "Night Owl Rides",
194
+ category: "Transport",
195
+ date: "Oct 11",
196
+ amount: "-$24.10",
197
+ },
198
+ {
199
+ id: "t5",
200
+ name: "Cryptflix Subscription",
201
+ category: "Entertainment",
202
+ date: "Oct 10",
203
+ amount: "-$19.99",
204
+ },
205
+ ],
206
+ },
207
+ },
208
+ {
209
+ id: "3",
210
+ type: "text",
211
+ label: "Syncing State",
212
+ props: {
213
+ heading: "Syncing your accounts",
214
+ description: "We're pulling in your latest transactions.\nThis usually takes a few seconds.",
215
+ buttonLabel: "Cancel",
216
+ },
217
+ },
218
+ {
219
+ id: "4",
220
+ type: "cta",
221
+ label: "Trust Notice",
222
+ props: {
223
+ heading: "Adding devices from people you trust",
224
+ description:
225
+ "When you approve a request, you grant someone full access to your account. They'll be able to change reservations and send messages on your behalf.",
226
+ },
227
+ },
232
228
  ];
233
229
 
234
230
  function defaultProps(type: BlockType): Record<string, unknown> {
235
- switch (type) {
236
- case 'hero':
237
- return {
238
- heading: 'Midnight at the Castle Gates',
239
- subheading: 'Gather the coven — the night is young and the candles are lit.',
240
- buttonLabel: 'Enter the night',
241
- alignment: 'center',
242
- };
243
- case 'text':
244
- return {content: 'Enter your text here.'};
245
- case 'image':
246
- return {};
247
- case 'button':
248
- return {label: 'Button', variant: 'primary', size: 'md'};
249
- case 'cards':
250
- return {
251
- cards: [
252
- {
253
- title: 'Pricing',
254
- description: 'Flexible plans for every team size.',
255
- },
256
- {
257
- title: 'Support',
258
- description: 'Get help whenever you need it.',
259
- },
260
- ],
261
- };
262
- case 'features':
263
- return {
264
- heading: 'Activity',
265
- description: '',
266
- items: [
267
- {
268
- id: 't1',
269
- name: 'New Item',
270
- category: 'General',
271
- date: 'Today',
272
- amount: '$0.00',
273
- },
274
- ],
275
- };
276
- case 'cta':
277
- return {
278
- heading: 'Join the Midnight Coven',
279
- description:
280
- 'Claim your seat at the table — letters from the crypt, once a moon.',
281
- primaryLabel: 'Join the coven',
282
- secondaryLabel: 'Read the lore',
283
- };
284
- }
231
+ switch (type) {
232
+ case "hero":
233
+ return {
234
+ heading: "Midnight at the Castle Gates",
235
+ subheading: "Gather the coven — the night is young and the candles are lit.",
236
+ buttonLabel: "Enter the night",
237
+ alignment: "center",
238
+ };
239
+ case "text":
240
+ return { content: "Enter your text here." };
241
+ case "image":
242
+ return {};
243
+ case "button":
244
+ return { label: "Button", variant: "primary", size: "md" };
245
+ case "cards":
246
+ return {
247
+ cards: [
248
+ {
249
+ title: "Pricing",
250
+ description: "Flexible plans for every team size.",
251
+ },
252
+ {
253
+ title: "Support",
254
+ description: "Get help whenever you need it.",
255
+ },
256
+ ],
257
+ };
258
+ case "features":
259
+ return {
260
+ heading: "Activity",
261
+ description: "",
262
+ items: [
263
+ {
264
+ id: "t1",
265
+ name: "New Item",
266
+ category: "General",
267
+ date: "Today",
268
+ amount: "$0.00",
269
+ },
270
+ ],
271
+ };
272
+ case "cta":
273
+ return {
274
+ heading: "Join the Midnight Coven",
275
+ description: "Claim your seat at the table — letters from the crypt, once a moon.",
276
+ primaryLabel: "Join the coven",
277
+ secondaryLabel: "Read the lore",
278
+ };
279
+ }
285
280
  }
286
281
 
287
282
  // ---------------------------------------------------------------------------
@@ -296,12 +291,12 @@ function defaultProps(type: BlockType): Record<string, unknown> {
296
291
  // against a definite height — and the host's <html>/<body> don't set one, so
297
292
  // the layout anchors a definite viewport height itself. No background; the
298
293
  // host owns the page surface.
299
- const pageStyle: CSSProperties = {height: '100dvh'};
294
+ const pageStyle: CSSProperties = { height: "100dvh" };
300
295
  // Canvas reflows to the chosen viewport width; VStack has no maxWidth prop.
301
296
  const canvasStyle = (maxWidth: number): CSSProperties => ({
302
- maxWidth,
303
- width: '100%',
304
- marginInline: 'auto',
297
+ maxWidth,
298
+ width: "100%",
299
+ marginInline: "auto",
305
300
  });
306
301
  // `clickable` and `selectedCard` are gone: the eight block cards are core's
307
302
  // `SelectableCard` now, which supplies the cursor, the hover overlay
@@ -313,12 +308,12 @@ const canvasStyle = (maxWidth: number): CSSProperties => ({
313
308
  // ring colour is unchanged and still stands: Comment #6272A4 cannot reach
314
309
  // 3:1 on #343746 at any alpha, and core's own selected ring has that ceiling.
315
310
  // The sidebar keeps its width when the canvas is wider than the window.
316
- const panelShrink: CSSProperties = {flexShrink: 0};
311
+ const panelShrink: CSSProperties = { flexShrink: 0 };
317
312
  // Square muted chip behind the CTA icon — Center handles the centering
318
313
  // and sizing; only the surface (radius + fill) needs custom CSS.
319
314
  const iconCircle: CSSProperties = {
320
- borderRadius: 'var(--radius-element)',
321
- backgroundColor: 'var(--color-selection)',
315
+ borderRadius: "var(--radius-element)",
316
+ backgroundColor: "var(--color-selection)",
322
317
  };
323
318
 
324
319
  // ---------------------------------------------------------------------------
@@ -329,132 +324,132 @@ const iconCircle: CSSProperties = {
329
324
  // Hero labels it "Subheading" and adds its own button/alignment controls;
330
325
  // the merge keeps the shared pair, not the per-branch extras.
331
326
  function HeadingDescriptionGroup({
332
- props,
333
- onUpdate,
334
- descriptionKey,
335
- descriptionLabel,
327
+ props,
328
+ onUpdate,
329
+ descriptionKey,
330
+ descriptionLabel,
336
331
  }: {
337
- props: Record<string, unknown>;
338
- onUpdate: (key: string, value: unknown) => void;
339
- descriptionKey: string;
340
- descriptionLabel: string;
332
+ props: Record<string, unknown>;
333
+ onUpdate: (key: string, value: unknown) => void;
334
+ descriptionKey: string;
335
+ descriptionLabel: string;
341
336
  }) {
342
- return (
343
- <>
344
- <TextInput
345
- label="Heading"
346
- value={(props.heading as string) ?? ''}
347
- onChange={(v: string) => onUpdate('heading', v)}
348
- />
349
- <TextArea
350
- label={descriptionLabel}
351
- value={(props[descriptionKey] as string) ?? ''}
352
- onChange={(v: string) => onUpdate(descriptionKey, v)}
353
- />
354
- </>
355
- );
337
+ return (
338
+ <>
339
+ <TextInput
340
+ label="Heading"
341
+ value={(props.heading as string) ?? ""}
342
+ onChange={(v: string) => onUpdate("heading", v)}
343
+ />
344
+ <TextArea
345
+ label={descriptionLabel}
346
+ value={(props[descriptionKey] as string) ?? ""}
347
+ onChange={(v: string) => onUpdate(descriptionKey, v)}
348
+ />
349
+ </>
350
+ );
356
351
  }
357
352
 
358
353
  function PropertiesForm({
359
- block,
360
- onUpdate,
354
+ block,
355
+ onUpdate,
361
356
  }: {
362
- block: Block;
363
- onUpdate: (key: string, value: unknown) => void;
357
+ block: Block;
358
+ onUpdate: (key: string, value: unknown) => void;
364
359
  }) {
365
- const {type, props} = block;
366
-
367
- switch (type) {
368
- case 'hero':
369
- return (
370
- <VStack gap={4}>
371
- <HeadingDescriptionGroup
372
- props={props}
373
- onUpdate={onUpdate}
374
- descriptionKey="subheading"
375
- descriptionLabel="Subheading"
376
- />
377
- <TextInput
378
- label="Button Label"
379
- value={(props.buttonLabel as string) ?? ''}
380
- onChange={(v: string) => onUpdate('buttonLabel', v)}
381
- />
382
- <Selector
383
- label="Alignment"
384
- value={(props.alignment as string) ?? 'center'}
385
- onChange={(v: string) => onUpdate('alignment', v)}
386
- options={[
387
- {label: 'Left', value: 'left'},
388
- {label: 'Center', value: 'center'},
389
- {label: 'Right', value: 'right'},
390
- ]}
391
- />
392
- </VStack>
393
- );
394
-
395
- case 'text':
396
- return (
397
- <VStack gap={4}>
398
- <HeadingDescriptionGroup
399
- props={props}
400
- onUpdate={onUpdate}
401
- descriptionKey="description"
402
- descriptionLabel="Description"
403
- />
404
- <TextInput
405
- label="Button Label"
406
- value={(props.buttonLabel as string) ?? ''}
407
- onChange={(v: string) => onUpdate('buttonLabel', v)}
408
- />
409
- </VStack>
410
- );
411
- case 'features':
412
- case 'cta':
413
- return (
414
- <VStack gap={4}>
415
- <HeadingDescriptionGroup
416
- props={props}
417
- onUpdate={onUpdate}
418
- descriptionKey="description"
419
- descriptionLabel="Description"
420
- />
421
- </VStack>
422
- );
423
-
424
- case 'button':
425
- return (
426
- <VStack gap={4}>
427
- <TextInput
428
- label="Label"
429
- value={(props.label as string) ?? ''}
430
- onChange={(v: string) => onUpdate('label', v)}
431
- />
432
- <Selector
433
- label="Variant"
434
- value={(props.variant as string) ?? 'primary'}
435
- onChange={(v: string) => onUpdate('variant', v)}
436
- options={[
437
- {label: 'Primary', value: 'primary'},
438
- {label: 'Secondary', value: 'secondary'},
439
- {label: 'Ghost', value: 'ghost'},
440
- ]}
441
- />
442
- <Selector
443
- label="Size"
444
- value={(props.size as string) ?? 'md'}
445
- onChange={(v: string) => onUpdate('size', v)}
446
- options={[
447
- {label: 'Small', value: 'sm'},
448
- {label: 'Medium', value: 'md'},
449
- {label: 'Large', value: 'lg'},
450
- ]}
451
- />
452
- </VStack>
453
- );
454
-
455
- default:
456
- return <EmptyState title="No configurable properties" isCompact />;
457
- }
360
+ const { type, props } = block;
361
+
362
+ switch (type) {
363
+ case "hero":
364
+ return (
365
+ <VStack gap={4}>
366
+ <HeadingDescriptionGroup
367
+ props={props}
368
+ onUpdate={onUpdate}
369
+ descriptionKey="subheading"
370
+ descriptionLabel="Subheading"
371
+ />
372
+ <TextInput
373
+ label="Button Label"
374
+ value={(props.buttonLabel as string) ?? ""}
375
+ onChange={(v: string) => onUpdate("buttonLabel", v)}
376
+ />
377
+ <Selector
378
+ label="Alignment"
379
+ value={(props.alignment as string) ?? "center"}
380
+ onChange={(v: string) => onUpdate("alignment", v)}
381
+ options={[
382
+ { label: "Left", value: "left" },
383
+ { label: "Center", value: "center" },
384
+ { label: "Right", value: "right" },
385
+ ]}
386
+ />
387
+ </VStack>
388
+ );
389
+
390
+ case "text":
391
+ return (
392
+ <VStack gap={4}>
393
+ <HeadingDescriptionGroup
394
+ props={props}
395
+ onUpdate={onUpdate}
396
+ descriptionKey="description"
397
+ descriptionLabel="Description"
398
+ />
399
+ <TextInput
400
+ label="Button Label"
401
+ value={(props.buttonLabel as string) ?? ""}
402
+ onChange={(v: string) => onUpdate("buttonLabel", v)}
403
+ />
404
+ </VStack>
405
+ );
406
+ case "features":
407
+ case "cta":
408
+ return (
409
+ <VStack gap={4}>
410
+ <HeadingDescriptionGroup
411
+ props={props}
412
+ onUpdate={onUpdate}
413
+ descriptionKey="description"
414
+ descriptionLabel="Description"
415
+ />
416
+ </VStack>
417
+ );
418
+
419
+ case "button":
420
+ return (
421
+ <VStack gap={4}>
422
+ <TextInput
423
+ label="Label"
424
+ value={(props.label as string) ?? ""}
425
+ onChange={(v: string) => onUpdate("label", v)}
426
+ />
427
+ <Selector
428
+ label="Variant"
429
+ value={(props.variant as string) ?? "primary"}
430
+ onChange={(v: string) => onUpdate("variant", v)}
431
+ options={[
432
+ { label: "Primary", value: "primary" },
433
+ { label: "Secondary", value: "secondary" },
434
+ { label: "Ghost", value: "ghost" },
435
+ ]}
436
+ />
437
+ <Selector
438
+ label="Size"
439
+ value={(props.size as string) ?? "md"}
440
+ onChange={(v: string) => onUpdate("size", v)}
441
+ options={[
442
+ { label: "Small", value: "sm" },
443
+ { label: "Medium", value: "md" },
444
+ { label: "Large", value: "lg" },
445
+ ]}
446
+ />
447
+ </VStack>
448
+ );
449
+
450
+ default:
451
+ return <EmptyState title="No configurable properties" isCompact />;
452
+ }
458
453
  }
459
454
 
460
455
  // ---------------------------------------------------------------------------
@@ -462,166 +457,139 @@ function PropertiesForm({
462
457
  // ---------------------------------------------------------------------------
463
458
 
464
459
  function BlockPreview({
465
- block,
466
- isSelected,
467
- onSelect,
460
+ block,
461
+ isSelected,
462
+ onSelect,
468
463
  }: {
469
- block: Block;
470
- isSelected: boolean;
471
- onSelect: () => void;
464
+ block: Block;
465
+ isSelected: boolean;
466
+ onSelect: () => void;
472
467
  }) {
473
- const {type, props} = block;
474
-
475
- // One SelectableCard wrapper for every block; only the inner content
476
- // switches. Keeps padding/label/selection in one place.
477
- let content: ReactNode;
478
- switch (type) {
479
- case 'hero':
480
- content = (
481
- <VStack gap={4}>
482
- <Heading level={3}>
483
- {(props.heading as string) || 'Hero Heading'}
484
- </Heading>
485
- <Text type="body" color="secondary">
486
- {(props.subheading as string) || 'A whisper from the crypt…'}
487
- </Text>
488
- {(props.buttonLabel as string) && (
489
- <Button label={props.buttonLabel as string} />
490
- )}
491
- </VStack>
492
- );
493
- break;
494
-
495
- case 'text':
496
- content = props.heading ? (
497
- <EmptyState
498
- title={props.heading as string}
499
- description={props.description as string}
500
- icon={<Icon icon={FileText} color="secondary" />}
501
- actions={
502
- (props.buttonLabel as string) ? (
503
- <Button
504
- label={props.buttonLabel as string}
505
- variant="secondary"
506
- />
507
- ) : undefined
508
- }
509
- />
510
- ) : (
511
- <Text type="body">
512
- {(props.content as string) || 'Ink your midnight thoughts here…'}
513
- </Text>
514
- );
515
- break;
516
- case 'image':
517
- content = (
518
- <EmptyState
519
- title="Image Block"
520
- description="Drop an image or enter a URL"
521
- icon={<Icon icon={Image} color="secondary" />}
522
- isCompact
523
- />
524
- );
525
- break;
526
-
527
- case 'button':
528
- content = (
529
- <Center>
530
- <Button
531
- label={(props.label as string) || 'Button'}
532
- variant={
533
- (props.variant as 'primary' | 'secondary' | 'ghost') ||
534
- 'primary'
535
- }
536
- size={(props.size as 'sm' | 'md' | 'lg') || 'md'}
537
- />
538
- </Center>
539
- );
540
- break;
541
-
542
-
543
- case 'features': {
544
- const items = (props.items as Transaction[]) || [];
545
- content = (
546
- <VStack gap={4}>
547
- <HStack gap={3} vAlign="start" hAlign="between">
548
- <VStack gap={1}>
549
- <Heading level={3}>
550
- {(props.heading as string) || 'Features'}
551
- </Heading>
552
- {(props.description as string) && (
553
- <Text type="body" color="secondary">
554
- {props.description as string}
555
- </Text>
556
- )}
557
- </VStack>
558
- <Button label="View All" variant="secondary" />
559
- </HStack>
560
- <Table
561
- data={items}
562
- columns={TRANSACTION_COLUMNS}
563
- idKey="id"
564
- hasHover
565
- textOverflow="truncate"
566
- />
567
- </VStack>
568
- );
569
- break;
570
- }
571
-
572
- case 'cards': {
573
- const cardItems =
574
- (props.cards as Array<{title: string; description: string}>) || [];
575
- content = (
576
- <VStack gap={4}>
577
- <Heading level={3}>Cards</Heading>
578
- <Divider />
579
- <List density="balanced" hasDividers={false}>
580
- {cardItems.map(card => (
581
- <ListItem
582
- key={card.title}
583
- label={card.title}
584
- description={card.description}
585
- />
586
- ))}
587
- </List>
588
- </VStack>
589
- );
590
- break;
591
- }
592
-
593
- case 'cta':
594
- content = (
595
- <HStack gap={4} vAlign="start">
596
- <Center width={40} height={40} style={iconCircle}>
597
- <Icon icon={Lock} color="secondary" />
598
- </Center>
599
- <VStack gap={1}>
600
- <Heading level={3}>
601
- {(props.heading as string) || 'Notice'}
602
- </Heading>
603
- <Text type="body" color="secondary">
604
- {(props.description as string) ||
605
- 'Words from the crypt gather here…'}
606
- </Text>
607
- </VStack>
608
- </HStack>
609
- );
610
- break;
611
-
612
- default:
613
- return null;
614
- }
615
-
616
- return (
617
- <SelectableCard
618
- padding={4}
619
- label={block.label}
620
- isSelected={isSelected}
621
- onChange={onSelect}>
622
- {content}
623
- </SelectableCard>
624
- );
468
+ const { type, props } = block;
469
+
470
+ // One SelectableCard wrapper for every block; only the inner content
471
+ // switches. Keeps padding/label/selection in one place.
472
+ let content: ReactNode;
473
+ switch (type) {
474
+ case "hero":
475
+ content = (
476
+ <VStack gap={4}>
477
+ <Heading level={3}>{(props.heading as string) || "Hero Heading"}</Heading>
478
+ <Text type="body" color="secondary">
479
+ {(props.subheading as string) || "A whisper from the crypt…"}
480
+ </Text>
481
+ {(props.buttonLabel as string) && <Button label={props.buttonLabel as string} />}
482
+ </VStack>
483
+ );
484
+ break;
485
+
486
+ case "text":
487
+ content = props.heading ? (
488
+ <EmptyState
489
+ title={props.heading as string}
490
+ description={props.description as string}
491
+ icon={<Icon icon={FileText} color="secondary" />}
492
+ actions={
493
+ (props.buttonLabel as string) ? (
494
+ <Button label={props.buttonLabel as string} variant="secondary" />
495
+ ) : undefined
496
+ }
497
+ />
498
+ ) : (
499
+ <Text type="body">{(props.content as string) || "Ink your midnight thoughts here…"}</Text>
500
+ );
501
+ break;
502
+ case "image":
503
+ content = (
504
+ <EmptyState
505
+ title="Image Block"
506
+ description="Drop an image or enter a URL"
507
+ icon={<Icon icon={Image} color="secondary" />}
508
+ isCompact
509
+ />
510
+ );
511
+ break;
512
+
513
+ case "button":
514
+ content = (
515
+ <Center>
516
+ <Button
517
+ label={(props.label as string) || "Button"}
518
+ variant={(props.variant as "primary" | "secondary" | "ghost") || "primary"}
519
+ size={(props.size as "sm" | "md" | "lg") || "md"}
520
+ />
521
+ </Center>
522
+ );
523
+ break;
524
+
525
+ case "features": {
526
+ const items = (props.items as Transaction[]) || [];
527
+ content = (
528
+ <VStack gap={4}>
529
+ <HStack gap={3} vAlign="start" hAlign="between">
530
+ <VStack gap={1}>
531
+ <Heading level={3}>{(props.heading as string) || "Features"}</Heading>
532
+ {(props.description as string) && (
533
+ <Text type="body" color="secondary">
534
+ {props.description as string}
535
+ </Text>
536
+ )}
537
+ </VStack>
538
+ <Button label="View All" variant="secondary" />
539
+ </HStack>
540
+ <Table
541
+ data={items}
542
+ columns={TRANSACTION_COLUMNS}
543
+ idKey="id"
544
+ hasHover
545
+ textOverflow="truncate"
546
+ />
547
+ </VStack>
548
+ );
549
+ break;
550
+ }
551
+
552
+ case "cards": {
553
+ const cardItems = (props.cards as Array<{ title: string; description: string }>) || [];
554
+ content = (
555
+ <VStack gap={4}>
556
+ <Heading level={3}>Cards</Heading>
557
+ <Divider />
558
+ <List density="balanced" hasDividers={false}>
559
+ {cardItems.map((card) => (
560
+ <ListItem key={card.title} label={card.title} description={card.description} />
561
+ ))}
562
+ </List>
563
+ </VStack>
564
+ );
565
+ break;
566
+ }
567
+
568
+ case "cta":
569
+ content = (
570
+ <HStack gap={4} vAlign="start">
571
+ <Center width={40} height={40} style={iconCircle}>
572
+ <Icon icon={Lock} color="secondary" />
573
+ </Center>
574
+ <VStack gap={1}>
575
+ <Heading level={3}>{(props.heading as string) || "Notice"}</Heading>
576
+ <Text type="body" color="secondary">
577
+ {(props.description as string) || "Words from the crypt gather here…"}
578
+ </Text>
579
+ </VStack>
580
+ </HStack>
581
+ );
582
+ break;
583
+
584
+ default:
585
+ return null;
586
+ }
587
+
588
+ return (
589
+ <SelectableCard padding={4} label={block.label} isSelected={isSelected} onChange={onSelect}>
590
+ {content}
591
+ </SelectableCard>
592
+ );
625
593
  }
626
594
 
627
595
  // ---------------------------------------------------------------------------
@@ -629,344 +597,343 @@ function BlockPreview({
629
597
  // ---------------------------------------------------------------------------
630
598
 
631
599
  export default function PageEditor() {
632
- const [blocks, setBlocks] = useState<Block[]>(DEFAULT_BLOCKS);
633
- const [selectedId, setSelectedId] = useState<string | null>(null);
634
- const [sidebarTab, setSidebarTab] = useState<SidebarTab>('blocks');
635
- const [pageTitle, setPageTitle] = useState('Page Editor');
636
- const [isEditingTitle, setIsEditingTitle] = useState(false);
637
- const [viewport, setViewport] = useState<ViewportSize>('desktop');
638
- // Mobile only: the customizations (tabs + Add Block/Layers) open in a
639
- // fullscreen dialog over the preview, since there's no room to dock them.
640
- const [isCustomizeOpen, setIsCustomizeOpen] = useState(false);
641
-
642
- // On phones the editor stacks: the panel sits in the header slot (full width,
643
- // above the canvas) showing just its toolbar, so the canvas isn't crushed
644
- // beside a 320px sidebar. On desktop the full panel (tabs + lists) shows.
645
- const isMobile = useMediaQuery('(max-width: 768px)');
646
-
647
- const selectedBlock = blocks.find(b => b.id === selectedId) ?? null;
648
-
649
- const updateBlockProp = useCallback(
650
- (id: string, key: string, value: unknown) => {
651
- setBlocks(prev =>
652
- prev.map(b =>
653
- b.id === id ? {...b, props: {...b.props, [key]: value}} : b,
654
- ),
655
- );
656
- },
657
- [],
658
- );
659
-
660
- const moveBlock = useCallback((id: string, dir: -1 | 1) => {
661
- setBlocks(prev => {
662
- const idx = prev.findIndex(b => b.id === id);
663
- if (idx < 0) {
664
- return prev;
665
- }
666
- const target = idx + dir;
667
- if (target < 0 || target >= prev.length) {
668
- return prev;
669
- }
670
- const next = [...prev];
671
- [next[idx], next[target]] = [next[target], next[idx]];
672
- return next;
673
- });
674
- }, []);
675
-
676
- const deleteBlock = useCallback(
677
- (id: string) => {
678
- setBlocks(prev => prev.filter(b => b.id !== id));
679
- if (selectedId === id) {
680
- setSelectedId(null);
681
- }
682
- },
683
- [selectedId],
684
- );
685
-
686
- const addBlock = useCallback((type: BlockType) => {
687
- const id = crypto.randomUUID();
688
- const newBlock: Block = {
689
- id,
690
- type,
691
- label: BLOCK_META[type].label,
692
- props: defaultProps(type),
693
- };
694
- setBlocks(prev => [...prev, newBlock]);
695
- setSelectedId(id);
696
- setSidebarTab('properties');
697
- }, []);
698
-
699
- const selectBlock = useCallback(
700
- (id: string) => {
701
- setSelectedId(prev => (prev === id ? null : id));
702
- setSidebarTab('properties');
703
- // On mobile, tapping a block on the canvas opens its properties dialog.
704
- if (isMobile) {
705
- setIsCustomizeOpen(true);
706
- }
707
- },
708
- [isMobile],
709
- );
710
-
711
- // --- sidebar content ---
712
-
713
- const blocksTabContent = (
714
- <VStack gap={2}>
715
- <VStack gap={1}>
716
- <Heading level={3}>Add Block</Heading>
717
- <List density="balanced" hasDividers={false}>
718
- {(Object.keys(BLOCK_META) as BlockType[]).map(type => (
719
- <ListItem
720
- key={type}
721
- label={BLOCK_META[type].label}
722
- startContent={
723
- <Icon icon={BLOCK_META[type].icon} color="secondary" />
724
- }
725
- onClick={() => addBlock(type)}
726
- />
727
- ))}
728
- </List>
729
- </VStack>
730
-
731
- <VStack gap={1}>
732
- <Heading level={3}>Layers</Heading>
733
- <List density="balanced" hasDividers={false}>
734
- {blocks.map(block => (
735
- <ListItem
736
- key={block.id}
737
- label={block.label}
738
- isSelected={block.id === selectedId}
739
- onClick={() => selectBlock(block.id)}
740
- startContent={
741
- <Icon icon={BLOCK_META[block.type].icon} color="secondary" />
742
- }
743
- endContent={
744
- <HStack gap={1}>
745
- <Button
746
- label="Move up"
747
- icon={<Icon icon={ChevronUp} size="sm" />}
748
- variant="ghost"
749
- size="sm"
750
- onClick={(e: MouseEvent) => {
751
- e.stopPropagation();
752
- moveBlock(block.id, -1);
753
- }}
754
- isIconOnly
755
- />
756
- <Button
757
- label="Move down"
758
- icon={<Icon icon={ChevronDown} size="sm" />}
759
- variant="ghost"
760
- size="sm"
761
- onClick={(e: MouseEvent) => {
762
- e.stopPropagation();
763
- moveBlock(block.id, 1);
764
- }}
765
- isIconOnly
766
- />
767
- <Button
768
- label="Delete"
769
- icon={<Icon icon={Trash2} size="sm" />}
770
- variant="ghost"
771
- size="sm"
772
- onClick={(e: MouseEvent) => {
773
- e.stopPropagation();
774
- deleteBlock(block.id);
775
- }}
776
- isIconOnly
777
- />
778
- </HStack>
779
- }
780
- />
781
- ))}
782
- </List>
783
- </VStack>
784
- </VStack>
785
- );
786
-
787
- const propertiesTabContent = selectedBlock ? (
788
- <PropertiesForm
789
- block={selectedBlock}
790
- onUpdate={(key, value) => updateBlockProp(selectedBlock.id, key, value)}
791
- />
792
- ) : (
793
- <EmptyState
794
- title="No block selected"
795
- description="Select a block to edit its properties"
796
- isCompact
797
- />
798
- );
799
-
800
- // Tabs + the active tab's content. Shown inline in the sidebar on desktop,
801
- // and inside a fullscreen dialog on mobile.
802
- const editingContent = (
803
- <VStack gap={4}>
804
- <VStack gap={0}>
805
- <TabList
806
- layout="fill"
807
- value={sidebarTab}
808
- onChange={(v: string) => setSidebarTab(v as SidebarTab)}>
809
- <Tab value="blocks" label="Blocks" />
810
- <Tab value="properties" label="Properties" />
811
- </TabList>
812
- <Divider />
813
- </VStack>
814
- <Section variant="transparent" padding={4}>
815
- {sidebarTab === 'blocks' ? blocksTabContent : propertiesTabContent}
816
- </Section>
817
- </VStack>
818
- );
819
-
820
- const sidebar = (
821
- <LayoutPanel
822
- hasDivider={!isMobile}
823
- padding={0}
824
- width={isMobile ? '100%' : 320}
825
- style={panelShrink}>
826
- <VStack gap={4}>
827
- {/* Panel Header — mobile/desktop branches are exclusive: exactly one h1. */}
828
- <Section variant="transparent" padding={4}>
829
- {isMobile ? (
830
- // Mobile: the title, an Edit button that opens the customizations
831
- // dialog, and the primary action.
832
- <HStack gap={3} vAlign="center" hAlign="between">
833
- <Heading level={1} type="display-2">{pageTitle}</Heading>
834
- <HStack gap={2} vAlign="center">
835
- <Button
836
- label="Edit"
837
- icon={<Icon icon={SlidersHorizontal} size="sm" />}
838
- variant="ghost"
839
- isIconOnly
840
- onClick={() => setIsCustomizeOpen(true)}
841
- />
842
- <Button label="Publish" variant="primary" />
843
- </HStack>
844
- </HStack>
845
- ) : (
846
- <VStack gap={4}>
847
- {isEditingTitle ? (
848
- <TextInput
849
- label="Page title"
850
- isLabelHidden
851
- value={pageTitle}
852
- onChange={setPageTitle}
853
- onKeyDown={(e: KeyboardEvent) => {
854
- if (e.key === 'Enter') {
855
- setIsEditingTitle(false);
856
- }
857
- }}
858
- hasAutoFocus
859
- onBlur={() => setIsEditingTitle(false)}
860
- />
861
- ) : (
862
- <Heading level={1} type="display-2">{pageTitle}</Heading>
863
- )}
864
-
865
- <Toolbar
866
- label="Viewport and actions"
867
- startContent={
868
- <SegmentedControl
869
- label="Viewport size"
870
- value={viewport}
871
- onChange={(v: string) => setViewport(v as ViewportSize)}>
872
- <SegmentedControlItem
873
- value="desktop"
874
- label="Desktop"
875
- icon={<Icon icon={Monitor} size="sm" />}
876
- isLabelHidden
877
- />
878
- <SegmentedControlItem
879
- value="tablet"
880
- label="Tablet"
881
- icon={<Icon icon={Tablet} size="sm" />}
882
- isLabelHidden
883
- />
884
- <SegmentedControlItem
885
- value="phone"
886
- label="Phone"
887
- icon={<Icon icon={Smartphone} size="sm" />}
888
- isLabelHidden
889
- />
890
- </SegmentedControl>
891
- }
892
- endContent={
893
- <HStack gap={2}>
894
- <Button
895
- label="Preview"
896
- icon={<Icon icon={Eye} size="sm" />}
897
- variant="ghost"
898
- isIconOnly
899
- />
900
- <Button label="Publish" variant="primary" />
901
- </HStack>
902
- }
903
- />
904
- </VStack>
905
- )}
906
- </Section>
907
-
908
- {!isMobile && editingContent}
909
- </VStack>
910
- </LayoutPanel>
911
- );
912
-
913
- return (
914
- <>
915
- <Layout
916
- style={pageStyle}
917
- height="fill"
918
- header={isMobile ? sidebar : undefined}
919
- start={isMobile ? undefined : sidebar}
920
- content={
921
- <LayoutContent padding={8}>
922
- <VStack gap={4} style={canvasStyle(VIEWPORT_MAX[viewport])}>
923
- {blocks.length > 0 ? (
924
- blocks.map(block => (
925
- <BlockPreview
926
- key={block.id}
927
- block={block}
928
- isSelected={block.id === selectedId}
929
- onSelect={() => selectBlock(block.id)}
930
- />
931
- ))
932
- ) : (
933
- <EmptyState
934
- title="No blocks yet"
935
- description="Add blocks from the sidebar to start building your page"
936
- icon={<Icon icon={CirclePlus} color="secondary" />}
937
- />
938
- )}
939
- </VStack>
940
- </LayoutContent>
941
- }
942
- />
943
-
944
- {/* Mobile: customizations open in a fullscreen dialog over the preview. */}
945
- <Dialog
946
- isOpen={isMobile && isCustomizeOpen}
947
- onOpenChange={setIsCustomizeOpen}
948
- variant="fullscreen"
949
- purpose="info"
950
- padding={0}>
951
- <Layout
952
- height="fill"
953
- header={
954
- <LayoutHeader hasDivider>
955
- <HStack gap={3} vAlign="center" hAlign="between">
956
- <Heading level={3}>Customize</Heading>
957
- <Button
958
- label="Close"
959
- icon={<Icon icon={X} size="sm" />}
960
- variant="ghost"
961
- isIconOnly
962
- onClick={() => setIsCustomizeOpen(false)}
963
- />
964
- </HStack>
965
- </LayoutHeader>
966
- }
967
- content={<LayoutContent padding={0}>{editingContent}</LayoutContent>}
968
- />
969
- </Dialog>
970
- </>
971
- );
600
+ const [blocks, setBlocks] = useState<Block[]>(DEFAULT_BLOCKS);
601
+ const [selectedId, setSelectedId] = useState<string | null>(null);
602
+ const [sidebarTab, setSidebarTab] = useState<SidebarTab>("blocks");
603
+ const [pageTitle, setPageTitle] = useState("Page Editor");
604
+ const [isEditingTitle, setIsEditingTitle] = useState(false);
605
+ const [viewport, setViewport] = useState<ViewportSize>("desktop");
606
+ // Mobile only: the customizations (tabs + Add Block/Layers) open in a
607
+ // fullscreen dialog over the preview, since there's no room to dock them.
608
+ const [isCustomizeOpen, setIsCustomizeOpen] = useState(false);
609
+
610
+ // On phones the editor stacks: the panel sits in the header slot (full width,
611
+ // above the canvas) showing just its toolbar, so the canvas isn't crushed
612
+ // beside a 320px sidebar. On desktop the full panel (tabs + lists) shows.
613
+ const isMobile = useMediaQuery("(max-width: 768px)");
614
+
615
+ const selectedBlock = blocks.find((b) => b.id === selectedId) ?? null;
616
+
617
+ const updateBlockProp = useCallback((id: string, key: string, value: unknown) => {
618
+ setBlocks((prev) =>
619
+ prev.map((b) => (b.id === id ? { ...b, props: { ...b.props, [key]: value } } : b)),
620
+ );
621
+ }, []);
622
+
623
+ const moveBlock = useCallback((id: string, dir: -1 | 1) => {
624
+ setBlocks((prev) => {
625
+ const idx = prev.findIndex((b) => b.id === id);
626
+ if (idx < 0) {
627
+ return prev;
628
+ }
629
+ const target = idx + dir;
630
+ if (target < 0 || target >= prev.length) {
631
+ return prev;
632
+ }
633
+ const next = [...prev];
634
+ [next[idx], next[target]] = [next[target], next[idx]];
635
+ return next;
636
+ });
637
+ }, []);
638
+
639
+ const deleteBlock = useCallback(
640
+ (id: string) => {
641
+ setBlocks((prev) => prev.filter((b) => b.id !== id));
642
+ if (selectedId === id) {
643
+ setSelectedId(null);
644
+ }
645
+ },
646
+ [selectedId],
647
+ );
648
+
649
+ const addBlock = useCallback((type: BlockType) => {
650
+ const id = crypto.randomUUID();
651
+ const newBlock: Block = {
652
+ id,
653
+ type,
654
+ label: BLOCK_META[type].label,
655
+ props: defaultProps(type),
656
+ };
657
+ setBlocks((prev) => [...prev, newBlock]);
658
+ setSelectedId(id);
659
+ setSidebarTab("properties");
660
+ }, []);
661
+
662
+ const selectBlock = useCallback(
663
+ (id: string) => {
664
+ setSelectedId((prev) => (prev === id ? null : id));
665
+ setSidebarTab("properties");
666
+ // On mobile, tapping a block on the canvas opens its properties dialog.
667
+ if (isMobile) {
668
+ setIsCustomizeOpen(true);
669
+ }
670
+ },
671
+ [isMobile],
672
+ );
673
+
674
+ // --- sidebar content ---
675
+
676
+ const blocksTabContent = (
677
+ <VStack gap={2}>
678
+ <VStack gap={1}>
679
+ <Heading level={3}>Add Block</Heading>
680
+ <List density="balanced" hasDividers={false}>
681
+ {(Object.keys(BLOCK_META) as BlockType[]).map((type) => (
682
+ <ListItem
683
+ key={type}
684
+ label={BLOCK_META[type].label}
685
+ startContent={<Icon icon={BLOCK_META[type].icon} color="secondary" />}
686
+ onClick={() => addBlock(type)}
687
+ />
688
+ ))}
689
+ </List>
690
+ </VStack>
691
+
692
+ <VStack gap={1}>
693
+ <Heading level={3}>Layers</Heading>
694
+ <List density="balanced" hasDividers={false}>
695
+ {blocks.map((block) => (
696
+ <ListItem
697
+ key={block.id}
698
+ label={block.label}
699
+ isSelected={block.id === selectedId}
700
+ onClick={() => selectBlock(block.id)}
701
+ startContent={<Icon icon={BLOCK_META[block.type].icon} color="secondary" />}
702
+ endContent={
703
+ <HStack gap={1}>
704
+ <Button
705
+ label="Move up"
706
+ icon={<Icon icon={ChevronUp} size="sm" />}
707
+ variant="ghost"
708
+ size="sm"
709
+ onClick={(e: MouseEvent) => {
710
+ e.stopPropagation();
711
+ moveBlock(block.id, -1);
712
+ }}
713
+ isIconOnly
714
+ />
715
+ <Button
716
+ label="Move down"
717
+ icon={<Icon icon={ChevronDown} size="sm" />}
718
+ variant="ghost"
719
+ size="sm"
720
+ onClick={(e: MouseEvent) => {
721
+ e.stopPropagation();
722
+ moveBlock(block.id, 1);
723
+ }}
724
+ isIconOnly
725
+ />
726
+ <Button
727
+ label="Delete"
728
+ icon={<Icon icon={Trash2} size="sm" />}
729
+ variant="ghost"
730
+ size="sm"
731
+ onClick={(e: MouseEvent) => {
732
+ e.stopPropagation();
733
+ deleteBlock(block.id);
734
+ }}
735
+ isIconOnly
736
+ />
737
+ </HStack>
738
+ }
739
+ />
740
+ ))}
741
+ </List>
742
+ </VStack>
743
+ </VStack>
744
+ );
745
+
746
+ const propertiesTabContent = selectedBlock ? (
747
+ <PropertiesForm
748
+ block={selectedBlock}
749
+ onUpdate={(key, value) => updateBlockProp(selectedBlock.id, key, value)}
750
+ />
751
+ ) : (
752
+ <EmptyState
753
+ title="No block selected"
754
+ description="Select a block to edit its properties"
755
+ isCompact
756
+ />
757
+ );
758
+
759
+ // Tabs + the active tab's content. Shown inline in the sidebar on desktop,
760
+ // and inside a fullscreen dialog on mobile.
761
+ const editingContent = (
762
+ <VStack gap={4}>
763
+ <VStack gap={0}>
764
+ <TabList
765
+ layout="fill"
766
+ value={sidebarTab}
767
+ onChange={(v: string) => setSidebarTab(v as SidebarTab)}
768
+ >
769
+ <Tab value="blocks" label="Blocks" />
770
+ <Tab value="properties" label="Properties" />
771
+ </TabList>
772
+ <Divider />
773
+ </VStack>
774
+ <Section variant="transparent" padding={4}>
775
+ {sidebarTab === "blocks" ? blocksTabContent : propertiesTabContent}
776
+ </Section>
777
+ </VStack>
778
+ );
779
+
780
+ const sidebar = (
781
+ <LayoutPanel
782
+ hasDivider={!isMobile}
783
+ padding={0}
784
+ width={isMobile ? "100%" : 320}
785
+ style={panelShrink}
786
+ >
787
+ <VStack gap={4}>
788
+ {/* Panel Header — mobile/desktop branches are exclusive: exactly one h1. */}
789
+ <Section variant="transparent" padding={4}>
790
+ {isMobile ? (
791
+ // Mobile: the title, an Edit button that opens the customizations
792
+ // dialog, and the primary action.
793
+ <HStack gap={3} vAlign="center" hAlign="between">
794
+ <Heading level={1} type="display-2">
795
+ {pageTitle}
796
+ </Heading>
797
+ <HStack gap={2} vAlign="center">
798
+ <Button
799
+ label="Edit"
800
+ icon={<Icon icon={SlidersHorizontal} size="sm" />}
801
+ variant="ghost"
802
+ isIconOnly
803
+ onClick={() => setIsCustomizeOpen(true)}
804
+ />
805
+ <Button label="Publish" variant="primary" />
806
+ </HStack>
807
+ </HStack>
808
+ ) : (
809
+ <VStack gap={4}>
810
+ {isEditingTitle ? (
811
+ <TextInput
812
+ label="Page title"
813
+ isLabelHidden
814
+ value={pageTitle}
815
+ onChange={setPageTitle}
816
+ onKeyDown={(e: KeyboardEvent) => {
817
+ if (e.key === "Enter") {
818
+ setIsEditingTitle(false);
819
+ }
820
+ }}
821
+ hasAutoFocus
822
+ onBlur={() => setIsEditingTitle(false)}
823
+ />
824
+ ) : (
825
+ <Heading level={1} type="display-2">
826
+ {pageTitle}
827
+ </Heading>
828
+ )}
829
+
830
+ <Toolbar
831
+ label="Viewport and actions"
832
+ startContent={
833
+ <SegmentedControl
834
+ label="Viewport size"
835
+ value={viewport}
836
+ onChange={(v: string) => setViewport(v as ViewportSize)}
837
+ >
838
+ <SegmentedControlItem
839
+ value="desktop"
840
+ label="Desktop"
841
+ icon={<Icon icon={Monitor} size="sm" />}
842
+ isLabelHidden
843
+ />
844
+ <SegmentedControlItem
845
+ value="tablet"
846
+ label="Tablet"
847
+ icon={<Icon icon={Tablet} size="sm" />}
848
+ isLabelHidden
849
+ />
850
+ <SegmentedControlItem
851
+ value="phone"
852
+ label="Phone"
853
+ icon={<Icon icon={Smartphone} size="sm" />}
854
+ isLabelHidden
855
+ />
856
+ </SegmentedControl>
857
+ }
858
+ endContent={
859
+ <HStack gap={2}>
860
+ <Button
861
+ label="Preview"
862
+ icon={<Icon icon={Eye} size="sm" />}
863
+ variant="ghost"
864
+ isIconOnly
865
+ />
866
+ <Button label="Publish" variant="primary" />
867
+ </HStack>
868
+ }
869
+ />
870
+ </VStack>
871
+ )}
872
+ </Section>
873
+
874
+ {!isMobile && editingContent}
875
+ </VStack>
876
+ </LayoutPanel>
877
+ );
878
+
879
+ return (
880
+ <>
881
+ <Layout
882
+ style={pageStyle}
883
+ height="fill"
884
+ header={isMobile ? sidebar : undefined}
885
+ start={isMobile ? undefined : sidebar}
886
+ content={
887
+ <LayoutContent padding={8}>
888
+ <VStack gap={4} style={canvasStyle(VIEWPORT_MAX[viewport])}>
889
+ {blocks.length > 0 ? (
890
+ blocks.map((block) => (
891
+ <BlockPreview
892
+ key={block.id}
893
+ block={block}
894
+ isSelected={block.id === selectedId}
895
+ onSelect={() => selectBlock(block.id)}
896
+ />
897
+ ))
898
+ ) : (
899
+ <EmptyState
900
+ title="No blocks yet"
901
+ description="Add blocks from the sidebar to start building your page"
902
+ icon={<Icon icon={CirclePlus} color="secondary" />}
903
+ />
904
+ )}
905
+ </VStack>
906
+ </LayoutContent>
907
+ }
908
+ />
909
+
910
+ {/* Mobile: customizations open in a fullscreen dialog over the preview. */}
911
+ <Dialog
912
+ isOpen={isMobile && isCustomizeOpen}
913
+ onOpenChange={setIsCustomizeOpen}
914
+ variant="fullscreen"
915
+ purpose="info"
916
+ padding={0}
917
+ >
918
+ <Layout
919
+ height="fill"
920
+ header={
921
+ <LayoutHeader hasDivider>
922
+ <HStack gap={3} vAlign="center" hAlign="between">
923
+ <Heading level={3}>Customize</Heading>
924
+ <Button
925
+ label="Close"
926
+ icon={<Icon icon={X} size="sm" />}
927
+ variant="ghost"
928
+ isIconOnly
929
+ onClick={() => setIsCustomizeOpen(false)}
930
+ />
931
+ </HStack>
932
+ </LayoutHeader>
933
+ }
934
+ content={<LayoutContent padding={0}>{editingContent}</LayoutContent>}
935
+ />
936
+ </Dialog>
937
+ </>
938
+ );
972
939
  }