makersclaw 0.6.0 → 0.8.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 (152) hide show
  1. package/README.md +56 -9
  2. package/package.json +1 -1
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +6 -6
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +10 -4
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +126 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +3 -3
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +24 -12
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chart/cartesian.jsx +1 -1
  67. package/registry/blocks/chart/core.jsx +33 -23
  68. package/registry/blocks/chart/pie.jsx +1 -1
  69. package/registry/blocks/chat/README.md +47 -14
  70. package/registry/blocks/chat/demo.jsx +2 -2
  71. package/registry/blocks/code-editor/README.md +4 -0
  72. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  73. package/registry/blocks/comment-box/README.md +20 -13
  74. package/registry/blocks/contact-section/README.md +7 -6
  75. package/registry/blocks/csv-import/README.md +34 -12
  76. package/registry/blocks/data-table/README.md +41 -8
  77. package/registry/blocks/diagram/diagram.jsx +1 -1
  78. package/registry/blocks/doc-editor/README.md +18 -6
  79. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  80. package/registry/blocks/doc-page/README.md +16 -6
  81. package/registry/blocks/file-upload/README.md +7 -1
  82. package/registry/blocks/flow-canvas/README.md +30 -12
  83. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  84. package/registry/blocks/footer/README.md +7 -4
  85. package/registry/blocks/gantt/README.md +18 -12
  86. package/registry/blocks/job-panel/README.md +1 -1
  87. package/registry/blocks/json-editor/README.md +16 -4
  88. package/registry/blocks/map/README.md +19 -3
  89. package/registry/blocks/map/map-view.jsx +1 -1
  90. package/registry/blocks/map/maplibre.js +1 -1
  91. package/registry/blocks/mind-map/README.md +20 -7
  92. package/registry/blocks/org-chart/README.md +14 -9
  93. package/registry/blocks/page-builder/README.md +14 -7
  94. package/registry/blocks/page-builder/demo.jsx +3 -3
  95. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  96. package/registry/blocks/page-builder/page-view.jsx +1 -1
  97. package/registry/blocks/page-builder/page.js +1 -1
  98. package/registry/blocks/pdf-viewer/README.md +26 -21
  99. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  100. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  101. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  102. package/registry/blocks/product-detail/README.md +13 -7
  103. package/registry/blocks/product-detail/demo.jsx +1 -1
  104. package/registry/blocks/product-grid/README.md +12 -10
  105. package/registry/blocks/record-form/README.md +26 -9
  106. package/registry/blocks/record-notes/README.md +42 -30
  107. package/registry/blocks/search-dialog/README.md +38 -7
  108. package/registry/blocks/settings-list/README.md +29 -17
  109. package/registry/blocks/setup-checklist/README.md +12 -14
  110. package/registry/blocks/share-dialog/README.md +1 -1
  111. package/registry/blocks/sidebar/README.md +23 -13
  112. package/registry/blocks/signature-pad/README.md +15 -4
  113. package/registry/blocks/sketch-canvas/README.md +25 -10
  114. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  115. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  116. package/registry/blocks/spreadsheet/README.md +17 -8
  117. package/registry/blocks/stat-cards/README.md +17 -8
  118. package/registry/blocks/task-list/README.md +14 -11
  119. package/registry/blocks/task-model/README.md +3 -3
  120. package/registry/blocks/transaction-list/README.md +15 -7
  121. package/registry/blocks/whiteboard/README.md +34 -22
  122. package/src/cli.mjs +33 -6
  123. package/src/install.mjs +285 -26
  124. package/src/registry.mjs +109 -1
  125. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  126. package/templates/next/browser/app/layout.jsx +94 -0
  127. package/templates/next/browser/app/page.jsx +60 -0
  128. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  129. package/templates/next/browser/lib/actions.js +16 -0
  130. package/templates/next/browser/lib/data.js +43 -0
  131. package/templates/next/files/README.md +1100 -89
  132. package/templates/next/files/app/frame.js +128 -4
  133. package/templates/next/files/app/shell.jsx +54 -37
  134. package/templates/next/files/check/run.mjs +1514 -45
  135. package/templates/next/files/components/ui/index.js +1 -0
  136. package/templates/next/files/components/ui/item.jsx +2 -2
  137. package/templates/next/files/components/ui/layout.jsx +307 -0
  138. package/templates/next/files/components/ui/notice.jsx +1 -1
  139. package/templates/next/files/components/ui/separator.jsx +1 -1
  140. package/templates/next/files/lib/actions.js +170 -51
  141. package/templates/next/files/lib/brain-server.js +96 -0
  142. package/templates/next/files/lib/brain.js +413 -0
  143. package/templates/next/files/lib/claw.js +15 -1
  144. package/templates/next/files/lib/data.js +119 -29
  145. package/templates/next/files/lib/db.js +97 -17
  146. package/templates/next/files/lib/door.js +680 -0
  147. package/templates/next/files/lib/hooks.js +71 -2
  148. package/templates/next/files/lib/platform.js +93 -4
  149. package/templates/next/files/next.config.mjs +36 -2
  150. package/templates/next/files/surfaces.js +3 -2
  151. package/templates/next/template.json +18 -3
  152. package/templates/next/files/schema.sql +0 -22
package/r/core.json CHANGED
@@ -153,7 +153,7 @@
153
153
  "path": "templates/next/files/components/ui/index.js",
154
154
  "type": "registry:ui",
155
155
  "target": "@components/makersclaw/ui/index.js",
156
- "content": "// The kit. Import from \"../components/ui\" (a surface) or \"./ui\" (a\n// component). Every part is the workspace's look over the browser's own\n// behaviour; none of them takes a colour. README §3b.\nexport { cx } from \"./cx\";\nexport { Icon, ICON_NAMES } from \"./icon\";\nexport { Button, IconButton } from \"./button\";\nexport { Field, Input, TimeInput, Textarea, Select, Switch, Checkbox } from \"./field\";\nexport { Card, Notice, Badge, Kicker, Empty } from \"./notice\";\nexport { Sheet, Confirm } from \"./sheet\";\nexport { Tabs, TabPanel } from \"./tabs\";\nexport { Menu, MenuPanel } from \"./menu\";\nexport { ContextMenu } from \"./context-menu\";\nexport { Popover, usePopoverPosition, panelClass } from \"./popover\";\nexport {\n Calendar,\n parseDate,\n isDateString,\n toDateString,\n todayString,\n addDays,\n addMonths,\n startOfMonth,\n endOfMonth,\n dayOfWeek,\n formatDate,\n toLocalDate,\n firstDayOfWeek,\n} from \"./calendar\";\nexport { DatePicker, DateInput, DATE_PRESETS } from \"./date-picker\";\nexport { Tooltip, HoverCard } from \"./tooltip\";\nexport { Depiction } from \"./depiction\";\nexport { toast, Toaster } from \"./toast\";\nexport { Carousel } from \"./carousel\";\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle } from \"./resizable\";\nexport { Avatar, AvatarGroup } from \"./avatar\";\nexport { Breadcrumb } from \"./breadcrumb\";\nexport { Pagination } from \"./pagination\";\nexport { RadioGroup, Radio } from \"./radio\";\nexport { Toggle, ToggleGroup } from \"./toggle\";\nexport { ButtonGroup, ButtonGroupSeparator } from \"./button-group\";\nexport { Collapsible, Accordion } from \"./collapsible\";\nexport { Item, ItemGroup } from \"./item\";\nexport { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from \"./table\";\nexport { Spinner, Skeleton, Progress } from \"./feedback\";\nexport { Distribution } from \"./distribution\";\nexport { Swatch } from \"./swatch\";\nexport { Stat, StatGroup, Delta, StatusDot, Legend, Meter } from \"./stat\";\nexport { Steps } from \"./steps\";\nexport { Separator, Kbd, KbdGroup, AspectRatio } from \"./separator\";\nexport { ScrollArea } from \"./scroll-area\";\nexport { Slider } from \"./slider\";\nexport { InputGroup, PasswordInput } from \"./input-group\";\nexport { Image } from \"./image\";\nexport { FileInput, FileDrop, fileMatches } from \"./file-input\";\nexport { InputOTP } from \"./input-otp\";\nexport { Command, CommandDialog, commandScore, commandFilter } from \"./command\";\nexport { Combobox } from \"./combobox\";\nexport { Rating } from \"./rating\";\n"
156
+ "content": "// The kit. Import from \"../components/ui\" (a surface) or \"./ui\" (a\n// component). Every part is the workspace's look over the browser's own\n// behaviour; none of them takes a colour. README §3b.\nexport { cx } from \"./cx\";\nexport { Icon, ICON_NAMES } from \"./icon\";\nexport { Button, IconButton } from \"./button\";\nexport { Field, Input, TimeInput, Textarea, Select, Switch, Checkbox } from \"./field\";\nexport { Card, Notice, Badge, Kicker, Empty } from \"./notice\";\nexport { Sheet, Confirm } from \"./sheet\";\nexport { Tabs, TabPanel } from \"./tabs\";\nexport { Menu, MenuPanel } from \"./menu\";\nexport { ContextMenu } from \"./context-menu\";\nexport { Popover, usePopoverPosition, panelClass } from \"./popover\";\nexport {\n Calendar,\n parseDate,\n isDateString,\n toDateString,\n todayString,\n addDays,\n addMonths,\n startOfMonth,\n endOfMonth,\n dayOfWeek,\n formatDate,\n toLocalDate,\n firstDayOfWeek,\n} from \"./calendar\";\nexport { DatePicker, DateInput, DATE_PRESETS } from \"./date-picker\";\nexport { Tooltip, HoverCard } from \"./tooltip\";\nexport { Depiction } from \"./depiction\";\nexport { toast, Toaster } from \"./toast\";\nexport { Carousel } from \"./carousel\";\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle } from \"./resizable\";\nexport { Avatar, AvatarGroup } from \"./avatar\";\nexport { Breadcrumb } from \"./breadcrumb\";\nexport { Pagination } from \"./pagination\";\nexport { RadioGroup, Radio } from \"./radio\";\nexport { Toggle, ToggleGroup } from \"./toggle\";\nexport { ButtonGroup, ButtonGroupSeparator } from \"./button-group\";\nexport { Collapsible, Accordion } from \"./collapsible\";\nexport { Item, ItemGroup } from \"./item\";\nexport { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from \"./table\";\nexport { Spinner, Skeleton, Progress } from \"./feedback\";\nexport { Distribution } from \"./distribution\";\nexport { Swatch } from \"./swatch\";\nexport { Stat, StatGroup, Delta, StatusDot, Legend, Meter } from \"./stat\";\nexport { Steps } from \"./steps\";\nexport { Separator, Kbd, KbdGroup, AspectRatio } from \"./separator\";\nexport { ScrollArea } from \"./scroll-area\";\nexport { AppLayout, DocumentLayout } from \"./layout\";\nexport { Slider } from \"./slider\";\nexport { InputGroup, PasswordInput } from \"./input-group\";\nexport { Image } from \"./image\";\nexport { FileInput, FileDrop, fileMatches } from \"./file-input\";\nexport { InputOTP } from \"./input-otp\";\nexport { Command, CommandDialog, commandScore, commandFilter } from \"./command\";\nexport { Combobox } from \"./combobox\";\nexport { Rating } from \"./rating\";\n"
157
157
  },
