canvas-ui-sdk 0.3.23 → 4.0.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 (177) hide show
  1. package/README.md +25 -5
  2. package/dist/charts.js +11 -6
  3. package/dist/charts.js.map +1 -1
  4. package/dist/index.d.ts +1233 -153
  5. package/dist/index.js +3562 -447
  6. package/dist/index.js.map +1 -1
  7. package/mcp/dist/index.js +1195 -149
  8. package/package.json +1 -1
  9. package/prompts/.cursorrules +96 -0
  10. package/prompts/.windsurfrules +96 -0
  11. package/prompts/CLAUDE.md +22 -0
  12. package/prompts/copilot-instructions.md +96 -0
  13. package/registry/blocks/activity-feed.json +12 -1
  14. package/registry/blocks/blog-cards.json +10 -2
  15. package/registry/blocks/bottom-action-bar.json +27 -0
  16. package/registry/blocks/bottom-input-chat-widget.json +9 -1
  17. package/registry/blocks/category-grid.json +10 -2
  18. package/registry/blocks/centered-hero.json +9 -1
  19. package/registry/blocks/chat-message.json +8 -1
  20. package/registry/blocks/circular-progress-bar-list.json +11 -1
  21. package/registry/blocks/confirmation-popup.json +10 -1
  22. package/registry/blocks/contact-form-popup.json +10 -1
  23. package/registry/blocks/content-dropzone.json +8 -0
  24. package/registry/blocks/content-with-image.json +9 -1
  25. package/registry/blocks/core-values-grid.json +10 -2
  26. package/registry/blocks/credit-card-display.json +9 -1
  27. package/registry/blocks/cta-banner.json +10 -2
  28. package/registry/blocks/destination-cards.json +10 -1
  29. package/registry/blocks/detail-drawer.json +10 -1
  30. package/registry/blocks/details-popup.json +10 -1
  31. package/registry/blocks/editable-list.json +29 -0
  32. package/registry/blocks/empty-state.json +10 -2
  33. package/registry/blocks/faq-accordion.json +9 -1
  34. package/registry/blocks/faqs-table.json +10 -1
  35. package/registry/blocks/feature-with-image.json +9 -1
  36. package/registry/blocks/featured-news-cards.json +10 -2
  37. package/registry/blocks/featured-places.json +10 -2
  38. package/registry/blocks/features-comparison.json +9 -1
  39. package/registry/blocks/feedback-popup.json +9 -1
  40. package/registry/blocks/filter-popover.json +8 -1
  41. package/registry/blocks/fixed-column-data-table.json +11 -1
  42. package/registry/blocks/flair-banner.json +9 -1
  43. package/registry/blocks/footer-navbar.json +9 -1
  44. package/registry/blocks/form-group.json +14 -3
  45. package/registry/blocks/form-popup.json +31 -0
  46. package/registry/blocks/gallery-section.json +10 -2
  47. package/registry/blocks/gradient-banner.json +10 -2
  48. package/registry/blocks/graph-metric-tiles.json +1 -1
  49. package/registry/blocks/grid-tiles-list.json +10 -1
  50. package/registry/blocks/hero-dark-centered.json +9 -1
  51. package/registry/blocks/hero-dark-with-image.json +9 -1
  52. package/registry/blocks/hero-fullwidth-image.json +9 -1
  53. package/registry/blocks/hero-section.json +9 -1
  54. package/registry/blocks/how-it-works.json +9 -1
  55. package/registry/blocks/image-feed-with-nested-comments.json +10 -1
  56. package/registry/blocks/image-popup.json +10 -1
  57. package/registry/blocks/invoice-popup.json +10 -1
  58. package/registry/blocks/large-image-labels-list.json +10 -1
  59. package/registry/blocks/list-popup.json +28 -0
  60. package/registry/blocks/loader.json +9 -1
  61. package/registry/blocks/login-branding-panel.json +10 -2
  62. package/registry/blocks/menu-section.json +9 -1
  63. package/registry/blocks/menufocus-template.json +9 -1
  64. package/registry/blocks/messenger-sidebar.json +11 -2
  65. package/registry/blocks/metrics-section.json +10 -2
  66. package/registry/blocks/mobile-bottom-nav.json +10 -2
  67. package/registry/blocks/monthly-calendar-widget.json +9 -1
  68. package/registry/blocks/multistep-form-popup.json +34 -0
  69. package/registry/blocks/nested-comments-table.json +9 -1
  70. package/registry/blocks/nested-data-table.json +10 -1
  71. package/registry/blocks/nps-survey-popup.json +27 -0
  72. package/registry/blocks/office-locations.json +10 -2
  73. package/registry/blocks/order-summary-sidebar.json +27 -0
  74. package/registry/blocks/page-header-section.json +9 -1
  75. package/registry/blocks/pagination.json +8 -1
  76. package/registry/blocks/participant-list.json +9 -1
  77. package/registry/blocks/persona-card.json +10 -1
  78. package/registry/blocks/personalize-feed-popup.json +27 -0
  79. package/registry/blocks/pill-tabs.json +9 -1
  80. package/registry/blocks/place-detail-panel.json +11 -1
  81. package/registry/blocks/pricing-cards.json +10 -2
  82. package/registry/blocks/pricing-cta.json +9 -1
  83. package/registry/blocks/pricing-plans-popup.json +10 -1
  84. package/registry/blocks/profile-card.json +12 -2
  85. package/registry/blocks/profile-grid-tiles-list.json +10 -1
  86. package/registry/blocks/profile-image-uploader.json +9 -1
  87. package/registry/blocks/profile-info-cards.json +10 -1
  88. package/registry/blocks/progress-bar.json +8 -1
  89. package/registry/blocks/prompt-template.json +1 -1
  90. package/registry/blocks/purchase-confirmation-popup.json +10 -1
  91. package/registry/blocks/reservation-card.json +26 -0
  92. package/registry/blocks/reviews-grid.json +10 -2
  93. package/registry/blocks/reviews-table.json +10 -1
  94. package/registry/blocks/screen-prompt-template.json +1 -1
  95. package/registry/blocks/search-bar.json +9 -2
  96. package/registry/blocks/search-sidebar.json +9 -2
  97. package/registry/blocks/settings-list-row.json +9 -1
  98. package/registry/blocks/share-project-popup.json +36 -0
  99. package/registry/blocks/sidebar-cards.json +10 -2
  100. package/registry/blocks/sidebar-profile-card.json +10 -2
  101. package/registry/blocks/slideshow-grid-tiles.json +10 -2
  102. package/registry/blocks/slideshow-popup.json +10 -1
  103. package/registry/blocks/small-edit-popup.json +29 -0
  104. package/registry/blocks/social-feed.json +10 -1
  105. package/registry/blocks/social-proof.json +9 -1
  106. package/registry/blocks/standard-data-table.json +13 -1
  107. package/registry/blocks/standard-list-with-image.json +10 -1
  108. package/registry/blocks/step-tracker.json +9 -1
  109. package/registry/blocks/store-location-map.json +9 -1
  110. package/registry/blocks/team-cards-grid.json +9 -1
  111. package/registry/blocks/team-circular-grid.json +9 -1
  112. package/registry/blocks/terms-of-service-popup.json +10 -1
  113. package/registry/blocks/testimonial-carousel.json +10 -2
  114. package/registry/blocks/tile-image-gallery.json +26 -0
  115. package/registry/blocks/title-group.json +10 -1
  116. package/registry/blocks/upvoting-posts-table.json +10 -1
  117. package/registry/blocks/vertical-how-it-works.json +9 -1
  118. package/registry/blocks/vertical-step-tracker.json +9 -1
  119. package/registry/blocks/video-chat-controls.json +9 -1
  120. package/registry/blocks/video-content-section.json +9 -1
  121. package/registry/blocks/video-playlist.json +9 -1
  122. package/registry/blocks/video-popup.json +9 -1
  123. package/registry/blocks/view-profile-popup.json +10 -1
  124. package/registry/blocks/webcam-preview.json +9 -1
  125. package/registry/hooks/use-css-variable-sync.json +10 -1
  126. package/registry/hooks/use-mobile.json +9 -1
  127. package/registry/index.json +1526 -147
  128. package/registry/layout/account-settings-shell.json +10 -1
  129. package/registry/layout/dashboard-shell.json +12 -1
  130. package/registry/layout/double-sidebar-shell.json +11 -2
  131. package/registry/layout/double-sidebar.json +9 -1
  132. package/registry/layout/header.json +10 -1
  133. package/registry/layout/icon-sidebar-shell.json +9 -1
  134. package/registry/layout/icon-sidebar.json +9 -1
  135. package/registry/layout/mobile-menu-shell.json +10 -1
  136. package/registry/layout/multistep-progressbar-shell.json +9 -1
  137. package/registry/layout/multistep-shell.json +11 -1
  138. package/registry/layout/multistep-sidebar-shell.json +10 -2
  139. package/registry/layout/project-context-shell.json +1 -1
  140. package/registry/layout/search-bar-shell.json +8 -1
  141. package/registry/layout/sidebar-nav.json +7 -1
  142. package/registry/layout/sidebar.json +9 -2
  143. package/registry/layout/standard-page-shell.json +10 -1
  144. package/registry/layout/vertical-multistep-shell.json +10 -1
  145. package/registry/ui/avatar.json +9 -1
  146. package/registry/ui/button.json +9 -1
  147. package/registry/ui/calendar.json +9 -1
  148. package/registry/ui/checkbox.json +8 -1
  149. package/registry/ui/date-input.json +9 -1
  150. package/registry/ui/dialog.json +8 -1
  151. package/registry/ui/dropdown-menu.json +8 -1
  152. package/registry/ui/file-uploader.json +9 -1
  153. package/registry/ui/image-uploader.json +9 -1
  154. package/registry/ui/input.json +8 -1
  155. package/registry/ui/label.json +8 -1
  156. package/registry/ui/line-tabs.json +9 -1
  157. package/registry/ui/multiselect-checkbox-field.json +9 -1
  158. package/registry/ui/multiselect-tags.json +9 -1
  159. package/registry/ui/popover.json +8 -1
  160. package/registry/ui/radio-group.json +9 -1
  161. package/registry/ui/range-input.json +8 -1
  162. package/registry/ui/scroll-area.json +8 -1
  163. package/registry/ui/searchbox.json +9 -1
  164. package/registry/ui/select.json +9 -1
  165. package/registry/ui/selectable-pills.json +11 -1
  166. package/registry/ui/separator.json +8 -1
  167. package/registry/ui/sheet.json +9 -1
  168. package/registry/ui/sidebar.json +8 -1
  169. package/registry/ui/skeleton.json +8 -1
  170. package/registry/ui/slider.json +10 -2
  171. package/registry/ui/switch.json +9 -1
  172. package/registry/ui/tabs.json +8 -1
  173. package/registry/ui/text-input.json +8 -1
  174. package/registry/ui/textarea.json +9 -1
  175. package/registry/ui/tooltip.json +8 -1
  176. package/registry/ui/typography.json +9 -1
  177. package/styles/tokens.reference.css +21 -0