158
158
  {
159
159
  "path": "templates/next/files/components/ui/input-group.jsx",
@@ -171,7 +171,13 @@
171
171
  "path": "templates/next/files/components/ui/item.jsx",
172
172
  "type": "registry:ui",
173
173
  "target": "@components/makersclaw/ui/item.jsx",
174
- "content": "import Link from \"next/link\";\nimport { Children } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Item — the list row: a contact, a file, a run, a setting. Media on the\n// left, title and description, meta on the right, then its own actions.\n// With `href` or `onClick` the WHOLE row is the target — the title is the\n// real <a> or <button> and stretches over the row, so the row's own\n// actions stay separate buttons (never a button inside a link) and a\n// screen reader hears the title, not the whole row read out.\n//\n// media a node on the left: an avatar, an <img>, a logo\n// icon or a glyph from ./icon, drawn in a muted square\n// title the one line that names the thing (truncates)\n// description up to two lines under it\n// meta a count, a date, a Badge — muted, on the right\n// actions buttons on the far right (IconButton, Menu); they sit above\n// the row's target, so they click on their own\n// href a next/link row · `onClick` a button row · neither: static\n// size default (56px) · sm (44px, for dense lists and rails) ·\n// lg (64px, roomier: a pipeline's runs, a list read at leisure)\n// variant plain (no fill) · outline (a bordered card) · muted (a filled panel)\n// layout row (default) · stacked — a card for a grid: icon or media on\n// top, then the title (up to two lines), the description (up\n// to three), children, and meta at the foot; actions sit in the\n// top-right corner. It fills its grid cell's height, so a row\n// of cards lines up. Pair it with variant=\"outline\" or \"muted\".\n// The link or button target and what goes where are the row's\n//\n// Where the other props go: `aria-current`, `onClick`, `target` and `rel`\n// belong to the row's target, so they go on its <a> or <button> (with\n// `href`, `onClick` rides on the link: analytics, closing a sheet).\n// aria-current on the target fills the row, so the current page looks\n// like what a screen reader says it is. Every other prop (data-*, id,\n// aria-label…) goes on the row's <div>.\n//\n// ItemGroup stacks plain Items with a rule between each, as a list.\n\nconst VARIANTS = {\n plain: \"\",\n outline: \"border border-border bg-card text-card-foreground\",\n muted: \"bg-muted\",\n};\n\nconst SIZES = {\n default: { row: \"min-h-14 gap-3 px-3 py-2.5\", icon: \"size-10\", glyph: 18, desc: \"line-clamp-2 text-sm\" },\n sm: { row: \"min-h-11 gap-2.5 px-2.5 py-1.5\", icon: \"size-8\", glyph: 16, desc: \"truncate text-xs\" },\n lg: { row: \"min-h-16 gap-3.5 px-4 py-3\", icon: \"size-11\", glyph: 20, desc: \"line-clamp-2 text-sm\" },\n};\n\n// layout=\"stacked\": the card's padding and gaps, and its icon square.\nconst STACKED = {\n default: { box: \"gap-3 p-4\", icon: \"size-10\", glyph: 18 },\n sm: { box: \"gap-2 p-3\", icon: \"size-8\", glyph: 16 },\n lg: { box: \"gap-4 p-5\", icon: \"size-12\", glyph: 22 },\n};\n\nconst TARGET = \"outline-none after:absolute after:inset-0\";\n\n// The row filled while its target (or a static row itself) is current.\nconst CURRENT =\n \"has-[[data-item-target][aria-current]:not([aria-current=false])]:bg-accent \" +\n \"[&[aria-current]:not([aria-current=false])]:bg-accent\";\n\nexport function Item({\n media,\n icon,\n title,\n description,\n meta,\n actions,\n href,\n onClick,\n size = \"default\",\n variant = \"plain\",\n layout = \"row\",\n disabled,\n className,\n children,\n \"aria-current\": ariaCurrent,\n target,\n rel,\n ...rest\n}) {\n const s = SIZES[size] ?? SIZES.default;\n const interactive = Boolean(href || onClick) && !disabled;\n\n let name = title;\n let targeted = false;\n if (href && !disabled) {\n targeted = true;\n name = (\n <Link href={href} onClick={onClick} target={target} rel={rel} aria-current={ariaCurrent} data-item-target className={TARGET}>\n {title}\n </Link>\n );\n } else if (onClick) {\n targeted = true;\n name = (\n <button\n type=\"button\"\n onClick={onClick}\n disabled={disabled}\n aria-current={ariaCurrent}\n data-item-target\n className={cx(TARGET, \"text-left disabled:cursor-not-allowed\")}\n >\n {title}\n </button>\n );\n }\n\n const ring =\n interactive &&\n \"hover:bg-accent has-[[data-item-target]:focus-visible]:ring-2 has-[[data-item-target]:focus-visible]:ring-inset has-[[data-item-target]:focus-visible]:ring-ring\";\n\n if (layout === \"stacked\") {\n const k = STACKED[size] ?? STACKED.default;\n return (\n <div\n className={cx(\"relative flex h-full flex-col items-start rounded-lg text-sm\", VARIANTS[variant] ?? VARIANTS.plain, k.box, ring, CURRENT, disabled && \"opacity-50\", className)}\n aria-current={targeted ? undefined : ariaCurrent}\n {...rest}\n >\n {icon ? (\n <span className={cx(\"flex shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground\", variant === \"muted\" && \"bg-background\", k.icon)}>\n <Icon name={icon} size={k.glyph} />\n </span>\n ) : media ? (\n <span className=\"flex shrink-0 items-center\">{media}</span>\n ) : null}\n <div className={cx(\"w-full min-w-0 flex-1\", actions && !icon && !media && \"pr-10\")}>\n <p className=\"line-clamp-2 font-medium text-foreground\">{name}</p>\n {description ? <p className=\"mt-1 line-clamp-3 text-sm text-muted-foreground\">{description}</p> : null}\n {children ? <div className=\"mt-2\">{children}</div> : null}\n </div>\n {meta !== undefined && meta !== null ? (\n <div data-nums className=\"text-xs text-muted-foreground\">\n {meta}\n </div>\n ) : null}\n {actions ? <div className=\"absolute right-2 top-2 z-10 flex items-center gap-1\">{actions}</div> : null}\n </div>\n );\n }\n\n return (\n <div\n className={cx(\n \"relative flex items-center rounded-lg text-sm\",\n VARIANTS[variant] ?? VARIANTS.plain,\n s.row,\n ring,\n CURRENT,\n disabled && \"opacity-50\",\n className,\n )}\n aria-current={targeted ? undefined : ariaCurrent}\n {...rest}\n >\n {icon ? (\n <span className={cx(\"flex shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground\", variant === \"muted\" && \"bg-background\", s.icon)}>\n <Icon name={icon} size={s.glyph} />\n </span>\n ) : media ? (\n <span className=\"flex shrink-0 items-center\">{media}</span>\n ) : null}\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate font-medium text-foreground\">{name}</p>\n {description ? <p className={cx(\"text-muted-foreground\", s.desc)}>{description}</p> : null}\n {children ? <div className=\"mt-1.5\">{children}</div> : null}\n </div>\n {meta !== undefined && meta !== null ? (\n <div data-nums className=\"shrink-0 text-xs text-muted-foreground\">\n {meta}\n </div>\n ) : null}\n {actions ? <div className=\"relative z-10 flex shrink-0 items-center gap-1\">{actions}</div> : null}\n </div>\n );\n}\n\n/** Items as a list, a rule between each. `variant=\"outline\"` boxes the\n * whole list; put plain Items in it. */\nexport function ItemGroup({ variant = \"plain\", className, children, \"aria-label\": ariaLabel }) {\n const rows = Children.toArray(children).filter(Boolean);\n if (rows.length === 0) return null;\n return (\n <ul\n role=\"list\"\n aria-label={ariaLabel}\n className={cx(\n \"flex flex-col\",\n variant === \"outline\" && \"overflow-hidden rounded-lg border border-border bg-card text-card-foreground [&>li>*]:rounded-none\",\n className,\n )}\n >\n {rows.map((row, i) => (\n <li key={row.key ?? i} className={cx(i > 0 && \"border-t border-border\")}>\n {row}\n </li>\n ))}\n </ul>\n );\n}\n"
174
+ "content": "import Link from \"next/link\";\nimport { Children } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Item — the list row: a contact, a file, a run, a setting. Media on the\n// left, title and description, meta on the right, then its own actions.\n// With `href` or `onClick` the WHOLE row is the target — the title is the\n// real <a> or <button> and stretches over the row, so the row's own\n// actions stay separate buttons (never a button inside a link) and a\n// screen reader hears the title, not the whole row read out.\n//\n// media a node on the left: an avatar, an <img>, a logo\n// icon or a glyph from ./icon, drawn in a muted square\n// title the one line that names the thing (truncates)\n// description up to two lines under it\n// meta a count, a date, a Badge — muted, on the right\n// actions buttons on the far right (IconButton, Menu); they sit above\n// the row's target, so they click on their own\n// href a next/link row · `onClick` a button row · neither: a plain row\n// size default (56px) · sm (44px, for dense lists and rails) ·\n// lg (64px, roomier: a pipeline's runs, a list read at leisure)\n// variant plain (no fill) · outline (a bordered card) · muted (a filled panel)\n// layout row (default) · stacked — a card for a grid: icon or media on\n// top, then the title (up to two lines), the description (up\n// to three), children, and meta at the foot; actions sit in the\n// top-right corner. It fills its grid cell's height, so a row\n// of cards lines up. Pair it with variant=\"outline\" or \"muted\".\n// The link or button target and what goes where are the row's\n//\n// Where the other props go: `aria-current`, `onClick`, `target` and `rel`\n// belong to the row's target, so they go on its <a> or <button> (with\n// `href`, `onClick` rides on the link: analytics, closing a sheet).\n// aria-current on the target fills the row, so the current page looks\n// like what a screen reader says it is. Every other prop (data-*, id,\n// aria-label…) goes on the row's <div>.\n//\n// ItemGroup stacks plain Items with a rule between each, as a list.\n\nconst VARIANTS = {\n plain: \"\",\n outline: \"border border-border bg-card text-card-foreground\",\n muted: \"bg-muted\",\n};\n\nconst SIZES = {\n default: { row: \"min-h-14 gap-3 px-3 py-2.5\", icon: \"size-10\", glyph: 18, desc: \"line-clamp-2 text-sm\" },\n sm: { row: \"min-h-11 gap-2.5 px-2.5 py-1.5\", icon: \"size-8\", glyph: 16, desc: \"truncate text-xs\" },\n lg: { row: \"min-h-16 gap-3.5 px-4 py-3\", icon: \"size-11\", glyph: 20, desc: \"line-clamp-2 text-sm\" },\n};\n\n// layout=\"stacked\": the card's padding and gaps, and its icon square.\nconst STACKED = {\n default: { box: \"gap-3 p-4\", icon: \"size-10\", glyph: 18 },\n sm: { box: \"gap-2 p-3\", icon: \"size-8\", glyph: 16 },\n lg: { box: \"gap-4 p-5\", icon: \"size-12\", glyph: 22 },\n};\n\nconst TARGET = \"outline-none after:absolute after:inset-0\";\n\n// The row filled while its target (or a plain row itself) is current.\nconst CURRENT =\n \"has-[[data-item-target][aria-current]:not([aria-current=false])]:bg-accent \" +\n \"[&[aria-current]:not([aria-current=false])]:bg-accent\";\n\nexport function Item({\n media,\n icon,\n title,\n description,\n meta,\n actions,\n href,\n onClick,\n size = \"default\",\n variant = \"plain\",\n layout = \"row\",\n disabled,\n className,\n children,\n \"aria-current\": ariaCurrent,\n target,\n rel,\n ...rest\n}) {\n const s = SIZES[size] ?? SIZES.default;\n const interactive = Boolean(href || onClick) && !disabled;\n\n let name = title;\n let targeted = false;\n if (href && !disabled) {\n targeted = true;\n name = (\n <Link href={href} onClick={onClick} target={target} rel={rel} aria-current={ariaCurrent} data-item-target className={TARGET}>\n {title}\n </Link>\n );\n } else if (onClick) {\n targeted = true;\n name = (\n <button\n type=\"button\"\n onClick={onClick}\n disabled={disabled}\n aria-current={ariaCurrent}\n data-item-target\n className={cx(TARGET, \"text-left disabled:cursor-not-allowed\")}\n >\n {title}\n </button>\n );\n }\n\n const ring =\n interactive &&\n \"hover:bg-accent has-[[data-item-target]:focus-visible]:ring-2 has-[[data-item-target]:focus-visible]:ring-inset has-[[data-item-target]:focus-visible]:ring-ring\";\n\n if (layout === \"stacked\") {\n const k = STACKED[size] ?? STACKED.default;\n return (\n <div\n className={cx(\"relative flex h-full flex-col items-start rounded-lg text-sm\", VARIANTS[variant] ?? VARIANTS.plain, k.box, ring, CURRENT, disabled && \"opacity-50\", className)}\n aria-current={targeted ? undefined : ariaCurrent}\n {...rest}\n >\n {icon ? (\n <span className={cx(\"flex shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground\", variant === \"muted\" && \"bg-background\", k.icon)}>\n <Icon name={icon} size={k.glyph} />\n </span>\n ) : media ? (\n <span className=\"flex shrink-0 items-center\">{media}</span>\n ) : null}\n <div className={cx(\"w-full min-w-0 flex-1\", actions && !icon && !media && \"pr-10\")}>\n <p className=\"line-clamp-2 font-medium text-foreground\">{name}</p>\n {description ? <p className=\"mt-1 line-clamp-3 text-sm text-muted-foreground\">{description}</p> : null}\n {children ? <div className=\"mt-2\">{children}</div> : null}\n </div>\n {meta !== undefined && meta !== null ? (\n <div data-nums className=\"text-xs text-muted-foreground\">\n {meta}\n </div>\n ) : null}\n {actions ? <div className=\"absolute right-2 top-2 z-10 flex items-center gap-1\">{actions}</div> : null}\n </div>\n );\n }\n\n return (\n <div\n className={cx(\n \"relative flex items-center rounded-lg text-sm\",\n VARIANTS[variant] ?? VARIANTS.plain,\n s.row,\n ring,\n CURRENT,\n disabled && \"opacity-50\",\n className,\n )}\n aria-current={targeted ? undefined : ariaCurrent}\n {...rest}\n >\n {icon ? (\n <span className={cx(\"flex shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground\", variant === \"muted\" && \"bg-background\", s.icon)}>\n <Icon name={icon} size={s.glyph} />\n </span>\n ) : media ? (\n <span className=\"flex shrink-0 items-center\">{media}</span>\n ) : null}\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate font-medium text-foreground\">{name}</p>\n {description ? <p className={cx(\"text-muted-foreground\", s.desc)}>{description}</p> : null}\n {children ? <div className=\"mt-1.5\">{children}</div> : null}\n </div>\n {meta !== undefined && meta !== null ? (\n <div data-nums className=\"shrink-0 text-xs text-muted-foreground\">\n {meta}\n </div>\n ) : null}\n {actions ? <div className=\"relative z-10 flex shrink-0 items-center gap-1\">{actions}</div> : null}\n </div>\n );\n}\n\n/** Items as a list, a rule between each. `variant=\"outline\"` boxes the\n * whole list; put plain Items in it. */\nexport function ItemGroup({ variant = \"plain\", className, children, \"aria-label\": ariaLabel }) {\n const rows = Children.toArray(children).filter(Boolean);\n if (rows.length === 0) return null;\n return (\n <ul\n role=\"list\"\n aria-label={ariaLabel}\n className={cx(\n \"flex flex-col\",\n variant === \"outline\" && \"overflow-hidden rounded-lg border border-border bg-card text-card-foreground [&>li>*]:rounded-none\",\n className,\n )}\n >\n {rows.map((row, i) => (\n <li key={row.key ?? i} className={cx(i > 0 && \"border-t border-border\")}>\n {row}\n </li>\n ))}\n </ul>\n );\n}\n"
175
+ },
176
+ {
177
+ "path": "templates/next/files/components/ui/layout.jsx",
178
+ "type": "registry:ui",
179
+ "target": "@components/makersclaw/ui/layout.jsx",
180
+ "content": "\"use client\";\n\nimport { createContext, useContext, useEffect, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Button, IconButton } from \"./button\";\nimport { Sheet } from \"./sheet\";\n\n// Layouts — who owns the window's height, and who scrolls.\n//\n// Every design-first build of 2026-10-07 shipped a shell of `min-h-dvh`\n// that grew with its content: the page itself scrolled, no pane scrolled\n// on its own, and a footer, an inspector or a button below a long\n// article sat past the end of the iframe, where nothing could reach it.\n// So the kit owns the window's height, once, and the app's code never\n// sizes anything by the window (`npm run check` refuses min-h-dvh,\n// h-screen, 100vh, h-[80vh]… outside the kit, rule 12).\n//\n// AppLayout — the app frame. Exactly one owns the viewport: the\n// outermost. A layout inside another fills the slot it is in instead,\n// whatever `height` says, so there is never a second viewport owner. The\n// shell (app/shell.jsx) is the outermost one in every app the template\n// lays; a surface that needs an inspector or a fill of its own returns\n// an AppLayout as its outermost element, and it fills the shell's\n// content edge to edge (the content's padding drops for it).\n//\n// header the top bar, always in view (title, the surface's\n// commands). The bar draws its own bottom border\n// navigation the surfaces, a page tree: a rail beside the content\n// from md (768px); below md a Sheet from the leading\n// edge, opened by the menu button in the top bar. A\n// press on a link or a button inside closes it\n// navigationLabel the Sheet's title and the menu button's words\n// (\"Navigation\")\n// navigationWidth the rail, in px (176; 160 to 320)\n// children the content. It scrolls inside its own region\n// contentMode scroll (the default): the content region scrolls ·\n// fill: the region is a bounded flex column and the\n// child owns its own scrolling (a canvas, a grid, a\n// spreadsheet, an editor that scrolls itself)\n// contentLabel the content region's name for a screen reader (\"Content\")\n// contentClassName spacing and token backgrounds for the content\n// region (p-4 sm:p-6, bg-card). Never a height or an\n// overflow: the layout owns those\n// scrollKey when it changes the content scrolls back to its top\n// and the navigation Sheet closes: pass the route\n// (usePathname()), since the frame stays mounted while\n// the surfaces change inside it\n// inspector the details, review or feedback panel beside the\n// content, scrolling on its own\n// inspectorLabel the inspector region's name (\"Details\")\n// inspectorWidth in px (360; 240 to 560). Builds have needed 300 and 440\n// inspectorAt md · lg (the default) · xl: the width from which the\n// inspector sits beside the content\n// footer under the content, always in view (the save state,\n// the composer, the commands for what is above)\n// height viewport (the default): the frame is the window's\n// height · parent: it fills a bounded parent. Nested,\n// it is parent whatever this says\n// className the frame's own tokens; never a height or an overflow\n//\n// Below `inspectorAt`, where there is no room for two columns:\n// - contentMode scroll: the content and the inspector share ONE scroll\n// region, the inspector after the content. Everything is reachable\n// by scrolling, and nothing is hidden behind a button.\n// - contentMode fill: the content keeps the room and the inspector is\n// a bounded pane under it (at most 45% of the height), scrolling on\n// its own. A fill child has no flow to stack into, and a pane with\n// no bound under it is cut off (workspace-layout's fault, 2026-10-07).\n// The footer stays under both, in view.\n//\n// In print the frame lets go: no bound, no overflow, no navigation, no\n// header, inspector or footer; the content flows onto pages. A page meant\n// for print or export is a DocumentLayout inside the content.\n\nconst Nested = createContext(false);\n\nconst px = (value, fallback, min, max) => {\n const n = Number(value);\n return `${Number.isFinite(n) && n > 0 ? Math.min(max, Math.max(min, Math.round(n))) : fallback}px`;\n};\n\n// Tailwind reads class names whole from the source, so every breakpoint\n// is written out.\nconst BESIDE = {\n md: {\n main: \"md:grid md:grid-cols-[minmax(0,1fr)_var(--app-inspector)] md:grid-rows-[minmax(0,1fr)_auto]\",\n flow: \"md:contents\",\n content: \"md:col-start-1 md:row-start-1\",\n scrolls: \"md:min-h-0 md:overflow-y-auto md:overscroll-contain\",\n inspector: \"md:col-start-2 md:row-span-2 md:row-start-1 md:max-h-none md:min-h-0 md:overflow-y-auto md:border-l md:border-t-0\",\n footer: \"md:col-start-1 md:row-start-2\",\n },\n lg: {\n main: \"lg:grid lg:grid-cols-[minmax(0,1fr)_var(--app-inspector)] lg:grid-rows-[minmax(0,1fr)_auto]\",\n flow: \"lg:contents\",\n content: \"lg:col-start-1 lg:row-start-1\",\n scrolls: \"lg:min-h-0 lg:overflow-y-auto lg:overscroll-contain\",\n inspector: \"lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:max-h-none lg:min-h-0 lg:overflow-y-auto lg:border-l lg:border-t-0\",\n footer: \"lg:col-start-1 lg:row-start-2\",\n },\n xl: {\n main: \"xl:grid xl:grid-cols-[minmax(0,1fr)_var(--app-inspector)] xl:grid-rows-[minmax(0,1fr)_auto]\",\n flow: \"xl:contents\",\n content: \"xl:col-start-1 xl:row-start-1\",\n scrolls: \"xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain\",\n inspector: \"xl:col-start-2 xl:row-span-2 xl:row-start-1 xl:max-h-none xl:min-h-0 xl:overflow-y-auto xl:border-l xl:border-t-0\",\n footer: \"xl:col-start-1 xl:row-start-2\",\n },\n};\n\nconst REGION = \"outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\";\nconst LETS_GO = \"print:block print:h-auto print:min-h-0 print:overflow-visible\";\n\nexport function AppLayout({\n header,\n navigation,\n navigationLabel = \"Navigation\",\n navigationWidth,\n children,\n contentMode = \"scroll\",\n contentLabel = \"Content\",\n contentClassName,\n scrollKey,\n inspector,\n inspectorLabel = \"Details\",\n inspectorWidth,\n inspectorAt = \"lg\",\n footer,\n height = \"viewport\",\n className,\n}) {\n const nested = useContext(Nested);\n const bounded = nested || height === \"parent\";\n const fill = contentMode === \"fill\";\n const at = BESIDE[inspectorAt] ?? BESIDE.lg;\n const hasInspector = inspector != null && inspector !== false;\n const hasNavigation = navigation != null && navigation !== false;\n const shared = hasInspector && !fill; // below `at`: content and inspector in one scroll region\n const [open, setOpen] = useState(false);\n const flowRef = useRef(null);\n const contentRef = useRef(null);\n\n useEffect(() => {\n if (flowRef.current) flowRef.current.scrollTop = 0;\n if (contentRef.current) contentRef.current.scrollTop = 0;\n setOpen(false);\n }, [scrollKey]);\n\n return (\n <Nested.Provider value={true}>\n <div\n data-app-layout={bounded ? \"parent\" : \"viewport\"}\n style={{ \"--app-nav\": px(navigationWidth, 176, 160, 320), \"--app-inspector\": px(inspectorWidth, 360, 240, 560) }}\n className={cx(\n \"flex min-w-0 flex-col overflow-hidden bg-background text-foreground\",\n bounded ? \"h-full min-h-0 flex-1\" : \"h-dvh\",\n LETS_GO,\n className,\n )}\n >\n {header != null && header !== false ? (\n <div data-layout-header className=\"flex shrink-0 items-center gap-1 border-b border-border print:hidden\">\n {hasNavigation ? (\n <IconButton icon=\"menu\" label={`Open ${navigationLabel.toLowerCase()}`} onClick={() => setOpen(true)} className=\"ml-1 md:hidden\" />\n ) : null}\n <div className=\"min-w-0 flex-1\">{header}</div>\n </div>\n ) : hasNavigation ? (\n <div data-layout-header className=\"flex shrink-0 items-center border-b border-border px-1 py-1 md:hidden print:hidden\">\n <Button variant=\"quiet\" size=\"sm\" icon=\"menu\" onClick={() => setOpen(true)}>\n {navigationLabel}\n </Button>\n </div>\n ) : null}\n\n {hasNavigation ? (\n <Sheet open={open} onClose={() => setOpen(false)} title={navigationLabel} side=\"start\">\n {open ? (\n <div\n onClick={(e) => {\n if (e.target instanceof Element && e.target.closest(\"a,button\")) setOpen(false);\n }}\n >\n {navigation}\n </div>\n ) : null}\n </Sheet>\n ) : null}\n\n <div data-layout-body className={cx(\"flex min-h-0 min-w-0 flex-1\", LETS_GO)}>\n {hasNavigation ? (\n <div\n data-layout-navigation\n className=\"hidden shrink-0 flex-col overflow-y-auto overscroll-contain border-r border-border md:flex md:w-(--app-nav) print:hidden\"\n >\n {navigation}\n </div>\n ) : null}\n\n <div data-layout-main className={cx(\"flex min-h-0 min-w-0 flex-1 flex-col\", hasInspector && at.main, LETS_GO)}>\n <div\n ref={flowRef}\n data-layout-flow\n className={cx(\n \"flex min-h-0 min-w-0 flex-1 flex-col\",\n shared && \"overflow-y-auto overscroll-contain\",\n hasInspector && at.flow,\n LETS_GO,\n )}\n >\n <div\n ref={contentRef}\n data-layout-content\n role=\"region\"\n aria-label={contentLabel}\n tabIndex={0}\n className={cx(\n \"flex min-w-0 flex-col\",\n REGION,\n fill\n ? \"min-h-0 flex-1 overflow-hidden\"\n : shared\n ? cx(\"shrink-0\", at.scrolls)\n : \"min-h-0 flex-1 overflow-y-auto overscroll-contain\",\n hasInspector && at.content,\n LETS_GO,\n contentClassName,\n // A surface that is a frame of its own runs to the edges.\n \"has-[>[data-app-layout]]:p-0\",\n )}\n >\n {children}\n </div>\n {hasInspector ? (\n <div\n data-layout-inspector\n role=\"region\"\n aria-label={inspectorLabel}\n tabIndex={0}\n className={cx(\n \"min-w-0 shrink-0 border-t border-border print:hidden\",\n REGION,\n fill && \"max-h-[45%] overflow-y-auto overscroll-contain\",\n at.inspector,\n )}\n >\n {inspector}\n </div>\n ) : null}\n </div>\n {footer != null && footer !== false ? (\n <div data-layout-footer className={cx(\"shrink-0 border-t border-border print:hidden\", hasInspector && at.footer)}>\n {footer}\n </div>\n ) : null}\n </div>\n </div>\n </div>\n </Nested.Provider>\n );\n}\n\n// DocumentLayout — a page that flows: a report, an invoice, an article\n// meant for print or export. It owns no height and scrolls nothing; it\n// sits in the AppLayout's content (contentMode scroll), which scrolls it,\n// and in print the frame lets go and it flows onto pages.\n//\n// children the document\n// toolbar a bar held at the top while the document scrolls under it\n// (Print, Export, the zoom); never printed\n// width prose (the default: a measure for reading) · page (A4,\n// 210mm) · letter (8.5in) · full. In print it is the paper's\n// paper true: drawn as a sheet on the desk on screen (bg-card, a\n// hairline, padding); plain in print\n// align start (the default) · center\n// label the document's name for a screen reader (\"Document\")\n// className the document's own spacing and tokens; never a height\nconst WIDTHS = {\n prose: \"max-w-prose\",\n page: \"max-w-[210mm]\",\n letter: \"max-w-[8.5in]\",\n full: \"max-w-none\",\n};\n\nexport function DocumentLayout({ children, toolbar, width = \"prose\", paper = false, align = \"start\", label = \"Document\", className }) {\n return (\n <div data-document-layout className=\"flex min-w-0 flex-col\">\n {toolbar != null && toolbar !== false ? (\n <div data-document-toolbar className=\"sticky top-0 z-10 border-b border-border bg-background print:hidden\">\n {toolbar}\n </div>\n ) : null}\n <article\n aria-label={label}\n className={cx(\n \"w-full min-w-0 print:max-w-none\",\n WIDTHS[width] ?? WIDTHS.prose,\n align === \"center\" && \"mx-auto\",\n paper && \"my-6 rounded-lg border border-border bg-card p-8 text-card-foreground shadow-sm print:my-0 print:rounded-none print:border-0 print:p-0 print:shadow-none\",\n className,\n )}\n >\n {children}\n </article>\n </div>\n );\n}\n"
175
181
  },
176
182
  {
177
183
  "path": "templates/next/files/components/ui/menu.jsx",
@@ -183,7 +189,7 @@
183
189
  "path": "templates/next/files/components/ui/notice.jsx",
184
190
  "type": "registry:ui",
185
191
  "target": "@components/makersclaw/ui/notice.jsx",
186
- "content": "import { cx } from \"./cx\";\nimport { IconButton } from \"./button\";\nimport { Icon } from \"./icon\";\n\n// Static parts: a card, a notice, a badge, a kicker, the empty state.\n// Server-safe (no hooks), so a server surface can use them directly.\n\nconst TONES = {\n neutral: { box: \"border-border bg-card text-card-foreground\", icon: \"info\" },\n info: { box: \"border-info/40 bg-card text-card-foreground\", icon: \"info\" },\n success: { box: \"border-success/40 bg-card text-card-foreground\", icon: \"check\" },\n warning: { box: \"border-warning/50 bg-card text-card-foreground\", icon: \"warning\" },\n danger: { box: \"border-destructive/50 bg-card text-card-foreground\", icon: \"warning\" },\n};\n\n/** A raised panel with an optional heading row.\n * padding md (default, 16px) · sm (12px, a dense rail) · none (the\n * body runs to the border — a table, a list of rows; the\n * heading row keeps its own 16px inset)\n * titleSize md (default, 16px) · lg (20px, a page-level card's title)\n * Pick these here, never with a className: cx does not merge classes,\n * so a `p-0` laid over the default padding wins only by CSS order. */\nconst CARD_PADDING = { none: \"\", sm: \"p-3\", md: \"p-4\" };\nconst CARD_TITLE = { md: \"text-base\", lg: \"text-xl\" };\n\nexport function Card({\n title,\n description,\n actions,\n padding = \"md\",\n titleSize = \"md\",\n className,\n children,\n as: Tag = \"section\",\n}) {\n const pad = CARD_PADDING[padding] ?? CARD_PADDING.md;\n const bare = padding === \"none\";\n return (\n <Tag className={cx(\"rounded-lg border border-border bg-card text-card-foreground\", pad, bare && \"overflow-hidden\", className)}>\n {title || actions ? (\n <div className={cx(\"flex items-start justify-between gap-3\", bare ? \"px-4 pt-4 pb-3\" : \"mb-3\")}>\n <div className=\"min-w-0\">\n {title ? (\n <h2 className={cx(\"font-semibold leading-tight\", CARD_TITLE[titleSize] ?? CARD_TITLE.md)}>{title}</h2>\n ) : null}\n {description ? <p className=\"mt-0.5 text-sm text-muted-foreground\">{description}</p> : null}\n </div>\n {actions ? <div className=\"flex shrink-0 gap-2\">{actions}</div> : null}\n </div>\n ) : null}\n {children}\n </Tag>\n );\n}\n\n/** One line the user should read, with an optional action. `tone`\n * neutral · info · success · warning · danger. Colour is the signal, so\n * status tones are for status only — never decoration. */\nexport function Notice({ tone = \"neutral\", title, action, onDismiss, className, children }) {\n const t = TONES[tone] ?? TONES.neutral;\n return (\n <div role={tone === \"danger\" ? \"alert\" : \"status\"} className={cx(\"flex items-start gap-3 rounded-lg border p-3 text-sm\", t.box, className)}>\n <Icon name={t.icon} className=\"mt-0.5 shrink-0 text-muted-foreground\" />\n <div className=\"min-w-0 flex-1\">\n {title ? <p className=\"font-medium\">{title}</p> : null}\n {children ? <div className={cx(title && \"mt-0.5\", \"text-muted-foreground\")}>{children}</div> : null}\n </div>\n {action ? <div className=\"shrink-0\">{action}</div> : null}\n {onDismiss ? (\n <IconButton icon=\"x\" label=\"Dismiss\" size=\"sm\" onClick={onDismiss} className=\"-my-1 -mr-1\" />\n ) : null}\n </div>\n );\n}\n\n/** A small label for a state or a count.\n * tone neutral · primary · success · warning · danger · info\n * variant label (default: small caps, wide tracking; primary is a\n * solid fill, every other tone a tint) · soft (the case as\n * written, normal tracking, EVERY tone a tint — primary\n * included: \"Ready\", \"Daily over\", \"Feature\") · outline (the\n * case as written, a tone-coloured hairline on no fill) ·\n * code (the mono face, the case as written, normal tracking\n * — a version tag \"v2.4.0\", a commit \"a3f8c2d\", a path)\n * hue 1..5 — a categorical colour, read from --color-chart-N,\n * for KINDS of things (a DNS type, a platform, a content\n * type). Tone is for status; hue is for category, and a\n * tone given with it wins. With every variant: soft is the\n * case as written on a tint of the hue with a hue dot and\n * foreground text; label (and code) is filled with the hue\n * and black or white text, whichever reads; outline is a\n * hue hairline with foreground text. The text is never the\n * hue itself: a chart colour is not made to be read as type\n * Case is a variant, not its own prop: uppercase needs wide tracking and\n * sentence case needs none, so the two travel together, and a `case`\n * prop would have to be ignored by code. A soft primary lives on `soft`\n * because `label` primary is a solid fill apps already rely on. The\n * outline's ring is inset, so every variant is the same size in a row. */\nconst BADGE_LOOK = {\n label: {\n neutral: \"bg-muted text-muted-foreground\",\n primary: \"bg-primary text-primary-foreground\",\n success: \"bg-success/15 text-success\",\n warning: \"bg-warning/15 text-warning\",\n danger: \"bg-destructive/15 text-destructive\",\n info: \"bg-info/15 text-info\",\n },\n soft: {\n neutral: \"bg-muted text-muted-foreground\",\n primary: \"bg-primary/15 text-primary\",\n success: \"bg-success/15 text-success\",\n warning: \"bg-warning/15 text-warning\",\n danger: \"bg-destructive/15 text-destructive\",\n info: \"bg-info/15 text-info\",\n },\n outline: {\n neutral: \"bg-transparent text-foreground ring-1 ring-inset ring-border\",\n primary: \"bg-transparent text-primary ring-1 ring-inset ring-primary/50\",\n success: \"bg-transparent text-success ring-1 ring-inset ring-success/50\",\n warning: \"bg-transparent text-warning ring-1 ring-inset ring-warning/50\",\n danger: \"bg-transparent text-destructive ring-1 ring-inset ring-destructive/50\",\n info: \"bg-transparent text-info ring-1 ring-inset ring-info/50\",\n },\n};\nBADGE_LOOK.code = BADGE_LOOK.label;\n\nconst BADGE_FACE = {\n label: \"font-medium uppercase tracking-wide\",\n soft: \"font-medium\",\n outline: \"font-medium\",\n code: \"font-mono font-normal\",\n};\n\n// Categorical hues, literal so Tailwind sees every class. A filled hue's\n// text is black or white, picked from the fill's relative luminance (the\n// WCAG formula, on the clipped sRGB channels): what CSS contrast-color()\n// does, which no token can. A chart colour sits mid-lightness (chart-2…5\n// are clamped to OKLCH L 0.55–0.8, often past sRGB), where the theme's\n// light text fails on a light theme and its dark text on a dark primary.\n// Black below Y 0.179, white above: the better of the two is always\n// 4.58:1 or more. The soft tint is light (8%) so foreground text keeps\n// its contrast.\nconst HUE_LOOK = {\n label: {\n 1: \"bg-chart-1 text-[color:color(from_var(--color-chart-1)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 2: \"bg-chart-2 text-[color:color(from_var(--color-chart-2)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 3: \"bg-chart-3 text-[color:color(from_var(--color-chart-3)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 4: \"bg-chart-4 text-[color:color(from_var(--color-chart-4)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 5: \"bg-chart-5 text-[color:color(from_var(--color-chart-5)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n },\n soft: {\n 1: \"bg-chart-1/8 text-foreground\",\n 2: \"bg-chart-2/8 text-foreground\",\n 3: \"bg-chart-3/8 text-foreground\",\n 4: \"bg-chart-4/8 text-foreground\",\n 5: \"bg-chart-5/8 text-foreground\",\n },\n outline: {\n 1: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-1\",\n 2: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-2\",\n 3: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-3\",\n 4: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-4\",\n 5: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-5\",\n },\n};\nHUE_LOOK.code = HUE_LOOK.label;\nconst HUE_DOT = { 1: \"bg-chart-1\", 2: \"bg-chart-2\", 3: \"bg-chart-3\", 4: \"bg-chart-4\", 5: \"bg-chart-5\" };\n\nexport function Badge({ tone, hue, variant = \"label\", className, children }) {\n const v = BADGE_FACE[variant] ? variant : \"label\";\n const h = tone == null && HUE_DOT[hue] ? hue : null;\n const look = h ? HUE_LOOK[v][h] : BADGE_LOOK[v][tone ?? \"neutral\"] ?? BADGE_LOOK[v].neutral;\n return (\n <span className={cx(\"inline-flex items-center rounded-sm px-1.5 py-0.5 text-2xs\", h && v === \"soft\" && \"gap-1\", BADGE_FACE[v], look, className)}>\n {h && v === \"soft\" ? <span aria-hidden=\"true\" className={cx(\"size-1.5 shrink-0 rounded-full\", HUE_DOT[h])} /> : null}\n {children}\n </span>\n );\n}\n\n/** The small uppercase line above a heading. */\nexport function Kicker({ className, children }) {\n return <p className={cx(\"text-2xs font-medium uppercase tracking-wider text-muted-foreground\", className)}>{children}</p>;\n}\n\n/** What a surface shows when a real collection is empty AND there is no\n * fixture to show (a filter with no matches, a search with no results).\n * Says what would appear here and offers the one way to make it. */\nexport function Empty({ icon = \"search\", title, body, action, className }) {\n return (\n <div className={cx(\"grid place-items-center gap-2 rounded-lg border border-dashed border-border px-6 py-10 text-center\", className)}>\n <Icon name={icon} size={22} className=\"text-muted-foreground\" />\n <p className=\"text-sm font-medium\">{title}</p>\n {body ? <p className=\"max-w-prose text-sm text-muted-foreground\">{body}</p> : null}\n {action ? <div className=\"mt-2\">{action}</div> : null}\n </div>\n );\n}\n"
192
+ "content": "import { cx } from \"./cx\";\nimport { IconButton } from \"./button\";\nimport { Icon } from \"./icon\";\n\n// Plain parts: a card, a notice, a badge, a kicker, the empty state.\n// Server-safe (no hooks), so a server surface can use them directly.\n\nconst TONES = {\n neutral: { box: \"border-border bg-card text-card-foreground\", icon: \"info\" },\n info: { box: \"border-info/40 bg-card text-card-foreground\", icon: \"info\" },\n success: { box: \"border-success/40 bg-card text-card-foreground\", icon: \"check\" },\n warning: { box: \"border-warning/50 bg-card text-card-foreground\", icon: \"warning\" },\n danger: { box: \"border-destructive/50 bg-card text-card-foreground\", icon: \"warning\" },\n};\n\n/** A raised panel with an optional heading row.\n * padding md (default, 16px) · sm (12px, a dense rail) · none (the\n * body runs to the border — a table, a list of rows; the\n * heading row keeps its own 16px inset)\n * titleSize md (default, 16px) · lg (20px, a page-level card's title)\n * Pick these here, never with a className: cx does not merge classes,\n * so a `p-0` laid over the default padding wins only by CSS order. */\nconst CARD_PADDING = { none: \"\", sm: \"p-3\", md: \"p-4\" };\nconst CARD_TITLE = { md: \"text-base\", lg: \"text-xl\" };\n\nexport function Card({\n title,\n description,\n actions,\n padding = \"md\",\n titleSize = \"md\",\n className,\n children,\n as: Tag = \"section\",\n}) {\n const pad = CARD_PADDING[padding] ?? CARD_PADDING.md;\n const bare = padding === \"none\";\n return (\n <Tag className={cx(\"rounded-lg border border-border bg-card text-card-foreground\", pad, bare && \"overflow-hidden\", className)}>\n {title || actions ? (\n <div className={cx(\"flex items-start justify-between gap-3\", bare ? \"px-4 pt-4 pb-3\" : \"mb-3\")}>\n <div className=\"min-w-0\">\n {title ? (\n <h2 className={cx(\"font-semibold leading-tight\", CARD_TITLE[titleSize] ?? CARD_TITLE.md)}>{title}</h2>\n ) : null}\n {description ? <p className=\"mt-0.5 text-sm text-muted-foreground\">{description}</p> : null}\n </div>\n {actions ? <div className=\"flex shrink-0 gap-2\">{actions}</div> : null}\n </div>\n ) : null}\n {children}\n </Tag>\n );\n}\n\n/** One line the user should read, with an optional action. `tone`\n * neutral · info · success · warning · danger. Colour is the signal, so\n * status tones are for status only — never decoration. */\nexport function Notice({ tone = \"neutral\", title, action, onDismiss, className, children }) {\n const t = TONES[tone] ?? TONES.neutral;\n return (\n <div role={tone === \"danger\" ? \"alert\" : \"status\"} className={cx(\"flex items-start gap-3 rounded-lg border p-3 text-sm\", t.box, className)}>\n <Icon name={t.icon} className=\"mt-0.5 shrink-0 text-muted-foreground\" />\n <div className=\"min-w-0 flex-1\">\n {title ? <p className=\"font-medium\">{title}</p> : null}\n {children ? <div className={cx(title && \"mt-0.5\", \"text-muted-foreground\")}>{children}</div> : null}\n </div>\n {action ? <div className=\"shrink-0\">{action}</div> : null}\n {onDismiss ? (\n <IconButton icon=\"x\" label=\"Dismiss\" size=\"sm\" onClick={onDismiss} className=\"-my-1 -mr-1\" />\n ) : null}\n </div>\n );\n}\n\n/** A small label for a state or a count.\n * tone neutral · primary · success · warning · danger · info\n * variant label (default: small caps, wide tracking; primary is a\n * solid fill, every other tone a tint) · soft (the case as\n * written, normal tracking, EVERY tone a tint — primary\n * included: \"Ready\", \"Daily over\", \"Feature\") · outline (the\n * case as written, a tone-coloured hairline on no fill) ·\n * code (the mono face, the case as written, normal tracking\n * — a version tag \"v2.4.0\", a commit \"a3f8c2d\", a path)\n * hue 1..5 — a categorical colour, read from --color-chart-N,\n * for KINDS of things (a DNS type, a platform, a content\n * type). Tone is for status; hue is for category, and a\n * tone given with it wins. With every variant: soft is the\n * case as written on a tint of the hue with a hue dot and\n * foreground text; label (and code) is filled with the hue\n * and black or white text, whichever reads; outline is a\n * hue hairline with foreground text. The text is never the\n * hue itself: a chart colour is not made to be read as type\n * Case is a variant, not its own prop: uppercase needs wide tracking and\n * sentence case needs none, so the two travel together, and a `case`\n * prop would have to be ignored by code. A soft primary lives on `soft`\n * because `label` primary is a solid fill apps already rely on. The\n * outline's ring is inset, so every variant is the same size in a row. */\nconst BADGE_LOOK = {\n label: {\n neutral: \"bg-muted text-muted-foreground\",\n primary: \"bg-primary text-primary-foreground\",\n success: \"bg-success/15 text-success\",\n warning: \"bg-warning/15 text-warning\",\n danger: \"bg-destructive/15 text-destructive\",\n info: \"bg-info/15 text-info\",\n },\n soft: {\n neutral: \"bg-muted text-muted-foreground\",\n primary: \"bg-primary/15 text-primary\",\n success: \"bg-success/15 text-success\",\n warning: \"bg-warning/15 text-warning\",\n danger: \"bg-destructive/15 text-destructive\",\n info: \"bg-info/15 text-info\",\n },\n outline: {\n neutral: \"bg-transparent text-foreground ring-1 ring-inset ring-border\",\n primary: \"bg-transparent text-primary ring-1 ring-inset ring-primary/50\",\n success: \"bg-transparent text-success ring-1 ring-inset ring-success/50\",\n warning: \"bg-transparent text-warning ring-1 ring-inset ring-warning/50\",\n danger: \"bg-transparent text-destructive ring-1 ring-inset ring-destructive/50\",\n info: \"bg-transparent text-info ring-1 ring-inset ring-info/50\",\n },\n};\nBADGE_LOOK.code = BADGE_LOOK.label;\n\nconst BADGE_FACE = {\n label: \"font-medium uppercase tracking-wide\",\n soft: \"font-medium\",\n outline: \"font-medium\",\n code: \"font-mono font-normal\",\n};\n\n// Categorical hues, literal so Tailwind sees every class. A filled hue's\n// text is black or white, picked from the fill's relative luminance (the\n// WCAG formula, on the clipped sRGB channels): what CSS contrast-color()\n// does, which no token can. A chart colour sits mid-lightness (chart-2…5\n// are clamped to OKLCH L 0.55–0.8, often past sRGB), where the theme's\n// light text fails on a light theme and its dark text on a dark primary.\n// Black below Y 0.179, white above: the better of the two is always\n// 4.58:1 or more. The soft tint is light (8%) so foreground text keeps\n// its contrast.\nconst HUE_LOOK = {\n label: {\n 1: \"bg-chart-1 text-[color:color(from_var(--color-chart-1)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 2: \"bg-chart-2 text-[color:color(from_var(--color-chart-2)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 3: \"bg-chart-3 text-[color:color(from_var(--color-chart-3)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 4: \"bg-chart-4 text-[color:color(from_var(--color-chart-4)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 5: \"bg-chart-5 text-[color:color(from_var(--color-chart-5)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n },\n soft: {\n 1: \"bg-chart-1/8 text-foreground\",\n 2: \"bg-chart-2/8 text-foreground\",\n 3: \"bg-chart-3/8 text-foreground\",\n 4: \"bg-chart-4/8 text-foreground\",\n 5: \"bg-chart-5/8 text-foreground\",\n },\n outline: {\n 1: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-1\",\n 2: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-2\",\n 3: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-3\",\n 4: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-4\",\n 5: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-5\",\n },\n};\nHUE_LOOK.code = HUE_LOOK.label;\nconst HUE_DOT = { 1: \"bg-chart-1\", 2: \"bg-chart-2\", 3: \"bg-chart-3\", 4: \"bg-chart-4\", 5: \"bg-chart-5\" };\n\nexport function Badge({ tone, hue, variant = \"label\", className, children }) {\n const v = BADGE_FACE[variant] ? variant : \"label\";\n const h = tone == null && HUE_DOT[hue] ? hue : null;\n const look = h ? HUE_LOOK[v][h] : BADGE_LOOK[v][tone ?? \"neutral\"] ?? BADGE_LOOK[v].neutral;\n return (\n <span className={cx(\"inline-flex items-center rounded-sm px-1.5 py-0.5 text-2xs\", h && v === \"soft\" && \"gap-1\", BADGE_FACE[v], look, className)}>\n {h && v === \"soft\" ? <span aria-hidden=\"true\" className={cx(\"size-1.5 shrink-0 rounded-full\", HUE_DOT[h])} /> : null}\n {children}\n </span>\n );\n}\n\n/** The small uppercase line above a heading. */\nexport function Kicker({ className, children }) {\n return <p className={cx(\"text-2xs font-medium uppercase tracking-wider text-muted-foreground\", className)}>{children}</p>;\n}\n\n/** What a surface shows when a real collection is empty AND there is no\n * fixture to show (a filter with no matches, a search with no results).\n * Says what would appear here and offers the one way to make it. */\nexport function Empty({ icon = \"search\", title, body, action, className }) {\n return (\n <div className={cx(\"grid place-items-center gap-2 rounded-lg border border-dashed border-border px-6 py-10 text-center\", className)}>\n <Icon name={icon} size={22} className=\"text-muted-foreground\" />\n <p className=\"text-sm font-medium\">{title}</p>\n {body ? <p className=\"max-w-prose text-sm text-muted-foreground\">{body}</p> : null}\n {action ? <div className=\"mt-2\">{action}</div> : null}\n </div>\n );\n}\n"
187
193
  },