@@ -1,12 +1,22 @@
1
1
  {
2
2
  "name": "form-group",
3
3
  "type": "registry:block",
4
- "description": "Single-column form layout with header (title, sort, filter, action button), configurable fields, and footer (cancel/save). Supports text inputs, textareas, selects, date pickers, multiselect checkboxes, checkbox groups, radio groups, multiselect tags, image/file uploaders, and sliders.",
4
+ "description": "Single-column form layout with header (title, sort, filter, action button), configurable fields, and footer (cancel/save). Supports text inputs, textareas, selects, date pickers, multiselect checkboxes, checkbox groups, radio groups, multiselect tags, image/file uploaders, and sliders. Use for any data entry form, settings page, or create/edit interface.",
5
+ "keywords": [
6
+ "form",
7
+ "fields",
8
+ "input",
9
+ "data-entry",
10
+ "create",
11
+ "edit",
12
+ "settings"
13
+ ],
14
+ "visualWeight": "heavy",
5
15
  "files": [
6
16
  {
7
17
  "path": "components/blocks/form-group.tsx",
8
18
  "type": "registry:block",
9
- "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { Label } from \"../ui/label\";\nimport { TextInput } from \"../ui/text-input\";\nimport { Textarea } from \"../ui/textarea\";\nimport { DateInput } from \"../ui/date-input\";\nimport { Slider } from \"../ui/slider\";\nimport { RadioGroup, RadioGroupItem } from \"../ui/radio-group\";\nimport { CheckboxWithLabel } from \"../ui/checkbox\";\nimport { MultiselectTags } from \"../ui/multiselect-tags\";\nimport { MultiselectCheckboxField, type CheckboxOption } from \"../ui/multiselect-checkbox-field\";\nimport { ImageUploader, type UploadedImage } from \"../ui/image-uploader\";\nimport { FileUploader, type UploadedFile } from \"../ui/file-uploader\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { TitleGroup } from \"./title-group\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface FormFieldConfig {\n id: string;\n label: string;\n type:\n | \"text\"\n | \"textarea\"\n | \"select\"\n | \"date\"\n | \"multiselect-checkbox\"\n | \"checkbox-group\"\n | \"radio-group\"\n | \"multiselect-tags\"\n | \"image-uploader\"\n | \"file-uploader\"\n | \"slider\";\n placeholder?: string;\n options?: { id: string; label: string }[];\n value?: string | string[] | number[] | UploadedImage[] | UploadedFile[];\n min?: number;\n max?: number;\n step?: number;\n disabled?: boolean;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FormGroupProps {\n /** Form title */\n title?: string;\n /** Form description */\n description?: string;\n /** Flat array of field configurations */\n fields?: FormFieldConfig[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Cancel button text */\n cancelButtonText?: string;\n /** Save button text */\n saveButtonText?: string;\n /** Input size variant */\n inputSize?: \"sm\" | \"default\" | \"lg\";\n /** Callback when action button is clicked */\n onAddNew?: () => void;\n /** Callback when sort value changes */\n onSort?: (value: string) => void;\n /** Callback when filter value changes */\n onFilter?: (value: string) => void;\n /** Callback when cancel button is clicked */\n onCancel?: () => void;\n /** Callback when save button is clicked */\n onSave?: () => void;\n /** Callback when a field value changes */\n onFieldChange?: (fieldId: string, value: unknown) => void;\n /** Show header section */\n showHeader?: boolean;\n /** Show footer section */\n showFooter?: boolean;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"name-asc\", label: \"Name (A-Z)\" },\n { id: \"name-desc\", label: \"Name (Z-A)\" },\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n];\n\nconst defaultCheckboxOptions: CheckboxOption[] = [\n { id: \"option-1\", label: \"Option 1\" },\n { id: \"option-2\", label: \"Option 1\" },\n { id: \"option-3\", label: \"Option 1\" },\n { id: \"option-4\", label: \"Option 1\" },\n { id: \"option-5\", label: \"Option 1\" },\n];\n\nconst defaultSelectOptions = [\n { id: \"placeholder\", label: \"Placeholder\" },\n { id: \"option-1\", label: \"Option 1\" },\n { id: \"option-2\", label: \"Option 2\" },\n { id: \"option-3\", label: \"Option 3\" },\n];\n\nconst defaultFields: FormFieldConfig[] = [\n { id: \"field-1\", label: \"Label\", type: \"text\" },\n { id: \"field-2\", label: \"Label\", type: \"text\" },\n {\n id: \"field-3\",\n label: \"Label\",\n type: \"select\",\n options: defaultSelectOptions,\n },\n {\n id: \"field-4\",\n label: \"Label\",\n type: \"multiselect-checkbox\",\n options: defaultCheckboxOptions,\n value: [\"option-1\"],\n },\n { id: \"field-5\", label: \"Label\", type: \"date\", placeholder: \"2/21/2024\" },\n {\n id: \"field-6\",\n label: \"Label\",\n type: \"checkbox-group\",\n options: [\n { id: \"cb-1\", label: \"Label\" },\n { id: \"cb-2\", label: \"Label\" },\n { id: \"cb-3\", label: \"Label\" },\n { id: \"cb-4\", label: \"Label\" },\n { id: \"cb-5\", label: \"Label\" },\n { id: \"cb-6\", label: \"Label\" },\n ],\n },\n {\n id: \"field-7\",\n label: \"Label\",\n type: \"radio-group\",\n options: [\n { id: \"option-a\", label: \"Option\" },\n { id: \"option-b\", label: \"Option\" },\n { id: \"option-c\", label: \"Option\" },\n { id: \"option-d\", label: \"Option\" },\n ],\n },\n { id: \"field-8\", label: \"Label\", type: \"text\" },\n {\n id: \"field-9\",\n label: \"Label\",\n type: \"multiselect-tags\",\n value: [\"Choice A\", \"Choice B\"],\n },\n { id: \"field-10\", label: \"Label\", type: \"image-uploader\", placeholder: \"Drop image here\" },\n { id: \"field-11\", label: \"Label\", type: \"image-uploader\", placeholder: \"Drop images here\" },\n { id: \"field-12\", label: \"Label\", type: \"file-uploader\" },\n {\n id: \"field-13\",\n label: \"Label\",\n type: \"slider\",\n value: [0],\n min: 0,\n max: 1000,\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface FormFieldProps {\n field: FormFieldConfig;\n inputSize?: \"sm\" | \"default\" | \"lg\";\n onChange?: (fieldId: string, value: unknown) => void;\n}\n\nfunction FormField({ field, inputSize = \"default\", onChange }: FormFieldProps) {\n const [localValue, setLocalValue] = React.useState<unknown>(field.value);\n\n const [tags, setTags] = React.useState<string[]>(\n Array.isArray(field.value) && field.type === \"multiselect-tags\"\n ? (field.value as string[])\n : []\n );\n\n const [checkedItems, setCheckedItems] = React.useState<string[]>(\n Array.isArray(field.value) && field.type === \"checkbox-group\"\n ? (field.value as string[])\n : []\n );\n\n const [images, setImages] = React.useState<UploadedImage[]>(\n Array.isArray(field.value) && field.type === \"image-uploader\"\n ? (field.value as UploadedImage[])\n : []\n );\n const [files, setFiles] = React.useState<UploadedFile[]>(\n Array.isArray(field.value) && field.type === \"file-uploader\"\n ? (field.value as UploadedFile[])\n : []\n );\n\n const [sliderValue, setSliderValue] = React.useState<number[]>(\n Array.isArray(field.value) && field.type === \"slider\"\n ? (field.value as number[])\n : [0]\n );\n\n const handleChange = (value: unknown) => {\n setLocalValue(value);\n onChange?.(field.id, value);\n };\n\n const renderField = () => {\n switch (field.type) {\n case \"text\":\n return (\n <TextInput\n inputSize={inputSize}\n placeholder={field.placeholder}\n value={typeof localValue === \"string\" ? localValue : \"\"}\n onChange={(e) => handleChange(e.target.value)}\n disabled={field.disabled}\n />\n );\n\n case \"textarea\":\n return (\n <Textarea\n inputSize={inputSize}\n placeholder={field.placeholder}\n value={typeof localValue === \"string\" ? localValue : \"\"}\n onChange={(e) => handleChange(e.target.value)}\n disabled={field.disabled}\n />\n );\n\n case \"select\":\n return (\n <Select\n value={typeof localValue === \"string\" ? localValue : undefined}\n onValueChange={(val) => handleChange(val)}\n disabled={field.disabled}\n >\n <SelectTrigger inputSize={inputSize}>\n <SelectValue placeholder={field.placeholder || \"Select...\"} />\n </SelectTrigger>\n <SelectContent>\n {field.options?.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n );\n\n case \"date\":\n return (\n <DateInput\n inputSize={inputSize}\n placeholder={field.placeholder}\n value={typeof localValue === \"string\" ? localValue : \"\"}\n onChange={(val) => handleChange(val)}\n disabled={field.disabled}\n />\n );\n\n case \"multiselect-checkbox\":\n return (\n <MultiselectCheckboxField\n options={field.options}\n selectedValues={\n Array.isArray(localValue) ? (localValue as string[]) : []\n }\n onChange={(vals) => handleChange(vals)}\n inputSize={inputSize}\n disabled={field.disabled}\n />\n );\n\n case \"checkbox-group\":\n return (\n <div\n className=\"flex flex-wrap items-center\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {field.options?.map((option) => (\n <CheckboxWithLabel\n key={option.id}\n checked={checkedItems.includes(option.id)}\n onCheckedChange={(checked) => {\n const newChecked = checked\n ? [...checkedItems, option.id]\n : checkedItems.filter((id) => id !== option.id);\n setCheckedItems(newChecked);\n onChange?.(field.id, newChecked);\n }}\n disabled={field.disabled}\n >\n {option.label}\n </CheckboxWithLabel>\n ))}\n </div>\n );\n\n case \"radio-group\":\n return (\n <RadioGroup\n value={typeof localValue === \"string\" ? localValue : undefined}\n onValueChange={(val) => handleChange(val)}\n disabled={field.disabled}\n className=\"flex flex-wrap items-center\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {field.options?.map((option) => (\n <div\n key={option.id}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <RadioGroupItem value={option.id} id={`${field.id}-${option.id}`} />\n <label\n htmlFor={`${field.id}-${option.id}`}\n className=\"text-[var(--canvas-text-muted)] cursor-pointer\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n }}\n >\n {option.label}\n </label>\n </div>\n ))}\n </RadioGroup>\n );\n\n case \"multiselect-tags\":\n return (\n <MultiselectTags\n tags={tags}\n inputSize={inputSize}\n onAdd={(tag) => {\n const newTags = [...tags, tag];\n setTags(newTags);\n onChange?.(field.id, newTags);\n }}\n onRemove={(tag) => {\n const newTags = tags.filter((t) => t !== tag);\n setTags(newTags);\n onChange?.(field.id, newTags);\n }}\n disabled={field.disabled}\n />\n );\n\n case \"image-uploader\":\n return (\n <ImageUploader\n images={images}\n placeholder={field.placeholder}\n onImagesChange={(newImages) => {\n setImages(newImages);\n onChange?.(field.id, newImages);\n }}\n disabled={field.disabled}\n />\n );\n\n case \"file-uploader\":\n return (\n <FileUploader\n files={files}\n placeholder={field.placeholder}\n onFilesChange={(newFiles) => {\n setFiles(newFiles);\n onChange?.(field.id, newFiles);\n }}\n disabled={field.disabled}\n />\n );\n\n case \"slider\":\n return (\n <Slider\n inputSize={inputSize}\n value={sliderValue}\n onValueChange={(vals) => {\n setSliderValue(vals);\n onChange?.(field.id, vals);\n }}\n min={field.min ?? 0}\n max={field.max ?? 100}\n step={field.step ?? 1}\n showLabel\n labelFormatter={(vals) => `$${vals[0]}`}\n disabled={field.disabled}\n />\n );\n\n default:\n return null;\n }\n };\n\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <Label>{field.label}</Label>\n {renderField()}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Form Group Block\n *\n * A single-column form layout block with configurable fields, header section\n * (title, description, sort/filter, action button), and footer with action buttons.\n * Supports text inputs, textareas, selects, date pickers, multiselect checkboxes,\n * checkbox groups, radio groups, multiselect tags, image/file uploaders, and sliders.\n *\n * @example\n * ```tsx\n * <FormGroup\n * title=\"Create Entry\"\n * description=\"Fill in the details below\"\n * fields={[\n * { id: \"name\", label: \"Name\", type: \"text\" },\n * { id: \"bio\", label: \"Bio\", type: \"textarea\" },\n * ]}\n * onSave={() => console.log(\"Save\")}\n * onCancel={() => console.log(\"Cancel\")}\n * />\n * ```\n */\nexport function FormGroup({\n title = \"Title\",\n description = \"Description\",\n fields = defaultFields,\n sortOptions = defaultSortOptions,\n actionButtonText = \"Add new\",\n cancelButtonText = \"Cancel\",\n saveButtonText = \"Save changes\",\n inputSize = \"default\",\n onAddNew,\n onSort,\n onCancel,\n onSave,\n onFieldChange,\n showHeader = true,\n showFooter = true,\n className,\n}: FormGroupProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-lg)\" }}\n >\n {/* Header Section - outside the form border */}\n {showHeader && (\n <TitleGroup title={title} subtitle={description} sortOptions={sortOptions} onSort={onSort} actionButtonText={actionButtonText} onAction={onAddNew} />\n )}\n\n {/* Form Body - fields + footer inside the border */}\n <div\n className=\"flex flex-col w-full rounded-[var(--radius-sm)] border border-[var(--canvas-border)] bg-[var(--canvas-background)]\"\n style={{ padding: \"var(--spacing-3xl)\", gap: \"var(--spacing-3xl)\" }}\n >\n {/* Form Fields */}\n {fields.map((field) => (\n <FormField\n key={field.id}\n field={field}\n inputSize={inputSize}\n onChange={onFieldChange}\n />\n ))}\n\n {/* Footer Section */}\n {showFooter && (\n <div\n className=\"flex items-center justify-end w-full overflow-hidden\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <Button variant=\"neutral\" size=\"default\" onClick={onCancel}>\n {cancelButtonText}\n </Button>\n <Button variant=\"primary\" size=\"default\" onClick={onSave}>\n {saveButtonText}\n </Button>\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// Export sub-component for advanced usage\nexport { FormField };\n"
19
+ "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { Label } from \"../ui/label\";\nimport { TextInput } from \"../ui/text-input\";\nimport { Textarea } from \"../ui/textarea\";\nimport { DateInput } from \"../ui/date-input\";\nimport { Slider } from \"../ui/slider\";\nimport { RadioGroup, RadioGroupItem } from \"../ui/radio-group\";\nimport { CheckboxWithLabel } from \"../ui/checkbox\";\nimport { MultiselectTags } from \"../ui/multiselect-tags\";\nimport { MultiselectCheckboxField, type CheckboxOption } from \"../ui/multiselect-checkbox-field\";\nimport { ImageUploader, type UploadedImage } from \"../ui/image-uploader\";\nimport { FileUploader, type UploadedFile } from \"../ui/file-uploader\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { TitleGroup } from \"./title-group\";\nimport { EditableList } from \"./editable-list\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface FormFieldConfig {\n id: string;\n label: string;\n type:\n | \"text\"\n | \"textarea\"\n | \"select\"\n | \"date\"\n | \"multiselect-checkbox\"\n | \"checkbox-group\"\n | \"radio-group\"\n | \"multiselect-tags\"\n | \"image-uploader\"\n | \"file-uploader\"\n | \"slider\"\n | \"list\";\n placeholder?: string;\n options?: { id: string; label: string }[];\n value?: string | string[] | number[] | UploadedImage[] | UploadedFile[];\n min?: number;\n max?: number;\n step?: number;\n /** Items for the list field type */\n listItems?: string[];\n /** Placeholder for the list add input */\n addPlaceholder?: string;\n disabled?: boolean;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FormGroupProps {\n /** Form title */\n title?: string;\n /** Form description */\n description?: string;\n /** Flat array of field configurations */\n fields?: FormFieldConfig[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Cancel button text */\n cancelButtonText?: string;\n /** Save button text */\n saveButtonText?: string;\n /** Input size variant */\n inputSize?: \"sm\" | \"default\" | \"lg\";\n /** Callback when action button is clicked */\n onAddNew?: () => void;\n /** Callback when sort value changes */\n onSort?: (value: string) => void;\n /** Callback when filter value changes */\n onFilter?: (value: string) => void;\n /** Callback when cancel button is clicked */\n onCancel?: () => void;\n /** Callback when save button is clicked */\n onSave?: () => void;\n /** Callback when a field value changes */\n onFieldChange?: (fieldId: string, value: unknown) => void;\n /** Show header section */\n showHeader?: boolean;\n /** Show footer section */\n showFooter?: boolean;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"name-asc\", label: \"Name (A-Z)\" },\n { id: \"name-desc\", label: \"Name (Z-A)\" },\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n];\n\nconst defaultCheckboxOptions: CheckboxOption[] = [\n { id: \"option-1\", label: \"Email notifications\" },\n { id: \"option-2\", label: \"SMS alerts\" },\n { id: \"option-3\", label: \"Push notifications\" },\n { id: \"option-4\", label: \"Weekly digest\" },\n { id: \"option-5\", label: \"Monthly report\" },\n];\n\nconst defaultSelectOptions = [\n { id: \"placeholder\", label: \"Select an option\" },\n { id: \"option-1\", label: \"Personal\" },\n { id: \"option-2\", label: \"Business\" },\n { id: \"option-3\", label: \"Enterprise\" },\n];\n\nconst defaultFields: FormFieldConfig[] = [\n { id: \"field-1\", label: \"Full name\", type: \"text\" },\n { id: \"field-2\", label: \"Email address\", type: \"text\" },\n {\n id: \"field-3\",\n label: \"Account type\",\n type: \"select\",\n options: defaultSelectOptions,\n },\n {\n id: \"field-4\",\n label: \"Notification preferences\",\n type: \"multiselect-checkbox\",\n options: defaultCheckboxOptions,\n value: [\"option-1\"],\n },\n { id: \"field-5\", label: \"Start date\", type: \"date\", placeholder: \"2/21/2024\" },\n {\n id: \"field-6\",\n label: \"Interests\",\n type: \"checkbox-group\",\n options: [\n { id: \"cb-1\", label: \"Design\" },\n { id: \"cb-2\", label: \"Engineering\" },\n { id: \"cb-3\", label: \"Marketing\" },\n { id: \"cb-4\", label: \"Sales\" },\n { id: \"cb-5\", label: \"Support\" },\n { id: \"cb-6\", label: \"Operations\" },\n ],\n },\n {\n id: \"field-7\",\n label: \"Experience level\",\n type: \"radio-group\",\n options: [\n { id: \"option-a\", label: \"Beginner\" },\n { id: \"option-b\", label: \"Intermediate\" },\n { id: \"option-c\", label: \"Advanced\" },\n { id: \"option-d\", label: \"Expert\" },\n ],\n },\n { id: \"field-8\", label: \"Company name\", type: \"text\" },\n {\n id: \"field-9\",\n label: \"Skills\",\n type: \"multiselect-tags\",\n value: [\"React\", \"TypeScript\"],\n },\n { id: \"field-10\", label: \"Profile photo\", type: \"image-uploader\", placeholder: \"Drop image here\" },\n { id: \"field-11\", label: \"Portfolio images\", type: \"image-uploader\", placeholder: \"Drop images here\" },\n { id: \"field-12\", label: \"Resume\", type: \"file-uploader\" },\n {\n id: \"field-13\",\n label: \"Budget range\",\n type: \"slider\",\n value: [0],\n min: 0,\n max: 1000,\n },\n {\n id: \"field-14\",\n label: \"References\",\n type: \"list\",\n listItems: [\"Alice Johnson\", \"Bob Chen\", \"Carol Martinez\"],\n addPlaceholder: \"Add reference\",\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface FormFieldProps {\n field: FormFieldConfig;\n inputSize?: \"sm\" | \"default\" | \"lg\";\n onChange?: (fieldId: string, value: unknown) => void;\n}\n\nfunction FormField({ field, inputSize = \"default\", onChange }: FormFieldProps) {\n const [localValue, setLocalValue] = React.useState<unknown>(field.value);\n\n const [tags, setTags] = React.useState<string[]>(\n Array.isArray(field.value) && field.type === \"multiselect-tags\"\n ? (field.value as string[])\n : []\n );\n\n const [checkedItems, setCheckedItems] = React.useState<string[]>(\n Array.isArray(field.value) && field.type === \"checkbox-group\"\n ? (field.value as string[])\n : []\n );\n\n const [images, setImages] = React.useState<UploadedImage[]>(\n Array.isArray(field.value) && field.type === \"image-uploader\"\n ? (field.value as UploadedImage[])\n : []\n );\n const [files, setFiles] = React.useState<UploadedFile[]>(\n Array.isArray(field.value) && field.type === \"file-uploader\"\n ? (field.value as UploadedFile[])\n : []\n );\n\n const [sliderValue, setSliderValue] = React.useState<number[]>(\n Array.isArray(field.value) && field.type === \"slider\"\n ? (field.value as number[])\n : [0]\n );\n\n const [listItems, setListItems] = React.useState<string[]>(\n field.listItems ?? []\n );\n\n const handleChange = (value: unknown) => {\n setLocalValue(value);\n onChange?.(field.id, value);\n };\n\n const renderField = () => {\n switch (field.type) {\n case \"text\":\n return (\n <TextInput\n inputSize={inputSize}\n placeholder={field.placeholder}\n value={typeof localValue === \"string\" ? localValue : \"\"}\n onChange={(e) => handleChange(e.target.value)}\n disabled={field.disabled}\n />\n );\n\n case \"textarea\":\n return (\n <Textarea\n inputSize={inputSize}\n placeholder={field.placeholder}\n value={typeof localValue === \"string\" ? localValue : \"\"}\n onChange={(e) => handleChange(e.target.value)}\n disabled={field.disabled}\n />\n );\n\n case \"select\":\n return (\n <Select\n value={typeof localValue === \"string\" ? localValue : undefined}\n onValueChange={(val) => handleChange(val)}\n disabled={field.disabled}\n >\n <SelectTrigger inputSize={inputSize}>\n <SelectValue placeholder={field.placeholder || \"Select...\"} />\n </SelectTrigger>\n <SelectContent>\n {field.options?.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n );\n\n case \"date\":\n return (\n <DateInput\n inputSize={inputSize}\n placeholder={field.placeholder}\n value={typeof localValue === \"string\" ? localValue : \"\"}\n onChange={(val) => handleChange(val)}\n disabled={field.disabled}\n />\n );\n\n case \"multiselect-checkbox\":\n return (\n <MultiselectCheckboxField\n options={field.options}\n selectedValues={\n Array.isArray(localValue) ? (localValue as string[]) : []\n }\n onChange={(vals) => handleChange(vals)}\n inputSize={inputSize}\n disabled={field.disabled}\n />\n );\n\n case \"checkbox-group\":\n return (\n <div\n className=\"flex flex-wrap items-center\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {field.options?.map((option) => (\n <CheckboxWithLabel\n key={option.id}\n checked={checkedItems.includes(option.id)}\n onCheckedChange={(checked) => {\n const newChecked = checked\n ? [...checkedItems, option.id]\n : checkedItems.filter((id) => id !== option.id);\n setCheckedItems(newChecked);\n onChange?.(field.id, newChecked);\n }}\n disabled={field.disabled}\n >\n {option.label}\n </CheckboxWithLabel>\n ))}\n </div>\n );\n\n case \"radio-group\":\n return (\n <RadioGroup\n value={typeof localValue === \"string\" ? localValue : undefined}\n onValueChange={(val) => handleChange(val)}\n disabled={field.disabled}\n className=\"flex flex-wrap items-center\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {field.options?.map((option) => (\n <div\n key={option.id}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <RadioGroupItem value={option.id} id={`${field.id}-${option.id}`} />\n <label\n htmlFor={`${field.id}-${option.id}`}\n className=\"text-[var(--canvas-text-muted)] cursor-pointer\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n }}\n >\n {option.label}\n </label>\n </div>\n ))}\n </RadioGroup>\n );\n\n case \"multiselect-tags\":\n return (\n <MultiselectTags\n tags={tags}\n inputSize={inputSize}\n onAdd={(tag) => {\n const newTags = [...tags, tag];\n setTags(newTags);\n onChange?.(field.id, newTags);\n }}\n onRemove={(tag) => {\n const newTags = tags.filter((t) => t !== tag);\n setTags(newTags);\n onChange?.(field.id, newTags);\n }}\n disabled={field.disabled}\n />\n );\n\n case \"image-uploader\":\n return (\n <ImageUploader\n images={images}\n placeholder={field.placeholder}\n onImagesChange={(newImages) => {\n setImages(newImages);\n onChange?.(field.id, newImages);\n }}\n disabled={field.disabled}\n />\n );\n\n case \"file-uploader\":\n return (\n <FileUploader\n files={files}\n placeholder={field.placeholder}\n onFilesChange={(newFiles) => {\n setFiles(newFiles);\n onChange?.(field.id, newFiles);\n }}\n disabled={field.disabled}\n />\n );\n\n case \"slider\":\n return (\n <Slider\n inputSize={inputSize}\n value={sliderValue}\n onValueChange={(vals) => {\n setSliderValue(vals);\n onChange?.(field.id, vals);\n }}\n min={field.min ?? 0}\n max={field.max ?? 100}\n step={field.step ?? 1}\n showLabel\n labelFormatter={(vals) => `$${vals[0]}`}\n disabled={field.disabled}\n />\n );\n\n case \"list\":\n return (\n <EditableList\n items={listItems}\n onItemsChange={(newItems) => {\n setListItems(newItems);\n onChange?.(field.id, newItems);\n }}\n addPlaceholder={field.addPlaceholder}\n disabled={field.disabled}\n />\n );\n\n default:\n return null;\n }\n };\n\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <Label>{field.label}</Label>\n {renderField()}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Form Group Block\n *\n * A single-column form layout block with configurable fields, header section\n * (title, description, sort/filter, action button), and footer with action buttons.\n * Supports text inputs, textareas, selects, date pickers, multiselect checkboxes,\n * checkbox groups, radio groups, multiselect tags, image/file uploaders, and sliders.\n *\n * @example\n * ```tsx\n * <FormGroup\n * title=\"Create Entry\"\n * description=\"Fill in the details below\"\n * fields={[\n * { id: \"name\", label: \"Name\", type: \"text\" },\n * { id: \"bio\", label: \"Bio\", type: \"textarea\" },\n * ]}\n * onSave={() => console.log(\"Save\")}\n * onCancel={() => console.log(\"Cancel\")}\n * />\n * ```\n */\nexport function FormGroup({\n title = \"Title\",\n description = \"Description\",\n fields = defaultFields,\n sortOptions = defaultSortOptions,\n actionButtonText = \"Add new\",\n cancelButtonText = \"Cancel\",\n saveButtonText = \"Save changes\",\n inputSize = \"default\",\n onAddNew,\n onSort,\n onCancel,\n onSave,\n onFieldChange,\n showHeader = true,\n showFooter = true,\n className,\n}: FormGroupProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-lg)\" }}\n >\n {/* Header Section - outside the form border */}\n {showHeader && (\n <TitleGroup title={title} subtitle={description} sortOptions={sortOptions} onSort={onSort} actionButtonText={actionButtonText} onAction={onAddNew} />\n )}\n\n {/* Form Body - fields + footer inside the border */}\n <div\n className=\"flex flex-col w-full rounded-[var(--radius-sm)] border border-[var(--canvas-border)] bg-[var(--canvas-background)]\"\n style={{ padding: \"var(--spacing-3xl)\", gap: \"var(--spacing-3xl)\" }}\n >\n {/* Form Fields */}\n {fields.map((field) => (\n <FormField\n key={field.id}\n field={field}\n inputSize={inputSize}\n onChange={onFieldChange}\n />\n ))}\n\n {/* Footer Section */}\n {showFooter && (\n <div\n className=\"flex items-center justify-end w-full overflow-hidden\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <Button variant=\"neutral\" size=\"default\" onClick={onCancel}>\n {cancelButtonText}\n </Button>\n <Button variant=\"primary\" size=\"default\" onClick={onSave}>\n {saveButtonText}\n </Button>\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// Export sub-component for advanced usage\nexport { FormField };\n"
10
20
  }
11
21
  ],
12
22
  "dependencies": [],
@@ -25,6 +35,7 @@
25
35
  "ui/image-uploader",
26
36
  "ui/file-uploader",
27
37
  "ui/select",
28
- "blocks/title-group"
38
+ "blocks/title-group",
39
+ "blocks/editable-list"
29
40
  ]
30
41
  }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "form-popup",
3
+ "type": "registry:block",
4
+ "description": "Scrollable form modal supporting diverse field types: text, select, date, textarea, radio, checkbox, multiselect tags, image/file uploaders, and sliders. Centered dialog (~400-500px tall). Use for create/edit forms, settings dialogs, or any structured data input in a modal context.",
5
+ "keywords": [
6
+ "form",
7
+ "modal",
8
+ "fields",
9
+ "input",
10
+ "create",
11
+ "edit",
12
+ "dialog"
13
+ ],
14
+ "visualWeight": "medium",
15
+ "files": [
16
+ {
17
+ "path": "components/blocks/form-popup.tsx",
18
+ "type": "registry:block",
19
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n Dialog,\n DialogContent,\n DialogTitle,\n DialogDescription,\n} from \"../ui/dialog\";\nimport { Button } from \"../ui/button\";\nimport { FormField, type FormFieldConfig } from \"./form-group\";\nimport type { UploadedImage } from \"../ui/image-uploader\";\nimport type { UploadedFile } from \"../ui/file-uploader\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface FormPopupProps {\n /** Controls dialog visibility */\n open?: boolean;\n /** Callback when dialog open state changes */\n onOpenChange?: (open: boolean) => void;\n /** Dialog title */\n title?: string;\n /** Descriptive body text */\n description?: string;\n /** Form field configurations */\n fields?: FormFieldConfig[];\n /** Cancel button label */\n cancelLabel?: string;\n /** Save button label */\n saveLabel?: string;\n /** Input size variant */\n inputSize?: \"sm\" | \"default\" | \"lg\";\n /** Callback when save is clicked */\n onSave?: () => void;\n /** Callback when cancel is clicked */\n onCancel?: () => void;\n /** Callback when a field value changes */\n onFieldChange?: (fieldId: string, value: unknown) => void;\n /** Disables the save button */\n loading?: boolean;\n /** Additional class names */\n className?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TITLE = \"Submit a request\";\nconst DEFAULT_DESCRIPTION = \"Fill out the form below and we'll get back to you within 24 hours.\";\n\nconst defaultSelectOptions = [\n { id: \"placeholder\", label: \"Select a category\" },\n { id: \"option-1\", label: \"General inquiry\" },\n { id: \"option-2\", label: \"Technical support\" },\n { id: \"option-3\", label: \"Billing question\" },\n];\n\nconst DEFAULT_FIELDS: FormFieldConfig[] = [\n { id: \"fp-1\", label: \"Full name\", type: \"text\", placeholder: \"Jane Doe\" },\n { id: \"fp-2\", label: \"Message\", type: \"textarea\", placeholder: \"Describe your request...\" },\n {\n id: \"fp-3\",\n label: \"Category\",\n type: \"select\",\n options: defaultSelectOptions,\n placeholder: \"Select a category\",\n },\n { id: \"fp-4\", label: \"Preferred date\", type: \"date\", placeholder: \"2/21/2024\" },\n { id: \"fp-5\", label: \"Phone number\", type: \"text\", placeholder: \"+1 (555) 123-4567\" },\n {\n id: \"fp-6\",\n label: \"Priority\",\n type: \"radio-group\",\n options: [\n { id: \"option-a\", label: \"Low\" },\n { id: \"option-b\", label: \"Medium\" },\n { id: \"option-c\", label: \"High\" },\n { id: \"option-d\", label: \"Urgent\" },\n ],\n },\n {\n id: \"fp-7\",\n label: \"Tags\",\n type: \"multiselect-tags\",\n value: [\"Bug report\", \"Feature request\"] as string[],\n },\n { id: \"fp-8\", label: \"Screenshot\", type: \"image-uploader\", placeholder: \"Drop image here\" },\n { id: \"fp-9\", label: \"Attachment\", type: \"file-uploader\", placeholder: \"Drop file here\" },\n {\n id: \"fp-10\",\n label: \"Agreement\",\n type: \"checkbox-group\",\n options: [{ id: \"cb-1\", label: \"I agree to the terms and conditions\" }],\n },\n {\n id: \"fp-11\",\n label: \"Satisfaction rating\",\n type: \"slider\",\n value: [0] as number[],\n min: 0,\n max: 1000,\n },\n {\n id: \"fp-12\",\n label: \"Related links\",\n type: \"list\",\n listItems: [\"https://example.com/issue-1\", \"https://example.com/docs\"],\n addPlaceholder: \"Add URL\",\n },\n];\n\n// ---------------------------------------------------------------------------\n// FormPopup\n// ---------------------------------------------------------------------------\n\nexport function FormPopup({\n open,\n onOpenChange,\n title = DEFAULT_TITLE,\n description = DEFAULT_DESCRIPTION,\n fields = DEFAULT_FIELDS,\n cancelLabel = \"Cancel\",\n saveLabel = \"Save changes\",\n inputSize = \"default\",\n onSave,\n onCancel,\n onFieldChange,\n loading = false,\n className,\n}: FormPopupProps) {\n const handleCancel = () => {\n onCancel?.();\n onOpenChange?.(false);\n };\n\n const handleSave = () => {\n onSave?.();\n };\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent\n className={cn(\n \"p-0 gap-0 overflow-hidden\",\n \"rounded-[var(--radius-xl)]\",\n \"shadow-[0px_4px_24px_0px_rgba(0,0,0,0.1)]\",\n \"sm:max-w-[576px]\",\n \"max-h-[85vh] flex flex-col\",\n className\n )}\n showCloseButton\n >\n {/* Scrollable content */}\n <div\n className=\"flex flex-col overflow-y-auto flex-1\"\n style={{\n padding: \"var(--spacing-4xl)\",\n gap: \"var(--spacing-3xl)\",\n }}\n >\n {/* Title & Description */}\n <div className=\"flex flex-col\">\n <DialogTitle\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n fontWeight: 600,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </DialogTitle>\n <DialogDescription\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {description}\n </DialogDescription>\n </div>\n\n {/* Form Fields */}\n {fields.map((field) => (\n <FormField\n key={field.id}\n field={field}\n inputSize={inputSize}\n onChange={onFieldChange}\n />\n ))}\n\n {/* Actions */}\n <div className=\"flex w-full gap-[var(--spacing-3xl)] justify-end\">\n <Button variant=\"neutral\" onClick={handleCancel}>\n {cancelLabel}\n </Button>\n <Button\n variant=\"primary\"\n onClick={handleSave}\n disabled={loading}\n >\n {saveLabel}\n </Button>\n </div>\n </div>\n </DialogContent>\n </Dialog>\n );\n}\n"
20
+ }
21
+ ],
22
+ "dependencies": [],
23
+ "registryDependencies": [
24
+ "lib/utils",
25
+ "ui/dialog",
26
+ "ui/button",
27
+ "blocks/form-group",
28
+ "ui/image-uploader",
29
+ "ui/file-uploader"
30
+ ]
31
+ }
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "gallery-section",
3
3
  "type": "registry:block",