188
194
  {
189
195
  "path": "templates/next/files/components/ui/pagination.jsx",
@@ -225,7 +231,7 @@
225
231
  "path": "templates/next/files/components/ui/separator.jsx",
226
232
  "type": "registry:ui",
227
233
  "target": "@components/makersclaw/ui/separator.jsx",
228
- "content": "import { cx } from \"./cx\";\n\n// Small static parts that shape a layout: a separator, a key cap, a box\n// that keeps its ratio. Server-safe (no hooks).\n\n/**\n * A hairline between groups. Decorative by default — most lines only\n * group what spacing already groups, and a screen reader should not\n * announce them. Pass `decorative={false}` when the line divides two\n * sections a reader should know about (role=separator).\n *\n * orientation horizontal (full width) · vertical (full height of a\n * flex row: toolbar groups, breadcrumb-like rows)\n * decorative true (hidden from readers) · false (a separator)\n * label horizontal only: a word on the line (\"or\")\n */\nexport function Separator({ orientation = \"horizontal\", decorative = true, label, className }) {\n const vertical = orientation === \"vertical\";\n const a11y = decorative\n ? { role: \"none\" }\n : { role: \"separator\", \"aria-orientation\": vertical ? \"vertical\" : undefined };\n if (vertical) {\n return <div {...a11y} className={cx(\"w-px shrink-0 self-stretch bg-border\", className)} />;\n }\n if (label) {\n return (\n <div {...a11y} className={cx(\"flex items-center gap-3 text-xs text-muted-foreground\", className)}>\n <span className=\"h-px flex-1 bg-border\" />\n <span>{label}</span>\n <span className=\"h-px flex-1 bg-border\" />\n </div>\n );\n }\n return <div {...a11y} className={cx(\"h-px w-full shrink-0 bg-border\", className)} />;\n}\n\n// Symbols a reader cannot say; each is shown to the eye and spelled out\n// to the ear.\nconst KEY_NAMES = {\n \"⌘\": \"Command\",\n \"⌥\": \"Option\",\n \"⇧\": \"Shift\",\n \"⌃\": \"Control\",\n \"↵\": \"Enter\",\n \"⏎\": \"Return\",\n \"⌫\": \"Backspace\",\n \"⌦\": \"Delete\",\n \"⎋\": \"Escape\",\n \"⇥\": \"Tab\",\n \"↑\": \"Up\",\n \"↓\": \"Down\",\n \"←\": \"Left\",\n \"→\": \"Right\",\n};\n\n/** One key, as printed on the keyboard: ⌘, Esc, K. A native <kbd>. */\nexport function Kbd({ className, children }) {\n const name = typeof children === \"string\" ? KEY_NAMES[children] : undefined;\n return (\n <kbd\n className={cx(\n \"inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-border bg-muted px-1\",\n \"font-sans text-2xs font-medium leading-none text-muted-foreground\",\n className,\n )}\n >\n {name ? (\n <>\n <span aria-hidden=\"true\">{children}</span>\n <span className=\"sr-only\">{name}</span>\n </>\n ) : (\n children\n )}\n </kbd>\n );\n}\n\n/** A combo pressed together (⌘ K). `keys` is [\"⌘\", \"K\"]; children work\n * too. Nested <kbd>s, which is how HTML spells a key combination. */\nexport function KbdGroup({ keys, className, children }) {\n return (\n <kbd className={cx(\"inline-flex items-center gap-1 font-sans\", className)}>\n {keys ? keys.map((k, i) => <Kbd key={i}>{k}</Kbd>) : children}\n </kbd>\n );\n}\n\nconst RATIOS = {\n square: \"aspect-square\",\n video: \"aspect-video\",\n portrait: \"aspect-3/4\",\n photo: \"aspect-4/3\",\n wide: \"aspect-21/9\",\n};\n\n/**\n * A box that keeps its shape as its width changes — a thumbnail, a video,\n * a map. Its one child fills it (an <img> is cropped to cover).\n *\n * ratio square · video (16:9) · portrait (3:4) · photo (4:3) · wide\n * (21:9), or a number (width / height, e.g. 2)\n */\nexport function AspectRatio({ ratio = \"video\", className, children }) {\n const named = typeof ratio === \"string\" ? RATIOS[ratio] : undefined;\n return (\n <div\n className={cx(\n \"relative w-full overflow-hidden\",\n named ?? (typeof ratio === \"number\" ? undefined : RATIOS.video),\n \"[&>*]:absolute [&>*]:inset-0 [&>*]:size-full [&>img]:object-cover\",\n className,\n )}\n style={typeof ratio === \"number\" && ratio > 0 ? { aspectRatio: ratio } : undefined}\n >\n {children}\n </div>\n );\n}\n"
234
+ "content": "import { cx } from \"./cx\";\n\n// Small plain parts that shape a layout: a separator, a key cap, a box\n// that keeps its ratio. Server-safe (no hooks).\n\n/**\n * A hairline between groups. Decorative by default — most lines only\n * group what spacing already groups, and a screen reader should not\n * announce them. Pass `decorative={false}` when the line divides two\n * sections a reader should know about (role=separator).\n *\n * orientation horizontal (full width) · vertical (full height of a\n * flex row: toolbar groups, breadcrumb-like rows)\n * decorative true (hidden from readers) · false (a separator)\n * label horizontal only: a word on the line (\"or\")\n */\nexport function Separator({ orientation = \"horizontal\", decorative = true, label, className }) {\n const vertical = orientation === \"vertical\";\n const a11y = decorative\n ? { role: \"none\" }\n : { role: \"separator\", \"aria-orientation\": vertical ? \"vertical\" : undefined };\n if (vertical) {\n return <div {...a11y} className={cx(\"w-px shrink-0 self-stretch bg-border\", className)} />;\n }\n if (label) {\n return (\n <div {...a11y} className={cx(\"flex items-center gap-3 text-xs text-muted-foreground\", className)}>\n <span className=\"h-px flex-1 bg-border\" />\n <span>{label}</span>\n <span className=\"h-px flex-1 bg-border\" />\n </div>\n );\n }\n return <div {...a11y} className={cx(\"h-px w-full shrink-0 bg-border\", className)} />;\n}\n\n// Symbols a reader cannot say; each is shown to the eye and spelled out\n// to the ear.\nconst KEY_NAMES = {\n \"⌘\": \"Command\",\n \"⌥\": \"Option\",\n \"⇧\": \"Shift\",\n \"⌃\": \"Control\",\n \"↵\": \"Enter\",\n \"⏎\": \"Return\",\n \"⌫\": \"Backspace\",\n \"⌦\": \"Delete\",\n \"⎋\": \"Escape\",\n \"⇥\": \"Tab\",\n \"↑\": \"Up\",\n \"↓\": \"Down\",\n \"←\": \"Left\",\n \"→\": \"Right\",\n};\n\n/** One key, as printed on the keyboard: ⌘, Esc, K. A native <kbd>. */\nexport function Kbd({ className, children }) {\n const name = typeof children === \"string\" ? KEY_NAMES[children] : undefined;\n return (\n <kbd\n className={cx(\n \"inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-border bg-muted px-1\",\n \"font-sans text-2xs font-medium leading-none text-muted-foreground\",\n className,\n )}\n >\n {name ? (\n <>\n <span aria-hidden=\"true\">{children}</span>\n <span className=\"sr-only\">{name}</span>\n </>\n ) : (\n children\n )}\n </kbd>\n );\n}\n\n/** A combo pressed together (⌘ K). `keys` is [\"⌘\", \"K\"]; children work\n * too. Nested <kbd>s, which is how HTML spells a key combination. */\nexport function KbdGroup({ keys, className, children }) {\n return (\n <kbd className={cx(\"inline-flex items-center gap-1 font-sans\", className)}>\n {keys ? keys.map((k, i) => <Kbd key={i}>{k}</Kbd>) : children}\n </kbd>\n );\n}\n\nconst RATIOS = {\n square: \"aspect-square\",\n video: \"aspect-video\",\n portrait: \"aspect-3/4\",\n photo: \"aspect-4/3\",\n wide: \"aspect-21/9\",\n};\n\n/**\n * A box that keeps its shape as its width changes — a thumbnail, a video,\n * a map. Its one child fills it (an <img> is cropped to cover).\n *\n * ratio square · video (16:9) · portrait (3:4) · photo (4:3) · wide\n * (21:9), or a number (width / height, e.g. 2)\n */\nexport function AspectRatio({ ratio = \"video\", className, children }) {\n const named = typeof ratio === \"string\" ? RATIOS[ratio] : undefined;\n return (\n <div\n className={cx(\n \"relative w-full overflow-hidden\",\n named ?? (typeof ratio === \"number\" ? undefined : RATIOS.video),\n \"[&>*]:absolute [&>*]:inset-0 [&>*]:size-full [&>img]:object-cover\",\n className,\n )}\n style={typeof ratio === \"number\" && ratio > 0 ? { aspectRatio: ratio } : undefined}\n >\n {children}\n </div>\n );\n}\n"
229
235
  },
230
236
  {
231
237
  "path": "templates/next/files/components/ui/sheet.jsx",
package/r/csv-import.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "CsvImport",
6
6
  "description": "Import a CSV in three calm steps: drop a file (or paste text, or download a template), match each file column to a field with automatic matches and sample values (or, with no fields, name and type a new table's fields from guessed types), then review every row in an editable spreadsheet where cells that do not fit their type are marked red and fixed in place, delete rows or leave the bad ones out, and import through one async onImport that answers { ok }. Quoted commas and newlines, TSV and semicolon files, byte-order marks, 50,000 rows.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/spreadsheet.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/spreadsheet.json"
10
10
  ],
11
11
  "dependencies": [
12
12
  "papaparse@5.7.0"
@@ -34,7 +34,7 @@
34
34
  "path": "registry/blocks/csv-import/README.md",
35
35
  "type": "registry:file",
36
36
  "target": "@components/makersclaw/blocks/csv-import/README.md",
37
- "content": "# CsvImport\n\nBring rows in from a CSV in three steps. **Upload**: drop a file or browse\n(or paste the text, or download a template of your fields to fill in).\n**Match columns**: each column in the file, with a few of its values,\nbeside the field it goes to — matched for you by name and common synonyms\n(\"E-mail address\" → Email, \"Organization\" → Company), changeable, or\n\"Don't import\"; required fields that are still unmatched are named.\n**Review**: every row in an editable spreadsheet. A cell that does not fit\nits field (\"last spring\" in a date, \"abc\" in an amount, a missing required\nvalue) keeps its text and gets a red corner; click it and type the right\nvalue, delete the row, or leave the rows with problems out. Then one\n**Import** — nothing is saved before it.\n\nWith no `fields`, it makes a new table instead: each column becomes a\nfield whose name you can change and whose type is guessed from its values\n(number, currency, date, checkbox, link, single or multiple select, text).\n\nThe CSV is read by [papaparse](https://www.papaparse.com) (MIT): commas,\ntabs, semicolons or pipes (guessed), quoted cells with commas and\nnewlines, a byte-order mark. Typed values come from the spreadsheet\nblock's column types, so \"$1,250\" is 1250 and \"11/02/2024\" is a day.\n\n```jsx\nimport { CsvImport } from \"../components/blocks/csv-import\"; // a Makersclaw app (\"./blocks/csv-import\" from components/)\nimport { CsvImport } from \"@/components/makersclaw/blocks/csv-import\"; // a shadcn project\n```\n\nIt is a client component. `makersclaw add csv-import` adds the\nspreadsheet block (it requires it) and `papaparse` (pinned, exact): run\n`npm install` after.\n\n## The data\n\n`fields` are the spreadsheet's columns — the same shape, the same types\n(its README, \"The data\"):\n\n```js\nconst FIELDS = [\n { key: \"name\", name: \"Name\", type: \"text\", required: true },\n { key: \"email\", name: \"Email\", type: \"text\", required: true },\n { key: \"stage\", name: \"Stage\", type: \"select\", options: [{ value: \"lead\", label: \"Lead\", hue: 2 }] },\n { key: \"mrr\", name: \"MRR\", type: \"currency\", precision: 0 },\n { key: \"owner\", name: \"Owner\", type: \"person\" }, // matched by name, first name, email or id in `people`\n { key: \"since\", name: \"Customer since\", type: \"date\" },\n];\n```\n\n`onImport` receives `{ rows, columns, skipped }`:\n\n- `rows` — typed values keyed by field (`{ id, name: \"Ada\", mrr: 1250,\n since: \"2025-03-14\", … }`). The `id` is a temporary one (\"import-3\");\n drop it and let the database make its own.\n- `columns` — the fields the rows use. Into existing fields, a select\n value that was not an option yet (\"Trial\") arrives as a new option on\n its field (`hue` set): save the options with the rows. As a new table,\n these are the new table's fields.\n- `skipped` — how many rows with problems were left out.\n\n## Props\n\n- `onImport({ rows, columns, skipped })` — required; async, answers\n `{ ok: true }` (optionally `imported`: the count you actually saved) or\n `{ ok: false, error }`. A failure keeps everything as it was and shows\n the error; the person can try again.\n- `fields` — the table to import into. Leave it out to make a new table.\n Read-only and computed fields are not offered.\n- `people` — `[{ id, name, email? }]`, for `person` fields.\n- `noun` — what a row is (`\"contact\"`): \"Import 8 contacts\". Default\n `\"row\"`.\n- `title` — the heading (default \"Import contacts from a CSV\");\n `description` — the line under it.\n- `maxRows` — the most rows one file may hold (`50000`).\n- `example` — `{ key: value }` for the template's example line (with\n `fields`, \"Download a template\" writes the field names and this line).\n `templateName` — the template's file name (`\"contacts-template.csv\"`).\n- `initialText` — CSV text to start from (skips the upload step);\n `initialName` — what to call it (`\"Pasted text\"`).\n- `onCancel` — shows Cancel on the first step.\n- `onDone` — the success screen's main button (`doneLabel`, default\n \"View contacts\"); without it only \"Import another file\" shows.\n- `locale` — for amounts and dates; `today` — `\"YYYY-MM-DD\"` for the date\n picker.\n\nThe helpers, for a flow of your own: `parseCsvText(text)` → `{ data,\ndelimiter, errors }` · `readCsvFile(file)` · `headersOf(data, hasHeader)` ·\n`autoMatch(headers, fields)` → a field key (or null) per column ·\n`guessColumns(headers, body)` → new fields with types and options ·\n`buildRows(body, mapping, fields, { ctx })` → `{ rows, problems,\ncreates, fields }` (each row's `__problems` is `{ key: message }`) ·\n`cleanRow(row)` · `templateCsv(fields, example)`.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useRouter } from \"next/navigation\";\nimport { insertRow } from \"../lib/actions\";\nimport { CsvImport } from \"./blocks/csv-import\";\n\nexport default function ImportContacts({ fields, team }) {\n const router = useRouter();\n return (\n <CsvImport\n fields={fields}\n people={team}\n noun=\"contact\"\n onImport={async ({ rows }) => {\n let imported = 0;\n for (const { id, ...values } of rows) {\n const r = await insertRow({ source: \"public.contacts\", values });\n if (!r.ok) return { ok: false, error: `Stopped after ${imported} contacts: ${r.error}` };\n imported++;\n }\n return { ok: true, imported };\n }}\n onDone={() => router.push(\"/contacts\")}\n />\n );\n}\n```\n\n## What it will not do\n\n- Excel files (.xlsx) are not read: save the sheet as CSV first (the\n upload step says so when one is dropped).\n- No merging with rows already in the table (update by email, skip\n duplicates): every imported row is new. Match on your side in\n `onImport` if you need it.\n- One file at a time; nothing is uploaded or kept — the rows go to\n `onImport` and nowhere else.\n- The review can fix cells and delete rows, not add rows or change the\n target fields (as a new table, it can rename and retype them).\n"
37
+ "content": "# CsvImport\n\nBring rows in from a CSV in three steps. **Upload**: drop a file or browse\n(or paste the text, or download a template of your fields to fill in).\n**Match columns**: each column in the file, with a few of its values,\nbeside the field it goes to — matched for you by name and common synonyms\n(\"E-mail address\" → Email, \"Organization\" → Company), changeable, or\n\"Don't import\"; required fields that are still unmatched are named.\n**Review**: every row in an editable spreadsheet. A cell that does not fit\nits field (\"last spring\" in a date, \"abc\" in an amount, a missing required\nvalue) keeps its text and gets a red corner; click it and type the right\nvalue, delete the row, or leave the rows with problems out. Then one\n**Import** — nothing is saved before it.\n\nWith no `fields`, it makes a new table instead: each column becomes a\nfield whose name you can change and whose type is guessed from its values\n(number, currency, date, checkbox, link, single or multiple select, text).\n\nThe CSV is read by [papaparse](https://www.papaparse.com) (MIT): commas,\ntabs, semicolons or pipes (guessed), quoted cells with commas and\nnewlines, a byte-order mark. Typed values come from the spreadsheet\nblock's column types, so \"$1,250\" is 1250 and \"11/02/2024\" is a day.\n\n```jsx\nimport { CsvImport } from \"../components/blocks/csv-import\"; // a Makersclaw app (\"./blocks/csv-import\" from components/)\nimport { CsvImport } from \"@/components/makersclaw/blocks/csv-import\"; // a shadcn project\n```\n\nIt is a client component. `makersclaw add csv-import` adds the\nspreadsheet block (it requires it) and `papaparse` (pinned, exact): run\n`npm install` after.\n\n## The data\n\n`fields` are the spreadsheet's columns — the same shape, the same types\n(its README, \"The data\"):\n\n```js\nconst FIELDS = [\n { key: \"name\", name: \"Name\", type: \"text\", required: true },\n { key: \"email\", name: \"Email\", type: \"text\", required: true },\n { key: \"stage\", name: \"Stage\", type: \"select\", options: [{ value: \"lead\", label: \"Lead\", hue: 2 }] },\n { key: \"mrr\", name: \"MRR\", type: \"currency\", precision: 0 },\n { key: \"owner\", name: \"Owner\", type: \"person\" }, // matched by name, first name, email or id in `people`\n { key: \"since\", name: \"Customer since\", type: \"date\" },\n];\n```\n\n`onImport` receives `{ rows, columns, skipped }`:\n\n- `rows` — typed values keyed by field (`{ id, name: \"Ada\", mrr: 1250,\n since: \"2025-03-14\", … }`). The `id` is a temporary one (\"import-3\");\n drop it and let the database make its own.\n- `columns` — the fields the rows use. Into existing fields, a select\n value that was not an option yet (\"Trial\") arrives as a new option on\n its field (`hue` set): save the options with the rows. As a new table,\n these are the new table's fields.\n- `skipped` — how many rows with problems were left out.\n\n## Props\n\n- `onImport({ rows, columns, skipped })` — required; async, answers\n `{ ok: true }` (optionally `imported`: the count you actually saved) or\n `{ ok: false, error }`. A failure keeps everything as it was and shows\n the error; the person can try again.\n- `fields` — the table to import into. Leave it out to make a new table.\n Read-only and computed fields are not offered.\n- `people` — `[{ id, name, email? }]`, for `person` fields.\n- `noun` — what a row is (`\"contact\"`): \"Import 8 contacts\". Default\n `\"row\"`.\n- `title` — the heading (default \"Import contacts from a CSV\");\n `description` — the line under it.\n- `maxRows` — the most rows one file may hold (`50000`).\n- `example` — `{ key: value }` for the template's example line (with\n `fields`, \"Download a template\" writes the field names and this line).\n `templateName` — the template's file name (`\"contacts-template.csv\"`).\n- `initialText` — CSV text to start from (skips the upload step);\n `initialName` — what to call it (`\"Pasted text\"`).\n- `onCancel` — shows Cancel on the first step.\n- `onDone` — the success screen's main button (`doneLabel`, default\n \"View contacts\"); without it only \"Import another file\" shows.\n- `locale` — for amounts and dates; `today` — `\"YYYY-MM-DD\"` for the date\n picker.\n\nThe helpers, for a flow of your own: `parseCsvText(text)` → `{ data,\ndelimiter, errors }` · `readCsvFile(file)` · `headersOf(data, hasHeader)` ·\n`autoMatch(headers, fields)` → a field key (or null) per column ·\n`guessColumns(headers, body)` → new fields with types and options ·\n`buildRows(body, mapping, fields, { ctx })` → `{ rows, problems,\ncreates, fields }` (each row's `__problems` is `{ key: message }`) ·\n`cleanRow(row)` · `templateCsv(fields, example)`.\n\n## Example\n\n```jsx\n// surfaces/import.jsx — client: contacts from a spreadsheet\n\"use client\";\nimport { useRouter } from \"next/navigation\";\nimport { useRows } from \"../lib/data\";\nimport { runAction } from \"../lib/actions\";\nimport { CsvImport } from \"../components/blocks/csv-import\";\n\nconst FIELDS = [\n { key: \"name\", name: \"Name\", type: \"text\", required: true },\n { key: \"email\", name: \"Email\", type: \"text\", required: true },\n { key: \"owner\", name: \"Owner\", type: \"person\" },\n];\n\nexport default function ImportContacts() {\n const router = useRouter();\n const { rows: team } = useRows({ source: \"people\", orderBy: \"name asc\" });\n return (\n <CsvImport\n fields={FIELDS}\n people={team}\n noun=\"contact\"\n onImport={async ({ rows }) => {\n // One action, one transaction: a write per row would stop halfway,\n // and the door takes 300 writes a minute.\n const r = await runAction({ name: \"import_contacts\", args: { rows: rows.map(({ id, ...values }) => values) } });\n return r.ok ? { ok: true, imported: r.result?.imported } : r;\n }}\n onDone={() => router.push(\"/s/contacts\")}\n />\n );\n}\n```\n\nThe action, named in `references/actions.md` and made with `sql`:\n\n```sql\ncreate or replace function import_contacts(args jsonb) returns jsonb\nlanguage sql as $$\n with made as (\n insert into contacts (name, email, owner)\n select name, email, owner from jsonb_populate_recordset(null::contacts, args->'rows')\n returning 1\n )\n select jsonb_build_object('imported', count(*)) from made\n$$;\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Excel files (.xlsx) are not read: save the sheet as CSV first (the\n upload step says so when one is dropped).\n- No merging with rows already in the table (update by email, skip\n duplicates): every imported row is new. Match on your side in\n `onImport` if you need it.\n- One file at a time; nothing is uploaded or kept — the rows go to\n `onImport` and nowhere else.\n- The review can fix cells and delete rows, not add rows or change the\n target fields (as a new table, it can rename and retype them).\n"
38
38
  }
39
39
  ],