4
- "description": "Image gallery with title and grid layout.",
4
+ "description": "3-column masonry-style image gallery with varying image heights. Header with title, subtitle, and two description paragraphs. Light and dark variants. Use for photo galleries, portfolio showcases, or visual inspiration sections.",
5
+ "keywords": [
6
+ "gallery",
7
+ "images",
8
+ "masonry",
9
+ "photos",
10
+ "portfolio"
11
+ ],
12
+ "visualWeight": "heavy",
5
13
  "files": [
6
14
  {
7
15
  "path": "components/blocks/marketing/gallery-section.tsx",
8
16
  "type": "registry:block",
9
- "content": "\"use client\";\n\nimport { Typography } from \"../../ui/typography\";\n\ninterface GallerySectionProps {\n variant?: \"light\" | \"dark\";\n subtitle?: string;\n title?: string;\n description1?: string;\n description2?: string;\n images?: string[];\n}\n\nconst defaultImages = [\n \"https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=400&h=350&fit=crop\",\n \"https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?w=400&h=405&fit=crop\",\n \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=400&h=453&fit=crop\",\n \"https://images.unsplash.com/photo-1534430480872-3498386e7856?w=400&h=380&fit=crop\",\n \"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=400&h=300&fit=crop\",\n \"https://images.unsplash.com/photo-1530841377377-3ff06c0ca713?w=400&h=489&fit=crop\",\n];\n\nexport function GallerySection({ \n variant = \"light\",\n subtitle = \"GALLERY\",\n title = \"Discover the best places\",\n description1 = \"Traveling is an exciting way to discover new places, cultures, and experiences. Whether you're a seasoned traveler or new to exploring, there are countless destinations waiting to be discovered.\",\n description2 = \"From bustling cities to secluded beaches, from ancient ruins to modern landmarks, there's something for everyone. Get inspired by pictures from our trusted community below.\",\n images = defaultImages,\n}: GallerySectionProps) {\n const isDark = variant === \"dark\";\n \n return (\n <section \n className=\"w-full px-4 md:px-8 lg:px-20 py-16 md:py-24\"\n style={{\n backgroundColor: isDark ? \"var(--canvas-dark-section-bg)\" : \"var(--canvas-background)\",\n }}\n >\n <div className=\"w-full max-w-[1240px] mx-auto flex flex-col gap-12\">\n {/* Header */}\n <div className=\"flex flex-col gap-6\">\n <div className=\"flex flex-col gap-3\">\n <Typography variant=\"body-s\" as=\"p\" color=\"muted\">\n {subtitle}\n </Typography>\n <Typography \n variant=\"h3\" \n as=\"h2\"\n {...(isDark && { style: { color: \"white\" } })}\n >\n {title}\n </Typography>\n </div>\n <div className=\"flex flex-col md:flex-row gap-8 md:gap-16\">\n <Typography \n variant=\"body-l\" \n color=\"muted\"\n className=\"flex-1\"\n {...(isDark && { style: { color: \"var(--canvas-text-placeholder)\" } })}\n >\n {description1}\n </Typography>\n <Typography \n variant=\"body-l\" \n color=\"muted\"\n className=\"flex-1\"\n {...(isDark && { style: { color: \"var(--canvas-text-placeholder)\" } })}\n >\n {description2}\n </Typography>\n </div>\n </div>\n\n {/* Images Grid - 3 columns with varying heights */}\n <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8\">\n {/* Column 1 */}\n <div className=\"flex flex-col gap-8\">\n <div \n className=\"w-full h-[350px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[0]} \n alt=\"Gallery image 1\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div \n className=\"w-full h-[405px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[1]} \n alt=\"Gallery image 2\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n \n {/* Column 2 */}\n <div className=\"flex flex-col gap-8\">\n <div \n className=\"w-full h-[453px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[2]} \n alt=\"Gallery image 3\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div \n className=\"w-full h-[380px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[3]} \n alt=\"Gallery image 4\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n \n {/* Column 3 */}\n <div className=\"flex flex-col gap-8\">\n <div \n className=\"w-full h-[300px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[4]} \n alt=\"Gallery image 5\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div \n className=\"w-full h-[489px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[5]} \n alt=\"Gallery image 6\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n </div>\n </div>\n </section>\n );\n}\n"
17
+ "content": "\"use client\";\n\nimport { Typography } from \"../../ui/typography\";\n\ninterface GallerySectionProps {\n variant?: \"light\" | \"dark\";\n subtitle?: string;\n title?: string;\n description1?: string;\n description2?: string;\n images?: string[];\n}\n\nconst defaultImages = [\n \"https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=400&h=350&fit=crop\",\n \"https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?w=400&h=405&fit=crop\",\n \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=400&h=453&fit=crop\",\n \"https://images.unsplash.com/photo-1534430480872-3498386e7856?w=400&h=380&fit=crop\",\n \"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=400&h=300&fit=crop\",\n \"https://images.unsplash.com/photo-1530841377377-3ff06c0ca713?w=400&h=489&fit=crop\",\n];\n\nexport function GallerySection({ \n variant = \"light\",\n subtitle = \"GALLERY\",\n title = \"Discover the best places\",\n description1 = \"Traveling is an exciting way to discover new places, cultures, and experiences. Whether you're a seasoned traveler or new to exploring, there are countless destinations waiting to be discovered.\",\n description2 = \"From bustling cities to secluded beaches, from ancient ruins to modern landmarks, there's something for everyone. Get inspired by pictures from our trusted community below.\",\n images = defaultImages,\n}: GallerySectionProps) {\n const isDark = variant === \"dark\";\n \n return (\n <section \n className=\"w-full px-4 md:px-8 lg:px-20 py-16 md:py-24\"\n style={{\n backgroundColor: isDark ? \"var(--canvas-dark-section-bg)\" : \"var(--canvas-background)\",\n }}\n >\n <div className=\"w-full max-w-[1240px] mx-auto flex flex-col gap-12\">\n {/* Header */}\n <div className=\"flex flex-col gap-6\">\n <div className=\"flex flex-col gap-3\">\n <Typography variant=\"body-s\" as=\"p\" color=\"muted\">\n {subtitle}\n </Typography>\n <Typography \n variant=\"h3\" \n as=\"h2\"\n {...(isDark && { style: { color: \"white\" } })}\n >\n {title}\n </Typography>\n </div>\n <div className=\"flex flex-col md:flex-row gap-8 md:gap-16\">\n <Typography \n variant=\"body-l\" \n color=\"muted\"\n className=\"flex-1\"\n {...(isDark && { style: { color: \"var(--canvas-text-placeholder)\" } })}\n >\n {description1}\n </Typography>\n <Typography \n variant=\"body-l\" \n color=\"muted\"\n className=\"flex-1\"\n {...(isDark && { style: { color: \"var(--canvas-text-placeholder)\" } })}\n >\n {description2}\n </Typography>\n </div>\n </div>\n\n {/* Images Grid - 3 columns with varying heights */}\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8\">\n {/* Column 1 */}\n <div className=\"flex flex-col gap-8\">\n <div \n className=\"w-full h-[350px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[0]} \n alt=\"Gallery image 1\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div \n className=\"w-full h-[405px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[1]} \n alt=\"Gallery image 2\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n \n {/* Column 2 */}\n <div className=\"flex flex-col gap-8\">\n <div \n className=\"w-full h-[453px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[2]} \n alt=\"Gallery image 3\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div \n className=\"w-full h-[380px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[3]} \n alt=\"Gallery image 4\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n \n {/* Column 3 */}\n <div className=\"flex flex-col gap-8\">\n <div \n className=\"w-full h-[300px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[4]} \n alt=\"Gallery image 5\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div \n className=\"w-full h-[489px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[5]} \n alt=\"Gallery image 6\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n </div>\n </div>\n </section>\n );\n}\n"
10
18
  }
11
19
  ],
12
20
  "dependencies": [],
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "gradient-banner",
3
3
  "type": "registry:block",
4
- "description": "Banner with gradient background. Alternative to FlairBanner.",
4
+ "description": "Full-width banner with gradient background and optional title/subtitle text. Use as a decorative page header or profile banner area.",
5
+ "keywords": [
6
+ "banner",
7
+ "gradient",
8
+ "header",
9
+ "decorative",
10
+ "profile"
11
+ ],
12
+ "visualWeight": "spacer",
5
13
  "files": [
6
14
  {
7
15
  "path": "components/blocks/gradient-banner.tsx",
8
16
  "type": "registry:block",
9
- "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\n\ninterface GradientBannerProps {\n /** Height of the banner */\n height?: string;\n /** Additional class names */\n className?: string;\n /** Children to render inside the banner */\n children?: React.ReactNode;\n}\n\n/**\n * Convert hex color to HSL\n */\nfunction hexToHsl(hex: string): { h: number; s: number; l: number } {\n // Remove # if present\n hex = hex.replace(/^#/, \"\");\n\n // Parse hex values\n const r = parseInt(hex.substring(0, 2), 16) / 255;\n const g = parseInt(hex.substring(2, 4), 16) / 255;\n const b = parseInt(hex.substring(4, 6), 16) / 255;\n\n const max = Math.max(r, g, b);\n const min = Math.min(r, g, b);\n let h = 0;\n let s = 0;\n const l = (max + min) / 2;\n\n if (max !== min) {\n const d = max - min;\n s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n\n switch (max) {\n case r:\n h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n break;\n case g:\n h = ((b - r) / d + 2) / 6;\n break;\n case b:\n h = ((r - g) / d + 4) / 6;\n break;\n }\n }\n\n return { h: h * 360, s: s * 100, l: l * 100 };\n}\n\n/**\n * Convert HSL to hex color\n */\nfunction hslToHex(h: number, s: number, l: number): string {\n s /= 100;\n l /= 100;\n\n const c = (1 - Math.abs(2 * l - 1)) * s;\n const x = c * (1 - Math.abs(((h / 60) % 2) - 1));\n const m = l - c / 2;\n\n let r = 0, g = 0, b = 0;\n\n if (h >= 0 && h < 60) {\n r = c; g = x; b = 0;\n } else if (h >= 60 && h < 120) {\n r = x; g = c; b = 0;\n } else if (h >= 120 && h < 180) {\n r = 0; g = c; b = x;\n } else if (h >= 180 && h < 240) {\n r = 0; g = x; b = c;\n } else if (h >= 240 && h < 300) {\n r = x; g = 0; b = c;\n } else if (h >= 300 && h < 360) {\n r = c; g = 0; b = x;\n }\n\n const toHex = (n: number) => {\n const hex = Math.round((n + m) * 255).toString(16);\n return hex.length === 1 ? \"0\" + hex : hex;\n };\n\n return `#${toHex(r)}${toHex(g)}${toHex(b)}`;\n}\n\n/**\n * Generate analogous colors based on a primary color\n * Creates a gradient with colors adjacent on the color wheel\n * Uses darker lightness values similar to the flair background (~25-40%)\n */\nfunction generateAnalogousGradient(primaryColor: string): string {\n const hsl = hexToHsl(primaryColor);\n \n // Target lightness around 25-40% to match flair background darkness\n const baseLightness = 30;\n \n // Create analogous colors by shifting hue\n // Spread across the color wheel for a smooth gradient with rich, dark colors\n const colors = [\n hslToHex((hsl.h - 30 + 360) % 360, Math.min(hsl.s + 15, 100), baseLightness + 10), // Shifted left\n hslToHex((hsl.h - 10 + 360) % 360, Math.min(hsl.s + 10, 100), baseLightness + 5), // Slightly shifted\n hslToHex(hsl.h, Math.min(hsl.s + 5, 100), baseLightness), // Primary base\n hslToHex((hsl.h + 20) % 360, Math.min(hsl.s + 10, 100), baseLightness + 5), // Shifted right\n hslToHex((hsl.h + 45) % 360, Math.min(hsl.s + 15, 100), baseLightness + 10), // Further right\n ];\n\n return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 25%, ${colors[2]} 50%, ${colors[3]} 75%, ${colors[4]} 100%)`;\n}\n\n/**\n * Canvas Design System - Gradient Banner Component\n *\n * A dynamic gradient banner that generates analogous colors\n * based on the primary theme color. Updates live when the\n * primary color changes.\n */\nexport function GradientBanner({\n height = \"200px\",\n className,\n children,\n}: GradientBannerProps) {\n const [gradient, setGradient] = useState<string>(\n \"linear-gradient(135deg, #1a4a7a 0%, #093378 50%, #3d1a78 100%)\"\n );\n\n useEffect(() => {\n // Function to update gradient based on primary color\n const updateGradient = () => {\n const primaryColor = getComputedStyle(document.documentElement)\n .getPropertyValue(\"--canvas-primary\")\n .trim();\n \n if (primaryColor) {\n const newGradient = generateAnalogousGradient(primaryColor);\n setGradient(newGradient);\n }\n };\n\n // Initial update\n updateGradient();\n\n // Create a MutationObserver to watch for style changes on :root\n const observer = new MutationObserver((mutations) => {\n mutations.forEach((mutation) => {\n if (mutation.type === \"attributes\" && mutation.attributeName === \"style\") {\n updateGradient();\n }\n });\n });\n\n // Observe the document element for style attribute changes\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"style\"],\n });\n\n // Also listen for custom theme change events if they exist\n const handleThemeChange = () => updateGradient();\n window.addEventListener(\"theme-change\", handleThemeChange);\n\n return () => {\n observer.disconnect();\n window.removeEventListener(\"theme-change\", handleThemeChange);\n };\n }, []);\n\n return (\n <div\n className={cn(\"relative w-full overflow-hidden\", className)}\n style={{\n height,\n background: gradient,\n }}\n >\n {children}\n </div>\n );\n}\n\n"
17
+ "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\n\ninterface GradientBannerProps {\n /** Height of the banner */\n height?: string;\n /** Additional class names */\n className?: string;\n /** Children to render inside the banner */\n children?: React.ReactNode;\n}\n\n/**\n * Convert hex color to HSL\n */\nfunction hexToHsl(hex: string): { h: number; s: number; l: number } {\n // Remove # if present\n hex = hex.replace(/^#/, \"\");\n\n // Parse hex values\n const r = parseInt(hex.substring(0, 2), 16) / 255;\n const g = parseInt(hex.substring(2, 4), 16) / 255;\n const b = parseInt(hex.substring(4, 6), 16) / 255;\n\n const max = Math.max(r, g, b);\n const min = Math.min(r, g, b);\n let h = 0;\n let s = 0;\n const l = (max + min) / 2;\n\n if (max !== min) {\n const d = max - min;\n s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n\n switch (max) {\n case r:\n h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n break;\n case g:\n h = ((b - r) / d + 2) / 6;\n break;\n case b:\n h = ((r - g) / d + 4) / 6;\n break;\n }\n }\n\n return { h: h * 360, s: s * 100, l: l * 100 };\n}\n\n/**\n * Convert HSL to hex color\n */\nfunction hslToHex(h: number, s: number, l: number): string {\n s /= 100;\n l /= 100;\n\n const c = (1 - Math.abs(2 * l - 1)) * s;\n const x = c * (1 - Math.abs(((h / 60) % 2) - 1));\n const m = l - c / 2;\n\n let r = 0, g = 0, b = 0;\n\n if (h >= 0 && h < 60) {\n r = c; g = x; b = 0;\n } else if (h >= 60 && h < 120) {\n r = x; g = c; b = 0;\n } else if (h >= 120 && h < 180) {\n r = 0; g = c; b = x;\n } else if (h >= 180 && h < 240) {\n r = 0; g = x; b = c;\n } else if (h >= 240 && h < 300) {\n r = x; g = 0; b = c;\n } else if (h >= 300 && h < 360) {\n r = c; g = 0; b = x;\n }\n\n const toHex = (n: number) => {\n const hex = Math.round((n + m) * 255).toString(16);\n return hex.length === 1 ? \"0\" + hex : hex;\n };\n\n return `#${toHex(r)}${toHex(g)}${toHex(b)}`;\n}\n\n/**\n * Generate a smooth mesh gradient that respects the primary color's actual\n * hue, saturation, AND lightness. Uses multiple overlapping radial gradients\n * with wide falloffs to create an organic, non-stripey result.\n */\nfunction generateMeshGradient(primaryColor: string): string {\n const hsl = hexToHsl(primaryColor);\n const sat = Math.min(hsl.s + 10, 100);\n const l = hsl.l;\n\n const clamp = (v: number) => Math.max(5, Math.min(95, v));\n\n const primary = hslToHex(hsl.h, sat, clamp(l));\n const primaryLight = hslToHex(hsl.h, sat, clamp(l + 12));\n const primaryPale = hslToHex(hsl.h, Math.max(sat - 10, 20), clamp(l + 24));\n const neighbor = hslToHex((hsl.h + 30) % 360, sat, clamp(l + 10));\n const neighborSoft = hslToHex((hsl.h + 20) % 360, Math.max(sat - 15, 20), clamp(l + 18));\n const base = hslToHex(hsl.h, Math.max(sat - 25, 25), clamp(l + 22));\n\n return [\n // Large soft primary wash from top-left\n `radial-gradient(180% 150% at -10% -20%, ${primary} 0%, transparent 70%)`,\n // Mid-tone bridge across top\n `radial-gradient(200% 100% at 40% -30%, ${primaryLight} 0%, transparent 65%)`,\n // Soft analogous bloom from bottom-right\n `radial-gradient(160% 140% at 110% 120%, ${neighbor} 0%, transparent 65%)`,\n // Gentle transition layer in center\n `radial-gradient(120% 80% at 60% 60%, ${neighborSoft} 0%, transparent 80%)`,\n // Very wide pale fill for smooth base\n `radial-gradient(200% 200% at 30% 80%, ${primaryPale} 0%, transparent 90%)`,\n base,\n ].join(\", \");\n}\n\n/**\n * Canvas Design System - Gradient Banner Component\n *\n * A dynamic mesh-style gradient banner that layers radial gradients\n * using the primary color, a darker variant, and an analogous accent\n * from the adjacent position on the color wheel. Updates live when\n * the primary color changes.\n */\nexport function GradientBanner({\n height = \"200px\",\n className,\n children,\n}: GradientBannerProps) {\n const [gradient, setGradient] = useState<string>(\n \"radial-gradient(180% 150% at -10% -20%, #5b9de8 0%, transparent 70%), radial-gradient(200% 100% at 40% -30%, #7aafe8 0%, transparent 65%), radial-gradient(160% 140% at 110% 120%, #7bb8e8 0%, transparent 65%), radial-gradient(120% 80% at 60% 60%, #8ec4ed 0%, transparent 80%), radial-gradient(200% 200% at 30% 80%, #a3caf5 0%, transparent 90%), #d6e4f7\"\n );\n\n useEffect(() => {\n // Function to update gradient based on primary color.\n // Check body first (where most theme drawers set variables), then :root.\n const updateGradient = () => {\n const primaryColor = (\n getComputedStyle(document.body).getPropertyValue(\"--canvas-primary\").trim() ||\n getComputedStyle(document.documentElement).getPropertyValue(\"--canvas-primary\").trim()\n );\n\n if (primaryColor) {\n const newGradient = generateMeshGradient(primaryColor);\n setGradient(newGradient);\n }\n };\n\n // Initial update\n updateGradient();\n\n // Watch both document.documentElement and document.body for style changes,\n // since theme systems may set CSS variables on either target.\n const observer = new MutationObserver((mutations) => {\n mutations.forEach((mutation) => {\n if (mutation.type === \"attributes\" && mutation.attributeName === \"style\") {\n updateGradient();\n }\n });\n });\n\n const observerConfig = { attributes: true, attributeFilter: [\"style\"] };\n observer.observe(document.documentElement, observerConfig);\n if (document.body) {\n observer.observe(document.body, observerConfig);\n }\n\n const handleThemeChange = () => updateGradient();\n window.addEventListener(\"theme-change\", handleThemeChange);\n\n return () => {\n observer.disconnect();\n window.removeEventListener(\"theme-change\", handleThemeChange);\n };\n }, []);\n\n return (\n <div\n className={cn(\"relative w-full overflow-hidden\", className)}\n style={{\n height,\n background: gradient,\n }}\n >\n {children}\n </div>\n );\n}\n\n"
10
18
  }
11
19
  ],
12
20
  "dependencies": [],