40
40
  "categories": [
@@ -56,7 +56,7 @@
56
56
  "docs": "Installed to components/makersclaw/blocks/csv-import. Import: import { CsvImport, parseCsvText, readCsvFile, headersOf, autoMatch, guessColumns, buildRows, cleanRow, templateCsv } from \"@/components/makersclaw/blocks/csv-import\". Props and an example: components/makersclaw/blocks/csv-import/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
57
57
  "meta": {
58
58
  "makersclaw": {
59
- "version": "0.6.0",
59
+ "version": "0.8.0",
60
60
  "useWhen": "People bring records in from a spreadsheet or another tool: contacts, leads, products, a price list, an old CRM export, a first table for a new app. Built on spreadsheet. Not for uploading files to keep (file-upload) or for editing records already in the app (spreadsheet, data-table)."
61
61
  }
62
62
  }
package/r/data-table.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Data table",
6
6
  "description": "Records as a table the founder can work: sort by a column (shift for several), search every column, faceted filters with counts, column visibility, row selection with a bulk-actions bar, a menu per row or its first actions as buttons in the row (async, with a spinner and a failure notice), row click, pagination with a page size, loading, empty and no-matches states, and cards on a phone. Opt in to expandable rows, row groups with subtotals, an aligned totals row, arrow-key navigation and a sticky first column. Client-side over rows, or driven by a server.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -31,7 +31,7 @@
31
31
  "path": "registry/blocks/data-table/README.md",
32
32
  "type": "registry:file",
33
33
  "target": "@components/makersclaw/blocks/data-table/README.md",
34
- "content": "# Data table\n\nRecords as a table the founder can work. Click a header to sort (shift-click\nto sort by more than one column), search every column, narrow by facets that\nshow how many rows each choice leaves, pick which columns show, select rows\nand act on them together from a bar that appears, act on one row from its\nmenu or from buttons in the row, open a row with a click, and move through\npages of a chosen size. Opt in to rows that open to show their details,\nrows filed under group headings with subtotals, an aligned totals row,\narrow keys over the rows, and a first column that stays put while a wide table scrolls sideways.\nLoading shows skeleton rows; \"no rows yet\" and \"nothing matches\" are two\ndifferent states, the second with a way out. It runs over `rows` in the\nbrowser, or — with `manual` — shows the page a server chose.\n\n```jsx\nimport { DataTable } from \"../components/blocks/data-table\"; // a Makersclaw app (\"./blocks/data-table\" from components/)\nimport { DataTable } from \"@/components/makersclaw/blocks/data-table\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4).\n\n## The data\n\nAny array of objects with a unique id (`idColumn`, default `\"id\"`). Each\ncolumn reads one key of a row, or draws the cell itself with `cell(row)`.\n\n```js\nconst COLUMNS = [\n { key: \"number\", header: \"Invoice\", sortable: true, hideable: false },\n { key: \"customer\", header: \"Customer\", sortable: true, cell: (r) => <CustomerCell r={r} /> },\n { key: \"status\", header: \"Status\", sortable: true, cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge> },\n { key: \"amount\", header: \"Amount\", sortable: true, align: \"end\", cell: (r) => money(r.amount) },\n { key: \"method\", header: \"Method\", hidden: true },\n];\n```\n\nA column:\n\n- `key` — the row key it reads (and its id for sort, filters, visibility).\n- `header` — what the header says; default made from the key.\n- `cell(row)` — what the cell draws; default the value as text. Kit parts\n and tokens only.\n- `sortable` — the header becomes a sort button.\n- `sortValue(row)` — what it sorts by, when the cell is not the value\n (a status in its own order, a date string, a nested field). Numbers sort\n as numbers, strings naturally (\"INV-9\" before \"INV-10\"); blanks sort\n last either way.\n- `searchValue(row)` — what the search matches; default `sortValue` or\n the value. `searchable: false` leaves the column out of the search.\n- `align` — `\"start\"` (default) or `\"end\"` (amounts: right-aligned,\n tabular figures). `numeric: true` means the same.\n- `width` — a CSS width for the column (`\"8rem\"`).\n- `hideable` — default true; false keeps it out of the Columns menu.\n- `hidden` — starts hidden (the Columns menu can show it).\n- `className` · `headerClassName` — layout on its cells or its header.\n Never colour.\n\nAny other key on a column is kept and ignored, so a block built on this\none can carry its own (report-table's `kind`).\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n examples show, sort and filter like rows, but a bulk action never\n receives them (they are not rows): selecting only examples and pressing\n one says so and changes nothing.\n- `columns` — as above.\n- `idColumn` — default `\"id\"`.\n- `label` — what the table is called to a screen reader, default `\"Table\"`.\n- `noun` — what a row is (\"invoice\"): used in \"1–10 of 60 invoices\", the\n facet counts and the announcements. Default `\"row\"`.\n- `nounPlural` — the plural, when the regular ending is wrong for it:\n `noun=\"person\" nounPlural=\"people\"`. Default: `noun` with the regular\n English ending — \"invoice\" → \"invoices\", \"company\" → \"companies\",\n \"address\" → \"addresses\", \"batch\" → \"batches\", \"analysis\" → \"analyses\".\n\nSearch\n\n- `searchable` — default true: a search field with a glyph leads the\n toolbar. Every word must appear somewhere in the row.\n- `searchPlaceholder` — default `\"Search…\"`.\n- `search` · `defaultSearch` · `onSearchChange(text)` — the text.\n\nFacets\n\n- `facets` — `[{ key, label?, options?, value?(row) }]`: one multi-choice\n Combobox per facet, each option with the number of rows it would show\n at its right (counted over the search and the other facets). `options`\n `[{ value, label }]` fixes the choices and their order; without it they\n are the values found in `rows`. `value(row)` when the facet is not a\n plain key.\n- `filters` · `defaultFilters` · `onFiltersChange(filters)` — the chosen\n values, `{ status: [\"paid\", \"overdue\"], owner: [\"Maya\"] }`.\n\nSort\n\n- `sort` · `defaultSort` · `onSortChange(sort)` — `[{ key, dir: \"asc\" | \"desc\" }]`,\n the first entry sorts first. A press cycles a column asc → desc → off.\n- `multiSort` — default true: shift-click adds a column to the sort (each\n sorted header shows its place). False: one column at a time.\n\nSelection\n\n- `selectable` — checkboxes on every row and a select-this-page box in\n the header. On by default when `bulkActions`, `selection` or\n `onSelectionChange` is given.\n- `selection` · `defaultSelection` · `onSelectionChange(ids)` — the chosen\n ids, as strings. A selection survives search, filters and paging.\n- `bulkActions` — `[{ label, onSelect(rows, ids), icon?, tone?: \"danger\", disabled?, keepSelection? }]`,\n or `(selectedRows) => [...]`. With rows selected a bar shows the count,\n \"Select all N\" (when the whole page is chosen and more match), Clear,\n and these as buttons. `onSelect` may be async; return\n `{ ok: false, error }` (or throw) and the bar says why it failed and\n keeps the selection. Anything else clears the selection, unless\n `keepSelection: true` (an export).\n\nColumns\n\n- `hiddenColumns` · `defaultHiddenColumns` · `onHiddenColumnsChange(keys)` —\n the keys not shown. Default: the columns marked `hidden`. The Columns\n menu (checkbox items) sets it; the last column shown cannot be hidden.\n\nPages\n\n- `page` · `defaultPage` (1) · `onPageChange(page)` — 1-based. A new\n search, filter, sort or page size goes back to page 1; a page past the\n end (rows removed) goes to the last.\n- `pageSize` · `defaultPageSize` (10) · `onPageSizeChange(n)`, `pageSizes`\n (default `[10, 25, 50, 100]`) — the \"Rows per page\" choice.\n- `paginate` — default true; false shows every row and no pager.\n\nRows\n\n- `rowActions(row)` — the row's \"…\" menu, in kit `Menu` items\n (`[{ label, onSelect, icon?, tone?: \"danger\" }, { type: \"separator\" }, …]`;\n a falsy item is skipped). Nothing returned, no menu.\n- `inlineActions` — draw a row's first actions as buttons in the row\n instead of in the menu: a number (the first N of `rowActions(row)`) or\n `true` (all of them). An action with `inline: true` is always a button,\n one with `inline: false` never is; separators and labels stay in the\n menu, and the \"…\" menu holds whatever is left. A button with an `icon`\n is a square glyph button with a tooltip (on a phone card, the glyph and\n the words); without one it is a small text button (\"View\"). Its\n `onSelect(row)` may be async: the button spins and the row's other\n buttons wait, and `{ ok: false, error }` (or a throw) shows a danger\n notice above the table naming the action and the row. `tone: \"danger\"`\n and `disabled` work as in the menu. Default: none — every action in the\n menu, as before.\n- `onRowClick(row)` — a click on the row (not on its checkbox, menu or a\n link inside a cell), or Enter on a focused row: open your `Sheet`.\n- `rowTone(row)` — `\"success\" | \"info\" | \"warning\" | \"danger\" | \"highlighted\"`\n or nothing: a tint on the row's cells from the theme's status colours\n (hover deepens it). `\"highlighted\"` is a pinned or best row, not a\n status: a light primary tint and a bar at its leading edge. Status tones\n are for status only.\n- `rowClassName(row)` — more classes on the row (layout, weight). Never\n colour; that is `rowTone`.\n\nDetails (expandable rows)\n\n- `renderExpanded(row)` — what shows under a row when it is open. Adds a\n leading chevron column; the chevron turns when the row is open, and is\n a button with `aria-expanded`. A row with no `onRowClick` also opens on\n a click or Enter.\n- `canExpand(row)` — false leaves a row without a chevron. Default: every\n row can open.\n- `expanded` · `defaultExpanded` · `onExpandedChange(ids)` — the open ids,\n as strings.\n\nGroups\n\n- `groupBy` — a key or `(row) => value`: each group gets a heading row\n with its label and how many rows it holds. Sort first, then group: a\n sort orders rows inside their groups and never moves a group. Paging\n counts rows; a group split across pages repeats its heading.\n- `groupOrder` — the group values to put first, in order\n (`[\"overdue\", \"pending\"]`; the rest follow in the order they first\n appear in `rows`), or a compare function over two group values.\n Default: the order groups first appear in `rows`.\n- `groupLabel(value, rows)` — the heading's label. Default the value\n (`\"None\"` for a blank).\n- `groupSummary(rows)` — a node at the heading's right: a sum, a count.\n `rows` is the whole group, not only this page's part.\n- `groupFooter(rows)` — a subtotal row under the group, bold on a tinted\n background, on the page where the group ends. Return a map of column\n key to node (`{ amount: \"$4,200\" }`) for cells aligned under their\n columns (\"Subtotal\" fills the first column unless the map has it), or\n one node for a single right-aligned cell.\n\nTotals\n\n- `totals` — a map of column key to node, or `(rows) => map` over every\n row that matches the search and filters (all pages; in `manual`, the\n rows given): one row in the table's `<tfoot>`, each value under its\n column. `footer` is still the free node under the table.\n- `totalsLabel` — default `\"Total\"`, in the first column when the map\n has nothing for it; `null` for none.\n\nKeyboard\n\n- `keyboard` — arrow keys over the rows. One row is in the tab order at\n a time (the last one focused, else the first) and shows a focus ring.\n ↑ ↓ move, Home and End go to the first and last row on the page, Space\n selects the row (when `selectable`), Enter opens it as a click does,\n → and ← open and close its details, Esc clears the selection. Works on\n the phone cards too.\n\nLayout\n\n- `stickyFirstColumn` — the first shown column (and the chevron and\n checkbox before it) stays put while the table scrolls sideways, with an\n edge line. A wide table always scrolls inside its own box, never the\n page; details rows and group headings are drawn at the box's visible\n width.\n\nSlots and states\n\n- `toolbar` — your controls at the toolbar's end, before Columns: the one\n \"New invoice\" button, an export.\n- `footer` — a node under the table, above the pager: a total, a note.\n- `loading` — skeleton rows in place of the body (the toolbar stays).\n- `error` — a danger Notice above the table (a failed read).\n- `emptyTitle` · `emptyBody` · `emptyAction` · `emptyIcon` — the state for\n no rows at all. No matches has its own state with \"Clear filters\".\n- `mobile` — `\"scroll\"` (default): under 640px the table scrolls sideways\n inside its own box; the page never does. `\"cards\"`: under 640px each row\n is a card — the first shown column as its title, the others as\n label/value pairs, with its checkbox and menu. Pick cards when rows are\n read more than compared; scroll when columns are compared.\n- `cardTitle(row)` — the card's title instead of the first column (the\n card then lists every shown column).\n- `variant` — `\"outline\"` (a bordered card; default) or `\"plain\"` (no\n border, for a table already inside a `Card`).\n- `className` — layout around it (width, margin). Never colour.\n\n### Controlled or not — letting a server drive it\n\nEvery piece of state has three props: the value, a `default…` and an\n`on…Change`. Pass only the default (or nothing) and the table keeps it.\nPass the value and the table shows what you pass and calls `on…Change`\nwith what the founder asked for; you set it back. Each is independent, so\nyou can own just the page and leave the rest to the table.\n\nFor a table too big for the browser, set `manual`: the table stops\nsearching, filtering, sorting and slicing, `rows` is the current page, and\n`rowCount` is how many rows the server has. Own `search`, `filters`,\n`sort`, `page` and `pageSize`, re-read on each change, and give each facet\nits `options` with a `count` (`{ value, label, count }`), since the table\ncannot count rows it does not have. With `manual`, a bulk action's second\nargument, `ids`, is every selected id, including rows not on this page.\n\n```jsx\nconst [q, setQ] = useState({ search: \"\", filters: {}, sort: [], page: 1, pageSize: 25 });\n// …read rows + total for q on the server (a Server Action, a route)…\n<DataTable manual rows={pageRows} rowCount={total} loading={pending}\n search={q.search} onSearchChange={(search) => setQ((s) => ({ ...s, search }))}\n filters={q.filters} onFiltersChange={(filters) => setQ((s) => ({ ...s, filters }))}\n sort={q.sort} onSortChange={(sort) => setQ((s) => ({ ...s, sort }))}\n page={q.page} onPageChange={(page) => setQ((s) => ({ ...s, page }))}\n pageSize={q.pageSize} onPageSizeChange={(pageSize) => setQ((s) => ({ ...s, pageSize }))}\n columns={COLUMNS} facets={[{ key: \"status\", options: statusCounts }]} />\n```\n\nThe pure helpers the table uses are exported too — `processRows`,\n`searchRows`, `filterRows`, `sortRows`, `facetOptions`, `nextSort`,\n`groupRows`, `groupValueOf` and `useControllable` — so a surface can compute the same answer the table\nshows (a total over the filtered rows) without drawing it twice.\n\n## Example\n\n```jsx\n// components/Invoices.jsx — client: the table, the row sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { Badge, Button, Sheet } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { DataTable } from \"./blocks/data-table\";\n\nconst TONE = { paid: \"success\", pending: \"info\", overdue: \"danger\", draft: \"neutral\" };\nconst COLUMNS = [\n { key: \"number\", header: \"Invoice\", sortable: true, hideable: false },\n { key: \"customer\", header: \"Customer\", sortable: true },\n { key: \"status\", header: \"Status\", sortable: true, cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge> },\n { key: \"amount\", header: \"Amount\", sortable: true, align: \"end\", cell: (r) => `$${r.amount.toLocaleString()}` },\n { key: \"due\", header: \"Due\", sortable: true },\n];\nconst SAMPLE = [\n { number: \"INV-1001\", customer: \"Northwind\", status: \"paid\", amount: 1200, due: \"2026-10-01\" },\n { number: \"INV-1002\", customer: \"Acme Ltd\", status: \"overdue\", amount: 450, due: \"2026-09-01\" },\n];\n\nexport default function Invoices({ invoices }) {\n const fixture = useFixture(invoices, SAMPLE);\n const [open, setOpen] = useState(null);\n const markPaid = (rows) =>\n Promise.all(rows.map((r) => updateRow({ source: \"public.invoices\", id: r.id, patch: { status: \"paid\" } })))\n .then((all) => all.find((r) => r.ok === false) ?? { ok: true });\n return (\n <div className=\"grid gap-4\">\n {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}\n <DataTable\n label=\"Invoices\"\n noun=\"invoice\"\n rows={fixture.rows}\n columns={COLUMNS}\n facets={[{ key: \"status\", label: \"Status\" }]}\n defaultSort={[{ key: \"due\", dir: \"asc\" }]}\n bulkActions={[{ label: \"Mark paid\", icon: \"check\", onSelect: markPaid }]}\n rowActions={(r) => [{ label: \"Open\", icon: \"eye\", onSelect: () => setOpen(r) }]}\n onRowClick={setOpen}\n rowTone={(r) => (r.status === \"overdue\" ? \"warning\" : null)}\n toolbar={<Button variant=\"primary\" icon=\"plus\">New invoice</Button>}\n mobile=\"cards\"\n emptyTitle=\"No invoices yet\"\n />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.number}>\n {/* the invoice's fields */}\n </Sheet>\n </div>\n );\n}\n```\n\n### Details, groups and totals\n\nA budget: line items under departments, sorted within them, a subtotal\nper department, a total, the months scrolling under the line item.\n\n```jsx\nconst MONTHS = [\"jan\", \"feb\", \"mar\"];\nconst money = (n) => `$${n.toLocaleString()}`;\nconst sumOf = (rows, key) => rows.reduce((s, r) => s + r[key], 0);\nconst sums = (rows) => Object.fromEntries([...MONTHS, \"total\"].map((k) => [k, money(sumOf(rows, k))]));\nconst COLUMNS = [\n { key: \"item\", header: \"Line item\", sortable: true },\n ...MONTHS.map((m) => ({ key: m, align: \"end\", sortable: true, cell: (r) => money(r[m]) })),\n { key: \"total\", align: \"end\", sortable: true, cell: (r) => money(r.total) },\n];\n\n<DataTable label=\"Budget\" noun=\"line item\" rows={lines} columns={COLUMNS}\n paginate={false} keyboard stickyFirstColumn\n groupBy=\"department\" groupOrder={[\"Engineering\"]}\n groupSummary={(rows) => `${money(sumOf(rows, \"total\"))} this quarter`}\n groupFooter={sums} totals={sums}\n renderExpanded={(r) => <p className=\"text-sm\">{r.note}</p>}\n canExpand={(r) => Boolean(r.note)}\n defaultSort={[{ key: \"total\", dir: \"desc\" }]} />\n```\n\n### Buttons in the row\n\nTasks whose actions depend on their state, the common ones as buttons in\nthe row and the rest in the menu:\n\n```jsx\n<DataTable label=\"Tasks\" noun=\"task\" rows={tasks} columns={COLUMNS}\n rowActions={(t) => [\n t.status === \"todo\" && { label: \"Start\", icon: \"play\", inline: true, onSelect: (r) => setStatus(r, \"doing\") },\n t.status === \"doing\" && { label: \"Complete\", icon: \"check\", inline: true, onSelect: (r) => setStatus(r, \"done\") },\n { label: \"Delete\", icon: \"trash\", tone: \"danger\", inline: true, onSelect: remove }, // async, may return { ok: false, error }\n { label: \"Copy ID\", icon: \"copy\", onSelect: copyId },\n ]} />\n```\n\n`setStatus` and `remove` return a promise of `{ ok }`, as a Server Action\ndoes; a failure is said above the table.\n\n## What it will not do\n\n- Edit a cell in place: open the row (`onRowClick`) in a `Sheet`.\n- Fetch: rows come in as props; with `manual` you read each page.\n- Collapse a group, pin more than the first column, resize or reorder\n columns, or virtualise thousands of rows — page them (`manual`)\n instead.\n- Colour a column: a cell says what it means with kit parts (`Badge`,\n `StatusDot`); a row's tone is `rowTone`, and only for status.\n\nA table it cannot be is your own component in `components/`, built from\nthe kit's `Table` parts — not an edit to this block. Its files are fixed:\n`npm run check` refuses a change, and `makersclaw add data-table` restores\nthem.\n\n## Credits\n\nThe row buttons and the orders example follow blocks.so\n(https://github.com/ephraimduncan/blocks, MIT) and Creative Tim UI\n(https://github.com/creativetimofficial/ui, MIT); this is a rewrite on the\nMakersclaw kit, not their code.\n"
34
+ "content": "# Data table\n\nRecords as a table the founder can work. Click a header to sort (shift-click\nto sort by more than one column), search every column, narrow by facets that\nshow how many rows each choice leaves, pick which columns show, select rows\nand act on them together from a bar that appears, act on one row from its\nmenu or from buttons in the row, open a row with a click, and move through\npages of a chosen size. Opt in to rows that open to show their details,\nrows filed under group headings with subtotals, an aligned totals row,\narrow keys over the rows, and a first column that stays put while a wide table scrolls sideways.\nLoading shows skeleton rows; \"no rows yet\" and \"nothing matches\" are two\ndifferent states, the second with a way out. It runs over `rows` in the\nbrowser, or — with `manual` — shows the page a server chose.\n\n```jsx\nimport { DataTable } from \"../components/blocks/data-table\"; // a Makersclaw app (\"./blocks/data-table\" from components/)\nimport { DataTable } from \"@/components/makersclaw/blocks/data-table\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4).\n\n## The data\n\nAny array of objects with a unique id (`idColumn`, default `\"id\"`). Each\ncolumn reads one key of a row, or draws the cell itself with `cell(row)`.\n\n```js\nconst COLUMNS = [\n { key: \"number\", header: \"Invoice\", sortable: true, hideable: false },\n { key: \"customer\", header: \"Customer\", sortable: true, cell: (r) => <CustomerCell r={r} /> },\n { key: \"status\", header: \"Status\", sortable: true, cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge> },\n { key: \"amount\", header: \"Amount\", sortable: true, align: \"end\", cell: (r) => money(r.amount) },\n { key: \"method\", header: \"Method\", hidden: true },\n];\n```\n\nA column:\n\n- `key` — the row key it reads (and its id for sort, filters, visibility).\n- `header` — what the header says; default made from the key.\n- `cell(row)` — what the cell draws; default the value as text. Kit parts\n and tokens only.\n- `sortable` — the header becomes a sort button.\n- `sortValue(row)` — what it sorts by, when the cell is not the value\n (a status in its own order, a date string, a nested field). Numbers sort\n as numbers, strings naturally (\"INV-9\" before \"INV-10\"); blanks sort\n last either way.\n- `searchValue(row)` — what the search matches; default `sortValue` or\n the value. `searchable: false` leaves the column out of the search.\n- `align` — `\"start\"` (default) or `\"end\"` (amounts: right-aligned,\n tabular figures). `numeric: true` means the same.\n- `width` — a CSS width for the column (`\"8rem\"`).\n- `hideable` — default true; false keeps it out of the Columns menu.\n- `hidden` — starts hidden (the Columns menu can show it).\n- `className` · `headerClassName` — layout on its cells or its header.\n Never colour.\n\nAny other key on a column is kept and ignored, so a block built on this\none can carry its own (report-table's `kind`).\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n examples show, sort and filter like rows, but a bulk action never\n receives them (they are not rows): selecting only examples and pressing\n one says so and changes nothing.\n- `columns` — as above.\n- `idColumn` — default `\"id\"`.\n- `label` — what the table is called to a screen reader, default `\"Table\"`.\n- `noun` — what a row is (\"invoice\"): used in \"1–10 of 60 invoices\", the\n facet counts and the announcements. Default `\"row\"`.\n- `nounPlural` — the plural, when the regular ending is wrong for it:\n `noun=\"person\" nounPlural=\"people\"`. Default: `noun` with the regular\n English ending — \"invoice\" → \"invoices\", \"company\" → \"companies\",\n \"address\" → \"addresses\", \"batch\" → \"batches\", \"analysis\" → \"analyses\".\n\nSearch\n\n- `searchable` — default true: a search field with a glyph leads the\n toolbar. Every word must appear somewhere in the row.\n- `searchPlaceholder` — default `\"Search…\"`.\n- `search` · `defaultSearch` · `onSearchChange(text)` — the text.\n\nFacets\n\n- `facets` — `[{ key, label?, options?, value?(row) }]`: one multi-choice\n Combobox per facet, each option with the number of rows it would show\n at its right (counted over the search and the other facets). `options`\n `[{ value, label }]` fixes the choices and their order; without it they\n are the values found in `rows`. `value(row)` when the facet is not a\n plain key.\n- `filters` · `defaultFilters` · `onFiltersChange(filters)` — the chosen\n values, `{ status: [\"paid\", \"overdue\"], owner: [\"Maya\"] }`.\n\nSort\n\n- `sort` · `defaultSort` · `onSortChange(sort)` — `[{ key, dir: \"asc\" | \"desc\" }]`,\n the first entry sorts first. A press cycles a column asc → desc → off.\n- `multiSort` — default true: shift-click adds a column to the sort (each\n sorted header shows its place). False: one column at a time.\n\nSelection\n\n- `selectable` — checkboxes on every row and a select-this-page box in\n the header. On by default when `bulkActions`, `selection` or\n `onSelectionChange` is given.\n- `selection` · `defaultSelection` · `onSelectionChange(ids)` — the chosen\n ids, as strings. A selection survives search, filters and paging.\n- `bulkActions` — `[{ label, onSelect(rows, ids), icon?, tone?: \"danger\", disabled?, keepSelection? }]`,\n or `(selectedRows) => [...]`. With rows selected a bar shows the count,\n \"Select all N\" (when the whole page is chosen and more match), Clear,\n and these as buttons. `onSelect` may be async; return\n `{ ok: false, error }` (or throw) and the bar says why it failed and\n keeps the selection. Anything else clears the selection, unless\n `keepSelection: true` (an export).\n\nColumns\n\n- `hiddenColumns` · `defaultHiddenColumns` · `onHiddenColumnsChange(keys)` —\n the keys not shown. Default: the columns marked `hidden`. The Columns\n menu (checkbox items) sets it; the last column shown cannot be hidden.\n\nPages\n\n- `page` · `defaultPage` (1) · `onPageChange(page)` — 1-based. A new\n search, filter, sort or page size goes back to page 1; a page past the\n end (rows removed) goes to the last.\n- `pageSize` · `defaultPageSize` (10) · `onPageSizeChange(n)`, `pageSizes`\n (default `[10, 25, 50, 100]`) — the \"Rows per page\" choice.\n- `paginate` — default true; false shows every row and no pager.\n\nRows\n\n- `rowActions(row)` — the row's \"…\" menu, in kit `Menu` items\n (`[{ label, onSelect, icon?, tone?: \"danger\" }, { type: \"separator\" }, …]`;\n a falsy item is skipped). Nothing returned, no menu.\n- `inlineActions` — draw a row's first actions as buttons in the row\n instead of in the menu: a number (the first N of `rowActions(row)`) or\n `true` (all of them). An action with `inline: true` is always a button,\n one with `inline: false` never is; separators and labels stay in the\n menu, and the \"…\" menu holds whatever is left. A button with an `icon`\n is a square glyph button with a tooltip (on a phone card, the glyph and\n the words); without one it is a small text button (\"View\"). Its\n `onSelect(row)` may be async: the button spins and the row's other\n buttons wait, and `{ ok: false, error }` (or a throw) shows a danger\n notice above the table naming the action and the row. `tone: \"danger\"`\n and `disabled` work as in the menu. Default: none — every action in the\n menu, as before.\n- `onRowClick(row)` — a click on the row (not on its checkbox, menu or a\n link inside a cell), or Enter on a focused row: open your `Sheet`.\n- `rowTone(row)` — `\"success\" | \"info\" | \"warning\" | \"danger\" | \"highlighted\"`\n or nothing: a tint on the row's cells from the theme's status colours\n (hover deepens it). `\"highlighted\"` is a pinned or best row, not a\n status: a light primary tint and a bar at its leading edge. Status tones\n are for status only.\n- `rowClassName(row)` — more classes on the row (layout, weight). Never\n colour; that is `rowTone`.\n\nDetails (expandable rows)\n\n- `renderExpanded(row)` — what shows under a row when it is open. Adds a\n leading chevron column; the chevron turns when the row is open, and is\n a button with `aria-expanded`. A row with no `onRowClick` also opens on\n a click or Enter.\n- `canExpand(row)` — false leaves a row without a chevron. Default: every\n row can open.\n- `expanded` · `defaultExpanded` · `onExpandedChange(ids)` — the open ids,\n as strings.\n\nGroups\n\n- `groupBy` — a key or `(row) => value`: each group gets a heading row\n with its label and how many rows it holds. Sort first, then group: a\n sort orders rows inside their groups and never moves a group. Paging\n counts rows; a group split across pages repeats its heading.\n- `groupOrder` — the group values to put first, in order\n (`[\"overdue\", \"pending\"]`; the rest follow in the order they first\n appear in `rows`), or a compare function over two group values.\n Default: the order groups first appear in `rows`.\n- `groupLabel(value, rows)` — the heading's label. Default the value\n (`\"None\"` for a blank).\n- `groupSummary(rows)` — a node at the heading's right: a sum, a count.\n `rows` is the whole group, not only this page's part.\n- `groupFooter(rows)` — a subtotal row under the group, bold on a tinted\n background, on the page where the group ends. Return a map of column\n key to node (`{ amount: \"$4,200\" }`) for cells aligned under their\n columns (\"Subtotal\" fills the first column unless the map has it), or\n one node for a single right-aligned cell.\n\nTotals\n\n- `totals` — a map of column key to node, or `(rows) => map` over every\n row that matches the search and filters (all pages; in `manual`, the\n rows given): one row in the table's `<tfoot>`, each value under its\n column. `footer` is still the free node under the table.\n- `totalsLabel` — default `\"Total\"`, in the first column when the map\n has nothing for it; `null` for none.\n\nKeyboard\n\n- `keyboard` — arrow keys over the rows. One row is in the tab order at\n a time (the last one focused, else the first) and shows a focus ring.\n ↑ ↓ move, Home and End go to the first and last row on the page, Space\n selects the row (when `selectable`), Enter opens it as a click does,\n → and ← open and close its details, Esc clears the selection. Works on\n the phone cards too.\n\nLayout\n\n- `stickyFirstColumn` — the first shown column (and the chevron and\n checkbox before it) stays put while the table scrolls sideways, with an\n edge line. A wide table always scrolls inside its own box, never the\n page; details rows and group headings are drawn at the box's visible\n width.\n\nSlots and states\n\n- `toolbar` — your controls at the toolbar's end, before Columns: the one\n \"New invoice\" button, an export.\n- `footer` — a node under the table, above the pager: a total, a note.\n- `loading` — skeleton rows in place of the body (the toolbar stays).\n- `error` — a danger Notice above the table (a failed read).\n- `emptyTitle` · `emptyBody` · `emptyAction` · `emptyIcon` — the state for\n no rows at all. No matches has its own state with \"Clear filters\".\n- `mobile` — `\"scroll\"` (default): under 640px the table scrolls sideways\n inside its own box; the page never does. `\"cards\"`: under 640px each row\n is a card — the first shown column as its title, the others as\n label/value pairs, with its checkbox and menu. Pick cards when rows are\n read more than compared; scroll when columns are compared.\n- `cardTitle(row)` — the card's title instead of the first column (the\n card then lists every shown column).\n- `variant` — `\"outline\"` (a bordered card; default) or `\"plain\"` (no\n border, for a table already inside a `Card`).\n- `className` — layout around it (width, margin). Never colour.\n\n### Controlled or not — letting a server drive it\n\nEvery piece of state has three props: the value, a `default…` and an\n`on…Change`. Pass only the default (or nothing) and the table keeps it.\nPass the value and the table shows what you pass and calls `on…Change`\nwith what the founder asked for; you set it back. Each is independent, so\nyou can own just the page and leave the rest to the table.\n\nFor a table too big for the browser, set `manual`: the table stops\nsearching, filtering, sorting and slicing, `rows` is the current page, and\n`rowCount` is how many rows the server has. Own `search`, `filters`,\n`sort`, `page` and `pageSize`, re-read on each change, and give each facet\nits `options` with a `count` (`{ value, label, count }`), since the table\ncannot count rows it does not have. With `manual`, a bulk action's second\nargument, `ids`, is every selected id, including rows not on this page.\n\n```jsx\nconst [q, setQ] = useState({ search: \"\", filters: {}, sort: [], page: 1, pageSize: 25 });\n// One page through the door: the filter grammar, an order, limit and offset.\nconst filter = {\n ...Object.fromEntries(Object.entries(q.filters).filter(([, values]) => values.length)),\n customer: q.search ? { prefix: q.search } : undefined, // starts with, case ignored\n};\nconst page = useRows({ source: \"invoices\", filter, orderBy: q.sort.map((s) => `${s.key} ${s.dir}`).join(\", \") || undefined,\n limit: q.pageSize, offset: (q.page - 1) * q.pageSize });\nconst total = useCount({ source: \"invoices\", filter });\nconst { status, ...others } = filter; // a facet counts over the other filters\nconst byStatus = useCountBy({ source: \"invoices\", column: \"status\", filter: others });\nconst statusCounts = Object.entries(byStatus.counts).map(([value, count]) => ({ value, label: value, count }));\n\n<DataTable manual rows={page.rows} rowCount={total.count} loading={page.loading || total.loading}\n search={q.search} onSearchChange={(search) => setQ((s) => ({ ...s, search }))}\n filters={q.filters} onFiltersChange={(filters) => setQ((s) => ({ ...s, filters }))}\n sort={q.sort} onSortChange={(sort) => setQ((s) => ({ ...s, sort }))}\n page={q.page} onPageChange={(page) => setQ((s) => ({ ...s, page }))}\n pageSize={q.pageSize} onPageSizeChange={(pageSize) => setQ((s) => ({ ...s, pageSize }))}\n columns={COLUMNS} facets={[{ key: \"status\", options: statusCounts }]} />\n```\n\n`useRows`, `useCount` and `useCountBy` are `lib/data.js`'s in a browser\napp. `offset` goes up to 10,000; past it, page with `cursor` (the last\nanswer's `next`, given when `orderBy` names one column). A server app's\nown `loadRows` takes no `offset`: its `\"use client\"` component takes\nthese hooks from `lib/door.js`.\n\nThe pure helpers the table uses are exported too — `processRows`,\n`searchRows`, `filterRows`, `sortRows`, `facetOptions`, `nextSort`,\n`groupRows`, `groupValueOf` and `useControllable` — so a surface can compute the same answer the table\nshows (a total over the filtered rows) without drawing it twice.\n\n## Example\n\n```jsx\n// surfaces/invoices.jsx — client: read, hand over\n\"use client\";\nimport { useRows } from \"../lib/data\";\nimport Invoices from \"../components/Invoices\";\n\nexport default function InvoicesSurface() {\n const { rows, error, loading } = useRows({ source: \"invoices\", orderBy: \"due asc\" });\n if (loading) return null; // rows only once read: to useFixture, a read in flight looks like an empty table\n return <Invoices invoices={rows} error={error} />;\n}\n```\n\n```jsx\n// components/Invoices.jsx — client: the table, the row sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { Badge, Button, Notice, Sheet } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { DataTable } from \"./blocks/data-table\";\n\nconst TONE = { paid: \"success\", pending: \"info\", overdue: \"danger\", draft: \"neutral\" };\nconst COLUMNS = [\n { key: \"number\", header: \"Invoice\", sortable: true, hideable: false },\n { key: \"customer\", header: \"Customer\", sortable: true },\n { key: \"status\", header: \"Status\", sortable: true, cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge> },\n { key: \"amount\", header: \"Amount\", sortable: true, align: \"end\", cell: (r) => `$${Number(r.amount).toLocaleString()}` }, // numeric arrives as text\n { key: \"due\", header: \"Due\", sortable: true },\n];\nconst SAMPLE = [\n { number: \"INV-1001\", customer: \"Northwind\", status: \"paid\", amount: 1200, due: \"2026-10-01\" },\n { number: \"INV-1002\", customer: \"Acme Ltd\", status: \"overdue\", amount: 450, due: \"2026-09-01\" },\n];\n\nexport default function Invoices({ invoices, error }) {\n const fixture = useFixture(invoices, SAMPLE);\n const [open, setOpen] = useState(null);\n const markPaid = (rows) =>\n Promise.all(rows.map((r) => updateRow({ source: \"invoices\", id: r.id, patch: { status: \"paid\" } })))\n .then((all) => all.find((r) => r.ok === false) ?? { ok: true });\n return (\n <div className=\"grid gap-4\">\n {error ? <Notice tone=\"danger\">{error}</Notice> : null}\n {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}\n <DataTable\n label=\"Invoices\"\n noun=\"invoice\"\n rows={fixture.rows}\n columns={COLUMNS}\n facets={[{ key: \"status\", label: \"Status\" }]}\n defaultSort={[{ key: \"due\", dir: \"asc\" }]}\n bulkActions={[{ label: \"Mark paid\", icon: \"check\", onSelect: markPaid }]}\n rowActions={(r) => [{ label: \"Open\", icon: \"eye\", onSelect: () => setOpen(r) }]}\n onRowClick={setOpen}\n rowTone={(r) => (r.status === \"overdue\" ? \"warning\" : null)}\n toolbar={<Button variant=\"primary\" icon=\"plus\">New invoice</Button>}\n mobile=\"cards\"\n emptyTitle=\"No invoices yet\"\n />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.number}>\n {/* the invoice's fields */}\n </Sheet>\n </div>\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n### Details, groups and totals\n\nA budget: line items under departments, sorted within them, a subtotal\nper department, a total, the months scrolling under the line item.\n\n```jsx\nconst MONTHS = [\"jan\", \"feb\", \"mar\"];\nconst money = (n) => `$${n.toLocaleString()}`;\nconst sumOf = (rows, key) => rows.reduce((s, r) => s + r[key], 0);\nconst sums = (rows) => Object.fromEntries([...MONTHS, \"total\"].map((k) => [k, money(sumOf(rows, k))]));\nconst COLUMNS = [\n { key: \"item\", header: \"Line item\", sortable: true },\n ...MONTHS.map((m) => ({ key: m, align: \"end\", sortable: true, cell: (r) => money(r[m]) })),\n { key: \"total\", align: \"end\", sortable: true, cell: (r) => money(r.total) },\n];\n\n<DataTable label=\"Budget\" noun=\"line item\" rows={lines} columns={COLUMNS}\n paginate={false} keyboard stickyFirstColumn\n groupBy=\"department\" groupOrder={[\"Engineering\"]}\n groupSummary={(rows) => `${money(sumOf(rows, \"total\"))} this quarter`}\n groupFooter={sums} totals={sums}\n renderExpanded={(r) => <p className=\"text-sm\">{r.note}</p>}\n canExpand={(r) => Boolean(r.note)}\n defaultSort={[{ key: \"total\", dir: \"desc\" }]} />\n```\n\n### Buttons in the row\n\nTasks whose actions depend on their state, the common ones as buttons in\nthe row and the rest in the menu:\n\n```jsx\n<DataTable label=\"Tasks\" noun=\"task\" rows={tasks} columns={COLUMNS}\n rowActions={(t) => [\n t.status === \"todo\" && { label: \"Start\", icon: \"play\", inline: true, onSelect: (r) => setStatus(r, \"doing\") },\n t.status === \"doing\" && { label: \"Complete\", icon: \"check\", inline: true, onSelect: (r) => setStatus(r, \"done\") },\n { label: \"Delete\", icon: \"trash\", tone: \"danger\", inline: true, onSelect: remove }, // async, may return { ok: false, error }\n { label: \"Copy ID\", icon: \"copy\", onSelect: copyId },\n ]} />\n```\n\n`setStatus` and `remove` return a promise of `{ ok }`, as `updateRow` and\n`deleteRow` do; a failure is said above the table.\n\n## What it will not do\n\n- Edit a cell in place: open the row (`onRowClick`) in a `Sheet`.\n- Fetch: rows come in as props; with `manual` you read each page.\n- Collapse a group, pin more than the first column, resize or reorder\n columns, or virtualise thousands of rows — page them (`manual`)\n instead.\n- Colour a column: a cell says what it means with kit parts (`Badge`,\n `StatusDot`); a row's tone is `rowTone`, and only for status.\n\nA table it cannot be is your own component in `components/`, built from\nthe kit's `Table` parts — not an edit to this block. Its files are fixed:\n`npm run check` refuses a change, and `makersclaw add data-table` restores\nthem.\n\n## Credits\n\nThe row buttons and the orders example follow blocks.so\n(https://github.com/ephraimduncan/blocks, MIT) and Creative Tim UI\n(https://github.com/creativetimofficial/ui, MIT); this is a rewrite on the\nMakersclaw kit, not their code.\n"
35
35
  }