@@ -6,7 +6,7 @@
6
6
  {
7
7
  "path": "components/blocks/graph-metric-tiles.tsx",
8
8
  "type": "registry:block",
9
- "content": "\"use client\";\n\nimport { useState, useEffect, useCallback } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n ArrowUpRight,\n ArrowDownRight,\n DotsThreeVertical,\n Gear,\n} from \"@phosphor-icons/react\";\nimport {\n Chart as ChartJS,\n CategoryScale,\n LinearScale,\n PointElement,\n LineElement,\n Title,\n Tooltip,\n Legend,\n Filler,\n ArcElement,\n} from \"chart.js\";\nimport { Line, Doughnut } from \"react-chartjs-2\";\n\n// Register Chart.js components\nChartJS.register(\n CategoryScale,\n LinearScale,\n PointElement,\n LineElement,\n Title,\n Tooltip,\n Legend,\n Filler,\n ArcElement\n);\n\n// ============================================\n// Custom Hook: useCSSVariableColors\n// ============================================\n\ninterface CSSVariableColors {\n primary: string;\n background: string;\n text: string;\n textMuted: string;\n textPlaceholder: string;\n border: string;\n success: string;\n chartColor1: string;\n chartColor2: string;\n chartColor3: string;\n chartColor4: string;\n chartColor5: string;\n chartLineColor: string;\n chartAreaColor: string;\n}\n\nfunction useCSSVariableColors(): CSSVariableColors {\n const [colors, setColors] = useState<CSSVariableColors>({\n primary: \"#1165ef\",\n background: \"#ffffff\",\n text: \"#0d121c\",\n textMuted: \"#4b5565\",\n textPlaceholder: \"#6c7684\",\n border: \"#e9eef3\",\n success: \"#08875d\",\n chartColor1: \"#e45451\",\n chartColor2: \"#69bdbc\",\n chartColor3: \"#f8f578\",\n chartColor4: \"#f2b66b\",\n chartColor5: \"#6c7684\",\n chartLineColor: \"#1165ef\",\n chartAreaColor: \"rgba(17, 101, 239, 0.1)\",\n });\n\n const readColors = useCallback(() => {\n if (typeof window === \"undefined\") return;\n // Read from document.body since theme overrides are applied there\n const style = getComputedStyle(document.body);\n const primary = style.getPropertyValue(\"--canvas-primary\").trim() || \"#1165ef\";\n\n // Derive chart area color from primary with 10% opacity\n const hexToRgba = (hex: string, alpha: number): string => {\n const r = parseInt(hex.slice(1, 3), 16);\n const g = parseInt(hex.slice(3, 5), 16);\n const b = parseInt(hex.slice(5, 7), 16);\n return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n };\n\n setColors({\n primary,\n background: style.getPropertyValue(\"--canvas-background\").trim() || \"#ffffff\",\n text: style.getPropertyValue(\"--canvas-text\").trim() || \"#0d121c\",\n textMuted: style.getPropertyValue(\"--canvas-text-muted\").trim() || \"#4b5565\",\n textPlaceholder: style.getPropertyValue(\"--canvas-text-placeholder\").trim() || \"#6c7684\",\n border: style.getPropertyValue(\"--canvas-border\").trim() || \"#e9eef3\",\n success: style.getPropertyValue(\"--canvas-success\").trim() || \"#08875d\",\n chartColor1: style.getPropertyValue(\"--chart-color-1\").trim() || \"#e45451\",\n chartColor2: style.getPropertyValue(\"--chart-color-2\").trim() || \"#69bdbc\",\n chartColor3: style.getPropertyValue(\"--chart-color-3\").trim() || \"#f8f578\",\n chartColor4: style.getPropertyValue(\"--chart-color-4\").trim() || \"#f2b66b\",\n chartColor5: style.getPropertyValue(\"--chart-color-5\").trim() || \"#6c7684\",\n chartLineColor: style.getPropertyValue(\"--chart-line-color\").trim() || primary,\n chartAreaColor: style.getPropertyValue(\"--chart-area-color\").trim() || hexToRgba(primary, 0.1),\n });\n }, []);\n\n useEffect(() => {\n readColors();\n\n // Watch both document.body and document.documentElement for style changes\n // Theme drawer writes to body, stylesheet/iframe sync writes to documentElement\n const observer = new MutationObserver(() => {\n readColors();\n });\n\n observer.observe(document.body, {\n attributes: true,\n attributeFilter: [\"style\"],\n });\n\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"style\"],\n });\n\n // Also listen for custom event that variables drawer might dispatch\n const handleVariableChange = () => readColors();\n window.addEventListener(\"css-variables-changed\", handleVariableChange);\n\n return () => {\n observer.disconnect();\n window.removeEventListener(\"css-variables-changed\", handleVariableChange);\n };\n }, [readColors]);\n\n return colors;\n}\n\n// ============================================\n// Types\n// ============================================\n\nexport interface MetricCardData {\n id: string;\n label: string;\n value: string;\n change?: {\n value: string;\n direction: \"up\" | \"down\";\n };\n}\n\nexport interface MetricListItem {\n id: string;\n date?: string;\n label: string;\n value: string;\n}\n\nexport interface DonutChartLegendItem {\n id: string;\n label: string;\n value: string;\n color: string;\n}\n\nexport interface ProgressBarItem {\n id: string;\n label: string;\n sublabel?: string;\n value?: string;\n progress: number; // 0-100\n imageUrl?: string;\n color?: string;\n}\n\n// ============================================\n// Shared Sub-components\n// ============================================\n\ninterface WidgetCardProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction WidgetCard({ children, className }: WidgetCardProps) {\n return (\n <div\n className={cn(\"flex flex-col overflow-hidden\", className)}\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-nav, 8px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0, 0, 0, 0.02)\",\n }}\n >\n {children}\n </div>\n );\n}\n\ninterface WidgetHeaderProps {\n label: string;\n onMenuClick?: () => void;\n}\n\nfunction WidgetHeader({ label, onMenuClick }: WidgetHeaderProps) {\n return (\n <div className=\"flex items-center justify-between w-full\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {label}\n </span>\n <button\n onClick={onMenuClick}\n className=\"flex items-center justify-center rounded-full hover:bg-[var(--canvas-surface)]\"\n style={{ width: 32, height: 32 }}\n >\n <DotsThreeVertical size={20} weight=\"bold\" color=\"var(--canvas-text-placeholder)\" />\n </button>\n </div>\n );\n}\n\ninterface WidgetFooterProps {\n manageLabel?: string;\n viewDetailsLabel?: string;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nfunction WidgetFooter({\n manageLabel = \"Manage\",\n viewDetailsLabel = \"View details >\",\n onManageClick,\n onViewDetailsClick,\n}: WidgetFooterProps) {\n return (\n <div\n className=\"flex items-center justify-between w-full\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-2xl, 20px) var(--spacing-4xl, 32px)\",\n }}\n >\n <button\n onClick={onManageClick}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md, 8px)\" }}\n >\n <Gear size={16} color=\"var(--canvas-text-placeholder)\" />\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {manageLabel}\n </span>\n </button>\n <button\n onClick={onViewDetailsClick}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-primary)\",\n }}\n >\n {viewDetailsLabel}\n </button>\n </div>\n );\n}\n\n// ============================================\n// MetricCard Component\n// ============================================\n\nexport interface MetricCardProps {\n label: string;\n value: string;\n change?: {\n value: string;\n direction: \"up\" | \"down\";\n };\n className?: string;\n}\n\nexport function MetricCard({ label, value, change, className }: MetricCardProps) {\n return (\n <WidgetCard className={cn(\"flex-1 min-w-[200px]\", className)}>\n <div\n className=\"flex flex-col justify-center\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n gap: \"var(--spacing-lg, 12px)\",\n }}\n >\n {/* Label */}\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {label}\n </span>\n\n {/* Value */}\n <span\n style={{\n fontFamily: \"var(--typo-h4-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h4-size, 36px)\",\n fontWeight: 700,\n lineHeight: 1.25,\n letterSpacing: \"-0.01em\",\n color: \"var(--canvas-text)\",\n }}\n >\n {value}\n </span>\n\n {/* Change indicator */}\n {change && (\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-xs, 4px)\" }}>\n {change.direction === \"up\" ? (\n <ArrowUpRight size={20} weight=\"bold\" color=\"var(--canvas-success)\" />\n ) : (\n <ArrowDownRight size={20} weight=\"bold\" color=\"var(--canvas-destructive)\" />\n )}\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: change.direction === \"up\" ? \"var(--canvas-success)\" : \"var(--canvas-destructive)\",\n }}\n >\n {change.value}\n </span>\n </div>\n )}\n </div>\n </WidgetCard>\n );\n}\n\n// ============================================\n// MetricCardsRow Component\n// ============================================\n\nexport interface MetricCardsRowProps {\n metrics: MetricCardData[];\n columns?: 2 | 3 | 4;\n className?: string;\n}\n\nexport function MetricCardsRow({ metrics, columns = 4, className }: MetricCardsRowProps) {\n return (\n <div\n className={cn(\"flex flex-wrap w-full\", className)}\n style={{ gap: \"var(--spacing-4xl, 32px)\" }}\n >\n {metrics.slice(0, columns).map((metric) => (\n <MetricCard\n key={metric.id}\n label={metric.label}\n value={metric.value}\n change={metric.change}\n />\n ))}\n </div>\n );\n}\n\n// ============================================\n// LineChartWidget Component\n// ============================================\n\nexport interface LineChartWidgetProps {\n label: string;\n value: string;\n description?: string;\n chartData?: number[];\n chartLabels?: string[];\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nexport function LineChartWidget({\n label,\n value,\n description,\n chartData = [30, 45, 35, 50, 40, 60, 55, 70, 65, 80, 75, 90],\n chartLabels = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"],\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: LineChartWidgetProps) {\n const colors = useCSSVariableColors();\n\n const data = {\n labels: chartLabels,\n datasets: [\n {\n data: chartData,\n fill: true,\n backgroundColor: colors.chartAreaColor,\n borderColor: colors.chartLineColor,\n borderWidth: 2,\n tension: 0.4,\n pointRadius: 0,\n pointHoverRadius: 4,\n pointHoverBackgroundColor: colors.chartLineColor,\n pointHoverBorderColor: colors.background,\n pointHoverBorderWidth: 2,\n },\n ],\n };\n\n const options = {\n responsive: true,\n maintainAspectRatio: false,\n plugins: {\n legend: {\n display: false,\n },\n tooltip: {\n backgroundColor: colors.text,\n titleColor: colors.background,\n bodyColor: colors.background,\n padding: 12,\n cornerRadius: 8,\n displayColors: false,\n },\n },\n scales: {\n x: {\n grid: {\n display: false,\n },\n ticks: {\n color: colors.textPlaceholder,\n font: {\n size: 12,\n },\n },\n border: {\n display: false,\n },\n },\n y: {\n grid: {\n color: colors.border,\n },\n ticks: {\n color: colors.textPlaceholder,\n font: {\n size: 12,\n },\n },\n border: {\n display: false,\n },\n },\n },\n interaction: {\n intersect: false,\n mode: \"index\" as const,\n },\n };\n\n return (\n <WidgetCard className={cn(\"w-full\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={label} onMenuClick={onMenuClick} />\n\n {/* Value & Description */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {value}\n </span>\n {description && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {description}\n </span>\n )}\n </div>\n\n {/* Chart */}\n <div style={{ height: 300, width: \"100%\" }}>\n <Line data={data} options={options} />\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// DonutChartWidget Component\n// ============================================\n\nexport interface DonutChartWidgetProps {\n label: string;\n value: string;\n description?: string;\n legendItems?: DonutChartLegendItem[];\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nconst defaultDonutLegendItems: DonutChartLegendItem[] = [\n { id: \"1\", label: \"Product sales\", value: \"$400,000\", color: \"chartColor1\" },\n { id: \"2\", label: \"Subscription\", value: \"$350,000\", color: \"chartColor2\" },\n { id: \"3\", label: \"Advertising\", value: \"$300,000\", color: \"chartColor3\" },\n { id: \"4\", label: \"Services fees\", value: \"$250,000\", color: \"chartColor4\" },\n { id: \"5\", label: \"Royalties\", value: \"$200,000\", color: \"chartColor5\" },\n { id: \"6\", label: \"Affiliate marketing\", value: \"$197,454\", color: \"chartColor5\" },\n { id: \"7\", label: \"Licensing\", value: \"$180,000\", color: \"chartColor5\" },\n { id: \"8\", label: \"Sponsorship\", value: \"$150,000\", color: \"chartColor5\" },\n];\n\nexport function DonutChartWidget({\n label,\n value,\n description,\n legendItems = defaultDonutLegendItems,\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: DonutChartWidgetProps) {\n const colors = useCSSVariableColors();\n\n const getColorValue = (colorKey: string): string => {\n const colorMap: Record<string, string> = {\n chartColor1: colors.chartColor1,\n chartColor2: colors.chartColor2,\n chartColor3: colors.chartColor3,\n chartColor4: colors.chartColor4,\n chartColor5: colors.chartColor5,\n };\n return colorMap[colorKey] || colorKey;\n };\n\n const chartColors = legendItems.map((item) => getColorValue(item.color));\n const chartValues = legendItems.map((item) => {\n const numericValue = parseFloat(item.value.replace(/[$,]/g, \"\"));\n return isNaN(numericValue) ? 0 : numericValue;\n });\n\n const data = {\n labels: legendItems.map((item) => item.label),\n datasets: [\n {\n data: chartValues,\n backgroundColor: chartColors,\n borderColor: colors.background,\n borderWidth: 3,\n hoverBorderColor: colors.background,\n hoverBorderWidth: 3,\n },\n ],\n };\n\n const options = {\n responsive: true,\n maintainAspectRatio: false,\n cutout: \"60%\",\n plugins: {\n legend: {\n display: false,\n },\n tooltip: {\n backgroundColor: colors.text,\n titleColor: colors.background,\n bodyColor: colors.background,\n padding: 12,\n cornerRadius: 8,\n },\n },\n };\n\n return (\n <WidgetCard className={cn(\"w-full\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={label} onMenuClick={onMenuClick} />\n\n {/* Value & Description */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {value}\n </span>\n {description && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {description}\n </span>\n )}\n </div>\n\n {/* Chart + Legend */}\n <div\n className=\"flex items-start w-full\"\n style={{\n gap: \"var(--spacing-4xl, 32px)\",\n padding: \"var(--spacing-3xl, 24px) 0\",\n borderTop: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Donut Chart */}\n <div style={{ width: 280, height: 280, flexShrink: 0 }}>\n <Doughnut data={data} options={options} />\n </div>\n\n {/* Legend Grid */}\n <div\n className=\"flex-1 grid grid-cols-2\"\n style={{ gap: \"var(--spacing-3xl, 24px)\" }}\n >\n {legendItems.map((item) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md, 8px)\" }}\n >\n <div\n className=\"rounded-full\"\n style={{\n width: 8,\n height: 8,\n backgroundColor: getColorValue(item.color),\n flexShrink: 0,\n }}\n />\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n }}\n >\n {item.value}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 400,\n lineHeight: 1.4,\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.label}\n </span>\n </div>\n </div>\n ))}\n </div>\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// MetricListCard Component\n// ============================================\n\nexport interface MetricListCardProps {\n headerLabel: string;\n title: string;\n subtitle?: string;\n items: MetricListItem[];\n viewDetailsLabel?: string;\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nconst defaultMetricListItems: MetricListItem[] = [\n { id: \"1\", date: \"Aug 9\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"2\", date: \"Jul 18\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"3\", date: \"Jul 12\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"4\", date: \"Jun 8\", label: \"Software subscription\", value: \"-$40\" },\n];\n\nexport function MetricListCard({\n headerLabel,\n title,\n subtitle,\n items = defaultMetricListItems,\n viewDetailsLabel = \"View transactions >\",\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: MetricListCardProps) {\n return (\n <WidgetCard className={cn(\"flex-1 min-w-0\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={headerLabel} onMenuClick={onMenuClick} />\n\n {/* Title & Subtitle */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </span>\n {subtitle && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {subtitle}\n </span>\n )}\n </div>\n\n {/* List Items */}\n <div className=\"flex flex-col\">\n {items.map((item, index) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{\n padding: \"var(--spacing-lg, 12px) 0\",\n gap: \"var(--spacing-3xl, 24px)\",\n borderTop: index === 0 ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n >\n {item.date && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text-placeholder)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.date}\n </span>\n )}\n <span\n className=\"flex-1\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n }}\n >\n {item.label}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.value}\n </span>\n </div>\n ))}\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n viewDetailsLabel={viewDetailsLabel}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// ProgressMetricCard Component\n// ============================================\n\nexport interface ProgressMetricCardProps {\n headerLabel: string;\n title: string;\n subtitle?: string;\n items: ProgressBarItem[];\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nconst defaultProgressItems: ProgressBarItem[] = [\n { id: \"1\", label: \"$55,000\", progress: 80, imageUrl: \"/logos/shopify.png\" },\n { id: \"2\", label: \"$30,000\", progress: 50, imageUrl: \"/logos/etsy.png\" },\n { id: \"3\", label: \"$16,493\", progress: 30, imageUrl: \"/logos/amazon.png\" },\n { id: \"4\", label: \"$13,800\", progress: 25, imageUrl: \"/logos/dribbble.png\" },\n];\n\nexport function ProgressMetricCard({\n headerLabel,\n title,\n subtitle,\n items = defaultProgressItems,\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: ProgressMetricCardProps) {\n const colors = useCSSVariableColors();\n\n return (\n <WidgetCard className={cn(\"flex-1 min-w-0\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={headerLabel} onMenuClick={onMenuClick} />\n\n {/* Title & Subtitle */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </span>\n {subtitle && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {subtitle}\n </span>\n )}\n </div>\n\n {/* Progress Items */}\n <div className=\"flex flex-col\">\n {items.map((item, index) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{\n padding: \"var(--spacing-lg, 12px) 0\",\n gap: \"var(--spacing-xl, 16px)\",\n borderTop: index === 0 ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: index < items.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n height: 64,\n }}\n >\n {/* Image */}\n {item.imageUrl && (\n <div\n className=\"flex items-center justify-center\"\n style={{ width: 32, height: 32, flexShrink: 0 }}\n >\n <img\n src={item.imageUrl}\n alt=\"\"\n style={{\n maxWidth: \"100%\",\n maxHeight: \"100%\",\n objectFit: \"contain\",\n }}\n onError={(e) => {\n // Hide image on error\n (e.target as HTMLImageElement).style.display = \"none\";\n }}\n />\n </div>\n )}\n\n {/* Progress Bar */}\n <div\n className=\"flex-1\"\n style={{\n height: 8,\n backgroundColor: \"var(--canvas-border)\",\n borderRadius: \"var(--spacing-3xl, 24px)\",\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${item.progress}%`,\n height: \"100%\",\n backgroundColor: item.color || colors.primary,\n borderRadius: \"var(--spacing-3xl, 24px)\",\n }}\n />\n </div>\n\n {/* Value */}\n <div className=\"flex flex-col items-end\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.label}\n </span>\n {item.sublabel && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 500,\n lineHeight: 1.4,\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.sublabel}\n </span>\n )}\n </div>\n </div>\n ))}\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// TwoColumnWidgets Layout Component\n// ============================================\n\nexport interface TwoColumnWidgetsProps {\n children: React.ReactNode;\n className?: string;\n}\n\nexport function TwoColumnWidgets({ children, className }: TwoColumnWidgetsProps) {\n return (\n <div\n className={cn(\"flex w-full items-start\", className)}\n style={{ gap: \"var(--spacing-4xl, 32px)\" }}\n >\n {children}\n </div>\n );\n}\n\n// ============================================\n// DashboardHeader Component\n// ============================================\n\nexport interface DashboardHeaderProps {\n title: string;\n subtitle?: string;\n className?: string;\n}\n\nexport function DashboardHeader({ title, subtitle, className }: DashboardHeaderProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xs, 4px)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size, 24px)\",\n fontWeight: \"var(--typo-h6-weight, 600)\",\n lineHeight: \"var(--typo-h6-line-height, 1.25)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n {subtitle && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n )}\n </div>\n );\n}\n\n// ============================================\n// Full Dashboard Demo Component\n// ============================================\n\nexport interface GraphMetricTilesDemoProps {\n className?: string;\n}\n\nexport function GraphMetricTilesDemo({ className }: GraphMetricTilesDemoProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-3xl, 24px)\" }}\n >\n {/* Header */}\n <DashboardHeader title=\"Dashboard\" subtitle=\"View your company's metric\" />\n\n {/* 4-Column Metrics Row */}\n <MetricCardsRow\n metrics={[\n { id: \"1\", label: \"Sales\", value: \"164\", change: { value: \"34\", direction: \"up\" } },\n { id: \"2\", label: \"Revenue\", value: \"$5,462\", change: { value: \"$3,462\", direction: \"up\" } },\n { id: \"3\", label: \"Expenses\", value: \"$1,642\", change: { value: \"$642\", direction: \"up\" } },\n { id: \"4\", label: \"Profit\", value: \"$3,820\", change: { value: \"$2,820\", direction: \"up\" } },\n ]}\n columns={4}\n />\n\n {/* Line Chart Widget */}\n <LineChartWidget\n label=\"Profit and loss\"\n value=\"$127,454\"\n description=\"Your expenses decreased by 8% this month\"\n />\n\n {/* Donut Chart Widget */}\n <DonutChartWidget\n label=\"Revenue\"\n value=\"$2,127,454\"\n description=\"Your revenue increased by 25% this month\"\n />\n\n {/* Two Column - Transactions & Financial Summary */}\n <TwoColumnWidgets>\n <MetricListCard\n headerLabel=\"Recent activities\"\n title=\"Transactions\"\n subtitle=\"You spent $2,321 in the past 7 days\"\n items={[\n { id: \"1\", date: \"Aug 9\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"2\", date: \"Jul 18\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"3\", date: \"Jul 12\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"4\", date: \"Jun 8\", label: \"Software subscription\", value: \"-$40\" },\n ]}\n />\n <MetricListCard\n headerLabel=\"Cash\"\n title=\"Financial Summary\"\n subtitle=\"You have positive cash flow\"\n items={[\n { id: \"1\", label: \"ProjectCo March invoice\", value: \"$80,000\" },\n { id: \"2\", label: \"Tax refund\", value: \"$5,646\" },\n { id: \"3\", label: \"Kohort February invoice\", value: \"$64,000\" },\n { id: \"4\", label: \"License fee\", value: \"$7,500\" },\n ]}\n />\n </TwoColumnWidgets>\n\n {/* Two Column - Progress Metrics */}\n <TwoColumnWidgets>\n <ProgressMetricCard\n headerLabel=\"Revenue\"\n title=\"Sources\"\n subtitle=\"Third-party marketplaces\"\n items={[\n { id: \"1\", label: \"$55,000\", progress: 80, color: \"#95bf47\" },\n { id: \"2\", label: \"$30,000\", progress: 50, color: \"#f27123\" },\n { id: \"3\", label: \"$16,493\", progress: 30, color: \"#faa11f\" },\n { id: \"4\", label: \"$13,800\", progress: 25, color: \"#ff91e9\" },\n ]}\n />\n <ProgressMetricCard\n headerLabel=\"Net income\"\n title=\"Trend analysis\"\n subtitle=\"2020 - 2024\"\n items={[\n { id: \"1\", label: \"$132,000\", sublabel: \"2024\", progress: 100 },\n { id: \"2\", label: \"$78,252\", sublabel: \"2023\", progress: 60 },\n { id: \"3\", label: \"$36,493\", sublabel: \"2022\", progress: 28 },\n { id: \"4\", label: \"$21,800\", sublabel: \"2021\", progress: 17 },\n ]}\n />\n </TwoColumnWidgets>\n </div>\n );\n}\n"
9
+ "content": "\"use client\";\n\nimport { useState, useEffect, useCallback } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n ArrowUpRight,\n ArrowDownRight,\n DotsThreeVertical,\n Gear,\n} from \"@phosphor-icons/react\";\nimport {\n Chart as ChartJS,\n CategoryScale,\n LinearScale,\n PointElement,\n LineElement,\n Title,\n Tooltip,\n Legend,\n Filler,\n ArcElement,\n} from \"chart.js\";\nimport { Line, Doughnut } from \"react-chartjs-2\";\n\n// Register Chart.js components\nChartJS.register(\n CategoryScale,\n LinearScale,\n PointElement,\n LineElement,\n Title,\n Tooltip,\n Legend,\n Filler,\n ArcElement\n);\n\n// ============================================\n// Custom Hook: useCSSVariableColors\n// ============================================\n\ninterface CSSVariableColors {\n primary: string;\n background: string;\n text: string;\n textMuted: string;\n textPlaceholder: string;\n border: string;\n success: string;\n chartColor1: string;\n chartColor2: string;\n chartColor3: string;\n chartColor4: string;\n chartColor5: string;\n chartLineColor: string;\n chartAreaColor: string;\n}\n\nfunction useCSSVariableColors(): CSSVariableColors {\n const [colors, setColors] = useState<CSSVariableColors>({\n primary: \"#1165ef\",\n background: \"#ffffff\",\n text: \"#0d121c\",\n textMuted: \"#4b5565\",\n textPlaceholder: \"#6c7684\",\n border: \"#e9eef3\",\n success: \"#08875d\",\n chartColor1: \"#e45451\",\n chartColor2: \"#69bdbc\",\n chartColor3: \"#f8f578\",\n chartColor4: \"#f2b66b\",\n chartColor5: \"#6c7684\",\n chartLineColor: \"#1165ef\",\n chartAreaColor: \"rgba(17, 101, 239, 0.1)\",\n });\n\n const readColors = useCallback(() => {\n if (typeof window === \"undefined\") return;\n // Read from document.body since theme overrides are applied there\n const style = getComputedStyle(document.body);\n const primary = style.getPropertyValue(\"--canvas-primary\").trim() || \"#1165ef\";\n\n // Derive chart area color from primary with 10% opacity\n const hexToRgba = (hex: string, alpha: number): string => {\n const r = parseInt(hex.slice(1, 3), 16);\n const g = parseInt(hex.slice(3, 5), 16);\n const b = parseInt(hex.slice(5, 7), 16);\n return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n };\n\n setColors({\n primary,\n background: style.getPropertyValue(\"--canvas-background\").trim() || \"#ffffff\",\n text: style.getPropertyValue(\"--canvas-text\").trim() || \"#0d121c\",\n textMuted: style.getPropertyValue(\"--canvas-text-muted\").trim() || \"#4b5565\",\n textPlaceholder: style.getPropertyValue(\"--canvas-text-placeholder\").trim() || \"#6c7684\",\n border: style.getPropertyValue(\"--canvas-border\").trim() || \"#e9eef3\",\n success: style.getPropertyValue(\"--canvas-success\").trim() || \"#08875d\",\n chartColor1: style.getPropertyValue(\"--chart-color-1\").trim() || \"#e45451\",\n chartColor2: style.getPropertyValue(\"--chart-color-2\").trim() || \"#69bdbc\",\n chartColor3: style.getPropertyValue(\"--chart-color-3\").trim() || \"#f8f578\",\n chartColor4: style.getPropertyValue(\"--chart-color-4\").trim() || \"#f2b66b\",\n chartColor5: style.getPropertyValue(\"--chart-color-5\").trim() || \"#6c7684\",\n chartLineColor: style.getPropertyValue(\"--chart-line-color\").trim() || primary,\n chartAreaColor: style.getPropertyValue(\"--chart-area-color\").trim() || hexToRgba(primary, 0.1),\n });\n }, []);\n\n useEffect(() => {\n readColors();\n\n // Watch both document.body and document.documentElement for style changes\n // Theme drawer writes to body, stylesheet/iframe sync writes to documentElement\n const observer = new MutationObserver(() => {\n readColors();\n });\n\n observer.observe(document.body, {\n attributes: true,\n attributeFilter: [\"style\"],\n });\n\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"style\"],\n });\n\n // Also listen for custom event that variables drawer might dispatch\n const handleVariableChange = () => readColors();\n window.addEventListener(\"css-variables-changed\", handleVariableChange);\n\n return () => {\n observer.disconnect();\n window.removeEventListener(\"css-variables-changed\", handleVariableChange);\n };\n }, [readColors]);\n\n return colors;\n}\n\n// ============================================\n// Types\n// ============================================\n\nexport interface MetricCardData {\n id: string;\n label: string;\n value: string;\n change?: {\n value: string;\n direction: \"up\" | \"down\";\n };\n}\n\nexport interface MetricListItem {\n id: string;\n date?: string;\n label: string;\n value: string;\n}\n\nexport interface DonutChartLegendItem {\n id: string;\n label: string;\n value: string;\n color: string;\n}\n\nexport interface ProgressBarItem {\n id: string;\n label: string;\n sublabel?: string;\n value?: string;\n progress: number; // 0-100\n imageUrl?: string;\n color?: string;\n}\n\n// ============================================\n// Shared Sub-components\n// ============================================\n\ninterface WidgetCardProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction WidgetCard({ children, className }: WidgetCardProps) {\n return (\n <div\n className={cn(\"flex flex-col overflow-hidden\", className)}\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-nav, 8px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0, 0, 0, 0.02)\",\n }}\n >\n {children}\n </div>\n );\n}\n\ninterface WidgetHeaderProps {\n label: string;\n onMenuClick?: () => void;\n}\n\nfunction WidgetHeader({ label, onMenuClick }: WidgetHeaderProps) {\n return (\n <div className=\"flex items-center justify-between w-full\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {label}\n </span>\n <button\n onClick={onMenuClick}\n className=\"flex items-center justify-center rounded-full hover:bg-[var(--canvas-surface)]\"\n style={{ width: 32, height: 32 }}\n >\n <DotsThreeVertical size={20} weight=\"bold\" color=\"var(--canvas-text-placeholder)\" />\n </button>\n </div>\n );\n}\n\ninterface WidgetFooterProps {\n manageLabel?: string;\n viewDetailsLabel?: string;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nfunction WidgetFooter({\n manageLabel = \"Manage\",\n viewDetailsLabel = \"View details >\",\n onManageClick,\n onViewDetailsClick,\n}: WidgetFooterProps) {\n return (\n <div\n className=\"flex items-center justify-between w-full\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-2xl, 20px) var(--spacing-4xl, 32px)\",\n }}\n >\n <button\n onClick={onManageClick}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md, 8px)\" }}\n >\n <Gear size={16} color=\"var(--canvas-text-placeholder)\" />\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {manageLabel}\n </span>\n </button>\n <button\n onClick={onViewDetailsClick}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-primary)\",\n }}\n >\n {viewDetailsLabel}\n </button>\n </div>\n );\n}\n\n// ============================================\n// MetricCard Component\n// ============================================\n\nexport interface MetricCardProps {\n label: string;\n value: string;\n change?: {\n value: string;\n direction: \"up\" | \"down\";\n };\n className?: string;\n}\n\nexport function MetricCard({ label, value, change, className }: MetricCardProps) {\n return (\n <WidgetCard className={cn(\"\", className)}>\n <div\n className=\"flex flex-col justify-center\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n gap: \"var(--spacing-lg, 12px)\",\n }}\n >\n {/* Label */}\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {label}\n </span>\n\n {/* Value */}\n <span\n style={{\n fontFamily: \"var(--typo-h4-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h4-size, 36px)\",\n fontWeight: 700,\n lineHeight: 1.25,\n letterSpacing: \"-0.01em\",\n color: \"var(--canvas-text)\",\n }}\n >\n {value}\n </span>\n\n {/* Change indicator */}\n {change && (\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-xs, 4px)\" }}>\n {change.direction === \"up\" ? (\n <ArrowUpRight size={20} weight=\"bold\" color=\"var(--canvas-success)\" />\n ) : (\n <ArrowDownRight size={20} weight=\"bold\" color=\"var(--canvas-destructive)\" />\n )}\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: change.direction === \"up\" ? \"var(--canvas-success)\" : \"var(--canvas-destructive)\",\n }}\n >\n {change.value}\n </span>\n </div>\n )}\n </div>\n </WidgetCard>\n );\n}\n\n// ============================================\n// MetricCardsRow Component\n// ============================================\n\nexport interface MetricCardsRowProps {\n metrics: MetricCardData[];\n columns?: 2 | 3 | 4;\n className?: string;\n}\n\nconst metricGridClasses: Record<2 | 3 | 4, string> = {\n 2: \"grid-cols-1 sm:grid-cols-2\",\n 3: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\",\n 4: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-4\",\n};\n\nexport function MetricCardsRow({ metrics, columns = 4, className }: MetricCardsRowProps) {\n return (\n <div\n className={cn(\"grid w-full\", metricGridClasses[columns], className)}\n style={{ gap: \"var(--spacing-4xl, 32px)\" }}\n >\n {metrics.slice(0, columns).map((metric) => (\n <MetricCard\n key={metric.id}\n label={metric.label}\n value={metric.value}\n change={metric.change}\n />\n ))}\n </div>\n );\n}\n\n// ============================================\n// LineChartWidget Component\n// ============================================\n\nexport interface LineChartWidgetProps {\n label: string;\n value: string;\n description?: string;\n chartData?: number[];\n chartLabels?: string[];\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nexport function LineChartWidget({\n label,\n value,\n description,\n chartData = [30, 45, 35, 50, 40, 60, 55, 70, 65, 80, 75, 90],\n chartLabels = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"],\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: LineChartWidgetProps) {\n const colors = useCSSVariableColors();\n\n const data = {\n labels: chartLabels,\n datasets: [\n {\n data: chartData,\n fill: true,\n backgroundColor: colors.chartAreaColor,\n borderColor: colors.chartLineColor,\n borderWidth: 2,\n tension: 0.4,\n pointRadius: 0,\n pointHoverRadius: 4,\n pointHoverBackgroundColor: colors.chartLineColor,\n pointHoverBorderColor: colors.background,\n pointHoverBorderWidth: 2,\n },\n ],\n };\n\n const options = {\n responsive: true,\n maintainAspectRatio: false,\n plugins: {\n legend: {\n display: false,\n },\n tooltip: {\n backgroundColor: colors.text,\n titleColor: colors.background,\n bodyColor: colors.background,\n padding: 12,\n cornerRadius: 8,\n displayColors: false,\n },\n },\n scales: {\n x: {\n grid: {\n display: false,\n },\n ticks: {\n color: colors.textPlaceholder,\n font: {\n size: 12,\n },\n },\n border: {\n display: false,\n },\n },\n y: {\n grid: {\n color: colors.border,\n },\n ticks: {\n color: colors.textPlaceholder,\n font: {\n size: 12,\n },\n },\n border: {\n display: false,\n },\n },\n },\n interaction: {\n intersect: false,\n mode: \"index\" as const,\n },\n };\n\n return (\n <WidgetCard className={cn(\"w-full\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={label} onMenuClick={onMenuClick} />\n\n {/* Value & Description */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {value}\n </span>\n {description && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {description}\n </span>\n )}\n </div>\n\n {/* Chart */}\n <div className=\"h-[200px] sm:h-[300px] w-full\">\n <Line data={data} options={options} />\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// DonutChartWidget Component\n// ============================================\n\nexport interface DonutChartWidgetProps {\n label: string;\n value: string;\n description?: string;\n legendItems?: DonutChartLegendItem[];\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nconst defaultDonutLegendItems: DonutChartLegendItem[] = [\n { id: \"1\", label: \"Product sales\", value: \"$400,000\", color: \"chartColor1\" },\n { id: \"2\", label: \"Subscription\", value: \"$350,000\", color: \"chartColor2\" },\n { id: \"3\", label: \"Advertising\", value: \"$300,000\", color: \"chartColor3\" },\n { id: \"4\", label: \"Services fees\", value: \"$250,000\", color: \"chartColor4\" },\n { id: \"5\", label: \"Royalties\", value: \"$200,000\", color: \"chartColor5\" },\n { id: \"6\", label: \"Affiliate marketing\", value: \"$197,454\", color: \"chartColor5\" },\n { id: \"7\", label: \"Licensing\", value: \"$180,000\", color: \"chartColor5\" },\n { id: \"8\", label: \"Sponsorship\", value: \"$150,000\", color: \"chartColor5\" },\n];\n\nexport function DonutChartWidget({\n label,\n value,\n description,\n legendItems = defaultDonutLegendItems,\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: DonutChartWidgetProps) {\n const colors = useCSSVariableColors();\n\n const getColorValue = (colorKey: string): string => {\n const colorMap: Record<string, string> = {\n chartColor1: colors.chartColor1,\n chartColor2: colors.chartColor2,\n chartColor3: colors.chartColor3,\n chartColor4: colors.chartColor4,\n chartColor5: colors.chartColor5,\n };\n return colorMap[colorKey] || colorKey;\n };\n\n const chartColors = legendItems.map((item) => getColorValue(item.color));\n const chartValues = legendItems.map((item) => {\n const numericValue = parseFloat(item.value.replace(/[$,]/g, \"\"));\n return isNaN(numericValue) ? 0 : numericValue;\n });\n\n const data = {\n labels: legendItems.map((item) => item.label),\n datasets: [\n {\n data: chartValues,\n backgroundColor: chartColors,\n borderColor: colors.background,\n borderWidth: 3,\n hoverBorderColor: colors.background,\n hoverBorderWidth: 3,\n },\n ],\n };\n\n const options = {\n responsive: true,\n maintainAspectRatio: false,\n cutout: \"60%\",\n plugins: {\n legend: {\n display: false,\n },\n tooltip: {\n backgroundColor: colors.text,\n titleColor: colors.background,\n bodyColor: colors.background,\n padding: 12,\n cornerRadius: 8,\n },\n },\n };\n\n return (\n <WidgetCard className={cn(\"w-full\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={label} onMenuClick={onMenuClick} />\n\n {/* Value & Description */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {value}\n </span>\n {description && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {description}\n </span>\n )}\n </div>\n\n {/* Chart + Legend */}\n <div\n className=\"flex flex-col sm:flex-row items-start w-full\"\n style={{\n gap: \"var(--spacing-4xl, 32px)\",\n padding: \"var(--spacing-3xl, 24px) 0\",\n borderTop: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Donut Chart */}\n <div className=\"w-full sm:w-[280px]\" style={{ height: 280, flexShrink: 0 }}>\n <Doughnut data={data} options={options} />\n </div>\n\n {/* Legend Grid */}\n <div\n className=\"flex-1 grid grid-cols-2\"\n style={{ gap: \"var(--spacing-3xl, 24px)\" }}\n >\n {legendItems.map((item) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md, 8px)\" }}\n >\n <div\n className=\"rounded-full\"\n style={{\n width: 8,\n height: 8,\n backgroundColor: getColorValue(item.color),\n flexShrink: 0,\n }}\n />\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n }}\n >\n {item.value}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 400,\n lineHeight: 1.4,\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.label}\n </span>\n </div>\n </div>\n ))}\n </div>\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// MetricListCard Component\n// ============================================\n\nexport interface MetricListCardProps {\n headerLabel: string;\n title: string;\n subtitle?: string;\n items: MetricListItem[];\n viewDetailsLabel?: string;\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nconst defaultMetricListItems: MetricListItem[] = [\n { id: \"1\", date: \"Aug 9\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"2\", date: \"Jul 18\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"3\", date: \"Jul 12\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"4\", date: \"Jun 8\", label: \"Software subscription\", value: \"-$40\" },\n];\n\nexport function MetricListCard({\n headerLabel,\n title,\n subtitle,\n items = defaultMetricListItems,\n viewDetailsLabel = \"View transactions >\",\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: MetricListCardProps) {\n return (\n <WidgetCard className={cn(\"flex-1 min-w-0\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={headerLabel} onMenuClick={onMenuClick} />\n\n {/* Title & Subtitle */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </span>\n {subtitle && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {subtitle}\n </span>\n )}\n </div>\n\n {/* List Items */}\n <div className=\"flex flex-col\">\n {items.map((item, index) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{\n padding: \"var(--spacing-lg, 12px) 0\",\n gap: \"var(--spacing-3xl, 24px)\",\n borderTop: index === 0 ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n >\n {item.date && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text-placeholder)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.date}\n </span>\n )}\n <span\n className=\"flex-1\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n }}\n >\n {item.label}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.value}\n </span>\n </div>\n ))}\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n viewDetailsLabel={viewDetailsLabel}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// ProgressMetricCard Component\n// ============================================\n\nexport interface ProgressMetricCardProps {\n headerLabel: string;\n title: string;\n subtitle?: string;\n items: ProgressBarItem[];\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nconst defaultProgressItems: ProgressBarItem[] = [\n { id: \"1\", label: \"$55,000\", progress: 80, imageUrl: \"/logos/shopify.png\" },\n { id: \"2\", label: \"$30,000\", progress: 50, imageUrl: \"/logos/etsy.png\" },\n { id: \"3\", label: \"$16,493\", progress: 30, imageUrl: \"/logos/amazon.png\" },\n { id: \"4\", label: \"$13,800\", progress: 25, imageUrl: \"/logos/dribbble.png\" },\n];\n\nexport function ProgressMetricCard({\n headerLabel,\n title,\n subtitle,\n items = defaultProgressItems,\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: ProgressMetricCardProps) {\n const colors = useCSSVariableColors();\n\n return (\n <WidgetCard className={cn(\"flex-1 min-w-0\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={headerLabel} onMenuClick={onMenuClick} />\n\n {/* Title & Subtitle */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </span>\n {subtitle && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {subtitle}\n </span>\n )}\n </div>\n\n {/* Progress Items */}\n <div className=\"flex flex-col\">\n {items.map((item, index) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{\n padding: \"var(--spacing-lg, 12px) 0\",\n gap: \"var(--spacing-xl, 16px)\",\n borderTop: index === 0 ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: index < items.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n height: 64,\n }}\n >\n {/* Image */}\n {item.imageUrl && (\n <div\n className=\"flex items-center justify-center\"\n style={{ width: 32, height: 32, flexShrink: 0 }}\n >\n <img\n src={item.imageUrl}\n alt=\"\"\n style={{\n maxWidth: \"100%\",\n maxHeight: \"100%\",\n objectFit: \"contain\",\n }}\n onError={(e) => {\n // Hide image on error\n (e.target as HTMLImageElement).style.display = \"none\";\n }}\n />\n </div>\n )}\n\n {/* Progress Bar */}\n <div\n className=\"flex-1\"\n style={{\n height: 8,\n backgroundColor: \"var(--canvas-border)\",\n borderRadius: \"var(--spacing-3xl, 24px)\",\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${item.progress}%`,\n height: \"100%\",\n backgroundColor: item.color || colors.primary,\n borderRadius: \"var(--spacing-3xl, 24px)\",\n }}\n />\n </div>\n\n {/* Value */}\n <div className=\"flex flex-col items-end\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.label}\n </span>\n {item.sublabel && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 500,\n lineHeight: 1.4,\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.sublabel}\n </span>\n )}\n </div>\n </div>\n ))}\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// TwoColumnWidgets Layout Component\n// ============================================\n\nexport interface TwoColumnWidgetsProps {\n children: React.ReactNode;\n className?: string;\n}\n\nexport function TwoColumnWidgets({ children, className }: TwoColumnWidgetsProps) {\n return (\n <div\n className={cn(\"flex flex-col sm:flex-row w-full items-start\", className)}\n style={{ gap: \"var(--spacing-4xl, 32px)\" }}\n >\n {children}\n </div>\n );\n}\n\n// ============================================\n// DashboardHeader Component\n// ============================================\n\nexport interface DashboardHeaderProps {\n title: string;\n subtitle?: string;\n className?: string;\n}\n\nexport function DashboardHeader({ title, subtitle, className }: DashboardHeaderProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xs, 4px)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size, 24px)\",\n fontWeight: \"var(--typo-h6-weight, 600)\",\n lineHeight: \"var(--typo-h6-line-height, 1.25)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n {subtitle && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n )}\n </div>\n );\n}\n\n// ============================================\n// Full Dashboard Demo Component\n// ============================================\n\nexport interface GraphMetricTilesDemoProps {\n className?: string;\n}\n\nexport function GraphMetricTilesDemo({ className }: GraphMetricTilesDemoProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-3xl, 24px)\" }}\n >\n {/* Header */}\n <DashboardHeader title=\"Dashboard\" subtitle=\"View your company's metric\" />\n\n {/* 4-Column Metrics Row */}\n <MetricCardsRow\n metrics={[\n { id: \"1\", label: \"Sales\", value: \"164\", change: { value: \"34\", direction: \"up\" } },\n { id: \"2\", label: \"Revenue\", value: \"$5,462\", change: { value: \"$3,462\", direction: \"up\" } },\n { id: \"3\", label: \"Expenses\", value: \"$1,642\", change: { value: \"$642\", direction: \"up\" } },\n { id: \"4\", label: \"Profit\", value: \"$3,820\", change: { value: \"$2,820\", direction: \"up\" } },\n ]}\n columns={4}\n />\n\n {/* Line Chart Widget */}\n <LineChartWidget\n label=\"Profit and loss\"\n value=\"$127,454\"\n description=\"Your expenses decreased by 8% this month\"\n />\n\n {/* Donut Chart Widget */}\n <DonutChartWidget\n label=\"Revenue\"\n value=\"$2,127,454\"\n description=\"Your revenue increased by 25% this month\"\n />\n\n {/* Two Column - Transactions & Financial Summary */}\n <TwoColumnWidgets>\n <MetricListCard\n headerLabel=\"Recent activities\"\n title=\"Transactions\"\n subtitle=\"You spent $2,321 in the past 7 days\"\n items={[\n { id: \"1\", date: \"Aug 9\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"2\", date: \"Jul 18\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"3\", date: \"Jul 12\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"4\", date: \"Jun 8\", label: \"Software subscription\", value: \"-$40\" },\n ]}\n />\n <MetricListCard\n headerLabel=\"Cash\"\n title=\"Financial Summary\"\n subtitle=\"You have positive cash flow\"\n items={[\n { id: \"1\", label: \"ProjectCo March invoice\", value: \"$80,000\" },\n { id: \"2\", label: \"Tax refund\", value: \"$5,646\" },\n { id: \"3\", label: \"Kohort February invoice\", value: \"$64,000\" },\n { id: \"4\", label: \"License fee\", value: \"$7,500\" },\n ]}\n />\n </TwoColumnWidgets>\n\n {/* Two Column - Progress Metrics */}\n <TwoColumnWidgets>\n <ProgressMetricCard\n headerLabel=\"Revenue\"\n title=\"Sources\"\n subtitle=\"Third-party marketplaces\"\n items={[\n { id: \"1\", label: \"$55,000\", progress: 80, color: \"#95bf47\" },\n { id: \"2\", label: \"$30,000\", progress: 50, color: \"#f27123\" },\n { id: \"3\", label: \"$16,493\", progress: 30, color: \"#faa11f\" },\n { id: \"4\", label: \"$13,800\", progress: 25, color: \"#ff91e9\" },\n ]}\n />\n <ProgressMetricCard\n headerLabel=\"Net income\"\n title=\"Trend analysis\"\n subtitle=\"2020 - 2024\"\n items={[\n { id: \"1\", label: \"$132,000\", sublabel: \"2024\", progress: 100 },\n { id: \"2\", label: \"$78,252\", sublabel: \"2023\", progress: 60 },\n { id: \"3\", label: \"$36,493\", sublabel: \"2022\", progress: 28 },\n { id: \"4\", label: \"$21,800\", sublabel: \"2021\", progress: 17 },\n ]}\n />\n </TwoColumnWidgets>\n </div>\n );\n}\n"
10
10
  }
11
11
  ],
12
12
  "dependencies": [
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "grid-tiles-list",
3
3
  "type": "registry:block",
4
- "description": "Responsive grid of property/listing tiles with configurable 2-5 columns. Features images, star ratings, prices, favorite buttons, and optional tags. Includes header with sort/filter controls.",
4
+ "description": "Configurable 2-5 column image card grid. Each tile has a square image, title, star rating, location text, price, and favorite button. Optional tag badges. Header with sort/filter controls and action button. Use for product catalogs, property listings, course directories, or any visual item grid.",
5
+ "keywords": [
6
+ "grid",
7
+ "tiles",
8
+ "products",
9
+ "catalog",
10
+ "cards",
11
+ "listings"
12
+ ],
13
+ "visualWeight": "medium",
5
14
  "files": [
6
15
  {
7
16
  "path": "components/blocks/grid-tiles-list.tsx",
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "hero-dark-centered",
3
3
  "type": "registry:block",
4
- "description": "Dark centered hero with multi-field search (location, dates, guests).",
4
+ "description": "Dark-background centered hero with large heading, subtitle, and multi-field search bar (location, dates, guests). Use for search-focused homepages like travel, real estate, or marketplace sites.",
5
+ "keywords": [
6
+ "hero",
7
+ "dark",
8
+ "centered",
9
+ "search",
10
+ "landing"
11
+ ],
12
+ "visualWeight": "spacer",
5
13
  "files": [
6
14
  {
7
15
  "path": "components/blocks/marketing/hero-dark-centered.tsx",
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "hero-dark-with-image",
3
3
  "type": "registry:block",
4
- "description": "Dark hero section with text on left and image on right.",
4
+ "description": "Dark-background hero section with heading and subtitle on the left, large feature image on the right. Stacks vertically on mobile. Use for product launches, feature announcements, or SaaS landing pages.",
5
+ "keywords": [
6
+ "hero",
7
+ "dark",
8
+ "feature",
9
+ "product",
10
+ "landing"
11
+ ],
12
+ "visualWeight": "spacer",
5
13
  "files": [
6
14
  {
7
15
  "path": "components/blocks/marketing/hero-dark-with-image.tsx",
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "hero-fullwidth-image",
3
3
  "type": "registry:block",
4
- "description": "Full-width hero image with centered content and multi-field search.",
4
+ "description": "Full-width hero with background image covering entire section, centered heading and subtitle overlay, and multi-field search bar. Use for visually immersive landing pages.",
5
+ "keywords": [
6
+ "hero",
7
+ "fullwidth",
8
+ "image",
9
+ "immersive",
10
+ "landing"
11
+ ],
12
+ "visualWeight": "spacer",
5
13
  "files": [
6
14
  {
7
15
  "path": "components/blocks/marketing/hero-fullwidth-image.tsx",
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "hero-section",
3
3
  "type": "registry:block",
4
- "description": "Hero with background image and simple search bar overlay.",
4
+ "description": "Full-width hero banner with background image, large heading, subtitle text, and optional search bar overlay. Use as the main visual entry point for landing pages, homepages, or category pages.",
5
+ "keywords": [
6
+ "hero",
7
+ "banner",
8
+ "landing",
9
+ "search",
10
+ "homepage"
11
+ ],
12
+ "visualWeight": "spacer",
5
13
  "files": [
6
14
  {
7
15
  "path": "components/blocks/marketing/hero-section.tsx",
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "how-it-works",
3
3
  "type": "registry:block",
4
- "description": "Horizontal 3-step how it works section.",
4
+ "description": "4-column grid of feature items, each with a large icon, title, and description. Light and dark variants. Use for 'how it works' steps, feature highlights, value propositions, or benefit lists.",
5
+ "keywords": [
6
+ "features",
7
+ "steps",
8
+ "benefits",
9
+ "how-it-works",
10
+ "value"
11
+ ],
12
+ "visualWeight": "medium",
5
13
  "files": [
6
14
  {
7
15
  "path": "components/blocks/marketing/how-it-works.tsx",