36
36
  ],
37
37
  "categories": [
@@ -62,7 +62,7 @@
62
62
  "docs": "Installed to components/makersclaw/blocks/data-table. Import: import { DataTable, useControllable, processRows, sortRows, searchRows, filterRows, facetOptions, nextSort, groupRows, groupValueOf } from \"@/components/makersclaw/blocks/data-table\". Props and an example: components/makersclaw/blocks/data-table/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
63
63
  "meta": {
64
64
  "makersclaw": {
65
- "version": "0.6.0",
65
+ "version": "0.8.0",
66
66
  "useWhen": "A list of records to scan, find, sort and act on: invoices, customers, orders, leads, users, tickets. Not for records that move through stages (a board), a few key numbers (stats), or a grid of cards whose picture matters more than its columns."
67
67
  }
68
68
  }
package/r/diagram.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Diagram",
6
6
  "description": "Draws Mermaid text (flowcharts, sequence, class, state, ER, Gantt, pie, mind maps, timelines, git graphs…) in the workspace theme: the tokens are read from the page at run time and handed to Mermaid, so nodes, lines, text and chart colours are the theme's and follow light/dark. Zoom and pan (buttons, Cmd/Ctrl+scroll, pinch, drag, keys), fit to view, a Source tab with line numbers, copy the source or the SVG, download SVG. A mistake shows its line and Mermaid's words, and keeps the last drawing that worked. On the server the frame shows the Mermaid text.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "mermaid@12.0.0"
@@ -21,7 +21,7 @@
21
21
  "path": "registry/blocks/diagram/diagram.jsx",
22
22
  "type": "registry:component",
23
23
  "target": "@components/makersclaw/blocks/diagram/diagram.jsx",
24
- "content": "\"use client\";\n\n// Diagram — Mermaid text drawn in the workspace theme, with zoom and pan.\n//\n// Client only, one static bundle: Mermaid needs the DOM to measure text,\n// and the app's check refuses import() (so no next/dynamic). The modules\n// load with the page; the drawing happens after the first effect (the\n// mount guard). Until then — on the server and in the first client paint —\n// the frame shows the Mermaid text itself, so the HTML already says what\n// the diagram is.\n//\n// Colours: the tokens are read from the page at run time (palette.js) and\n// handed to Mermaid as its theme; a change of workspace theme or of\n// light/dark reads them again and redraws.\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button, Empty, Icon, IconButton, Menu, Notice, Skeleton, ToggleGroup, cx, toast } from \"../../ui\";\nimport { renderMermaid } from \"./mermaid\";\nimport { mermaidTheme, paletteKey, resolvePalette } from \"./palette\";\nimport { diagramKind, diagramTitle } from \"./source\";\n\nconst PAD = 24;\nconst MIN_K = 0.2;\nconst MAX_K = 4;\nconst px = (v) => (typeof v === \"number\" ? `${v}px` : v);\nconst clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n\n// The layout effect runs only in the browser; on the server it would warn.\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport function Diagram(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n if (!mounted) return <DiagramPlaceholder {...props} />;\n return <LiveDiagram {...props} />;\n}\n\nconst FRAME = \"flex min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground\";\n\nfunction heading(source, title) {\n return title ?? diagramTitle(source) ?? diagramKind(source) ?? \"Diagram\";\n}\n\n/** What the server renders: the frame, its heading, and the Mermaid text. */\nfunction DiagramPlaceholder({ source = \"\", title, height, minHeight = 160, toolbar = true, className }) {\n const kind = diagramKind(source);\n return (\n <figure className={cx(FRAME, className)} aria-busy=\"true\">\n {toolbar ? (\n <figcaption className=\"flex min-h-12 items-center gap-2 border-b border-border ps-4 pe-2\">\n <span className=\"truncate text-sm font-medium\">{heading(source, title)}</span>\n {kind && title ? <span className=\"truncate text-xs text-muted-foreground max-sm:hidden\">{kind}</span> : null}\n </figcaption>\n ) : null}\n <pre className=\"overflow-hidden px-4 py-3 font-mono text-xs leading-relaxed text-muted-foreground\" style={height != null ? { height: px(height) } : { minHeight: px(minHeight), maxHeight: px(minHeight * 2) }}>\n {source}\n </pre>\n </figure>\n );\n}\n\nfunction LiveDiagram({ source = \"\", title, label, height, minHeight = 160, maxHeight = 560, toolbar = true, look = \"classic\", onRender, className }) {\n const frame = useRef(null);\n const viewport = useRef(null);\n const content = useRef(null);\n const [palette, setPalette] = useState(null);\n const [result, setResult] = useState(null);\n const [good, setGood] = useState(null);\n const [size, setSize] = useState(null);\n const [box, setBox] = useState({ w: 0, h: 0 });\n const [t, setT] = useState({ k: 1, x: 0, y: 0 });\n const [pristine, setPristine] = useState(true);\n const [smooth, setSmooth] = useState(false);\n const [tab, setTab] = useState(\"diagram\");\n const [dragging, setDragging] = useState(false);\n const onRenderRef = useRef(onRender);\n onRenderRef.current = onRender;\n\n // The tokens, read now and again whenever the theme may have changed.\n useEffect(() => {\n const el = frame.current;\n let raf = 0;\n const read = () => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(() => {\n const p = resolvePalette(el);\n setPalette((prev) => (prev && paletteKey(prev) === paletteKey(p) ? prev : p));\n });\n };\n read();\n const mo = new MutationObserver(read);\n const attrs = { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] };\n mo.observe(document.documentElement, attrs);\n mo.observe(document.head, { childList: true });\n const themed = el.parentElement?.closest(\"[data-theme]\");\n if (themed) mo.observe(themed, attrs);\n const mq = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n mq?.addEventListener?.(\"change\", read);\n // A theme stylesheet that finishes loading changes the tokens too.\n const onLoad = (e) => {\n if (e.target?.tagName === \"LINK\") read();\n };\n document.addEventListener(\"load\", onLoad, true);\n return () => {\n cancelAnimationFrame(raf);\n mo.disconnect();\n mq?.removeEventListener?.(\"change\", read);\n document.removeEventListener(\"load\", onLoad, true);\n };\n }, []);\n\n // Draw: after the text rests for a moment (someone may be typing it).\n useEffect(() => {\n if (!palette) return;\n if (!source.trim()) {\n setResult(null);\n setGood(null);\n return;\n }\n let live = true;\n const timer = setTimeout(() => {\n const font = getComputedStyle(frame.current).fontFamily;\n renderMermaid(source, { themeVariables: mermaidTheme(palette, { fontFamily: font }), look, fontFamily: font }).then((r) => {\n if (!live) return;\n setResult(r);\n if (r.ok) setGood(r.svg);\n onRenderRef.current?.(r);\n });\n }, result ? 150 : 0);\n return () => {\n live = false;\n clearTimeout(timer);\n };\n // `result` only picks the delay; it must not redraw.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [source, palette, look]);\n\n // The drawing's own size, once it is in the page; Mermaid's width=\"100%\"\n // and max-width are taken off so zoom is ours.\n useIsoLayoutEffect(() => {\n const svg = content.current?.querySelector(\"svg\");\n if (!svg) return;\n const vb = svg.viewBox?.baseVal;\n let w = vb?.width || 0;\n let h = vb?.height || 0;\n if (!w || !h) {\n const r = svg.getBoundingClientRect();\n w = r.width || 400;\n h = r.height || 300;\n }\n svg.style.maxWidth = \"none\";\n svg.setAttribute(\"width\", String(w));\n svg.setAttribute(\"height\", String(h));\n svg.setAttribute(\"aria-hidden\", \"true\");\n setSize((s) => (s && s.w === w && s.h === h ? s : { w, h }));\n }, [good, tab]);\n\n // The viewport's size.\n useEffect(() => {\n const el = viewport.current;\n if (!el) return;\n const measure = () => setBox((b) => (b.w === el.clientWidth && b.h === el.clientHeight ? b : { w: el.clientWidth, h: el.clientHeight }));\n measure();\n const ro = new ResizeObserver(measure);\n ro.observe(el);\n return () => ro.disconnect();\n }, [tab, good]);\n\n // How tall the canvas is: `height`, else the drawing at the width's fit.\n const autoH = size && box.w ? size.h * Math.min(1, (box.w - 2 * PAD) / size.w) + 2 * PAD : minHeight;\n const canvasH = height != null ? px(height) : `${Math.round(clamp(autoH, minHeight, maxHeight))}px`;\n\n const fitted = () => {\n if (!size || !box.w || !box.h) return null;\n const k = clamp(Math.min(1, (box.w - 2 * PAD) / size.w, (box.h - 2 * PAD) / size.h), MIN_K, MAX_K);\n return { k, x: (box.w - size.w * k) / 2, y: (box.h - size.h * k) / 2 };\n };\n\n // Untouched, the drawing follows the frame (a resize, a new drawing).\n useEffect(() => {\n if (!pristine) return;\n const f = fitted();\n if (f) setT(f);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [size, box.w, box.h, pristine]);\n\n const glide = () => {\n setSmooth(true);\n setTimeout(() => setSmooth(false), 180);\n };\n const zoomAt = (factor, cx0 = box.w / 2, cy0 = box.h / 2, animate = true) => {\n setPristine(false);\n if (animate) glide();\n setT((cur) => {\n const k = clamp(cur.k * factor, MIN_K, MAX_K);\n return { k, x: cx0 - ((cx0 - cur.x) * k) / cur.k, y: cy0 - ((cy0 - cur.y) * k) / cur.k };\n });\n };\n const fit = () => {\n glide();\n setPristine(true);\n const f = fitted();\n if (f) setT(f);\n };\n const actual = () => {\n if (!size) return;\n glide();\n setPristine(false);\n setT({ k: 1, x: (box.w - size.w) / 2, y: (box.h - size.h) / 2 });\n };\n const pan = (dx, dy) => {\n setPristine(false);\n setT((cur) => ({ ...cur, x: cur.x + dx, y: cur.y + dy }));\n };\n\n // Pointer: drag pans; two fingers pinch; Cmd/Ctrl+wheel (and a trackpad\n // pinch) zooms at the pointer. A plain wheel scrolls the page, as it should.\n const pointers = useRef(new Map());\n const gesture = useRef(null);\n const local = (e) => {\n const r = viewport.current.getBoundingClientRect();\n return { x: e.clientX - r.left, y: e.clientY - r.top };\n };\n const onPointerDown = (e) => {\n if (e.button !== 0 || !good) return;\n viewport.current.setPointerCapture?.(e.pointerId);\n pointers.current.set(e.pointerId, local(e));\n const pts = [...pointers.current.values()];\n gesture.current = pts.length === 2 ? { pinch: Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y), t } : { from: local(e), t };\n setDragging(true);\n };\n const onPointerMove = (e) => {\n if (!pointers.current.has(e.pointerId) || !gesture.current) return;\n pointers.current.set(e.pointerId, local(e));\n const pts = [...pointers.current.values()];\n const g = gesture.current;\n setPristine(false);\n if (pts.length === 2 && g.pinch) {\n const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n const mid = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };\n const k = clamp(g.t.k * (d / g.pinch), MIN_K, MAX_K);\n setT({ k, x: mid.x - ((mid.x - g.t.x) * k) / g.t.k, y: mid.y - ((mid.y - g.t.y) * k) / g.t.k });\n } else if (g.from) {\n const p = local(e);\n setT({ ...g.t, x: g.t.x + p.x - g.from.x, y: g.t.y + p.y - g.from.y });\n }\n };\n const onPointerUp = (e) => {\n pointers.current.delete(e.pointerId);\n const pts = [...pointers.current.values()];\n gesture.current = pts.length === 1 ? { from: pts[0], t } : null;\n if (!pts.length) setDragging(false);\n };\n useEffect(() => {\n const el = viewport.current;\n if (!el) return;\n const onWheel = (e) => {\n if (!(e.ctrlKey || e.metaKey)) return;\n e.preventDefault();\n const r = el.getBoundingClientRect();\n zoomAt(Math.exp(-e.deltaY * 0.01), e.clientX - r.left, e.clientY - r.top, false);\n };\n el.addEventListener(\"wheel\", onWheel, { passive: false });\n return () => el.removeEventListener(\"wheel\", onWheel);\n });\n const onKeyDown = (e) => {\n const step = e.shiftKey ? 160 : 48;\n const keys = {\n \"+\": () => zoomAt(1.25),\n \"=\": () => zoomAt(1.25),\n \"-\": () => zoomAt(0.8),\n _: () => zoomAt(0.8),\n 0: fit,\n 1: actual,\n ArrowLeft: () => pan(step, 0),\n ArrowRight: () => pan(-step, 0),\n ArrowUp: () => pan(0, step),\n ArrowDown: () => pan(0, -step),\n };\n const fn = keys[e.key];\n if (!fn || e.metaKey || e.ctrlKey || e.altKey) return;\n e.preventDefault();\n fn();\n };\n\n async function copy(text, what) {\n try {\n await navigator.clipboard.writeText(text);\n toast.success(`Copied ${what}`);\n } catch {\n toast.error(\"Could not reach the clipboard\");\n }\n }\n const download = () => {\n const blob = new Blob([good], { type: \"image/svg+xml\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${(title ?? diagramTitle(source) ?? diagramKind(source) ?? \"diagram\").toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\") || \"diagram\"}.svg`;\n a.click();\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const kind = diagramKind(source);\n const error = result && !result.ok ? result.error : null;\n const empty = !source.trim();\n const name = label ?? `${heading(source, title)}${kind && title ? `, ${kind.toLowerCase()}` : \"\"}`;\n\n return (\n <figure ref={frame} className={cx(FRAME, className)}>\n {toolbar ? (\n <figcaption className=\"flex min-h-12 items-center gap-2 border-b border-border py-1.5 ps-4 pe-1.5\">\n <div className=\"me-auto flex min-w-0 items-baseline gap-2\">\n <span className=\"truncate text-sm font-medium\">{heading(source, title)}</span>\n {kind && title ? <span className=\"truncate text-xs text-muted-foreground max-sm:hidden\">{kind}</span> : null}\n </div>\n <ToggleGroup\n size=\"sm\"\n aria-label=\"Show\"\n value={tab}\n onChange={setTab}\n items={[\n { value: \"diagram\", label: \"Diagram\" },\n { value: \"source\", label: \"Source\" },\n ]}\n />\n <Menu\n label=\"Diagram actions\"\n items={[\n { label: \"Copy Mermaid source\", icon: \"copy\", disabled: empty, onSelect: () => copy(source, \"the Mermaid source\") },\n { label: \"Copy SVG\", icon: \"code\", disabled: !good, onSelect: () => copy(good, \"the SVG\") },\n { label: \"Download SVG\", icon: \"download\", disabled: !good, onSelect: download },\n { type: \"separator\" },\n { label: \"Fit to view\", icon: \"maximize\", shortcut: \"0\", disabled: !good || tab !== \"diagram\", onSelect: fit },\n { label: \"Actual size\", icon: \"search\", shortcut: \"1\", disabled: !good || tab !== \"diagram\", onSelect: actual },\n ]}\n />\n </figcaption>\n ) : null}\n\n {tab === \"source\" ? (\n <SourceView source={source} errorLine={error?.line} height={height != null ? px(height) : canvasH} onCopy={() => copy(source, \"the Mermaid source\")} />\n ) : empty ? (\n <div className=\"p-3\">\n <Empty icon=\"git-branch\" title=\"No diagram yet\" body=\"When there is Mermaid text — a flowchart, a sequence, a timeline — it is drawn here.\" />\n </div>\n ) : (\n // raw: the canvas — a focusable surface that pans and zooms with the\n // pointer, wheel and keys; no kit part is a zoomable viewport.\n <div\n ref={viewport}\n role=\"img\"\n aria-label={name}\n aria-roledescription=\"diagram\"\n tabIndex={good ? 0 : -1}\n onKeyDown={onKeyDown}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={onPointerUp}\n onDoubleClick={(e) => {\n if (!good) return;\n const p = local(e);\n zoomAt(e.shiftKey ? 0.5 : 2, p.x, p.y);\n }}\n className={cx(\n \"relative touch-none overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n \"bg-[radial-gradient(var(--color-border)_1px,transparent_1px)] [background-size:16px_16px]\",\n good && (dragging ? \"cursor-grabbing\" : \"cursor-grab\"),\n )}\n style={{ height: canvasH }}\n >\n {good ? (\n <div\n ref={content}\n className={cx(\"absolute left-0 top-0 origin-top-left will-change-transform\", smooth && \"transition-transform duration-150 ease-out motion-reduce:transition-none\", error && \"opacity-40\")}\n style={{ transform: `translate(${t.x}px, ${t.y}px) scale(${t.k})`, visibility: size ? \"visible\" : \"hidden\" }}\n dangerouslySetInnerHTML={{ __html: good }}\n />\n ) : null}\n\n {!result && !good ? (\n <div className=\"absolute inset-0 grid place-items-center p-6\" aria-busy=\"true\">\n <Skeleton shape=\"rect\" className=\"h-full max-h-64 w-full max-w-md\" />\n <span className=\"sr-only\">Drawing the diagram</span>\n </div>\n ) : null}\n\n {error && !good ? <ErrorPanel error={error} source={source} onShow={() => setTab(\"source\")} /> : null}\n\n {error && good ? (\n <div className=\"absolute inset-x-3 top-3\" onPointerDown={(e) => e.stopPropagation()}>\n <Notice\n tone=\"warning\"\n title={`${error.message} — showing the last version that worked`}\n action={\n <Button size=\"sm\" variant=\"quiet\" onClick={() => setTab(\"source\")}>\n Show source\n </Button>\n }\n >\n {error.detail}\n </Notice>\n </div>\n ) : null}\n\n {good ? (\n // raw: a floating cluster (the map-style zoom pill); its buttons are kit parts.\n <div\n className=\"absolute bottom-3 right-3 flex items-center gap-0.5 rounded-md border border-border bg-popover p-0.5 text-popover-foreground shadow-sm\"\n onPointerDown={(e) => e.stopPropagation()}\n onDoubleClick={(e) => e.stopPropagation()}\n >\n <IconButton icon=\"minus\" size=\"sm\" label=\"Zoom out\" onClick={() => zoomAt(0.8)} disabled={t.k <= MIN_K} />\n <Button variant=\"quiet\" size=\"sm\" onClick={actual} className=\"tabular-nums max-sm:hidden\" title=\"Actual size (1)\">\n {Math.round(t.k * 100)}%\n </Button>\n <IconButton icon=\"plus\" size=\"sm\" label=\"Zoom in\" onClick={() => zoomAt(1.25)} disabled={t.k >= MAX_K} />\n <IconButton icon=\"maximize\" size=\"sm\" label=\"Fit to view (0)\" onClick={fit} />\n </div>\n ) : null}\n </div>\n )}\n </figure>\n );\n}\n\n/** A mistake with nothing drawn yet: what, where, and the lines around it. */\nfunction ErrorPanel({ error, source, onShow }) {\n const lines = source.split(\"\\n\");\n const at = error.line ?? null;\n const from = at ? Math.max(1, at - 2) : 1;\n const to = at ? Math.min(lines.length, at + 2) : Math.min(lines.length, 5);\n return (\n <div className=\"absolute inset-0 overflow-auto p-4\" onPointerDown={(e) => e.stopPropagation()}>\n <div role=\"alert\" className=\"mx-auto grid max-w-xl gap-3\">\n <div className=\"flex items-start gap-3\">\n <Icon name=\"warning\" className=\"mt-0.5 shrink-0 text-destructive\" />\n <div className=\"min-w-0\">\n <p className=\"text-sm font-medium\">{error.message}</p>\n {error.detail ? <p className=\"mt-1 text-sm text-muted-foreground\">{error.detail}</p> : null}\n </div>\n </div>\n <pre className=\"overflow-x-auto rounded-md border border-border bg-background py-2 font-mono text-xs leading-relaxed\">\n {lines.slice(from - 1, to).map((l, i) => {\n const n = from + i;\n const bad = n === at;\n return (\n <div key={n} className={cx(\"flex pe-3\", bad && \"bg-destructive/10\")}>\n <span className={cx(\"w-10 shrink-0 pe-3 text-right tabular-nums select-none\", bad ? \"text-destructive\" : \"text-muted-foreground\")}>{n}</span>\n <span className=\"whitespace-pre\">{l || \" \"}</span>\n </div>\n );\n })}\n </pre>\n <div>\n <Button size=\"sm\" icon=\"code\" onClick={onShow}>\n Show source\n </Button>\n </div>\n </div>\n </div>\n );\n}\n\n/** The Mermaid text, numbered, the mistake's line marked. */\nfunction SourceView({ source, errorLine, height, onCopy }) {\n const lines = source.split(\"\\n\");\n return (\n <div className=\"relative\">\n <pre className=\"overflow-auto py-3 font-mono text-xs leading-relaxed\" style={{ height }} tabIndex={0} aria-label=\"Mermaid source\">\n {lines.map((l, i) => (\n <div key={i} className={cx(\"flex pe-12\", errorLine === i + 1 && \"bg-destructive/10\")}>\n <span className={cx(\"w-12 shrink-0 pe-3 text-right tabular-nums select-none\", errorLine === i + 1 ? \"text-destructive\" : \"text-muted-foreground\")}>{i + 1}</span>\n <span className=\"whitespace-pre\">{l || \" \"}</span>\n </div>\n ))}\n </pre>\n <IconButton icon=\"copy\" size=\"sm\" label=\"Copy source\" onClick={onCopy} className=\"absolute right-2 top-2\" />\n </div>\n );\n}\n"
24
+ "content": "\"use client\";\n\n// Diagram — Mermaid text drawn in the workspace theme, with zoom and pan.\n//\n// Client only, one bundle: Mermaid needs the DOM to measure text,\n// and the app's check refuses import() (so no next/dynamic). The modules\n// load with the page; the drawing happens after the first effect (the\n// mount guard). Until then — on the server and in the first client paint —\n// the frame shows the Mermaid text itself, so the HTML already says what\n// the diagram is.\n//\n// Colours: the tokens are read from the page at run time (palette.js) and\n// handed to Mermaid as its theme; a change of workspace theme or of\n// light/dark reads them again and redraws.\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button, Empty, Icon, IconButton, Menu, Notice, Skeleton, ToggleGroup, cx, toast } from \"../../ui\";\nimport { renderMermaid } from \"./mermaid\";\nimport { mermaidTheme, paletteKey, resolvePalette } from \"./palette\";\nimport { diagramKind, diagramTitle } from \"./source\";\n\nconst PAD = 24;\nconst MIN_K = 0.2;\nconst MAX_K = 4;\nconst px = (v) => (typeof v === \"number\" ? `${v}px` : v);\nconst clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n\n// The layout effect runs only in the browser; on the server it would warn.\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport function Diagram(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n if (!mounted) return <DiagramPlaceholder {...props} />;\n return <LiveDiagram {...props} />;\n}\n\nconst FRAME = \"flex min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground\";\n\nfunction heading(source, title) {\n return title ?? diagramTitle(source) ?? diagramKind(source) ?? \"Diagram\";\n}\n\n/** What the server renders: the frame, its heading, and the Mermaid text. */\nfunction DiagramPlaceholder({ source = \"\", title, height, minHeight = 160, toolbar = true, className }) {\n const kind = diagramKind(source);\n return (\n <figure className={cx(FRAME, className)} aria-busy=\"true\">\n {toolbar ? (\n <figcaption className=\"flex min-h-12 items-center gap-2 border-b border-border ps-4 pe-2\">\n <span className=\"truncate text-sm font-medium\">{heading(source, title)}</span>\n {kind && title ? <span className=\"truncate text-xs text-muted-foreground max-sm:hidden\">{kind}</span> : null}\n </figcaption>\n ) : null}\n <pre className=\"overflow-hidden px-4 py-3 font-mono text-xs leading-relaxed text-muted-foreground\" style={height != null ? { height: px(height) } : { minHeight: px(minHeight), maxHeight: px(minHeight * 2) }}>\n {source}\n </pre>\n </figure>\n );\n}\n\nfunction LiveDiagram({ source = \"\", title, label, height, minHeight = 160, maxHeight = 560, toolbar = true, look = \"classic\", onRender, className }) {\n const frame = useRef(null);\n const viewport = useRef(null);\n const content = useRef(null);\n const [palette, setPalette] = useState(null);\n const [result, setResult] = useState(null);\n const [good, setGood] = useState(null);\n const [size, setSize] = useState(null);\n const [box, setBox] = useState({ w: 0, h: 0 });\n const [t, setT] = useState({ k: 1, x: 0, y: 0 });\n const [pristine, setPristine] = useState(true);\n const [smooth, setSmooth] = useState(false);\n const [tab, setTab] = useState(\"diagram\");\n const [dragging, setDragging] = useState(false);\n const onRenderRef = useRef(onRender);\n onRenderRef.current = onRender;\n\n // The tokens, read now and again whenever the theme may have changed.\n useEffect(() => {\n const el = frame.current;\n let raf = 0;\n const read = () => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(() => {\n const p = resolvePalette(el);\n setPalette((prev) => (prev && paletteKey(prev) === paletteKey(p) ? prev : p));\n });\n };\n read();\n const mo = new MutationObserver(read);\n const attrs = { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] };\n mo.observe(document.documentElement, attrs);\n mo.observe(document.head, { childList: true });\n const themed = el.parentElement?.closest(\"[data-theme]\");\n if (themed) mo.observe(themed, attrs);\n const mq = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n mq?.addEventListener?.(\"change\", read);\n // A theme stylesheet that finishes loading changes the tokens too.\n const onLoad = (e) => {\n if (e.target?.tagName === \"LINK\") read();\n };\n document.addEventListener(\"load\", onLoad, true);\n return () => {\n cancelAnimationFrame(raf);\n mo.disconnect();\n mq?.removeEventListener?.(\"change\", read);\n document.removeEventListener(\"load\", onLoad, true);\n };\n }, []);\n\n // Draw: after the text rests for a moment (someone may be typing it).\n useEffect(() => {\n if (!palette) return;\n if (!source.trim()) {\n setResult(null);\n setGood(null);\n return;\n }\n let live = true;\n const timer = setTimeout(() => {\n const font = getComputedStyle(frame.current).fontFamily;\n renderMermaid(source, { themeVariables: mermaidTheme(palette, { fontFamily: font }), look, fontFamily: font }).then((r) => {\n if (!live) return;\n setResult(r);\n if (r.ok) setGood(r.svg);\n onRenderRef.current?.(r);\n });\n }, result ? 150 : 0);\n return () => {\n live = false;\n clearTimeout(timer);\n };\n // `result` only picks the delay; it must not redraw.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [source, palette, look]);\n\n // The drawing's own size, once it is in the page; Mermaid's width=\"100%\"\n // and max-width are taken off so zoom is ours.\n useIsoLayoutEffect(() => {\n const svg = content.current?.querySelector(\"svg\");\n if (!svg) return;\n const vb = svg.viewBox?.baseVal;\n let w = vb?.width || 0;\n let h = vb?.height || 0;\n if (!w || !h) {\n const r = svg.getBoundingClientRect();\n w = r.width || 400;\n h = r.height || 300;\n }\n svg.style.maxWidth = \"none\";\n svg.setAttribute(\"width\", String(w));\n svg.setAttribute(\"height\", String(h));\n svg.setAttribute(\"aria-hidden\", \"true\");\n setSize((s) => (s && s.w === w && s.h === h ? s : { w, h }));\n }, [good, tab]);\n\n // The viewport's size.\n useEffect(() => {\n const el = viewport.current;\n if (!el) return;\n const measure = () => setBox((b) => (b.w === el.clientWidth && b.h === el.clientHeight ? b : { w: el.clientWidth, h: el.clientHeight }));\n measure();\n const ro = new ResizeObserver(measure);\n ro.observe(el);\n return () => ro.disconnect();\n }, [tab, good]);\n\n // How tall the canvas is: `height`, else the drawing at the width's fit.\n const autoH = size && box.w ? size.h * Math.min(1, (box.w - 2 * PAD) / size.w) + 2 * PAD : minHeight;\n const canvasH = height != null ? px(height) : `${Math.round(clamp(autoH, minHeight, maxHeight))}px`;\n\n const fitted = () => {\n if (!size || !box.w || !box.h) return null;\n const k = clamp(Math.min(1, (box.w - 2 * PAD) / size.w, (box.h - 2 * PAD) / size.h), MIN_K, MAX_K);\n return { k, x: (box.w - size.w * k) / 2, y: (box.h - size.h * k) / 2 };\n };\n\n // Untouched, the drawing follows the frame (a resize, a new drawing).\n useEffect(() => {\n if (!pristine) return;\n const f = fitted();\n if (f) setT(f);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [size, box.w, box.h, pristine]);\n\n const glide = () => {\n setSmooth(true);\n setTimeout(() => setSmooth(false), 180);\n };\n const zoomAt = (factor, cx0 = box.w / 2, cy0 = box.h / 2, animate = true) => {\n setPristine(false);\n if (animate) glide();\n setT((cur) => {\n const k = clamp(cur.k * factor, MIN_K, MAX_K);\n return { k, x: cx0 - ((cx0 - cur.x) * k) / cur.k, y: cy0 - ((cy0 - cur.y) * k) / cur.k };\n });\n };\n const fit = () => {\n glide();\n setPristine(true);\n const f = fitted();\n if (f) setT(f);\n };\n const actual = () => {\n if (!size) return;\n glide();\n setPristine(false);\n setT({ k: 1, x: (box.w - size.w) / 2, y: (box.h - size.h) / 2 });\n };\n const pan = (dx, dy) => {\n setPristine(false);\n setT((cur) => ({ ...cur, x: cur.x + dx, y: cur.y + dy }));\n };\n\n // Pointer: drag pans; two fingers pinch; Cmd/Ctrl+wheel (and a trackpad\n // pinch) zooms at the pointer. A plain wheel scrolls the page, as it should.\n const pointers = useRef(new Map());\n const gesture = useRef(null);\n const local = (e) => {\n const r = viewport.current.getBoundingClientRect();\n return { x: e.clientX - r.left, y: e.clientY - r.top };\n };\n const onPointerDown = (e) => {\n if (e.button !== 0 || !good) return;\n viewport.current.setPointerCapture?.(e.pointerId);\n pointers.current.set(e.pointerId, local(e));\n const pts = [...pointers.current.values()];\n gesture.current = pts.length === 2 ? { pinch: Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y), t } : { from: local(e), t };\n setDragging(true);\n };\n const onPointerMove = (e) => {\n if (!pointers.current.has(e.pointerId) || !gesture.current) return;\n pointers.current.set(e.pointerId, local(e));\n const pts = [...pointers.current.values()];\n const g = gesture.current;\n setPristine(false);\n if (pts.length === 2 && g.pinch) {\n const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n const mid = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };\n const k = clamp(g.t.k * (d / g.pinch), MIN_K, MAX_K);\n setT({ k, x: mid.x - ((mid.x - g.t.x) * k) / g.t.k, y: mid.y - ((mid.y - g.t.y) * k) / g.t.k });\n } else if (g.from) {\n const p = local(e);\n setT({ ...g.t, x: g.t.x + p.x - g.from.x, y: g.t.y + p.y - g.from.y });\n }\n };\n const onPointerUp = (e) => {\n pointers.current.delete(e.pointerId);\n const pts = [...pointers.current.values()];\n gesture.current = pts.length === 1 ? { from: pts[0], t } : null;\n if (!pts.length) setDragging(false);\n };\n useEffect(() => {\n const el = viewport.current;\n if (!el) return;\n const onWheel = (e) => {\n if (!(e.ctrlKey || e.metaKey)) return;\n e.preventDefault();\n const r = el.getBoundingClientRect();\n zoomAt(Math.exp(-e.deltaY * 0.01), e.clientX - r.left, e.clientY - r.top, false);\n };\n el.addEventListener(\"wheel\", onWheel, { passive: false });\n return () => el.removeEventListener(\"wheel\", onWheel);\n });\n const onKeyDown = (e) => {\n const step = e.shiftKey ? 160 : 48;\n const keys = {\n \"+\": () => zoomAt(1.25),\n \"=\": () => zoomAt(1.25),\n \"-\": () => zoomAt(0.8),\n _: () => zoomAt(0.8),\n 0: fit,\n 1: actual,\n ArrowLeft: () => pan(step, 0),\n ArrowRight: () => pan(-step, 0),\n ArrowUp: () => pan(0, step),\n ArrowDown: () => pan(0, -step),\n };\n const fn = keys[e.key];\n if (!fn || e.metaKey || e.ctrlKey || e.altKey) return;\n e.preventDefault();\n fn();\n };\n\n async function copy(text, what) {\n try {\n await navigator.clipboard.writeText(text);\n toast.success(`Copied ${what}`);\n } catch {\n toast.error(\"Could not reach the clipboard\");\n }\n }\n const download = () => {\n const blob = new Blob([good], { type: \"image/svg+xml\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${(title ?? diagramTitle(source) ?? diagramKind(source) ?? \"diagram\").toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\") || \"diagram\"}.svg`;\n a.click();\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const kind = diagramKind(source);\n const error = result && !result.ok ? result.error : null;\n const empty = !source.trim();\n const name = label ?? `${heading(source, title)}${kind && title ? `, ${kind.toLowerCase()}` : \"\"}`;\n\n return (\n <figure ref={frame} className={cx(FRAME, className)}>\n {toolbar ? (\n <figcaption className=\"flex min-h-12 items-center gap-2 border-b border-border py-1.5 ps-4 pe-1.5\">\n <div className=\"me-auto flex min-w-0 items-baseline gap-2\">\n <span className=\"truncate text-sm font-medium\">{heading(source, title)}</span>\n {kind && title ? <span className=\"truncate text-xs text-muted-foreground max-sm:hidden\">{kind}</span> : null}\n </div>\n <ToggleGroup\n size=\"sm\"\n aria-label=\"Show\"\n value={tab}\n onChange={setTab}\n items={[\n { value: \"diagram\", label: \"Diagram\" },\n { value: \"source\", label: \"Source\" },\n ]}\n />\n <Menu\n label=\"Diagram actions\"\n items={[\n { label: \"Copy Mermaid source\", icon: \"copy\", disabled: empty, onSelect: () => copy(source, \"the Mermaid source\") },\n { label: \"Copy SVG\", icon: \"code\", disabled: !good, onSelect: () => copy(good, \"the SVG\") },\n { label: \"Download SVG\", icon: \"download\", disabled: !good, onSelect: download },\n { type: \"separator\" },\n { label: \"Fit to view\", icon: \"maximize\", shortcut: \"0\", disabled: !good || tab !== \"diagram\", onSelect: fit },\n { label: \"Actual size\", icon: \"search\", shortcut: \"1\", disabled: !good || tab !== \"diagram\", onSelect: actual },\n ]}\n />\n </figcaption>\n ) : null}\n\n {tab === \"source\" ? (\n <SourceView source={source} errorLine={error?.line} height={height != null ? px(height) : canvasH} onCopy={() => copy(source, \"the Mermaid source\")} />\n ) : empty ? (\n <div className=\"p-3\">\n <Empty icon=\"git-branch\" title=\"No diagram yet\" body=\"When there is Mermaid text — a flowchart, a sequence, a timeline — it is drawn here.\" />\n </div>\n ) : (\n // raw: the canvas — a focusable surface that pans and zooms with the\n // pointer, wheel and keys; no kit part is a zoomable viewport.\n <div\n ref={viewport}\n role=\"img\"\n aria-label={name}\n aria-roledescription=\"diagram\"\n tabIndex={good ? 0 : -1}\n onKeyDown={onKeyDown}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={onPointerUp}\n onDoubleClick={(e) => {\n if (!good) return;\n const p = local(e);\n zoomAt(e.shiftKey ? 0.5 : 2, p.x, p.y);\n }}\n className={cx(\n \"relative touch-none overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n \"bg-[radial-gradient(var(--color-border)_1px,transparent_1px)] [background-size:16px_16px]\",\n good && (dragging ? \"cursor-grabbing\" : \"cursor-grab\"),\n )}\n style={{ height: canvasH }}\n >\n {good ? (\n <div\n ref={content}\n className={cx(\"absolute left-0 top-0 origin-top-left will-change-transform\", smooth && \"transition-transform duration-150 ease-out motion-reduce:transition-none\", error && \"opacity-40\")}\n style={{ transform: `translate(${t.x}px, ${t.y}px) scale(${t.k})`, visibility: size ? \"visible\" : \"hidden\" }}\n dangerouslySetInnerHTML={{ __html: good }}\n />\n ) : null}\n\n {!result && !good ? (\n <div className=\"absolute inset-0 grid place-items-center p-6\" aria-busy=\"true\">\n <Skeleton shape=\"rect\" className=\"h-full max-h-64 w-full max-w-md\" />\n <span className=\"sr-only\">Drawing the diagram</span>\n </div>\n ) : null}\n\n {error && !good ? <ErrorPanel error={error} source={source} onShow={() => setTab(\"source\")} /> : null}\n\n {error && good ? (\n <div className=\"absolute inset-x-3 top-3\" onPointerDown={(e) => e.stopPropagation()}>\n <Notice\n tone=\"warning\"\n title={`${error.message} — showing the last version that worked`}\n action={\n <Button size=\"sm\" variant=\"quiet\" onClick={() => setTab(\"source\")}>\n Show source\n </Button>\n }\n >\n {error.detail}\n </Notice>\n </div>\n ) : null}\n\n {good ? (\n // raw: a floating cluster (the map-style zoom pill); its buttons are kit parts.\n <div\n className=\"absolute bottom-3 right-3 flex items-center gap-0.5 rounded-md border border-border bg-popover p-0.5 text-popover-foreground shadow-sm\"\n onPointerDown={(e) => e.stopPropagation()}\n onDoubleClick={(e) => e.stopPropagation()}\n >\n <IconButton icon=\"minus\" size=\"sm\" label=\"Zoom out\" onClick={() => zoomAt(0.8)} disabled={t.k <= MIN_K} />\n <Button variant=\"quiet\" size=\"sm\" onClick={actual} className=\"tabular-nums max-sm:hidden\" title=\"Actual size (1)\">\n {Math.round(t.k * 100)}%\n </Button>\n <IconButton icon=\"plus\" size=\"sm\" label=\"Zoom in\" onClick={() => zoomAt(1.25)} disabled={t.k >= MAX_K} />\n <IconButton icon=\"maximize\" size=\"sm\" label=\"Fit to view (0)\" onClick={fit} />\n </div>\n ) : null}\n </div>\n )}\n </figure>\n );\n}\n\n/** A mistake with nothing drawn yet: what, where, and the lines around it. */\nfunction ErrorPanel({ error, source, onShow }) {\n const lines = source.split(\"\\n\");\n const at = error.line ?? null;\n const from = at ? Math.max(1, at - 2) : 1;\n const to = at ? Math.min(lines.length, at + 2) : Math.min(lines.length, 5);\n return (\n <div className=\"absolute inset-0 overflow-auto p-4\" onPointerDown={(e) => e.stopPropagation()}>\n <div role=\"alert\" className=\"mx-auto grid max-w-xl gap-3\">\n <div className=\"flex items-start gap-3\">\n <Icon name=\"warning\" className=\"mt-0.5 shrink-0 text-destructive\" />\n <div className=\"min-w-0\">\n <p className=\"text-sm font-medium\">{error.message}</p>\n {error.detail ? <p className=\"mt-1 text-sm text-muted-foreground\">{error.detail}</p> : null}\n </div>\n </div>\n <pre className=\"overflow-x-auto rounded-md border border-border bg-background py-2 font-mono text-xs leading-relaxed\">\n {lines.slice(from - 1, to).map((l, i) => {\n const n = from + i;\n const bad = n === at;\n return (\n <div key={n} className={cx(\"flex pe-3\", bad && \"bg-destructive/10\")}>\n <span className={cx(\"w-10 shrink-0 pe-3 text-right tabular-nums select-none\", bad ? \"text-destructive\" : \"text-muted-foreground\")}>{n}</span>\n <span className=\"whitespace-pre\">{l || \" \"}</span>\n </div>\n );\n })}\n </pre>\n <div>\n <Button size=\"sm\" icon=\"code\" onClick={onShow}>\n Show source\n </Button>\n </div>\n </div>\n </div>\n );\n}\n\n/** The Mermaid text, numbered, the mistake's line marked. */\nfunction SourceView({ source, errorLine, height, onCopy }) {\n const lines = source.split(\"\\n\");\n return (\n <div className=\"relative\">\n <pre className=\"overflow-auto py-3 font-mono text-xs leading-relaxed\" style={{ height }} tabIndex={0} aria-label=\"Mermaid source\">\n {lines.map((l, i) => (\n <div key={i} className={cx(\"flex pe-12\", errorLine === i + 1 && \"bg-destructive/10\")}>\n <span className={cx(\"w-12 shrink-0 pe-3 text-right tabular-nums select-none\", errorLine === i + 1 ? \"text-destructive\" : \"text-muted-foreground\")}>{i + 1}</span>\n <span className=\"whitespace-pre\">{l || \" \"}</span>\n </div>\n ))}\n </pre>\n <IconButton icon=\"copy\" size=\"sm\" label=\"Copy source\" onClick={onCopy} className=\"absolute right-2 top-2\" />\n </div>\n );\n}\n"
25
25
  },
26
26
  {
27
27
  "path": "registry/blocks/diagram/mermaid.js",
@@ -67,7 +67,7 @@
67
67
  "docs": "Installed to components/makersclaw/blocks/diagram. Import: import { Diagram, diagramKind, diagramTitle, describeError, mermaidTheme } from \"@/components/makersclaw/blocks/diagram\". Props and an example: components/makersclaw/blocks/diagram/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
68
68
  "meta": {
69
69
  "makersclaw": {
70
- "version": "0.6.0",
70
+ "version": "0.8.0",
71
71
  "useWhen": "A screen shows a diagram written as text: a process or decision flow an agent wrote, a sequence of API calls, a data model, a project timeline, an org or system sketch in docs, a mind map. Not for a board people arrange by hand (flow-canvas), freehand drawing (sketch-canvas), or charts of numbers (chart)."
72
72
  }
73
73
  }