makersclaw 0.2.0 → 0.3.1

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 (475) hide show
  1. package/THIRD-PARTY-NOTICES.md +180 -0
  2. package/package.json +3 -2
  3. package/r/annotate.json +62 -0
  4. package/r/approval-request.json +52 -0
  5. package/r/audio-player.json +74 -0
  6. package/r/auth-form.json +55 -0
  7. package/r/board.json +10 -6
  8. package/r/calendar-view.json +105 -0
  9. package/r/cart.json +62 -0
  10. package/r/chart.json +83 -0
  11. package/r/chat.json +95 -0
  12. package/r/code-editor.json +94 -0
  13. package/r/comment-box.json +64 -0
  14. package/r/contact-section.json +53 -0
  15. package/r/core.json +205 -8
  16. package/r/csv-import.json +63 -0
  17. package/r/data-table.json +69 -0
  18. package/r/diagram.json +74 -0
  19. package/r/doc-editor.json +82 -0
  20. package/r/doc-page.json +61 -0
  21. package/r/email-render.json +73 -0
  22. package/r/faq.json +50 -0
  23. package/r/file-upload.json +54 -0
  24. package/r/flow-canvas.json +93 -0
  25. package/r/footer.json +52 -0
  26. package/r/gantt.json +70 -0
  27. package/r/job-panel.json +12 -7
  28. package/r/json-editor.json +66 -0
  29. package/r/map.json +89 -0
  30. package/r/menubar.json +52 -0
  31. package/r/mind-map.json +67 -0
  32. package/r/navigation-menu.json +52 -0
  33. package/r/order-detail.json +54 -0
  34. package/r/org-chart.json +66 -0
  35. package/r/page-builder.json +105 -0
  36. package/r/payment-methods.json +51 -0
  37. package/r/pdf-document.json +79 -0
  38. package/r/pdf-viewer.json +88 -0
  39. package/r/product-detail.json +76 -0
  40. package/r/product-grid.json +65 -0
  41. package/r/record-form.json +67 -0
  42. package/r/record-notes.json +55 -0
  43. package/r/registry.json +3411 -29
  44. package/r/search-dialog.json +51 -0
  45. package/r/settings-list.json +66 -0
  46. package/r/setup-checklist.json +51 -0
  47. package/r/share-dialog.json +52 -0
  48. package/r/sidebar.json +69 -0
  49. package/r/signature-pad.json +72 -0
  50. package/r/sketch-canvas.json +99 -0
  51. package/r/spreadsheet.json +90 -0
  52. package/r/stat-cards.json +66 -0
  53. package/r/task-list.json +63 -0
  54. package/r/task-model.json +98 -0
  55. package/r/testimonials.json +52 -0
  56. package/r/transaction-list.json +53 -0
  57. package/r/whiteboard.json +66 -0
  58. package/registry/blocks/annotate/README.md +179 -0
  59. package/registry/blocks/annotate/annotate-data.js +250 -0
  60. package/registry/blocks/annotate/annotate.jsx +646 -0
  61. package/registry/blocks/annotate/block.json +10 -0
  62. package/registry/blocks/annotate/demo.jsx +135 -0
  63. package/registry/blocks/annotate/index.js +15 -0
  64. package/registry/blocks/approval-request/README.md +116 -0
  65. package/registry/blocks/approval-request/approval-request.jsx +386 -0
  66. package/registry/blocks/approval-request/block.json +10 -0
  67. package/registry/blocks/approval-request/demo.jsx +127 -0
  68. package/registry/blocks/approval-request/index.js +4 -0
  69. package/registry/blocks/audio-player/README.md +167 -0
  70. package/registry/blocks/audio-player/audio-player.jsx +623 -0
  71. package/registry/blocks/audio-player/block.json +14 -0
  72. package/registry/blocks/audio-player/colors.js +109 -0
  73. package/registry/blocks/audio-player/demo.jsx +209 -0
  74. package/registry/blocks/audio-player/index.js +6 -0
  75. package/registry/blocks/audio-player/time.js +67 -0
  76. package/registry/blocks/audio-player/wavesurfer.js +9 -0
  77. package/registry/blocks/auth-form/README.md +126 -0
  78. package/registry/blocks/auth-form/auth-form.jsx +568 -0
  79. package/registry/blocks/auth-form/block.json +10 -0
  80. package/registry/blocks/auth-form/demo.jsx +131 -0
  81. package/registry/blocks/auth-form/index.js +3 -0
  82. package/registry/blocks/board/README.md +55 -5
  83. package/registry/blocks/board/block.json +2 -2
  84. package/registry/blocks/board/board.jsx +230 -86
  85. package/registry/blocks/board/demo.jsx +103 -24
  86. package/registry/blocks/calendar-view/README.md +180 -0
  87. package/registry/blocks/calendar-view/agenda.jsx +147 -0
  88. package/registry/blocks/calendar-view/block.json +76 -0
  89. package/registry/blocks/calendar-view/calendar-model.js +442 -0
  90. package/registry/blocks/calendar-view/calendar.jsx +627 -0
  91. package/registry/blocks/calendar-view/demo.jsx +195 -0
  92. package/registry/blocks/calendar-view/draft.jsx +49 -0
  93. package/registry/blocks/calendar-view/index.js +39 -0
  94. package/registry/blocks/calendar-view/month.jsx +346 -0
  95. package/registry/blocks/calendar-view/paint.js +69 -0
  96. package/registry/blocks/calendar-view/time-grid.jsx +527 -0
  97. package/registry/blocks/calendar-view/tray.jsx +87 -0
  98. package/registry/blocks/cart/README.md +146 -0
  99. package/registry/blocks/cart/block.json +10 -0
  100. package/registry/blocks/cart/cart.jsx +597 -0
  101. package/registry/blocks/cart/demo.jsx +98 -0
  102. package/registry/blocks/cart/index.js +4 -0
  103. package/registry/blocks/cart/money.js +28 -0
  104. package/registry/blocks/chart/README.md +316 -0
  105. package/registry/blocks/chart/block.json +10 -0
  106. package/registry/blocks/chart/cartesian.jsx +642 -0
  107. package/registry/blocks/chart/core.jsx +262 -0
  108. package/registry/blocks/chart/demo.jsx +357 -0
  109. package/registry/blocks/chart/heatmap.jsx +336 -0
  110. package/registry/blocks/chart/index.js +8 -0
  111. package/registry/blocks/chart/pie.jsx +245 -0
  112. package/registry/blocks/chart/sparkline.jsx +88 -0
  113. package/registry/blocks/chat/README.md +273 -0
  114. package/registry/blocks/chat/attachment.jsx +95 -0
  115. package/registry/blocks/chat/block.json +10 -0
  116. package/registry/blocks/chat/chat.jsx +132 -0
  117. package/registry/blocks/chat/composer.jsx +364 -0
  118. package/registry/blocks/chat/demo.jsx +578 -0
  119. package/registry/blocks/chat/header.jsx +34 -0
  120. package/registry/blocks/chat/index.js +9 -0
  121. package/registry/blocks/chat/message.jsx +450 -0
  122. package/registry/blocks/chat/scroller.jsx +247 -0
  123. package/registry/blocks/chat/text.jsx +104 -0
  124. package/registry/blocks/code-editor/README.md +210 -0
  125. package/registry/blocks/code-editor/block.json +22 -0
  126. package/registry/blocks/code-editor/code-editor.jsx +534 -0
  127. package/registry/blocks/code-editor/codemirror.js +38 -0
  128. package/registry/blocks/code-editor/demo.jsx +205 -0
  129. package/registry/blocks/code-editor/index.js +8 -0
  130. package/registry/blocks/code-editor/json.js +317 -0
  131. package/registry/blocks/code-editor/sql.js +136 -0
  132. package/registry/blocks/code-editor/text.js +101 -0
  133. package/registry/blocks/code-editor/theme.js +186 -0
  134. package/registry/blocks/comment-box/README.md +123 -0
  135. package/registry/blocks/comment-box/block.json +10 -0
  136. package/registry/blocks/comment-box/comment-body.jsx +217 -0
  137. package/registry/blocks/comment-box/comment-box.jsx +198 -0
  138. package/registry/blocks/comment-box/comment-time.jsx +77 -0
  139. package/registry/blocks/comment-box/demo.jsx +141 -0
  140. package/registry/blocks/comment-box/index.js +8 -0
  141. package/registry/blocks/contact-section/README.md +152 -0
  142. package/registry/blocks/contact-section/block.json +11 -0
  143. package/registry/blocks/contact-section/contact-section.jsx +509 -0
  144. package/registry/blocks/contact-section/demo.jsx +93 -0
  145. package/registry/blocks/contact-section/index.js +4 -0
  146. package/registry/blocks/csv-import/README.md +133 -0
  147. package/registry/blocks/csv-import/block.json +13 -0
  148. package/registry/blocks/csv-import/csv-import.jsx +479 -0
  149. package/registry/blocks/csv-import/demo.jsx +106 -0
  150. package/registry/blocks/csv-import/import.js +200 -0
  151. package/registry/blocks/csv-import/index.js +7 -0
  152. package/registry/blocks/data-table/README.md +386 -0
  153. package/registry/blocks/data-table/block.json +10 -0
  154. package/registry/blocks/data-table/data-table.jsx +1164 -0
  155. package/registry/blocks/data-table/demo.jsx +588 -0
  156. package/registry/blocks/data-table/index.js +5 -0
  157. package/registry/blocks/data-table/model.js +169 -0
  158. package/registry/blocks/diagram/README.md +108 -0
  159. package/registry/blocks/diagram/block.json +13 -0
  160. package/registry/blocks/diagram/demo.jsx +113 -0
  161. package/registry/blocks/diagram/diagram.jsx +481 -0
  162. package/registry/blocks/diagram/index.js +7 -0
  163. package/registry/blocks/diagram/mermaid.js +52 -0
  164. package/registry/blocks/diagram/palette.js +313 -0
  165. package/registry/blocks/diagram/source.js +98 -0
  166. package/registry/blocks/doc-editor/README.md +215 -0
  167. package/registry/blocks/doc-editor/block.json +15 -0
  168. package/registry/blocks/doc-editor/demo.jsx +225 -0
  169. package/registry/blocks/doc-editor/doc-editor.jsx +291 -0
  170. package/registry/blocks/doc-editor/doc.js +309 -0
  171. package/registry/blocks/doc-editor/index.js +7 -0
  172. package/registry/blocks/doc-editor/schema.jsx +56 -0
  173. package/registry/blocks/doc-editor/shadcn-classes.js +110 -0
  174. package/registry/blocks/doc-editor/theme.js +82 -0
  175. package/registry/blocks/doc-page/README.md +119 -0
  176. package/registry/blocks/doc-page/autosave.js +104 -0
  177. package/registry/blocks/doc-page/block.json +10 -0
  178. package/registry/blocks/doc-page/demo.jsx +137 -0
  179. package/registry/blocks/doc-page/doc-page.jsx +276 -0
  180. package/registry/blocks/doc-page/index.js +4 -0
  181. package/registry/blocks/email-render/README.md +151 -0
  182. package/registry/blocks/email-render/block.json +14 -0
  183. package/registry/blocks/email-render/demo.jsx +153 -0
  184. package/registry/blocks/email-render/doc-email.jsx +210 -0
  185. package/registry/blocks/email-render/email.jsx +221 -0
  186. package/registry/blocks/email-render/index.js +10 -0
  187. package/registry/blocks/email-render/render.js +50 -0
  188. package/registry/blocks/email-render/theme.js +215 -0
  189. package/registry/blocks/faq/README.md +101 -0
  190. package/registry/blocks/faq/block.json +11 -0
  191. package/registry/blocks/faq/demo.jsx +89 -0
  192. package/registry/blocks/faq/faq.jsx +220 -0
  193. package/registry/blocks/faq/index.js +4 -0
  194. package/registry/blocks/file-upload/README.md +138 -0
  195. package/registry/blocks/file-upload/block.json +10 -0
  196. package/registry/blocks/file-upload/demo.jsx +204 -0
  197. package/registry/blocks/file-upload/file-upload.jsx +635 -0
  198. package/registry/blocks/file-upload/index.js +4 -0
  199. package/registry/blocks/flow-canvas/README.md +420 -0
  200. package/registry/blocks/flow-canvas/block.json +13 -0
  201. package/registry/blocks/flow-canvas/board.js +819 -0
  202. package/registry/blocks/flow-canvas/chrome.jsx +649 -0
  203. package/registry/blocks/flow-canvas/context.jsx +35 -0
  204. package/registry/blocks/flow-canvas/demo.jsx +249 -0
  205. package/registry/blocks/flow-canvas/edges.jsx +107 -0
  206. package/registry/blocks/flow-canvas/flow-canvas.jsx +1329 -0
  207. package/registry/blocks/flow-canvas/index.js +41 -0
  208. package/registry/blocks/flow-canvas/nodes.jsx +392 -0
  209. package/registry/blocks/flow-canvas/theme.js +174 -0
  210. package/registry/blocks/footer/README.md +133 -0
  211. package/registry/blocks/footer/block.json +11 -0
  212. package/registry/blocks/footer/demo.jsx +119 -0
  213. package/registry/blocks/footer/footer.jsx +406 -0
  214. package/registry/blocks/footer/index.js +4 -0
  215. package/registry/blocks/gantt/README.md +220 -0
  216. package/registry/blocks/gantt/block.json +10 -0
  217. package/registry/blocks/gantt/demo.jsx +148 -0
  218. package/registry/blocks/gantt/gantt.jsx +923 -0
  219. package/registry/blocks/gantt/index.js +7 -0
  220. package/registry/blocks/gantt/rows.jsx +468 -0
  221. package/registry/blocks/gantt/schedule.js +278 -0
  222. package/registry/blocks/job-panel/README.md +80 -0
  223. package/registry/blocks/job-panel/block.json +3 -3
  224. package/registry/blocks/job-panel/demo.jsx +230 -3
  225. package/registry/blocks/job-panel/job-panel.jsx +169 -3
  226. package/registry/blocks/json-editor/README.md +133 -0
  227. package/registry/blocks/json-editor/block.json +10 -0
  228. package/registry/blocks/json-editor/demo.jsx +92 -0
  229. package/registry/blocks/json-editor/index.js +7 -0
  230. package/registry/blocks/json-editor/json-editor.jsx +305 -0
  231. package/registry/blocks/json-editor/json-tree.jsx +393 -0
  232. package/registry/blocks/json-editor/tree.js +225 -0
  233. package/registry/blocks/map/README.md +221 -0
  234. package/registry/blocks/map/block.json +14 -0
  235. package/registry/blocks/map/demo.jsx +201 -0
  236. package/registry/blocks/map/geo.js +198 -0
  237. package/registry/blocks/map/index.js +8 -0
  238. package/registry/blocks/map/list.jsx +128 -0
  239. package/registry/blocks/map/map-view.jsx +715 -0
  240. package/registry/blocks/map/maplibre.js +30 -0
  241. package/registry/blocks/map/marks.jsx +146 -0
  242. package/registry/blocks/map/theme.js +142 -0
  243. package/registry/blocks/menubar/README.md +109 -0
  244. package/registry/blocks/menubar/block.json +10 -0
  245. package/registry/blocks/menubar/demo.jsx +128 -0
  246. package/registry/blocks/menubar/index.js +4 -0
  247. package/registry/blocks/menubar/menubar.jsx +199 -0
  248. package/registry/blocks/mind-map/README.md +206 -0
  249. package/registry/blocks/mind-map/block.json +10 -0
  250. package/registry/blocks/mind-map/demo.jsx +103 -0
  251. package/registry/blocks/mind-map/index.js +31 -0
  252. package/registry/blocks/mind-map/mind-map.jsx +1125 -0
  253. package/registry/blocks/mind-map/topic.jsx +143 -0
  254. package/registry/blocks/mind-map/tree.js +645 -0
  255. package/registry/blocks/navigation-menu/README.md +124 -0
  256. package/registry/blocks/navigation-menu/block.json +10 -0
  257. package/registry/blocks/navigation-menu/demo.jsx +84 -0
  258. package/registry/blocks/navigation-menu/index.js +4 -0
  259. package/registry/blocks/navigation-menu/navigation-menu.jsx +448 -0
  260. package/registry/blocks/order-detail/README.md +109 -0
  261. package/registry/blocks/order-detail/block.json +10 -0
  262. package/registry/blocks/order-detail/demo.jsx +88 -0
  263. package/registry/blocks/order-detail/index.js +4 -0
  264. package/registry/blocks/order-detail/order-detail.jsx +354 -0
  265. package/registry/blocks/org-chart/README.md +145 -0
  266. package/registry/blocks/org-chart/block.json +10 -0
  267. package/registry/blocks/org-chart/demo.jsx +94 -0
  268. package/registry/blocks/org-chart/index.js +7 -0
  269. package/registry/blocks/org-chart/org-chart.jsx +631 -0
  270. package/registry/blocks/org-chart/org.js +245 -0
  271. package/registry/blocks/org-chart/person.jsx +96 -0
  272. package/registry/blocks/page-builder/README.md +257 -0
  273. package/registry/blocks/page-builder/block.json +14 -0
  274. package/registry/blocks/page-builder/demo.jsx +145 -0
  275. package/registry/blocks/page-builder/grapes.js +18 -0
  276. package/registry/blocks/page-builder/index.js +11 -0
  277. package/registry/blocks/page-builder/page-builder.jsx +704 -0
  278. package/registry/blocks/page-builder/page-view.jsx +33 -0
  279. package/registry/blocks/page-builder/page.js +118 -0
  280. package/registry/blocks/page-builder/panels.jsx +588 -0
  281. package/registry/blocks/page-builder/sections.js +209 -0
  282. package/registry/blocks/page-builder/theme.js +87 -0
  283. package/registry/blocks/page-builder/thumbs.jsx +189 -0
  284. package/registry/blocks/page-builder/tokens.js +329 -0
  285. package/registry/blocks/payment-methods/README.md +104 -0
  286. package/registry/blocks/payment-methods/block.json +10 -0
  287. package/registry/blocks/payment-methods/demo.jsx +71 -0
  288. package/registry/blocks/payment-methods/index.js +4 -0
  289. package/registry/blocks/payment-methods/payment-methods.jsx +302 -0
  290. package/registry/blocks/pdf-document/README.md +213 -0
  291. package/registry/blocks/pdf-document/block.json +13 -0
  292. package/registry/blocks/pdf-document/demo.jsx +136 -0
  293. package/registry/blocks/pdf-document/index.js +33 -0
  294. package/registry/blocks/pdf-document/money.js +83 -0
  295. package/registry/blocks/pdf-document/pdf-document.jsx +144 -0
  296. package/registry/blocks/pdf-document/primitives.jsx +271 -0
  297. package/registry/blocks/pdf-document/quote.jsx +179 -0
  298. package/registry/blocks/pdf-document/theme.js +193 -0
  299. package/registry/blocks/pdf-viewer/README.md +191 -0
  300. package/registry/blocks/pdf-viewer/block.json +14 -0
  301. package/registry/blocks/pdf-viewer/demo.jsx +252 -0
  302. package/registry/blocks/pdf-viewer/helpers.js +270 -0
  303. package/registry/blocks/pdf-viewer/index.js +8 -0
  304. package/registry/blocks/pdf-viewer/notes.jsx +195 -0
  305. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1368 -0
  306. package/registry/blocks/pdf-viewer/pdfjs.js +37 -0
  307. package/registry/blocks/pdf-viewer/ssr-shim-end.js +6 -0
  308. package/registry/blocks/pdf-viewer/ssr-shim.js +44 -0
  309. package/registry/blocks/product-detail/README.md +217 -0
  310. package/registry/blocks/product-detail/block.json +10 -0
  311. package/registry/blocks/product-detail/demo.jsx +233 -0
  312. package/registry/blocks/product-detail/index.js +7 -0
  313. package/registry/blocks/product-detail/price.jsx +115 -0
  314. package/registry/blocks/product-detail/product-detail.jsx +348 -0
  315. package/registry/blocks/product-detail/product-media.jsx +102 -0
  316. package/registry/blocks/product-detail/product-options.jsx +144 -0
  317. package/registry/blocks/product-grid/README.md +162 -0
  318. package/registry/blocks/product-grid/block.json +10 -0
  319. package/registry/blocks/product-grid/demo.jsx +255 -0
  320. package/registry/blocks/product-grid/index.js +5 -0
  321. package/registry/blocks/product-grid/product-card.jsx +232 -0
  322. package/registry/blocks/product-grid/product-grid.jsx +235 -0
  323. package/registry/blocks/record-form/README.md +196 -0
  324. package/registry/blocks/record-form/block.json +10 -0
  325. package/registry/blocks/record-form/demo.jsx +525 -0
  326. package/registry/blocks/record-form/index.js +4 -0
  327. package/registry/blocks/record-form/record-fields.jsx +457 -0
  328. package/registry/blocks/record-form/record-form.jsx +415 -0
  329. package/registry/blocks/record-form/rules.js +81 -0
  330. package/registry/blocks/record-notes/README.md +139 -0
  331. package/registry/blocks/record-notes/block.json +10 -0
  332. package/registry/blocks/record-notes/demo.jsx +179 -0
  333. package/registry/blocks/record-notes/index.js +4 -0
  334. package/registry/blocks/record-notes/record-notes.jsx +266 -0
  335. package/registry/blocks/search-dialog/README.md +108 -0
  336. package/registry/blocks/search-dialog/block.json +10 -0
  337. package/registry/blocks/search-dialog/demo.jsx +116 -0
  338. package/registry/blocks/search-dialog/index.js +3 -0
  339. package/registry/blocks/search-dialog/search-dialog.jsx +235 -0
  340. package/registry/blocks/settings-list/README.md +171 -0
  341. package/registry/blocks/settings-list/block.json +10 -0
  342. package/registry/blocks/settings-list/demo.jsx +333 -0
  343. package/registry/blocks/settings-list/index.js +4 -0
  344. package/registry/blocks/settings-list/row-state.jsx +93 -0
  345. package/registry/blocks/settings-list/settings-list.jsx +349 -0
  346. package/registry/blocks/settings-list/settings-matrix.jsx +118 -0
  347. package/registry/blocks/setup-checklist/README.md +142 -0
  348. package/registry/blocks/setup-checklist/block.json +10 -0
  349. package/registry/blocks/setup-checklist/demo.jsx +159 -0
  350. package/registry/blocks/setup-checklist/index.js +4 -0
  351. package/registry/blocks/setup-checklist/setup-checklist.jsx +446 -0
  352. package/registry/blocks/share-dialog/README.md +113 -0
  353. package/registry/blocks/share-dialog/block.json +10 -0
  354. package/registry/blocks/share-dialog/demo.jsx +140 -0
  355. package/registry/blocks/share-dialog/index.js +3 -0
  356. package/registry/blocks/share-dialog/share-dialog.jsx +384 -0
  357. package/registry/blocks/sidebar/README.md +190 -0
  358. package/registry/blocks/sidebar/block.json +43 -0
  359. package/registry/blocks/sidebar/demo.jsx +577 -0
  360. package/registry/blocks/sidebar/index.js +4 -0
  361. package/registry/blocks/sidebar/sidebar-panel.jsx +149 -0
  362. package/registry/blocks/sidebar/sidebar.jsx +864 -0
  363. package/registry/blocks/signature-pad/README.md +137 -0
  364. package/registry/blocks/signature-pad/block.json +13 -0
  365. package/registry/blocks/signature-pad/colors.js +115 -0
  366. package/registry/blocks/signature-pad/demo.jsx +94 -0
  367. package/registry/blocks/signature-pad/index.js +6 -0
  368. package/registry/blocks/signature-pad/pad.js +7 -0
  369. package/registry/blocks/signature-pad/signature-pad.jsx +434 -0
  370. package/registry/blocks/signature-pad/signature.js +68 -0
  371. package/registry/blocks/sketch-canvas/README.md +280 -0
  372. package/registry/blocks/sketch-canvas/block.json +13 -0
  373. package/registry/blocks/sketch-canvas/demo.jsx +159 -0
  374. package/registry/blocks/sketch-canvas/excalidraw.js +40 -0
  375. package/registry/blocks/sketch-canvas/index.js +9 -0
  376. package/registry/blocks/sketch-canvas/palette.js +226 -0
  377. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1188 -0
  378. package/registry/blocks/sketch-canvas/sketch.js +179 -0
  379. package/registry/blocks/sketch-canvas/ssr-shim-end.js +6 -0
  380. package/registry/blocks/sketch-canvas/ssr-shim.js +45 -0
  381. package/registry/blocks/sketch-canvas/stamps.js +104 -0
  382. package/registry/blocks/sketch-canvas/theme.js +131 -0
  383. package/registry/blocks/spreadsheet/README.md +307 -0
  384. package/registry/blocks/spreadsheet/block.json +13 -0
  385. package/registry/blocks/spreadsheet/cells.jsx +455 -0
  386. package/registry/blocks/spreadsheet/chrome.jsx +325 -0
  387. package/registry/blocks/spreadsheet/demo.jsx +177 -0
  388. package/registry/blocks/spreadsheet/grid.js +387 -0
  389. package/registry/blocks/spreadsheet/index.js +34 -0
  390. package/registry/blocks/spreadsheet/spreadsheet.jsx +1732 -0
  391. package/registry/blocks/spreadsheet/types.js +473 -0
  392. package/registry/blocks/stat-cards/README.md +173 -0
  393. package/registry/blocks/stat-cards/block.json +10 -0
  394. package/registry/blocks/stat-cards/demo.jsx +257 -0
  395. package/registry/blocks/stat-cards/index.js +4 -0
  396. package/registry/blocks/stat-cards/stat-cards.jsx +307 -0
  397. package/registry/blocks/stat-cards/usage.jsx +243 -0
  398. package/registry/blocks/task-list/README.md +176 -0
  399. package/registry/blocks/task-list/block.json +10 -0
  400. package/registry/blocks/task-list/demo.jsx +134 -0
  401. package/registry/blocks/task-list/index.js +5 -0
  402. package/registry/blocks/task-list/task-list.jsx +907 -0
  403. package/registry/blocks/task-model/README.md +455 -0
  404. package/registry/blocks/task-model/block.json +167 -0
  405. package/registry/blocks/task-model/changes.js +89 -0
  406. package/registry/blocks/task-model/dates.js +371 -0
  407. package/registry/blocks/task-model/demo.jsx +215 -0
  408. package/registry/blocks/task-model/dnd.jsx +240 -0
  409. package/registry/blocks/task-model/index.js +97 -0
  410. package/registry/blocks/task-model/model.js +919 -0
  411. package/registry/blocks/task-model/parts.jsx +449 -0
  412. package/registry/blocks/task-model/toolbar.jsx +271 -0
  413. package/registry/blocks/testimonials/README.md +139 -0
  414. package/registry/blocks/testimonials/block.json +11 -0
  415. package/registry/blocks/testimonials/demo.jsx +137 -0
  416. package/registry/blocks/testimonials/index.js +4 -0
  417. package/registry/blocks/testimonials/testimonials.jsx +430 -0
  418. package/registry/blocks/transaction-list/README.md +126 -0
  419. package/registry/blocks/transaction-list/block.json +10 -0
  420. package/registry/blocks/transaction-list/demo.jsx +79 -0
  421. package/registry/blocks/transaction-list/index.js +4 -0
  422. package/registry/blocks/transaction-list/transaction-list.jsx +383 -0
  423. package/registry/blocks/whiteboard/README.md +179 -0
  424. package/registry/blocks/whiteboard/block.json +10 -0
  425. package/registry/blocks/whiteboard/demo.jsx +169 -0
  426. package/registry/blocks/whiteboard/index.js +6 -0
  427. package/registry/blocks/whiteboard/templates.js +150 -0
  428. package/registry/blocks/whiteboard/whiteboard.jsx +721 -0
  429. package/src/cli.mjs +19 -6
  430. package/src/install.mjs +18 -0
  431. package/src/registry.mjs +38 -4
  432. package/src/shadcn.mjs +14 -1
  433. package/templates/next/files/README.md +145 -11
  434. package/templates/next/files/app/globals.css +55 -4
  435. package/templates/next/files/check/run.mjs +3 -3
  436. package/templates/next/files/components/ui/avatar.jsx +141 -0
  437. package/templates/next/files/components/ui/breadcrumb.jsx +149 -0
  438. package/templates/next/files/components/ui/button-group.jsx +70 -0
  439. package/templates/next/files/components/ui/button.jsx +132 -12
  440. package/templates/next/files/components/ui/calendar.jsx +609 -0
  441. package/templates/next/files/components/ui/carousel.jsx +281 -0
  442. package/templates/next/files/components/ui/collapsible.jsx +208 -0
  443. package/templates/next/files/components/ui/combobox.jsx +608 -0
  444. package/templates/next/files/components/ui/command.jsx +577 -0
  445. package/templates/next/files/components/ui/context-menu.jsx +191 -0
  446. package/templates/next/files/components/ui/date-picker.jsx +360 -0
  447. package/templates/next/files/components/ui/distribution.jsx +117 -0
  448. package/templates/next/files/components/ui/feedback.jsx +143 -0
  449. package/templates/next/files/components/ui/field.jsx +115 -3
  450. package/templates/next/files/components/ui/file-input.jsx +234 -0
  451. package/templates/next/files/components/ui/icon.jsx +130 -8
  452. package/templates/next/files/components/ui/image.jsx +143 -0
  453. package/templates/next/files/components/ui/index.js +47 -2
  454. package/templates/next/files/components/ui/input-group.jsx +213 -0
  455. package/templates/next/files/components/ui/input-otp.jsx +170 -0
  456. package/templates/next/files/components/ui/item.jsx +206 -0
  457. package/templates/next/files/components/ui/menu.jsx +454 -82
  458. package/templates/next/files/components/ui/notice.jsx +126 -14
  459. package/templates/next/files/components/ui/pagination.jsx +156 -0
  460. package/templates/next/files/components/ui/popover.jsx +284 -0
  461. package/templates/next/files/components/ui/radio.jsx +193 -0
  462. package/templates/next/files/components/ui/rating.jsx +131 -0
  463. package/templates/next/files/components/ui/resizable.jsx +338 -0
  464. package/templates/next/files/components/ui/scroll-area.jsx +123 -0
  465. package/templates/next/files/components/ui/separator.jsx +119 -0
  466. package/templates/next/files/components/ui/sheet.jsx +96 -11
  467. package/templates/next/files/components/ui/slider.jsx +174 -0
  468. package/templates/next/files/components/ui/stat.jsx +574 -0
  469. package/templates/next/files/components/ui/steps.jsx +338 -0
  470. package/templates/next/files/components/ui/swatch.jsx +33 -0
  471. package/templates/next/files/components/ui/table.jsx +130 -0
  472. package/templates/next/files/components/ui/toast.jsx +440 -0
  473. package/templates/next/files/components/ui/toggle.jsx +195 -0
  474. package/templates/next/files/components/ui/tooltip.jsx +270 -0
  475. package/templates/next/template.json +264 -8
@@ -0,0 +1,74 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "audio-player",
4
+ "type": "registry:block",
5
+ "title": "AudioPlayer",
6
+ "description": "A waveform audio player (wavesurfer.js) in the workspace theme: play, skip back and forward, speed, mute; regions (spans of the audio in a theme tone, labelled in a lane under the waveform, click to play one) and markers (pins at moments, click to jump); a hover time readout; drag across the waveform to mark a new region; add a marker at the playhead; keyboard like a video player (K, J, L, M, arrows). Time events and a handle (seek, play a range) for syncing a transcript.",
7
+ "registryDependencies": [
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.3.1/r/core.json"
9
+ ],
10
+ "dependencies": [
11
+ "wavesurfer.js@8.0.0",
12
+ "@wavesurfer/react@1.0.12"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "registry/blocks/audio-player/index.js",
17
+ "type": "registry:component",
18
+ "target": "@components/makersclaw/blocks/audio-player/index.js",
19
+ "content": "// The AudioPlayer block — `makersclaw add audio-player`. Fixed: compose it\n// through its props (README.md beside this file). The blocks built on this\n// one (call-review…) import it, never wavesurfer.js directly. The time\n// helpers are plain functions and work on the server.\nexport { AudioPlayer, useAudioPlayer } from \"./audio-player\";\nexport { formatTime, formatRange, spokenTime, regionAt, TONES, RATES } from \"./time\";\n"
20
+ },
21
+ {
22
+ "path": "registry/blocks/audio-player/audio-player.jsx",
23
+ "type": "registry:component",
24
+ "target": "@components/makersclaw/blocks/audio-player/audio-player.jsx",
25
+ "content": "\"use client\";\n\n// AudioPlayer — a waveform player in the workspace theme (wavesurfer.js\n// underneath). Play, skip, speed and mute; regions (spans of the audio in\n// a tone: a topic, a speaker turn, a clip) and markers (moments: a\n// comment, a flag); a hover time readout; drag across the waveform to\n// mark a new region; keyboard throughout.\n//\n// What is ours and what is wavesurfer's: wavesurfer decodes the audio,\n// plays it and paints the waveform on its canvas. Everything drawn over\n// or around the waveform — regions, markers, the hover line, the drag\n// selection, the controls, the loading and error states — is ours, in the\n// kit and in tokens, so it follows the theme like any other block. The\n// canvas's own colours are read from the tokens at run time (colors.js).\n//\n// Client only in effect, not in import: the modules load anywhere, the\n// wavesurfer instance is created in an effect. The server (and the first\n// paint) shows the frame with a skeleton waveform.\nimport { useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from \"react\";\nimport { Button, Empty, IconButton, Menu, Notice, Tooltip, cx } from \"../../ui\";\nimport { useWavesurfer } from \"./wavesurfer\";\nimport { readTokens, waveColours, watchTheme } from \"./colors\";\nimport { RATES, clamp, cleanMarkers, cleanRegions, formatRange, formatRate, formatTime, regionAt, spokenTime } from \"./time\";\n\n/** A ref for the player's handle: `const player = useAudioPlayer()`, then\n * `<AudioPlayer playerRef={player} />` and `player.current?.seek(42)`. */\nexport function useAudioPlayer() {\n return useRef(null);\n}\n\n// Tailwind needs whole class names in the source: one per tone.\nconst TONE = {\n primary: { dot: \"bg-primary\", band: \"bg-primary/10\", active: \"bg-primary/20\", line: \"bg-primary/60\" },\n \"chart-2\": { dot: \"bg-chart-2\", band: \"bg-chart-2/10\", active: \"bg-chart-2/25\", line: \"bg-chart-2/60\" },\n \"chart-3\": { dot: \"bg-chart-3\", band: \"bg-chart-3/10\", active: \"bg-chart-3/25\", line: \"bg-chart-3/60\" },\n \"chart-4\": { dot: \"bg-chart-4\", band: \"bg-chart-4/10\", active: \"bg-chart-4/25\", line: \"bg-chart-4/60\" },\n \"chart-5\": { dot: \"bg-chart-5\", band: \"bg-chart-5/10\", active: \"bg-chart-5/25\", line: \"bg-chart-5/60\" },\n success: { dot: \"bg-success\", band: \"bg-success/10\", active: \"bg-success/20\", line: \"bg-success/60\" },\n warning: { dot: \"bg-warning\", band: \"bg-warning/10\", active: \"bg-warning/20\", line: \"bg-warning/60\" },\n info: { dot: \"bg-info\", band: \"bg-info/10\", active: \"bg-info/20\", line: \"bg-info/60\" },\n destructive: { dot: \"bg-destructive\", band: \"bg-destructive/10\", active: \"bg-destructive/20\", line: \"bg-destructive/60\" },\n muted: { dot: \"bg-muted-foreground\", band: \"bg-muted-foreground/10\", active: \"bg-muted-foreground/20\", line: \"bg-muted-foreground/50\" },\n};\nconst tone = (t, fallback = \"primary\") => TONE[t] ?? TONE[fallback];\n\nconst WAVE_HEIGHT = 72;\nconst DRAG_PX = 4; // a press that moves less than this is a click (seek)\nconst MIN_REGION = 0.5; // seconds: a shorter drag is treated as a click\nconst EDITABLE = /^(INPUT|TEXTAREA|SELECT)$/;\n\n// The skeleton's bars: a fixed, gentle shape (no randomness: the server\n// and the first client paint must match).\nconst SKELETON = Array.from({ length: 72 }, (_, i) => 22 + Math.round(38 * Math.abs(Math.sin(i * 0.37) * Math.cos(i * 0.11))));\n\nexport function AudioPlayer({\n src,\n peaks,\n duration: knownDuration,\n title,\n description,\n regions,\n markers,\n onAddMarker,\n onRegionCreate,\n onMarkerClick,\n onRegionClick,\n onActiveRegionChange,\n onReady,\n onPlay,\n onPause,\n onFinish,\n onSeek,\n onTimeUpdate,\n onError,\n rates = RATES,\n defaultRate = 1,\n skipSeconds = 15,\n emptyTitle = \"No recording yet\",\n emptyBody = \"When there is audio to play, it shows up here.\",\n emptyAction,\n bare = false,\n playerRef,\n className,\n}) {\n const rootRef = useRef(null);\n const waveRef = useRef(null);\n const surfaceRef = useRef(null);\n const tipId = useId();\n\n // A Blob plays through an object URL, made here and revoked when it goes.\n const isBlob = typeof Blob !== \"undefined\" && src instanceof Blob;\n const [blobUrl, setBlobUrl] = useState(null);\n useEffect(() => {\n if (!isBlob) return setBlobUrl(null);\n const u = URL.createObjectURL(src);\n setBlobUrl(u);\n return () => URL.revokeObjectURL(u);\n }, [src, isBlob]);\n const url = typeof src === \"string\" && src ? src : isBlob ? blobUrl ?? undefined : undefined;\n const hasAudio = Boolean((typeof src === \"string\" && src) || isBlob || peaks);\n\n // The instance. Every key is always passed (the hook's effect depends on\n // the list of values; its length must not change) and none of them is a\n // colour: colours are set after, so a theme change repaints instead of\n // rebuilding the player.\n const { wavesurfer: ws, isReady, isPlaying, currentTime } = useWavesurfer({\n container: waveRef,\n url,\n peaks,\n duration: knownDuration,\n height: WAVE_HEIGHT,\n barWidth: 2,\n barGap: 2,\n barRadius: 2,\n cursorWidth: 2,\n normalize: true,\n interact: false,\n hideScrollbar: true,\n autoScroll: false,\n });\n\n const [duration, setDuration] = useState(knownDuration > 0 ? knownDuration : 0);\n const [error, setError] = useState(null);\n const [rate, setRate] = useState(defaultRate);\n const [muted, setMuted] = useState(false);\n const [notice, setNotice] = useState(null);\n const [pending, setPending] = useState({ markers: [], regions: [] });\n const [hover, setHover] = useState(null); // ratio 0..1 under the pointer\n const [draft, setDraft] = useState(null); // { a, b } ratios while dragging a region\n const [width, setWidth] = useState(0);\n const drag = useRef(null);\n\n // The latest callbacks, so the instance's listeners never go stale.\n const cbs = useRef({});\n cbs.current = { onReady, onPlay, onPause, onFinish, onTimeUpdate, onError };\n\n // Settings the instance must keep across a reload (a new src).\n const settings = useRef({ rate, muted });\n settings.current = { rate, muted };\n\n useEffect(() => {\n if (!ws) return;\n setError(null);\n const off = [\n ws.on(\"ready\", (d) => {\n setDuration(d);\n setError(null);\n ws.setPlaybackRate(settings.current.rate);\n ws.setMuted(settings.current.muted);\n cbs.current.onReady?.({ duration: d });\n }),\n ws.on(\"decode\", (d) => setDuration(d)),\n ws.on(\"play\", () => cbs.current.onPlay?.()),\n ws.on(\"pause\", () => cbs.current.onPause?.()),\n ws.on(\"finish\", () => cbs.current.onFinish?.()),\n ws.on(\"timeupdate\", (t) => cbs.current.onTimeUpdate?.(t)),\n ws.on(\"error\", (e) => {\n setError(e ?? new Error(\"The audio could not be loaded.\"));\n cbs.current.onError?.(e);\n }),\n ];\n return () => off.forEach((f) => f());\n }, [ws]);\n\n // The canvas's colours, from the tokens where the player sits; again\n // whenever the theme or the scheme changes.\n useEffect(() => {\n if (!ws) return;\n return watchTheme(rootRef.current, () => {\n const c = waveColours(readTokens(rootRef.current, [\"foreground\", \"muted-foreground\", \"primary\"]));\n const opts = Object.fromEntries(Object.entries(c).filter(([, v]) => v));\n if (Object.keys(opts).length) ws.setOptions(opts);\n });\n }, [ws]);\n\n // The waveform's width in pixels: for hiding labels that would not fit.\n useEffect(() => {\n const el = surfaceRef.current;\n if (!el || typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const ready = Boolean(ws && isReady && duration > 0 && !error);\n const shownRegions = useMemo(() => cleanRegions([...(regions ?? []), ...pending.regions], duration), [regions, pending.regions, duration]);\n const shownMarkers = useMemo(() => cleanMarkers([...(markers ?? []), ...pending.markers], duration), [markers, pending.markers, duration]);\n const active = ready ? regionAt(shownRegions, currentTime) : null;\n\n // The region under the playhead, told once per change (transcript sync).\n const lastActive = useRef(undefined);\n useEffect(() => {\n const id = active?.id ?? null;\n if (lastActive.current === id) return;\n lastActive.current = id;\n onActiveRegionChange?.(active ?? null);\n }, [active, onActiveRegionChange]);\n\n // ---- actions --------------------------------------------------------------\n\n const seek = useCallback(\n (t) => {\n if (!ws || !(duration > 0)) return;\n const to = clamp(t, 0, duration);\n ws.setTime(to);\n onSeek?.(to);\n },\n [ws, duration, onSeek],\n );\n const skip = useCallback((d) => ws && seek(ws.getCurrentTime() + d), [ws, seek]);\n // play() can be refused by the browser (no user gesture yet): not an error to show.\n const quiet = (p) => p?.catch?.(() => {});\n const toggle = useCallback(() => ready && quiet(ws.playPause()), [ready, ws]);\n const changeRate = useCallback(\n (r) => {\n setRate(r);\n ws?.setPlaybackRate(r);\n },\n [ws],\n );\n const toggleMute = useCallback(() => {\n setMuted((m) => {\n ws?.setMuted(!m);\n return !m;\n });\n }, [ws]);\n const playRange = useCallback(\n (start, end) => {\n if (!ready) return;\n onSeek?.(start);\n quiet(ws.play(clamp(start, 0, duration), end == null ? undefined : clamp(end, 0, duration)));\n },\n [ready, ws, duration, onSeek],\n );\n const retry = useCallback(() => {\n if (!ws || !url) return;\n setError(null);\n ws.load(url).catch(() => {});\n }, [ws, url]);\n\n // Optimistic: the marker shows at once, dimmed, and goes if the save fails.\n const addMarker = useCallback(async () => {\n if (!onAddMarker || !ready) return;\n const time = ws.getCurrentTime();\n const temp = { id: `pending-${Date.now()}`, time, label: \"Saving…\", tone: \"muted\", pending: true };\n setPending((p) => ({ ...p, markers: [...p.markers, temp] }));\n setNotice(null);\n let res;\n try {\n res = await onAddMarker({ time });\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n setPending((p) => ({ ...p, markers: p.markers.filter((m) => m.id !== temp.id) }));\n if (!res?.ok) setNotice(`Couldn't add the marker at ${formatTime(time)}. ${res?.error || \"Try again.\"}`);\n }, [onAddMarker, ready, ws]);\n\n const createRegion = useCallback(\n async (start, end) => {\n if (!onRegionCreate) return;\n const temp = { id: `pending-${Date.now()}`, start, end, label: \"Saving…\", tone: \"muted\", pending: true };\n setPending((p) => ({ ...p, regions: [...p.regions, temp] }));\n setNotice(null);\n let res;\n try {\n res = await onRegionCreate({ start, end });\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n setPending((p) => ({ ...p, regions: p.regions.filter((r) => r.id !== temp.id) }));\n if (!res?.ok) setNotice(`Couldn't save the region ${formatRange(start, end)}. ${res?.error || \"Try again.\"}`);\n },\n [onRegionCreate],\n );\n\n useImperativeHandle(\n playerRef,\n () => ({\n play: () => ready && quiet(ws.play()),\n pause: () => ws?.pause(),\n toggle,\n seek,\n skip,\n playRange,\n playRegion: (id) => {\n const r = shownRegions.find((x) => x.id === id);\n if (r) playRange(r.start, r.end);\n },\n setRate: changeRate,\n setMuted: (m) => {\n setMuted(Boolean(m));\n ws?.setMuted(Boolean(m));\n },\n addMarker,\n getTime: () => ws?.getCurrentTime() ?? 0,\n getDuration: () => duration,\n isPlaying: () => Boolean(ws?.isPlaying()),\n isReady: () => ready,\n }),\n [ready, ws, toggle, seek, skip, playRange, shownRegions, changeRate, addMarker, duration],\n );\n\n // ---- pointer and keys on the waveform ---------------------------------------\n\n const ratioAt = (e) => {\n const r = surfaceRef.current?.getBoundingClientRect();\n return r && r.width ? clamp((e.clientX - r.left) / r.width, 0, 1) : 0;\n };\n\n function onPointerDown(e) {\n if (!ready || e.button > 0) return;\n e.currentTarget.setPointerCapture?.(e.pointerId);\n drag.current = { x: e.clientX, start: ratioAt(e), moved: false };\n }\n function onPointerMove(e) {\n const ratio = ratioAt(e);\n if (e.pointerType !== \"touch\") setHover(ratio);\n const d = drag.current;\n if (!d) return;\n if (!d.moved && Math.abs(e.clientX - d.x) < DRAG_PX) return;\n d.moved = true;\n if (onRegionCreate) setDraft({ a: d.start, b: ratio });\n else seek(ratio * duration); // no regions to make: a drag scrubs\n }\n function onPointerUp(e) {\n const d = drag.current;\n drag.current = null;\n if (!d) return;\n const ratio = ratioAt(e);\n if (!d.moved) return seek(ratio * duration);\n if (onRegionCreate) {\n setDraft(null);\n const start = Math.min(d.start, ratio) * duration;\n const end = Math.max(d.start, ratio) * duration;\n if (end - start >= MIN_REGION) createRegion(start, end);\n else seek(ratio * duration);\n }\n }\n function onPointerCancel() {\n drag.current = null;\n setDraft(null);\n }\n\n function onSurfaceKey(e) {\n if (!ready) return;\n const small = 5;\n const moves = { ArrowLeft: -small, ArrowRight: small, PageDown: -skipSeconds, PageUp: skipSeconds };\n if (e.key in moves) {\n e.preventDefault();\n skip(moves[e.key]);\n } else if (e.key === \"Home\") {\n e.preventDefault();\n seek(0);\n } else if (e.key === \"End\") {\n e.preventDefault();\n seek(duration);\n } else if (e.key === \" \" || e.key === \"Enter\") {\n e.preventDefault();\n toggle();\n }\n }\n\n // Player-wide letters, like a video player: K play/pause, J/L skip, M\n // marker. Never while typing, never with a modifier (those are the app's).\n function onRootKey(e) {\n if (!ready || e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return;\n const t = e.target;\n if (EDITABLE.test(t?.tagName ?? \"\") || t?.isContentEditable) return;\n const k = e.key.toLowerCase();\n if (k === \"k\") toggle();\n else if (k === \"j\") skip(-skipSeconds);\n else if (k === \"l\") skip(skipSeconds);\n else if (k === \"m\" && onAddMarker) addMarker();\n else return;\n e.preventDefault();\n }\n\n // ---- render -----------------------------------------------------------------\n\n const frame = bare ? \"\" : \"rounded-lg border border-border bg-card p-4 sm:p-5\";\n\n if (!hasAudio) {\n return (\n <div className={cx(frame, className)}>\n <Header title={title} description={description} />\n <Empty icon=\"mic\" title={emptyTitle} body={emptyBody} action={emptyAction} className={title || description ? \"mt-4\" : \"\"} />\n </div>\n );\n }\n\n const pct = (t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(3)}%`;\n const draftSpan = draft ? { from: Math.min(draft.a, draft.b), to: Math.max(draft.a, draft.b) } : null;\n const loading = !ready && !error;\n\n return (\n <div ref={rootRef} className={cx(frame, \"text-card-foreground\", className)} onKeyDown={onRootKey}>\n <Header title={title} description={description} />\n\n {/* Markers: pins above the waveform. */}\n <div className={cx(\"relative h-6\", (title || description) && \"mt-3\")}>\n {ready\n ? shownMarkers.map((m) => (\n <div key={m.id} className=\"absolute top-0 -translate-x-1/2\" style={{ left: pct(m.time) }}>\n <Tooltip label={`${formatTime(m.time)} · ${m.label ?? \"Marker\"}`} delay={150}>\n {/* raw: a pin placed on the timeline at its moment — a kit Button cannot be positioned by time or be this small */}\n <button\n type=\"button\"\n disabled={m.pending}\n aria-label={`${m.label ?? \"Marker\"}, at ${spokenTime(m.time)}`}\n onClick={() => {\n seek(m.time);\n onMarkerClick?.(m);\n }}\n className=\"group grid size-6 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default\"\n >\n <span\n className={cx(\n \"size-2.5 rounded-full ring-2 ring-card transition-transform duration-150 group-hover:scale-125 group-focus-visible:scale-125 motion-reduce:transition-none\",\n tone(m.tone).dot,\n m.pending && \"animate-pulse motion-reduce:animate-none\",\n Math.abs(currentTime - m.time) < 0.75 && \"scale-125\",\n )}\n />\n </button>\n </Tooltip>\n </div>\n ))\n : null}\n </div>\n\n {/* The waveform: wavesurfer's canvas, with our layer over it. */}\n <div className=\"relative\" style={{ height: WAVE_HEIGHT }}>\n <div ref={waveRef} className={cx(\"w-full transition-opacity duration-150 motion-reduce:transition-none\", loading && \"opacity-0\")} />\n\n {loading ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 flex items-center gap-[2px] overflow-hidden\">\n {SKELETON.map((h, i) => (\n <span key={i} className=\"w-[2px] flex-1 animate-pulse rounded-full bg-muted motion-reduce:animate-none\" style={{ height: `${h}%` }} />\n ))}\n </div>\n ) : null}\n\n {error ? (\n <div className=\"absolute inset-0 flex items-center\">\n <span aria-hidden=\"true\" className=\"h-px w-full bg-border\" />\n </div>\n ) : null}\n\n {/* raw: the seek surface — a slider over a canvas; the kit Slider is a native range input and cannot host the waveform, regions or markers */}\n <div\n ref={surfaceRef}\n role=\"slider\"\n tabIndex={ready ? 0 : -1}\n aria-label=\"Seek\"\n aria-valuemin={0}\n aria-valuemax={Math.round(duration)}\n aria-valuenow={Math.round(currentTime)}\n aria-valuetext={`${spokenTime(currentTime)} of ${spokenTime(duration)}`}\n aria-disabled={!ready || undefined}\n aria-describedby={tipId}\n onKeyDown={onSurfaceKey}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={onPointerCancel}\n onPointerLeave={() => setHover(null)}\n className={cx(\n \"absolute inset-0 z-10 touch-pan-y rounded-md outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n ready ? (onRegionCreate ? \"cursor-crosshair\" : \"cursor-pointer\") : \"cursor-default\",\n )}\n >\n {ready\n ? shownRegions.map((r) => (\n <span\n key={r.id}\n aria-hidden=\"true\"\n className={cx(\"pointer-events-none absolute inset-y-0 transition-colors duration-150 motion-reduce:transition-none\", active?.id === r.id ? tone(r.tone).active : tone(r.tone).band)}\n style={{ left: pct(r.start), width: pct(r.end - r.start) }}\n />\n ))\n : null}\n {ready\n ? shownMarkers.map((m) => (\n <span key={m.id} aria-hidden=\"true\" className={cx(\"pointer-events-none absolute inset-y-0 w-px\", tone(m.tone).line)} style={{ left: pct(m.time) }} />\n ))\n : null}\n\n {draftSpan ? (\n <span\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-y-0 border-x border-primary bg-primary/15\"\n style={{ left: `${draftSpan.from * 100}%`, width: `${(draftSpan.to - draftSpan.from) * 100}%` }}\n />\n ) : null}\n\n {ready && hover != null ? (\n <>\n <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-y-0 w-px bg-foreground/40\" style={{ left: `${hover * 100}%` }} />\n <span\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute -top-1 z-30 -translate-x-1/2 -translate-y-full rounded-md bg-foreground px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-background tabular-nums shadow-sm\"\n style={{ left: `${clamp(hover * 100, 4, 96)}%` }}\n >\n {draftSpan ? formatRange(draftSpan.from * duration, draftSpan.to * duration) : formatTime(hover * duration)}\n </span>\n </>\n ) : null}\n </div>\n <span id={tipId} className=\"sr-only\">\n {onRegionCreate ? \"Click to jump, drag to mark a region. \" : \"Click or drag to jump. \"}\n Arrow keys move 5 seconds, Page Up and Page Down {skipSeconds}, Space plays or pauses.\n </span>\n </div>\n\n {/* Regions: labels in a lane under the waveform; a click plays one. */}\n {ready && shownRegions.length ? (\n <div className=\"relative mt-1.5 h-7\">\n {shownRegions.map((r) => {\n const px = ((r.end - r.start) / duration) * width;\n const on = active?.id === r.id;\n return (\n <div key={r.id} className=\"absolute inset-y-0 px-px\" style={{ left: pct(r.start), width: pct(r.end - r.start) }}>\n <Tooltip label={`${r.label ?? \"Region\"} · ${formatRange(r.start, r.end)}`} delay={300}>\n {/* raw: a segment of the timeline, sized to its span — a kit Button has a fixed height and padding */}\n <button\n type=\"button\"\n disabled={r.pending}\n aria-label={`Play ${r.label ?? \"region\"}, ${spokenTime(r.start)} to ${spokenTime(r.end)}`}\n aria-current={on || undefined}\n onClick={() => {\n playRange(r.start, r.end);\n onRegionClick?.(r);\n }}\n className=\"group flex h-7 w-full min-w-0 flex-col gap-1 rounded-sm pt-0.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default\"\n >\n <span className={cx(\"h-1 w-full rounded-full transition-opacity duration-150 motion-reduce:transition-none\", tone(r.tone).dot, on ? \"opacity-100\" : \"opacity-60 group-hover:opacity-100\", r.pending && \"animate-pulse motion-reduce:animate-none\")} />\n {px >= 44 ? (\n <span className={cx(\"block w-full truncate text-xs leading-4\", on ? \"font-medium text-foreground\" : \"text-muted-foreground group-hover:text-foreground\")}>{r.label ?? \"Region\"}</span>\n ) : null}\n </button>\n </Tooltip>\n </div>\n );\n })}\n </div>\n ) : null}\n\n {error ? (\n <Notice\n tone=\"danger\"\n title=\"Couldn't play this recording\"\n className=\"mt-3\"\n action={\n url ? (\n <Button size=\"sm\" variant=\"secondary\" icon=\"refresh\" onClick={retry}>\n Try again\n </Button>\n ) : null\n }\n >\n The file may be missing, or in a format this browser can't play.\n </Notice>\n ) : null}\n\n {notice ? (\n <Notice tone=\"danger\" className=\"mt-3\" onDismiss={() => setNotice(null)}>\n {notice}\n </Notice>\n ) : null}\n\n {/* Controls: one row on a wide screen; on a phone the transport gets\n its own centred row and the time sits under it with the rest. */}\n <div className=\"mt-3 flex flex-wrap items-center gap-x-2 gap-y-2\">\n <div className=\"flex w-full items-center justify-center gap-2 sm:w-auto sm:justify-start\">\n <Tooltip label={`Back ${skipSeconds}s · J`}>\n <IconButton icon=\"rotate-ccw\" label={`Back ${skipSeconds} seconds`} disabled={!ready} onClick={() => skip(-skipSeconds)} />\n </Tooltip>\n <Tooltip label={isPlaying ? \"Pause · K\" : \"Play · K\"}>\n <IconButton icon={isPlaying ? \"pause\" : \"play\"} variant=\"primary\" label={isPlaying ? \"Pause\" : \"Play\"} disabled={!ready} onClick={toggle} />\n </Tooltip>\n <Tooltip label={`Forward ${skipSeconds}s · L`}>\n <IconButton icon=\"rotate-cw\" label={`Forward ${skipSeconds} seconds`} disabled={!ready} onClick={() => skip(skipSeconds)} />\n </Tooltip>\n </div>\n\n <p className=\"px-1 text-sm whitespace-nowrap text-muted-foreground tabular-nums sm:ml-1\">\n <span className=\"text-foreground\">{formatTime(currentTime)}</span>\n <span aria-hidden=\"true\"> / </span>\n <span className=\"sr-only\"> of </span>\n {ready || knownDuration ? formatTime(duration) : \"–:––\"}\n </p>\n\n <div className=\"ml-auto flex items-center gap-1\">\n {onAddMarker ? (\n <Tooltip label=\"Add a marker here · M\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"flag\" disabled={!ready} onClick={addMarker}>\n Marker\n </Button>\n </Tooltip>\n ) : null}\n <Menu\n label={`Playback speed, ${formatRate(rate)}`}\n trigger={<span className=\"px-1 text-sm font-medium tabular-nums\">{formatRate(rate)}</span>}\n items={[{ type: \"radio\", label: \"Speed\", value: String(rate), onValueChange: (v) => changeRate(Number(v)), options: rates.map((r) => ({ value: String(r), label: r === 1 ? \"Normal\" : formatRate(r) })) }]}\n />\n <Tooltip label={muted ? \"Unmute\" : \"Mute\"}>\n <IconButton icon={muted ? \"volume-x\" : \"volume\"} label={muted ? \"Unmute\" : \"Mute\"} size=\"sm\" onClick={toggleMute} aria-pressed={muted} />\n </Tooltip>\n </div>\n </div>\n </div>\n );\n}\n\nfunction Header({ title, description }) {\n if (!title && !description) return null;\n return (\n <div className=\"min-w-0\">\n {title ? <h3 className=\"truncate text-sm font-medium text-foreground\">{title}</h3> : null}\n {description ? <p className=\"mt-0.5 truncate text-xs text-muted-foreground\">{description}</p> : null}\n </div>\n );\n}\n"
26
+ },
27
+ {
28
+ "path": "registry/blocks/audio-player/wavesurfer.js",
29
+ "type": "registry:component",
30
+ "target": "@components/makersclaw/blocks/audio-player/wavesurfer.js",
31
+ "content": "// The one door to wavesurfer.js (BSD-3-Clause) and its React hook\n// (@wavesurfer/react, BSD-3-Clause). Every file in this block reaches the\n// library through here, and the blocks built on this one (call-review…)\n// reach it through AudioPlayer — never the packages directly.\n//\n// No shim is needed: both packages load on the server without touching\n// `window` (checked in node); the instance is only created in an effect,\n// so nothing runs until the browser.\nexport { useWavesurfer } from \"@wavesurfer/react\";\n"
32
+ },
33
+ {
34
+ "path": "registry/blocks/audio-player/colors.js",
35
+ "type": "registry:component",
36
+ "target": "@components/makersclaw/blocks/audio-player/colors.js",
37
+ "content": "// The waveform's colours are the workspace's tokens.\n//\n// wavesurfer.js paints a <canvas>, and a canvas needs a concrete colour —\n// it cannot read var(--color-primary). So the colours are read from the\n// CSS variables in effect where the player sits, at run time, and read\n// again when the theme or the scheme changes (the player watches for\n// that). No colour value is written in this file: every one comes from\n// the page. Same method as sketch-canvas's palette.js.\n//\n// Everything but readTokens() is a plain function, so it runs in node.\n\nconst byte = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, \"0\");\n\n/** [r, g, b, a(0-255)] → \"#rrggbb\", or \"#rrggbbaa\" when not opaque. */\nexport function toHex([r, g, b, a = 255]) {\n return \"#\" + byte(r) + byte(g) + byte(b) + (a >= 255 ? \"\" : byte(a));\n}\n\n/** The same colour at `alpha` (0-1) of its own opacity. */\nexport function withAlpha(rgba, alpha) {\n if (!rgba) return null;\n const [r, g, b, a = 255] = rgba;\n return toHex([r, g, b, a * Math.max(0, Math.min(1, alpha))]);\n}\n\n/** The waveform's colours from the slots read off the page. */\nexport function waveColours(slots) {\n const fg = slots?.foreground;\n const muted = slots?.[\"muted-foreground\"] ?? fg;\n const primary = slots?.primary ?? fg;\n return {\n waveColor: withAlpha(muted, 0.55),\n progressColor: primary ? toHex(primary) : null,\n cursorColor: primary ? toHex(primary) : null,\n };\n}\n\n// ---- the browser half -------------------------------------------------------\n\n/** Read `slots` (token names: \"primary\", \"muted-foreground\"…) from the CSS\n * variables in effect at `root`, as [r, g, b, a] bytes. A probe takes\n * `color: var(--color-<slot>)`; the browser resolves var(), color-mix()\n * and light-dark() into its computed colour, and a 1×1 canvas turns that\n * into bytes whatever syntax it came back in. */\nexport function readTokens(root, slots) {\n const out = {};\n if (typeof document === \"undefined\" || !root) return out;\n const probe = document.createElement(\"span\");\n probe.setAttribute(\"aria-hidden\", \"true\");\n probe.style.position = \"absolute\";\n probe.style.visibility = \"hidden\";\n probe.style.pointerEvents = \"none\";\n root.appendChild(probe);\n const canvas = document.createElement(\"canvas\");\n canvas.width = 1;\n canvas.height = 1;\n const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n try {\n for (const slot of slots) {\n probe.style.color = \"\";\n probe.style.color = `var(--color-${slot})`;\n const computed = getComputedStyle(probe).color;\n if (!computed || !ctx) continue;\n ctx.clearRect(0, 0, 1, 1);\n ctx.fillStyle = \"transparent\";\n ctx.fillStyle = computed;\n ctx.fillRect(0, 0, 1, 1);\n const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;\n if (a === 0) continue;\n out[slot] = [r, g, b, a];\n }\n } finally {\n probe.remove();\n }\n return out;\n}\n\n/** Call `fn` (soon, once) whenever something that sets the tokens may have\n * changed: a data-theme / class / style on <html> or the nearest themed\n * ancestor, a stylesheet added to <head>, the OS scheme. Returns the\n * cleanup. */\nexport function watchTheme(root, fn) {\n if (typeof document === \"undefined\") return () => {};\n let t = 0;\n const soon = () => {\n clearTimeout(t);\n t = setTimeout(fn, 30);\n };\n const obs = new MutationObserver((records) => {\n for (const r of records) for (const n of r.addedNodes ?? []) if (n.tagName === \"LINK\") n.addEventListener(\"load\", soon, { once: true });\n soon();\n });\n const attrs = { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] };\n obs.observe(document.documentElement, attrs);\n obs.observe(document.head, { childList: true });\n const scoped = root?.closest?.(\"[data-theme]\");\n if (scoped && scoped !== document.documentElement) {\n obs.observe(scoped, attrs);\n obs.observe(scoped, { childList: true });\n }\n const mq = typeof matchMedia !== \"undefined\" ? matchMedia(\"(prefers-color-scheme: dark)\") : null;\n mq?.addEventListener(\"change\", soon);\n soon();\n return () => {\n clearTimeout(t);\n obs.disconnect();\n mq?.removeEventListener(\"change\", soon);\n };\n}\n"
38
+ },
39
+ {
40
+ "path": "registry/blocks/audio-player/time.js",
41
+ "type": "registry:component",
42
+ "target": "@components/makersclaw/blocks/audio-player/time.js",
43
+ "content": "// Plain helpers for the player: time labels, where a moment falls, the\n// tones a region or a marker can wear. No DOM, so they run anywhere (a\n// server surface formatting a timestamp, a transcript lining up with the\n// audio).\n\n/** Seconds → \"0:07\", \"12:03\", \"1:02:09\". Negative and NaN read as 0. */\nexport function formatTime(seconds) {\n const s = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;\n const h = Math.floor(s / 3600);\n const m = Math.floor((s % 3600) / 60);\n const r = String(s % 60).padStart(2, \"0\");\n return h ? `${h}:${String(m).padStart(2, \"0\")}:${r}` : `${m}:${r}`;\n}\n\n/** \"0:14–0:26\" */\nexport const formatRange = (start, end) => `${formatTime(start)}–${formatTime(end)}`;\n\n/** \"1 minute 24 seconds\" — what a screen reader says for a time. */\nexport function spokenTime(seconds) {\n const s = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;\n const h = Math.floor(s / 3600);\n const m = Math.floor((s % 3600) / 60);\n const r = s % 60;\n const part = (n, w) => (n ? `${n} ${w}${n === 1 ? \"\" : \"s\"}` : \"\");\n return [part(h, \"hour\"), part(m, \"minute\"), part(r, \"second\")].filter(Boolean).join(\" \") || \"0 seconds\";\n}\n\nexport const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));\n\n/** The region playing at `time`: the one that started last among those\n * that hold it (so a short region inside a long one wins). Null if none. */\nexport function regionAt(regions, time) {\n let hit = null;\n for (const r of regions ?? []) {\n if (!(time >= r.start && time < r.end)) continue;\n if (!hit || r.start >= hit.start) hit = r;\n }\n return hit;\n}\n\n/** Regions and markers as given, cleaned: numbers, end after start,\n * inside the audio when its length is known, sorted by time. */\nexport function cleanRegions(regions, duration) {\n const max = duration > 0 ? duration : Infinity;\n return (regions ?? [])\n .filter((r) => r && Number.isFinite(+r.start) && Number.isFinite(+r.end))\n .map((r) => ({ ...r, start: clamp(+r.start, 0, max), end: clamp(+r.end, 0, max) }))\n .filter((r) => r.end > r.start)\n .sort((a, b) => a.start - b.start);\n}\n\nexport function cleanMarkers(markers, duration) {\n const max = duration > 0 ? duration : Infinity;\n return (markers ?? [])\n .filter((m) => m && Number.isFinite(+m.time))\n .map((m) => ({ ...m, time: clamp(+m.time, 0, max) }))\n .sort((a, b) => a.time - b.time);\n}\n\n/** The tones a region or a marker can wear: theme tokens, never colours. */\nexport const TONES = [\"primary\", \"chart-2\", \"chart-3\", \"chart-4\", \"chart-5\", \"success\", \"warning\", \"info\", \"destructive\", \"muted\"];\n\n/** The default playback speeds. */\nexport const RATES = [0.75, 1, 1.25, 1.5, 2];\n\n/** \"1.25×\" */\nexport const formatRate = (r) => `${r}×`;\n"
44
+ },
45
+ {
46
+ "path": "registry/blocks/audio-player/README.md",
47
+ "type": "registry:file",
48
+ "target": "@components/makersclaw/blocks/audio-player/README.md",
49
+ "content": "# AudioPlayer\n\nA waveform audio player in the workspace theme. Play, skip back and\nforward 15 seconds, change speed, mute. Hover the waveform to read the\ntime; click it to jump; drag across it to mark a region. **Regions** are\nspans of the audio in a theme tone — a topic, a speaker turn, a clip —\ntinted on the waveform and labelled in a lane under it; click a label to\nplay just that span. **Markers** are moments — a comment, a flag — shown\nas pins above the waveform; click one to jump there. \"Marker\" adds one at\nthe playhead. It loads with a skeleton waveform, says so plainly when a\nfile will not play, and shows a friendly empty state when there is no\naudio yet.\n\nIt is [wavesurfer.js](https://wavesurfer.xyz) (BSD-3-Clause) underneath:\nit decodes, plays and paints the waveform. Regions, markers, the hover\nline, the controls and every state are ours, in the kit. The blocks built\non this one — `call-review` — import it, never `wavesurfer.js`.\n\n```jsx\nimport { AudioPlayer, useAudioPlayer } from \"../components/blocks/audio-player\"; // a Makersclaw app (\"./blocks/audio-player\" from components/)\nimport { AudioPlayer, useAudioPlayer } from \"@/components/makersclaw/blocks/audio-player\"; // a shadcn project\n```\n\n`AudioPlayer` is a client component: render it from a `\"use client\"`\ncomponent. `formatTime`, `formatRange`, `spokenTime` and `regionAt` are\nplain functions and work on the server.\n\n`makersclaw add audio-player` adds `wavesurfer.js` and\n`@wavesurfer/react` (pinned, exact) to package.json: run `npm install`\nafter. Both load with the page (no `import()`); the player itself starts\nin the browser, and until then the frame shows a skeleton waveform.\n\n## The data\n\nTimes are **seconds** from the start of the audio (numbers, fractions\nallowed).\n\n```js\n// a region: a span of the audio\n{ id: \"pricing\", start: 12.4, end: 25.6, label: \"Pricing and SSO\", tone: \"chart-3\" }\n\n// a marker: a moment\n{ id: \"m1\", time: 17.2, label: \"Asked whether SSO is on every plan\", tone: \"warning\" }\n```\n\n- `id` — unique within its list; it comes back to you in the callbacks.\n- `label` — shown in the lane (a region) or the tooltip (both), and read\n to a screen reader. Keep it short.\n- `tone` — a theme token, never a colour: `primary` (the default),\n `chart-2` … `chart-5`, `success`, `warning`, `info`, `destructive`,\n `muted`. `TONES` lists them. Use the `chart-*` tones for kinds (topics,\n speakers) and the status tones for meaning (a risk, a win).\n- Any other keys ride along untouched (a comment's author, a transcript\n line's id…) and come back in `onRegionClick`, `onMarkerClick` and\n `onActiveRegionChange`.\n- Regions may overlap; where they do, the one that started last is the\n active one. Anything past the end of the audio is clipped to it.\n\n## Props\n\n- `src` — the audio: a URL (`https://…`, `/api/recordings/123`, a\n `data:` URL) or a `Blob` / `File` (from an upload, a recording). A Blob\n plays through an object URL the player makes and revokes. Nothing\n (`null`) shows the empty state.\n- `peaks` — optional precomputed waveform (`[Float32Array | number[]]`,\n one per channel) with `duration`: the waveform draws at once without\n decoding the file — use it for long calls. Keep the array's identity\n stable (state or `useMemo`); a new array rebuilds the player.\n- `duration` — seconds, with `peaks`. Otherwise it is read from the file.\n- `title`, `description` — the heading over the waveform and the muted\n line under it (\"Discovery call · Northwind\", \"Maya and Leo · Sep 18\").\n- `regions` — `[{ id, start, end, label?, tone? }]` (default none).\n- `markers` — `[{ id, time, label?, tone? }]` (default none).\n- `onAddMarker({ time })` — shows the \"Marker\" button (and the M key); the\n new pin appears at once, dimmed, while you save. Add the marker to\n `markers` and return `{ ok: true }`; return `{ ok: false, error }` (or\n throw) and the pin goes, with a notice that says why. Without it there\n is no button.\n- `onRegionCreate({ start, end })` — makes a drag across the waveform\n mark a region (a crosshair cursor, the span and its times shown while\n dragging; shorter than half a second counts as a click). Same promise\n as `onAddMarker`: add it to `regions`, return `{ ok }`. Without it a\n drag scrubs.\n- `onMarkerClick(marker)` — after the jump to it.\n- `onRegionClick(region)` — after it starts playing (it stops at its end).\n- `onActiveRegionChange(region | null)` — once each time the region under\n the playhead changes (a transcript highlighting the line being said).\n- `onTimeUpdate(seconds)` — continuously while playing (every frame):\n keep it cheap.\n- `onSeek(seconds)` — every jump: a click, a key, a skip, a pin, the handle.\n- `onReady({ duration })`, `onPlay()`, `onPause()`, `onFinish()`,\n `onError(error)` — as named.\n- `rates` — the speeds offered, default `[0.75, 1, 1.25, 1.5, 2]`.\n- `defaultRate` — the starting speed, default `1`.\n- `skipSeconds` — the skip buttons' step (and J / L, Page Up / Down),\n default `15`.\n- `emptyTitle`, `emptyBody`, `emptyAction` — the empty state's words and\n one action (a node: an upload button). Defaults: \"No recording yet\",\n \"When there is audio to play, it shows up here.\", none.\n- `bare` — no card around it (no border, background or padding), for a\n player inside your own card or row. Default `false`.\n- `playerRef` — a ref from `useAudioPlayer()` for the handle below.\n- `className` — on the outer element.\n\n### The handle\n\n```jsx\nconst player = useAudioPlayer();\n<AudioPlayer src={url} playerRef={player} />;\nplayer.current?.seek(84);\n```\n\n`play()`, `pause()`, `toggle()`, `seek(seconds)`, `skip(±seconds)`,\n`playRange(start, end?)` (stops at `end`), `playRegion(id)`,\n`setRate(n)`, `setMuted(bool)`, `addMarker()` (as the button),\n`getTime()`, `getDuration()`, `isPlaying()`, `isReady()`. Calls before\nthe audio is ready do nothing.\n\n### Keys\n\nOn the waveform (a slider, one Tab stop): ← / → 5 seconds, Page Down /\nPage Up `skipSeconds`, Home / End, Space or Enter to play or pause.\nAnywhere in the player (not while typing, not with ⌘ / Ctrl / Alt): K\nplay / pause, J / L back / forward `skipSeconds`, M add a marker. Pins\nand region labels are buttons in Tab order.\n\n## Example\n\nA call recording with its topics and comments, saved with\n`lib/actions.js` in a Makersclaw app.\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { insertRow } from \"../lib/actions\";\nimport { AudioPlayer } from \"./blocks/audio-player\";\n\nexport default function CallRecording({ call, topics, comments: initial }) {\n const [comments, setComments] = useState(initial);\n return (\n <AudioPlayer\n src={call.recording_url}\n title={call.title}\n description={`${call.participants} · ${call.date}`}\n regions={topics.map((t) => ({ id: t.id, start: t.start_s, end: t.end_s, label: t.name, tone: \"chart-2\" }))}\n markers={comments.map((c) => ({ id: c.id, time: c.at_s, label: c.body, tone: \"primary\" }))}\n onAddMarker={async ({ time }) => {\n const values = { call_id: call.id, at_s: time, body: \"New comment\" };\n const res = await insertRow({ source: \"public.call_comments\", values });\n if (res.ok) setComments((cs) => [...cs, { ...values, id: res.id }]);\n return res;\n }}\n />\n );\n}\n```\n\n## What it will not do\n\n- Record from the microphone, or play video.\n- Zoom or scroll the waveform: the whole recording always fits the\n width (a long call reads as an overview; use `peaks` so it draws fast).\n- Move, resize, rename or delete a region, or edit a marker, inside the\n player: those are your list's actions — change `regions` / `markers`.\n- Show a volume slider (mute only; the device's volume does the rest).\n- Stream: the file is fetched and decoded in the browser (pass `peaks`\n and `duration` for long files).\n"
50
+ }
51
+ ],
52
+ "categories": [
53
+ "audio",
54
+ "player",
55
+ "waveform",
56
+ "wavesurfer",
57
+ "recording",
58
+ "call",
59
+ "voice note",
60
+ "podcast",
61
+ "regions",
62
+ "markers",
63
+ "playback",
64
+ "speed",
65
+ "transcript"
66
+ ],
67
+ "docs": "Installed to components/makersclaw/blocks/audio-player. Import: import { AudioPlayer, useAudioPlayer, formatTime, formatRange, spokenTime, regionAt, TONES, RATES } from \"@/components/makersclaw/blocks/audio-player\". Props and an example: components/makersclaw/blocks/audio-player/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
68
+ "meta": {
69
+ "makersclaw": {
70
+ "version": "0.3.1",
71
+ "useWhen": "A screen plays a recording: a call or meeting recording, a voice note, a podcast clip, a voicemail, a sound file on a record. The call-review block is built on it. Not for video, not for recording audio from the microphone."
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "auth-form",
4
+ "type": "registry:block",
5
+ "title": "AuthForm",
6
+ "description": "The account screens in one form: sign in (password, email only, or provider buttons for Google, GitHub, Apple and Microsoft), sign up with extra fields, a magic link, a reset link, and changing a password against a live rules checklist. The auth is yours: each submit is an async callback that returns { ok } or an error the form shows.",
7
+ "registryDependencies": [
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.3.1/r/core.json"
9
+ ],
10
+ "dependencies": [],
11
+ "files": [
12
+ {
13
+ "path": "registry/blocks/auth-form/index.js",
14
+ "type": "registry:component",
15
+ "target": "@components/makersclaw/blocks/auth-form/index.js",
16
+ "content": "// The AuthForm block — `makersclaw add auth-form`. Fixed: compose it\n// through its props (README.md beside this file).\nexport { AuthForm, PASSWORD_RULES } from \"./auth-form\";\n"
17
+ },
18
+ {
19
+ "path": "registry/blocks/auth-form/auth-form.jsx",
20
+ "type": "registry:component",
21
+ "target": "@components/makersclaw/blocks/auth-form/auth-form.jsx",
22
+ "content": "\"use client\";\n\nimport { useId, useState } from \"react\";\nimport { Button, Card, Checkbox, Field, Icon, Input, Notice, PasswordInput, Select, Separator, cx } from \"../../ui\";\n\n// AuthForm — the screens around an account: sign in, sign up, a magic\n// link, a reset link, and changing a password. It draws the form and\n// holds its state; the auth itself is the caller's. Every submit goes out\n// through an async callback that returns { ok } or { ok: false, error },\n// and the form shows the error. See README.md beside this file.\n\nconst PROVIDERS = {\n google: { label: \"Google\", icon: \"google\" },\n github: { label: \"GitHub\", icon: \"github\" },\n apple: { label: \"Apple\", icon: \"apple\" },\n microsoft: { label: \"Microsoft\", icon: \"microsoft\" },\n sso: { label: \"single sign-on (SSO)\", icon: \"key\" },\n};\n\n/** The default rules a new password is checked against. */\nexport const PASSWORD_RULES = [\n { label: \"At least 8 characters\", test: (v) => v.length >= 8 },\n { label: \"An uppercase letter (A–Z)\", test: (v) => /[A-Z]/.test(v) },\n { label: \"A lowercase letter (a–z)\", test: (v) => /[a-z]/.test(v) },\n { label: \"A number (0–9)\", test: (v) => /\\d/.test(v) },\n { label: \"A symbol, like ! ? # or %\", test: (v) => /[^A-Za-z0-9\\s]/.test(v) },\n];\n\nconst COPY = {\n \"sign-in\": {\n title: \"Sign in\",\n description: \"Welcome back. Enter your details to continue.\",\n submit: \"Sign in\",\n verb: \"Sign in with\",\n },\n \"sign-up\": {\n title: \"Create your account\",\n description: \"Start with your name and work email.\",\n submit: \"Create account\",\n verb: \"Sign up with\",\n },\n \"magic-link\": {\n title: \"Sign in with a link\",\n description: \"We email you a link that signs you in. No password to remember.\",\n submit: \"Email me a link\",\n verb: \"Continue with\",\n },\n reset: {\n title: \"Reset your password\",\n description: \"Enter the email you signed up with and we send you a link to choose a new one.\",\n submit: \"Send reset link\",\n verb: \"Continue with\",\n },\n \"change-password\": {\n title: \"Change password\",\n description: \"Choose a strong password you do not use anywhere else.\",\n submit: \"Update password\",\n verb: \"Continue with\",\n },\n};\n\nconst MODES = Object.keys(COPY);\n\nconst DEFAULT_FIELDS = [{ name: \"name\", label: \"Full name\", autoComplete: \"name\", required: true }];\n\nfunction providerOf(p) {\n const id = typeof p === \"string\" ? p : p.id;\n const known = PROVIDERS[id] ?? {};\n return { id, label: (typeof p === \"object\" && p.label) || known.label || id, icon: (typeof p === \"object\" && p.icon) || known.icon || \"log-in\" };\n}\n\n// A link to another screen: an href (a route) or a function (switch in place).\nfunction Go({ to, children }) {\n if (!to) return null;\n return typeof to === \"function\" ? (\n <Button variant=\"link\" size=\"inherit\" onClick={to}>\n {children}\n </Button>\n ) : (\n <Button variant=\"link\" size=\"inherit\" href={to}>\n {children}\n </Button>\n );\n}\n\nfunction failure(result) {\n return result && result.ok === false ? result.error || \"That did not work. Try again.\" : null;\n}\n\nasync function attempt(fn) {\n try {\n return failure(await fn());\n } catch (e) {\n return e?.message || String(e);\n }\n}\n\n/** The password rules as a live checklist: met rules turn on as you type. */\nfunction RuleList({ rules, value, id }) {\n return (\n <ul id={id} className=\"grid gap-1.5\" aria-label=\"Password rules\">\n {rules.map((rule) => {\n const met = Boolean(rule.test(value));\n return (\n <li key={rule.label} className={cx(\"flex items-center gap-2 text-sm\", met ? \"text-foreground\" : \"text-muted-foreground\")}>\n <Icon name={met ? \"circle-check\" : \"circle\"} size={16} className={cx(\"shrink-0\", met ? \"text-success\" : \"text-muted-foreground\")} />\n <span>{rule.label}</span>\n <span className=\"sr-only\">{met ? \"(met)\" : \"(not met yet)\"}</span>\n </li>\n );\n })}\n </ul>\n );\n}\n\nexport function AuthForm({\n mode = \"sign-in\",\n onSubmit,\n providers = [],\n onProvider,\n providersFirst = false,\n password = true,\n remember = false,\n fields = DEFAULT_FIELDS,\n confirm = true,\n rules,\n requireCurrent = true,\n acceptTerms = false,\n newsletter,\n links = {},\n onCancel,\n logo,\n title,\n description,\n submitLabel,\n layout = \"card\",\n defaultEmail = \"\",\n tips,\n className,\n}) {\n const m = MODES.includes(mode) ? mode : \"sign-in\";\n const copy = COPY[m];\n const uid = useId();\n const idOf = (name) => `${uid}-${name}`;\n\n const signUp = m === \"sign-up\";\n const change = m === \"change-password\";\n const emailOnly = m === \"magic-link\" || m === \"reset\";\n const askPassword = (m === \"sign-in\" && password) || signUp;\n const newRules = rules === false ? [] : rules ?? (change ? PASSWORD_RULES : []);\n const extra = signUp ? fields ?? [] : [];\n\n const [values, setValues] = useState(() => ({ email: defaultEmail, remember: false, terms: false, newsletter: false }));\n const [errors, setErrors] = useState({});\n const [error, setError] = useState(null);\n const [busy, setBusy] = useState(null); // \"submit\" or a provider id\n const [sent, setSent] = useState(null); // the email a link went to\n const [done, setDone] = useState(false);\n\n const set = (name) => (e) => {\n const v = e?.target ? (e.target.type === \"checkbox\" ? e.target.checked : e.target.value) : e;\n setValues((s) => ({ ...s, [name]: v }));\n if (errors[name]) setErrors((s) => ({ ...s, [name]: undefined }));\n if (done) setDone(false);\n };\n\n const newPassword = String((change ? values.newPassword : values.password) ?? \"\");\n const choosing = signUp || change; // a new password is being chosen\n\n async function submit(e) {\n e.preventDefault();\n if (busy) return;\n setError(null);\n const next = {};\n if (choosing && newRules.length && !newRules.every((r) => r.test(newPassword))) {\n next[change ? \"newPassword\" : \"password\"] = \"Meet every rule in the list.\";\n }\n if (choosing && confirm && values.confirm !== newPassword) next.confirm = \"The two passwords do not match.\";\n if (change && requireCurrent && values.currentPassword && values.currentPassword === newPassword) {\n next.newPassword = \"Choose a password different from the current one.\";\n }\n if (signUp && acceptTerms && !values.terms) next.terms = \"Agree to the terms to continue.\";\n setErrors(next);\n if (Object.values(next).some(Boolean)) return;\n\n const payload = {};\n if (!change) payload.email = values.email.trim();\n if (askPassword) payload.password = values.password ?? \"\";\n if (m === \"sign-in\" && remember) payload.remember = Boolean(values.remember);\n for (const f of extra) payload[f.name] = values[f.name] ?? (f.type === \"select\" ? f.options?.[0]?.value ?? \"\" : \"\");\n if (signUp && newsletter) payload.newsletter = Boolean(values.newsletter);\n if (signUp && acceptTerms) payload.terms = true;\n if (change) {\n if (requireCurrent) payload.currentPassword = values.currentPassword ?? \"\";\n payload.newPassword = newPassword;\n }\n\n setBusy(\"submit\");\n const failed = await attempt(() => onSubmit?.(payload, m));\n setBusy(null);\n if (failed) {\n setError(failed);\n return;\n }\n if (emailOnly) setSent(payload.email);\n if (change) {\n setDone(true);\n setValues((s) => ({ ...s, currentPassword: \"\", newPassword: \"\", confirm: \"\" }));\n }\n }\n\n async function provider(id) {\n if (busy) return;\n setError(null);\n setBusy(id);\n const failed = await attempt(() => onProvider?.(id));\n setBusy(null);\n if (failed) setError(failed);\n }\n\n const heading = title ?? copy.title;\n const lede = description === undefined ? copy.description : description;\n const list = (providers ?? []).filter(Boolean).map(providerOf);\n const paired = providersFirst && list.length === 2;\n\n const providerButtons = list.length ? (\n <div className={cx(\"grid gap-2\", paired && \"sm:grid-cols-2\")}>\n {list.map((p) => (\n <Button\n key={p.id}\n variant=\"secondary\"\n icon={p.icon}\n loading={busy === p.id}\n disabled={Boolean(busy) && busy !== p.id}\n aria-label={paired ? `${copy.verb} ${p.label}` : undefined}\n onClick={() => provider(p.id)}\n >\n {paired ? p.label : `${copy.verb} ${p.label}`}\n </Button>\n ))}\n </div>\n ) : null;\n\n const disabled = Boolean(busy);\n\n // The screen a link-sending mode shows once the link is on its way.\n if (sent) {\n const body = (\n <div className=\"grid justify-items-center gap-3 text-center\">\n {logo ? <div className=\"mb-1 flex\">{logo}</div> : null}\n <span className=\"grid size-12 place-items-center rounded-full bg-muted text-foreground\">\n <Icon name=\"mail\" size={22} />\n </span>\n <h2 className=\"text-xl font-semibold\">Check your email</h2>\n <p className=\"text-sm text-muted-foreground\">\n {m === \"reset\" ? \"If an account exists for \" : \"We sent a sign-in link to \"}\n <span className=\"font-medium text-foreground\">{sent}</span>\n {m === \"reset\" ? \", a link to reset the password is on its way.\" : \". It works once and expires soon.\"}\n </p>\n <Button variant=\"secondary\" className=\"mt-2 w-full\" onClick={() => setSent(null)}>\n Use a different email\n </Button>\n {links[\"sign-in\"] ? (\n <p className=\"text-sm text-muted-foreground\">\n <Go to={links[\"sign-in\"]}>Back to sign in</Go>\n </p>\n ) : null}\n </div>\n );\n return <Frame layout={layout} className={className}>{body}</Frame>;\n }\n\n const forgot = m === \"sign-in\" && askPassword && links.reset ? <Go to={links.reset}>Forgot password?</Go> : null;\n const passwordRule = choosing && newRules.length ? idOf(\"rules\") : undefined;\n\n const form = (\n <form onSubmit={submit} className=\"grid gap-4\" aria-describedby={error ? idOf(\"error\") : undefined}>\n {extra.length ? (\n <div className=\"grid gap-4 sm:grid-cols-2\">\n {extra.map((f) => (\n <Field key={f.name} id={idOf(f.name)} label={f.label} hint={f.hint} required={f.required} error={errors[f.name]} className={f.half ? \"\" : \"sm:col-span-2\"}>\n {f.type === \"select\" ? (\n <Select id={idOf(f.name)} options={f.options ?? []} value={values[f.name] ?? f.options?.[0]?.value ?? \"\"} onChange={set(f.name)} disabled={disabled} required={f.required} />\n ) : (\n <Input\n id={idOf(f.name)}\n type={f.type ?? \"text\"}\n autoComplete={f.autoComplete}\n placeholder={f.placeholder}\n required={f.required}\n value={values[f.name] ?? \"\"}\n onChange={set(f.name)}\n disabled={disabled}\n />\n )}\n </Field>\n ))}\n </div>\n ) : null}\n\n {!change ? (\n <Field id={idOf(\"email\")} label=\"Email\" error={errors.email}>\n <Input\n id={idOf(\"email\")}\n type=\"email\"\n autoComplete={signUp ? \"email\" : \"username\"}\n inputMode=\"email\"\n placeholder=\"you@company.com\"\n required\n value={values.email}\n onChange={set(\"email\")}\n disabled={disabled}\n />\n </Field>\n ) : null}\n\n {change && requireCurrent ? (\n <Field id={idOf(\"currentPassword\")} label=\"Current password\" error={errors.currentPassword}>\n <PasswordInput id={idOf(\"currentPassword\")} required value={values.currentPassword ?? \"\"} onChange={set(\"currentPassword\")} disabled={disabled} />\n </Field>\n ) : null}\n\n {askPassword || change ? (\n <div className=\"grid gap-1.5\">\n {forgot ? (\n <div className=\"flex items-baseline justify-between gap-3\">\n <label htmlFor={idOf(\"password\")} className=\"text-sm font-medium text-foreground\">\n Password\n </label>\n <span className=\"text-sm\">{forgot}</span>\n </div>\n ) : null}\n <Field\n id={idOf(change ? \"newPassword\" : \"password\")}\n label={forgot ? undefined : change ? \"New password\" : \"Password\"}\n error={errors[change ? \"newPassword\" : \"password\"]}\n >\n <PasswordInput\n id={idOf(change ? \"newPassword\" : \"password\")}\n autoComplete={choosing ? \"new-password\" : \"current-password\"}\n required\n value={(change ? values.newPassword : values.password) ?? \"\"}\n onChange={set(change ? \"newPassword\" : \"password\")}\n aria-describedby={passwordRule}\n disabled={disabled}\n />\n </Field>\n </div>\n ) : null}\n\n {choosing && confirm ? (\n <Field id={idOf(\"confirm\")} label={change ? \"Confirm new password\" : \"Confirm password\"} error={errors.confirm}>\n <PasswordInput id={idOf(\"confirm\")} autoComplete=\"new-password\" required value={values.confirm ?? \"\"} onChange={set(\"confirm\")} disabled={disabled} />\n </Field>\n ) : null}\n\n {choosing && newRules.length && !change ? <RuleList id={passwordRule} rules={newRules} value={newPassword} /> : null}\n\n {m === \"sign-in\" && remember ? (\n <Checkbox id={idOf(\"remember\")} label={typeof remember === \"string\" ? remember : \"Remember me\"} checked={values.remember} onChange={set(\"remember\")} disabled={disabled} />\n ) : null}\n\n {signUp && (newsletter || acceptTerms) ? (\n <div className=\"grid\">\n {newsletter ? (\n <Checkbox id={idOf(\"newsletter\")} label={typeof newsletter === \"string\" ? newsletter : \"Send me product news, now and then\"} checked={values.newsletter} onChange={set(\"newsletter\")} disabled={disabled} />\n ) : null}\n {acceptTerms ? (\n <>\n <Checkbox\n id={idOf(\"terms\")}\n label={<TermsText links={links} lead=\"I agree to the\" />}\n checked={values.terms}\n onChange={set(\"terms\")}\n disabled={disabled}\n aria-invalid={errors.terms ? true : undefined}\n />\n {errors.terms ? (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n {errors.terms}\n </p>\n ) : null}\n </>\n ) : null}\n </div>\n ) : null}\n\n {error ? (\n <div id={idOf(\"error\")}>\n <Notice tone=\"danger\">{error}</Notice>\n </div>\n ) : null}\n {done ? <Notice tone=\"success\">Password updated. Use the new one next time you sign in.</Notice> : null}\n\n <div className={cx(change && onCancel ? \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\" : \"grid\")}>\n {change && onCancel ? (\n <Button variant=\"secondary\" onClick={onCancel} disabled={disabled}>\n Cancel\n </Button>\n ) : null}\n <Button type=\"submit\" variant=\"primary\" loading={busy === \"submit\"} disabled={disabled && busy !== \"submit\"}>\n {submitLabel ?? copy.submit}\n </Button>\n </div>\n </form>\n );\n\n const divider = list.length ? <Separator label={providersFirst ? \"or with email\" : \"or\"} /> : null;\n\n const footer = (\n <FooterLinks mode={m} links={links} acceptTerms={acceptTerms} />\n );\n\n const main = (\n <div className=\"grid gap-6\">\n <Head logo={logo} title={heading} description={lede} center={layout !== \"plain\"} />\n {providersFirst ? providerButtons : null}\n {providersFirst ? divider : null}\n {form}\n {!providersFirst ? divider : null}\n {!providersFirst ? providerButtons : null}\n {footer}\n </div>\n );\n\n if (change && newRules.length) {\n return (\n <Frame layout={layout} className={className} wide>\n <div className=\"grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,18rem)]\">\n <div className=\"grid content-start gap-6\">\n <Head logo={logo} title={heading} description={lede} icon=\"shield\" />\n {form}\n </div>\n <aside className=\"grid content-start gap-4 rounded-lg bg-muted p-4\" aria-label=\"Password requirements\">\n <div className=\"grid gap-1\">\n <h3 className=\"flex items-center gap-2 text-sm font-semibold\">\n <Icon name=\"shield-check\" size={16} className=\"text-muted-foreground\" />\n Password requirements\n </h3>\n <p className=\"text-sm text-muted-foreground\">Your new password needs every one of these.</p>\n </div>\n <RuleList id={passwordRule} rules={newRules} value={newPassword} />\n {tips?.length ? (\n <div className=\"grid gap-2 border-t border-border pt-4\">\n <h4 className=\"text-sm font-medium\">Good habits</h4>\n <ul className=\"grid list-disc gap-1 pl-5 text-sm text-muted-foreground\">\n {tips.map((t) => (\n <li key={t}>{t}</li>\n ))}\n </ul>\n </div>\n ) : null}\n </aside>\n </div>\n </Frame>\n );\n }\n\n return (\n <Frame layout={layout} className={className}>\n {main}\n </Frame>\n );\n}\n\nfunction Head({ logo, title, description, center, icon }) {\n return (\n <header className={cx(\"grid gap-2\", center && \"justify-items-center text-center\")}>\n {logo ? <div className=\"mb-1 flex\">{logo}</div> : null}\n {icon && !logo ? (\n <span className=\"mb-1 grid size-10 place-items-center rounded-md bg-muted text-foreground\">\n <Icon name={icon} size={20} />\n </span>\n ) : null}\n <h2 className=\"text-2xl font-semibold leading-tight tracking-tight\">{title}</h2>\n {description ? <p className=\"text-sm text-muted-foreground\">{description}</p> : null}\n </header>\n );\n}\n\nfunction TermsText({ links, lead }) {\n const terms = links.terms;\n const privacy = links.privacy;\n return (\n <span>\n {lead} {terms ? <Go to={terms}>Terms of Service</Go> : \"Terms of Service\"}\n {privacy ? (\n <>\n {\" \"}and <Go to={privacy}>Privacy Policy</Go>\n </>\n ) : null}\n </span>\n );\n}\n\nfunction FooterLinks({ mode, links, acceptTerms }) {\n const rows = [];\n if (mode === \"sign-in\" && links[\"sign-up\"]) {\n rows.push(\n <p key=\"up\">\n No account yet? <Go to={links[\"sign-up\"]}>Sign up</Go>\n </p>,\n );\n }\n if (mode === \"sign-in\" && links[\"magic-link\"]) {\n rows.push(\n <p key=\"ml\">\n <Go to={links[\"magic-link\"]}>Email me a sign-in link instead</Go>\n </p>,\n );\n }\n if (mode === \"magic-link\" && links[\"sign-in\"]) {\n rows.push(\n <p key=\"pw\">\n <Go to={links[\"sign-in\"]}>Sign in with a password instead</Go>\n </p>,\n );\n }\n if (mode === \"magic-link\" && links[\"sign-up\"]) {\n rows.push(\n <p key=\"up\">\n First time here? <Go to={links[\"sign-up\"]}>Create an account</Go>\n </p>,\n );\n }\n if (mode === \"sign-up\" && links[\"sign-in\"]) {\n rows.push(\n <p key=\"in\">\n Already have an account? <Go to={links[\"sign-in\"]}>Sign in</Go>\n </p>,\n );\n }\n if (mode === \"reset\" && links[\"sign-in\"]) {\n rows.push(\n <p key=\"in\">\n Remembered it? <Go to={links[\"sign-in\"]}>Back to sign in</Go>\n </p>,\n );\n }\n const legal = !acceptTerms && mode !== \"change-password\" && (links.terms || links.privacy);\n if (!rows.length && !legal) return null;\n return (\n <div className=\"grid gap-3 text-center text-sm text-muted-foreground\">\n {rows}\n {legal ? (\n <p className=\"text-xs\">\n <TermsText links={links} lead=\"By continuing, you agree to our\" />.\n </p>\n ) : null}\n </div>\n );\n}\n\n// Where the form sits: a card (the default), plain (in your own column),\n// or page (centred in the window, for a route of its own).\nfunction Frame({ layout, wide, className, children }) {\n const width = wide ? \"max-w-3xl\" : \"max-w-sm\";\n if (layout === \"plain\") return <div className={cx(\"w-full\", width, className)}>{children}</div>;\n const card = (\n <Card className={cx(\"w-full p-6 sm:p-8\", width, layout !== \"page\" && className)} padding=\"none\">\n {children}\n </Card>\n );\n if (layout === \"page\") {\n return <div className={cx(\"grid min-h-dvh w-full place-items-center px-4 py-10\", className)}>{card}</div>;\n }\n return card;\n}\n"
23
+ },
24
+ {
25
+ "path": "registry/blocks/auth-form/README.md",
26
+ "type": "registry:file",
27
+ "target": "@components/makersclaw/blocks/auth-form/README.md",
28
+ "content": "# AuthForm\n\nThe screens around an account, as one form: sign in, sign up, a magic\nlink, a password-reset link, and changing a password. It draws the\nfields, provider buttons, links and states, and holds what is typed. The\nauth is yours: each submit goes out through an async callback that\nreturns `{ ok: true }` or `{ ok: false, error }`, and the form shows the\nerror under the fields. It never signs anyone in by itself.\n\n```jsx\nimport { AuthForm } from \"../components/blocks/auth-form\"; // a Makersclaw app (\"./blocks/auth-form\" from components/)\nimport { AuthForm } from \"@/components/makersclaw/blocks/auth-form\"; // a shadcn project\n```\n\nA client component: render it from a `\"use client\"` component.\n\n## Modes\n\n- `sign-in` — email and password (or email only with `password={false}`),\n \"Forgot password?\" beside the password label when `links.reset` is set,\n optional \"Remember me\".\n- `sign-up` — your `fields` (default: full name), email, password and a\n confirm, optional news and terms checkboxes, optional password `rules`.\n- `magic-link` — email only. After `{ ok: true }` it shows \"Check your\n email\" with the address, and \"Use a different email\".\n- `reset` — email only; the same \"Check your email\" screen after it\n sends. It never says whether the account exists.\n- `change-password` — current, new and confirm, with the rules as a live\n checklist beside them (under them on a phone). On `{ ok: true }` the\n fields clear and a success line shows.\n\n## Props\n\n- `mode` — one of the modes above. Default `\"sign-in\"`.\n- `onSubmit(values, mode)` — async; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed, its message shown).\n `values` holds only what this mode asks: `email`, `password`,\n `remember`, each of your `fields` by `name`, `newsletter`, `terms`,\n `currentPassword`, `newPassword`. The form is busy (spinner, fields\n off) until it answers.\n- `providers` — buttons for other ways in: `\"google\"`, `\"github\"`,\n `\"apple\"`, `\"microsoft\"`, `\"sso\"`, or `{ id, label, icon }` for another\n one (`icon` a kit Icon name). Default none.\n- `onProvider(id)` — async, same `{ ok, error }` answer. Start your OAuth\n redirect here.\n- `providersFirst` — the buttons above the form (\"or with email\" under\n them) instead of under it. Two of them sit side by side from 640px.\n- `password` — `false`: sign in by email only. Default `true`.\n- `remember` — `true` (\"Remember me\") or your label (\"Remember me for 30\n days\"). Sign in only. Default off.\n- `fields` — sign up's extra fields, before the email:\n `[{ name, label, type?, options?, placeholder?, autoComplete?, required?, hint?, half? }]`.\n `type` is an input type or `\"select\"` (with `options: [{ value, label }]`);\n `half` puts two side by side from 640px. Default one field, `name`\n (\"Full name\").\n- `confirm` — ask for the new password twice. Default `true`.\n- `rules` — what a new password must meet: `[{ label, test(value) }]`.\n Default: none on sign up, `PASSWORD_RULES` on change password (8\n characters, upper, lower, a number, a symbol). `false` turns them off.\n Submit is held, with a message, until each one passes.\n- `requireCurrent` — change password asks for the current one. Default\n `true`; `false` for the page a reset link opens.\n- `acceptTerms` — sign up needs a ticked \"I agree to the Terms of\n Service and Privacy Policy\" box. Default off.\n- `newsletter` — sign up shows an opt-in box: `true`, or your label.\n- `links` — where the small links go, each an href or a function\n (switch screens in place): `\"sign-in\"`, `\"sign-up\"`, `reset`,\n `\"magic-link\"`, `terms`, `privacy`. A link shows only when it is given.\n With `terms` or `privacy` and no `acceptTerms`, a \"By continuing…\" line\n sits at the foot.\n- `onCancel` — change password gets a Cancel button.\n- `logo` — a node above the title (your mark).\n- `title`, `description` — override the mode's words. `description={null}`\n drops the line.\n- `submitLabel` — the main button's words.\n- `layout` — `card` (default, a 384px card) · `plain` (no card: your own\n column) · `page` (the card centred in the window, for a route of its\n own). Change password with rules is 768px wide.\n- `defaultEmail` — prefill the email.\n- `tips` — change password: short lines under the rules (\"Use a password\n manager\").\n- `className` — on the outer box.\n\n`PASSWORD_RULES` is exported too, to extend: `rules={[...PASSWORD_RULES, { label: \"Not your email\", test: (v) => !v.includes(\"@\") }]}`.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useRouter } from \"next/navigation\";\nimport { AuthForm } from \"../components/blocks/auth-form\";\n\nexport function SignIn({ signIn, startOAuth }) {\n const router = useRouter();\n return (\n <AuthForm\n mode=\"sign-in\"\n layout=\"page\"\n providers={[\"google\", \"github\"]}\n providersFirst\n remember\n links={{ \"sign-up\": \"/sign-up\", reset: \"/reset\", terms: \"/terms\", privacy: \"/privacy\" }}\n onSubmit={async ({ email, password, remember }) => {\n const r = await signIn(email, password, remember); // your server action\n if (r.ok) router.push(\"/\");\n return r; // { ok: false, error: \"Wrong email or password.\" } shows under the form\n }}\n onProvider={(id) => startOAuth(id)}\n />\n );\n}\n```\n\n## What it will not do\n\n- Authenticate. No session, no token, no OAuth: those are the callbacks'.\n- Rate-limit, add a CAPTCHA or hide whether an email is registered — the\n server does that (reset already says \"if an account exists\").\n- Send the email. `magic-link` and `reset` only show that it went.\n- Two-step codes: put the kit's `InputOTP` on the next screen.\n- Draw a colourful brand logo: provider marks are the kit's one-colour\n icons, in the text colour.\n\n## Credits\n\nPorted from blocks.so (https://blocks.so, login 01–09, MIT) and Creative Tim UI (https://github.com/creativetimofficial/ui, account-change-password-01, MIT), rewritten on the Makersclaw kit.\n"
29
+ }
30
+ ],
31
+ "categories": [
32
+ "auth",
33
+ "login",
34
+ "sign in",
35
+ "sign up",
36
+ "register",
37
+ "password",
38
+ "reset password",
39
+ "forgot password",
40
+ "change password",
41
+ "magic link",
42
+ "oauth",
43
+ "google",
44
+ "github",
45
+ "sso",
46
+ "account"
47
+ ],
48
+ "docs": "Installed to components/makersclaw/blocks/auth-form. Import: import { AuthForm, PASSWORD_RULES } from \"@/components/makersclaw/blocks/auth-form\". Props and an example: components/makersclaw/blocks/auth-form/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
49
+ "meta": {
50
+ "makersclaw": {
51
+ "version": "0.3.1",
52
+ "useWhen": "An app needs a sign-in, sign-up, forgot-password or change-password screen, or a settings page where a signed-in person changes their password."
53
+ }
54
+ }
55
+ }
package/r/board.json CHANGED
@@ -3,9 +3,9 @@
3
3
  "name": "board",
4
4
  "type": "registry:block",
5
5
  "title": "Board",
6
- "description": "Records as cards in columns, one per stage; drag a card to another column (mouse, touch, or the card's menu from the keyboard) and the stage is saved.",
6
+ "description": "Records as cards in columns, one per stage; drag a card to another column (mouse, touch, or the card's menu from the keyboard) and the stage is saved. Optional column icons, a per-column total (\"13pt\"), a category colour on each card's edge, and a stacked layout of folding sections with compact rows.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.2.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.3.1/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -19,13 +19,13 @@
19
19
  "path": "registry/blocks/board/board.jsx",
20
20
  "type": "registry:component",
21
21
  "target": "@components/makersclaw/blocks/board/board.jsx",
22
- "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { isSample } from \"../../parts\";\nimport { Button, Icon, Input, Menu, Notice, cx } from \"../../ui\";\n\n// Board — records as cards in columns, one column per stage, moved by\n// dragging or from the card's menu. The block owns the behaviour a board\n// gets wrong when it is drawn by hand: pointer dragging that works with a\n// mouse and on a phone, a keyboard path (the menu), the move shown at\n// once and put back with a reason when the write fails, examples that\n// move on screen and save nothing, the count in every header, and cards\n// no stage claims kept in view instead of lost. The surface owns what a\n// card says under its title (renderDetails) and how a move is saved\n// (onMove): the block imports nothing but React, the kit and the parts,\n// so it runs in a Makersclaw app and in any shadcn project alike.\n//\n// <Board rows={deals} groupBy=\"stage\" columns={STAGES}\n// onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}\n// titleOf={(d) => d.name} renderDetails={(d) => <DealFacts deal={d} />}\n// onOpen={setEditing} />\n//\n// README.md beside this file lists every prop.\n\nconst OTHER = \"__other\";\nconst DRAG_START_PX = { mouse: 5, pen: 6, touch: 8 };\n\nfunction humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"—\";\n}\n\nfunction normalizeColumns(columns) {\n return (columns ?? []).map((c) =>\n typeof c === \"string\" || typeof c === \"number\"\n ? { key: String(c), label: humanize(c) }\n : { ...c, key: String(c.key), label: c.label ?? humanize(c.key) },\n );\n}\n\nexport function Board({\n rows,\n groupBy,\n columns,\n idColumn = \"id\",\n onMove,\n renderDetails,\n titleOf = (row) => row.title ?? row.name ?? String(row[idColumn] ?? \"\"),\n metaOf,\n onOpen,\n actionsOf,\n onCreate,\n addLabel = \"Add\",\n createPlaceholder = \"Title, then Enter\",\n limits,\n label = \"Board\",\n emptyLabel = \"Nothing here yet\",\n otherLabel = \"No stage\",\n className,\n}) {\n const uid = useId();\n const boardRef = useRef(null);\n const dragRef = useRef(null);\n const suppressClick = useRef(false);\n const [overrides, setOverrides] = useState(() => new Map());\n const [drag, setDrag] = useState(null);\n const [error, setError] = useState(null);\n const [announce, setAnnounce] = useState(\"\");\n const [adding, setAdding] = useState(null);\n const [draft, setDraft] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [portal, setPortal] = useState(null);\n\n const movable = typeof onMove === \"function\";\n const list = rows ?? [];\n const keyOf = (row) => String(row?.[idColumn]);\n const cols = useMemo(() => normalizeColumns(columns), [columns]);\n const known = useMemo(() => new Set(cols.map((c) => c.key)), [cols]);\n const columnOf = (row) => {\n const moved = overrides.get(keyOf(row));\n const value = moved ?? (row?.[groupBy] == null ? \"\" : String(row[groupBy]));\n return known.has(value) ? value : OTHER;\n };\n\n useEffect(() => setPortal(document.body), []);\n\n // A move stays on screen until the rows the page re-reads carry it; then\n // the override has nothing left to say.\n useEffect(() => {\n setOverrides((current) => {\n if (current.size === 0) return current;\n const next = new Map(current);\n for (const [id, to] of current) {\n const row = list.find((r) => keyOf(r) === id);\n if (!row || String(row[groupBy]) === to) next.delete(id);\n }\n return next.size === current.size ? current : next;\n });\n // `list` changes identity with every read; that is the point.\n }, [rows, groupBy]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const byColumn = useMemo(() => {\n const out = new Map(cols.map((c) => [c.key, []]));\n out.set(OTHER, []);\n for (const row of list) out.get(columnOf(row)).push(row);\n return out;\n }, [list, cols, overrides]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const shown = byColumn.get(OTHER).length ? [...cols, { key: OTHER, label: otherLabel, other: true }] : cols;\n const labelOf = (key) => shown.find((c) => c.key === key)?.label ?? humanize(key);\n\n async function move(row, to) {\n const from = columnOf(row);\n if (!movable || to === from || to === OTHER || !known.has(to)) return;\n const id = keyOf(row);\n const title = titleOf(row);\n setError(null);\n setOverrides((m) => new Map(m).set(id, to));\n setAnnounce(`${title} moved to ${labelOf(to)}`);\n // An example moves on screen and saves nothing: it is not a row.\n if (isSample(row)) return;\n let result;\n try {\n result = await onMove(row, to, from === OTHER ? null : from);\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n if (result && result.ok === false) {\n setOverrides((m) => {\n const next = new Map(m);\n next.delete(id);\n return next;\n });\n setError(`“${title}” is back in ${labelOf(from)}: ${result.error ?? \"the move was not saved\"}`);\n setAnnounce(`${title} could not be moved`);\n }\n }\n\n // ---- pointer dragging ---------------------------------------------------\n // A mouse drags a card from anywhere on it; touch and pen drag from the\n // grip, so the column still scrolls under a finger.\n\n function onPointerDown(e, row) {\n if (!movable) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n const onGrip = Boolean(e.target.closest?.(\"[data-board-grip]\"));\n if (e.pointerType !== \"mouse\" && !onGrip) return;\n if (!onGrip && e.target.closest?.(\"input, textarea, select, a, [aria-haspopup], [role=menuitem]\")) return;\n // No text selection while a mouse drags; the click still lands.\n if (e.pointerType === \"mouse\") e.preventDefault();\n const card = e.currentTarget;\n const rect = card.getBoundingClientRect();\n dragRef.current = {\n row,\n id: keyOf(row),\n from: columnOf(row),\n pointerId: e.pointerId,\n type: e.pointerType,\n card,\n startX: e.clientX,\n startY: e.clientY,\n offsetX: e.clientX - rect.left,\n offsetY: e.clientY - rect.top,\n width: rect.width,\n active: false,\n over: null,\n x: e.clientX,\n y: e.clientY,\n };\n }\n\n function columnAt(x, y) {\n const el = document.elementFromPoint(x, y);\n const col = el?.closest?.(\"[data-board-column]\");\n return col && boardRef.current?.contains(col) ? col.getAttribute(\"data-board-column\") : null;\n }\n\n function onPointerMove(e) {\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n if (!d.active) {\n const far = Math.hypot(e.clientX - d.startX, e.clientY - d.startY) >= (DRAG_START_PX[d.type] ?? 5);\n if (!far) return;\n d.active = true;\n try {\n d.card.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is already gone; the move below still works\n }\n }\n e.preventDefault();\n d.x = e.clientX;\n d.y = e.clientY;\n d.over = columnAt(e.clientX, e.clientY);\n const board = boardRef.current;\n if (board) {\n const r = board.getBoundingClientRect();\n if (e.clientX < r.left + 48) board.scrollBy({ left: -18 });\n else if (e.clientX > r.right - 48) board.scrollBy({ left: 18 });\n }\n setDrag({ id: d.id, over: d.over, x: d.x, y: d.y, offsetX: d.offsetX, offsetY: d.offsetY, width: d.width, row: d.row });\n }\n\n function finish(e, drop) {\n const d = dragRef.current;\n if (!d || (e && e.pointerId !== d.pointerId)) return;\n dragRef.current = null;\n if (!d.active) return;\n suppressClick.current = true;\n setTimeout(() => {\n suppressClick.current = false;\n }, 0);\n setDrag(null);\n if (drop && d.over) move(d.row, d.over);\n }\n\n useEffect(() => {\n if (!drag) return undefined;\n const onKey = (e) => {\n if (e.key === \"Escape\") finish(null, false);\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [drag]); // eslint-disable-line react-hooks/exhaustive-deps\n\n async function create(e, column) {\n e.preventDefault();\n const title = draft.trim();\n if (!title || busy) return;\n setBusy(true);\n setError(null);\n let result;\n try {\n result = await onCreate(title, column);\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n setBusy(false);\n if (result && result.ok === false) setError(`“${title}” was not added: ${result.error ?? \"the write failed\"}`);\n else {\n setDraft(\"\");\n setAnnounce(`${title} added to ${labelOf(column)}`);\n }\n }\n\n function card(row, { ghost = false } = {}) {\n const title = titleOf(row);\n const body = (\n <>\n {onOpen ? (\n <button\n type=\"button\"\n onClick={() => onOpen(row)}\n className=\"block w-full rounded-sm text-left text-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring\"\n >\n {title}\n </button>\n ) : (\n <p className=\"text-sm font-medium\">{title}</p>\n )}\n {renderDetails ? (\n <div className=\"mt-2\">{renderDetails(row, { column: columnOf(row) })}</div>\n ) : metaOf ? (\n <div className=\"mt-1 text-xs text-muted-foreground\">{metaOf(row)}</div>\n ) : null}\n </>\n );\n const moveTo = movable\n ? shown\n .filter((c) => !c.other && c.key !== columnOf(row))\n .map((c) => ({ label: `Move to ${c.label}`, icon: \"arrow-right\", onSelect: () => move(row, c.key) }))\n : [];\n const items = [...(actionsOf?.(row) ?? []), ...moveTo];\n return (\n <div className=\"flex items-start gap-1\">\n {movable && !ghost ? (\n <span\n data-board-grip\n aria-hidden=\"true\"\n className=\"-ml-1 mt-0.5 hidden h-8 w-6 shrink-0 cursor-grab items-center justify-center text-muted-foreground [@media(hover:none)]:flex\"\n style={{ touchAction: \"none\" }}\n >\n <Icon name=\"grip\" size={16} />\n </span>\n ) : null}\n <div className=\"min-w-0 flex-1\">{body}</div>\n {!ghost ? <Menu items={items} label={`Actions for ${title}`} className=\"-mr-1 -mt-1 shrink-0\" /> : null}\n </div>\n );\n }\n\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {error ? (\n <Notice tone=\"danger\" onDismiss={() => setError(null)}>\n {error}\n </Notice>\n ) : null}\n <div\n ref={boardRef}\n role=\"region\"\n aria-label={label}\n tabIndex={0}\n className=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-lg pb-2 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:snap-none\"\n >\n {shown.map((col) => {\n const cards = byColumn.get(col.key) ?? [];\n const limit = limits?.[col.key];\n const over = typeof limit === \"number\" && cards.length > limit;\n const target = drag && drag.over === col.key && !col.other && columnOf(drag.row) !== col.key;\n const headingId = `${uid}-${col.key}`;\n return (\n <section\n key={col.key}\n data-board-column={col.other ? undefined : col.key}\n aria-labelledby={headingId}\n className={cx(\n \"flex max-h-[75dvh] w-[min(18rem,85vw)] shrink-0 snap-start flex-col rounded-lg border border-border bg-muted/50\",\n target && \"bg-accent ring-2 ring-ring\",\n )}\n >\n <header className=\"flex min-h-11 items-center justify-between gap-2 px-3\">\n <h3 id={headingId} className=\"truncate text-sm font-semibold\">\n {col.label}\n </h3>\n <span\n data-nums\n className={cx(\"shrink-0 font-mono text-xs\", over ? \"font-semibold text-warning\" : \"text-muted-foreground\")}\n title={typeof limit === \"number\" ? `${cards.length} of a limit of ${limit}` : undefined}\n >\n {typeof limit === \"number\" ? `${cards.length}/${limit}` : cards.length}\n </span>\n </header>\n <ul className=\"flex min-h-16 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2\">\n {cards.map((row) => {\n const id = keyOf(row);\n const lifted = drag?.id === id;\n return (\n <li\n key={id}\n data-board-card={id}\n onPointerDown={(e) => onPointerDown(e, row)}\n onPointerMove={onPointerMove}\n onPointerUp={(e) => finish(e, true)}\n onPointerCancel={(e) => finish(e, false)}\n onClickCapture={(e) => {\n if (suppressClick.current) {\n e.preventDefault();\n e.stopPropagation();\n }\n }}\n className={cx(\n \"rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm\",\n movable && \"sm:cursor-grab\",\n lifted && \"opacity-40\",\n )}\n >\n {card(row)}\n </li>\n );\n })}\n {cards.length === 0 ? (\n <li className=\"rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground\">{emptyLabel}</li>\n ) : null}\n </ul>\n {onCreate && !col.other ? (\n adding === col.key ? (\n <form onSubmit={(e) => create(e, col.key)} className=\"px-2 pb-2\">\n <label htmlFor={`${headingId}-new`} className=\"sr-only\">\n New in {col.label}\n </label>\n <Input\n id={`${headingId}-new`}\n autoFocus\n value={draft}\n disabled={busy}\n placeholder={createPlaceholder}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => {\n // Enter adds here, not through the form's implicit\n // submit: a synthetic Enter (an agent testing the\n // app) has no keypress to submit with. Never while\n // an input method is composing a word.\n if (e.key === \"Enter\" && !e.nativeEvent.isComposing) create(e, col.key);\n else if (e.key === \"Escape\") {\n setAdding(null);\n setDraft(\"\");\n }\n }}\n onBlur={() => {\n if (!draft.trim()) setAdding(null);\n }}\n />\n </form>\n ) : (\n <div className=\"px-2 pb-2\">\n <Button\n variant=\"quiet\"\n size=\"sm\"\n icon=\"plus\"\n className=\"w-full justify-start\"\n onClick={() => {\n setAdding(col.key);\n setDraft(\"\");\n }}\n >\n {addLabel}\n </Button>\n </div>\n )\n ) : null}\n </section>\n );\n })}\n </div>\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n {drag && portal\n ? createPortal(\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none fixed z-50 rotate-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg\"\n style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}\n >\n {card(drag.row, { ghost: true })}\n </div>,\n portal,\n )\n : null}\n </div>\n );\n}\n"
22
+ "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { isSample } from \"../../parts\";\nimport { Button, Collapsible, Icon, Input, Menu, Notice, cx } from \"../../ui\";\n\n// Board — records as cards in columns, one column per stage, moved by\n// dragging or from the card's menu. The block owns the behaviour a board\n// gets wrong when it is drawn by hand: pointer dragging that works with a\n// mouse and on a phone, a keyboard path (the menu), the move shown at\n// once and put back with a reason when the write fails, examples that\n// move on screen and save nothing, the count in every header, and cards\n// no stage claims kept in view instead of lost. The surface owns what a\n// card says under its title (renderDetails) and how a move is saved\n// (onMove): the block imports nothing but React, the kit and the parts,\n// so it runs in a Makersclaw app and in any shadcn project alike.\n//\n// <Board rows={deals} groupBy=\"stage\" columns={STAGES}\n// onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}\n// titleOf={(d) => d.name} renderDetails={(d) => <DealFacts deal={d} />}\n// onOpen={setEditing} />\n//\n// Opt-in: an icon per column (`columns[].icon`), a figure beside each\n// count (`aggregate(rows)` → \"13pt\"), a category's colour on each card's\n// edge (`accentOf(row)` → 1…5, `accentEdge` top | start), and\n// `layout=\"stacked\"` — the stages as folding sections of compact rows.\n//\n// README.md beside this file lists every prop.\n\nconst OTHER = \"__other\";\n\n// A card's accent: a category's colour on one edge, from the chart tokens\n// (chart-1…5), written out whole so Tailwind finds every class.\nconst ACCENTS = {\n top: {\n 1: \"border-t-[3px] border-t-chart-1\",\n 2: \"border-t-[3px] border-t-chart-2\",\n 3: \"border-t-[3px] border-t-chart-3\",\n 4: \"border-t-[3px] border-t-chart-4\",\n 5: \"border-t-[3px] border-t-chart-5\",\n },\n start: {\n 1: \"border-s-[3px] border-s-chart-1\",\n 2: \"border-s-[3px] border-s-chart-2\",\n 3: \"border-s-[3px] border-s-chart-3\",\n 4: \"border-s-[3px] border-s-chart-4\",\n 5: \"border-s-[3px] border-s-chart-5\",\n },\n};\n\nfunction accentHue(value) {\n const n = typeof value === \"string\" ? Number(value.replace(/^chart-/, \"\")) : value;\n return Number.isInteger(n) && n >= 1 && n <= 5 ? n : null;\n}\nconst DRAG_START_PX = { mouse: 5, pen: 6, touch: 8 };\n\nfunction humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"—\";\n}\n\nfunction normalizeColumns(columns) {\n return (columns ?? []).map((c) =>\n typeof c === \"string\" || typeof c === \"number\"\n ? { key: String(c), label: humanize(c) }\n : { ...c, key: String(c.key), label: c.label ?? humanize(c.key) },\n );\n}\n\nexport function Board({\n rows,\n groupBy,\n columns,\n idColumn = \"id\",\n onMove,\n renderDetails,\n titleOf = (row) => row.title ?? row.name ?? String(row[idColumn] ?? \"\"),\n metaOf,\n onOpen,\n actionsOf,\n onCreate,\n addLabel = \"Add\",\n createPlaceholder = \"Title, then Enter\",\n limits,\n aggregate,\n accentOf,\n accentEdge = \"top\",\n layout = \"columns\",\n defaultOpen,\n label = \"Board\",\n emptyLabel = \"Nothing here yet\",\n otherLabel = \"No stage\",\n className,\n}) {\n const uid = useId();\n const boardRef = useRef(null);\n const dragRef = useRef(null);\n const suppressClick = useRef(false);\n const [overrides, setOverrides] = useState(() => new Map());\n const [drag, setDrag] = useState(null);\n const [error, setError] = useState(null);\n const [announce, setAnnounce] = useState(\"\");\n const [adding, setAdding] = useState(null);\n const [draft, setDraft] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [portal, setPortal] = useState(null);\n\n const movable = typeof onMove === \"function\";\n const list = rows ?? [];\n const keyOf = (row) => String(row?.[idColumn]);\n const cols = useMemo(() => normalizeColumns(columns), [columns]);\n const known = useMemo(() => new Set(cols.map((c) => c.key)), [cols]);\n const columnOf = (row) => {\n const moved = overrides.get(keyOf(row));\n const value = moved ?? (row?.[groupBy] == null ? \"\" : String(row[groupBy]));\n return known.has(value) ? value : OTHER;\n };\n\n useEffect(() => setPortal(document.body), []);\n\n // A move stays on screen until the rows the page re-reads carry it; then\n // the override has nothing left to say.\n useEffect(() => {\n setOverrides((current) => {\n if (current.size === 0) return current;\n const next = new Map(current);\n for (const [id, to] of current) {\n const row = list.find((r) => keyOf(r) === id);\n if (!row || String(row[groupBy]) === to) next.delete(id);\n }\n return next.size === current.size ? current : next;\n });\n // `list` changes identity with every read; that is the point.\n }, [rows, groupBy]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const byColumn = useMemo(() => {\n const out = new Map(cols.map((c) => [c.key, []]));\n out.set(OTHER, []);\n for (const row of list) out.get(columnOf(row)).push(row);\n return out;\n }, [list, cols, overrides]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const stacked = layout === \"stacked\";\n const accent = (row, edge = accentEdge) => {\n if (!accentOf) return null;\n const hue = accentHue(accentOf(row));\n return hue ? (ACCENTS[edge] ?? ACCENTS.top)[hue] : null;\n };\n const shown = byColumn.get(OTHER).length ? [...cols, { key: OTHER, label: otherLabel, other: true }] : cols;\n const labelOf = (key) => shown.find((c) => c.key === key)?.label ?? humanize(key);\n\n async function move(row, to) {\n const from = columnOf(row);\n if (!movable || to === from || to === OTHER || !known.has(to)) return;\n const id = keyOf(row);\n const title = titleOf(row);\n setError(null);\n setOverrides((m) => new Map(m).set(id, to));\n setAnnounce(`${title} moved to ${labelOf(to)}`);\n // An example moves on screen and saves nothing: it is not a row.\n if (isSample(row)) return;\n let result;\n try {\n result = await onMove(row, to, from === OTHER ? null : from);\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n if (result && result.ok === false) {\n setOverrides((m) => {\n const next = new Map(m);\n next.delete(id);\n return next;\n });\n setError(`“${title}” is back in ${labelOf(from)}: ${result.error ?? \"the move was not saved\"}`);\n setAnnounce(`${title} could not be moved`);\n }\n }\n\n // ---- pointer dragging ---------------------------------------------------\n // A mouse drags a card from anywhere on it; touch and pen drag from the\n // grip, so the column still scrolls under a finger.\n\n function onPointerDown(e, row) {\n if (!movable) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n const onGrip = Boolean(e.target.closest?.(\"[data-board-grip]\"));\n if (e.pointerType !== \"mouse\" && !onGrip) return;\n if (!onGrip && e.target.closest?.(\"input, textarea, select, a, [aria-haspopup], [role=menuitem]\")) return;\n // No text selection while a mouse drags; the click still lands.\n if (e.pointerType === \"mouse\") e.preventDefault();\n const card = e.currentTarget;\n const rect = card.getBoundingClientRect();\n dragRef.current = {\n row,\n id: keyOf(row),\n from: columnOf(row),\n pointerId: e.pointerId,\n type: e.pointerType,\n card,\n startX: e.clientX,\n startY: e.clientY,\n offsetX: e.clientX - rect.left,\n offsetY: e.clientY - rect.top,\n width: rect.width,\n active: false,\n over: null,\n x: e.clientX,\n y: e.clientY,\n };\n }\n\n function columnAt(x, y) {\n const el = document.elementFromPoint(x, y);\n const col = el?.closest?.(\"[data-board-column]\");\n return col && boardRef.current?.contains(col) ? col.getAttribute(\"data-board-column\") : null;\n }\n\n function onPointerMove(e) {\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n if (!d.active) {\n const far = Math.hypot(e.clientX - d.startX, e.clientY - d.startY) >= (DRAG_START_PX[d.type] ?? 5);\n if (!far) return;\n d.active = true;\n try {\n d.card.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is already gone; the move below still works\n }\n }\n e.preventDefault();\n d.x = e.clientX;\n d.y = e.clientY;\n d.over = columnAt(e.clientX, e.clientY);\n const board = boardRef.current;\n if (board) {\n const r = board.getBoundingClientRect();\n if (e.clientX < r.left + 48) board.scrollBy({ left: -18 });\n else if (e.clientX > r.right - 48) board.scrollBy({ left: 18 });\n }\n setDrag({ id: d.id, over: d.over, x: d.x, y: d.y, offsetX: d.offsetX, offsetY: d.offsetY, width: d.width, row: d.row });\n }\n\n function finish(e, drop) {\n const d = dragRef.current;\n if (!d || (e && e.pointerId !== d.pointerId)) return;\n dragRef.current = null;\n if (!d.active) return;\n suppressClick.current = true;\n setTimeout(() => {\n suppressClick.current = false;\n }, 0);\n setDrag(null);\n if (drop && d.over) move(d.row, d.over);\n }\n\n useEffect(() => {\n if (!drag) return undefined;\n const onKey = (e) => {\n if (e.key === \"Escape\") finish(null, false);\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [drag]); // eslint-disable-line react-hooks/exhaustive-deps\n\n async function create(e, column) {\n e.preventDefault();\n const title = draft.trim();\n if (!title || busy) return;\n setBusy(true);\n setError(null);\n let result;\n try {\n result = await onCreate(title, column);\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n setBusy(false);\n if (result && result.ok === false) setError(`“${title}” was not added: ${result.error ?? \"the write failed\"}`);\n else {\n setDraft(\"\");\n setAnnounce(`${title} added to ${labelOf(column)}`);\n }\n }\n\n // The \"Add\" at a stage's foot, and the inline field it opens.\n function adder(col, headingId, pad = \"px-2 pb-2\") {\n if (!onCreate || col.other) return null;\n if (adding !== col.key) {\n return (\n <div className={pad}>\n <Button\n variant=\"quiet\"\n size=\"sm\"\n icon=\"plus\"\n align=\"start\"\n className=\"w-full\"\n onClick={() => {\n setAdding(col.key);\n setDraft(\"\");\n }}\n >\n {addLabel}\n </Button>\n </div>\n );\n }\n return (\n <form onSubmit={(e) => create(e, col.key)} className={pad}>\n <label htmlFor={`${headingId}-new`} className=\"sr-only\">\n New in {col.label}\n </label>\n <Input\n id={`${headingId}-new`}\n autoFocus\n value={draft}\n disabled={busy}\n placeholder={createPlaceholder}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => {\n // Enter adds here, not through the form's implicit submit: a\n // synthetic Enter (an agent testing the app) has no keypress to\n // submit with. Never while an input method is composing a word.\n if (e.key === \"Enter\" && !e.nativeEvent.isComposing) create(e, col.key);\n else if (e.key === \"Escape\") {\n setAdding(null);\n setDraft(\"\");\n }\n }}\n onBlur={() => {\n if (!draft.trim()) setAdding(null);\n }}\n />\n </form>\n );\n }\n\n function header(col, cards) {\n const limit = limits?.[col.key];\n const over = typeof limit === \"number\" && cards.length > limit;\n return {\n icon: col.icon ? <Icon name={col.icon} size={16} className=\"shrink-0 text-muted-foreground\" /> : null,\n total: aggregate ? (\n <span data-nums className=\"shrink-0 text-xs text-muted-foreground\">\n {aggregate(cards, col.other ? null : col.key)}\n </span>\n ) : null,\n count: (\n <span\n data-nums\n className={cx(\"shrink-0 font-mono text-xs\", over ? \"font-semibold text-warning\" : \"text-muted-foreground\")}\n title={typeof limit === \"number\" ? `${cards.length} of a limit of ${limit}` : undefined}\n >\n {typeof limit === \"number\" ? `${cards.length}/${limit}` : cards.length}\n </span>\n ),\n };\n }\n\n function moveItems(row) {\n return movable\n ? shown\n .filter((c) => !c.other && c.key !== columnOf(row))\n .map((c) => ({ label: `Move to ${c.label}`, icon: \"arrow-right\", onSelect: () => move(row, c.key) }))\n : [];\n }\n\n function titleButton(row, title, extra) {\n return onOpen ? (\n <button\n type=\"button\"\n onClick={() => onOpen(row)}\n className={cx(\"block w-full rounded-sm text-left text-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring\", extra)}\n >\n {title}\n </button>\n ) : (\n <p className={cx(\"text-sm font-medium\", extra)}>{title}</p>\n );\n }\n\n function card(row, { ghost = false } = {}) {\n const title = titleOf(row);\n const body = (\n <>\n {titleButton(row, title)}\n {renderDetails ? (\n <div className=\"mt-2\">{renderDetails(row, { column: columnOf(row), layout: \"columns\" })}</div>\n ) : metaOf ? (\n <div className=\"mt-1 text-xs text-muted-foreground\">{metaOf(row)}</div>\n ) : null}\n </>\n );\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <div className=\"flex items-start gap-1\">\n {movable && !ghost ? (\n <span\n data-board-grip\n aria-hidden=\"true\"\n className=\"-ml-1 mt-0.5 hidden h-8 w-6 shrink-0 cursor-grab items-center justify-center text-muted-foreground [@media(hover:none)]:flex\"\n style={{ touchAction: \"none\" }}\n >\n <Icon name=\"grip\" size={16} />\n </span>\n ) : null}\n <div className=\"min-w-0 flex-1\">{body}</div>\n {!ghost ? <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 -mt-2 -mb-2 shrink-0\" /> : null}\n </div>\n );\n }\n\n const notice = error ? (\n <Notice tone=\"danger\" onDismiss={() => setError(null)}>\n {error}\n </Notice>\n ) : null;\n const live = (\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n );\n\n // Stacked: the stages one under another, each a section that folds, the\n // records as compact rows. Nothing drags here — a row moves through its\n // menu (\"Move to …\"), the same path the keyboard takes on the columns —\n // so a phone scrolls the page, never a board sideways.\n if (stacked) {\n const opens = defaultOpen == null ? null : new Set((Array.isArray(defaultOpen) ? defaultOpen : [defaultOpen]).map(String));\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div role=\"region\" aria-label={label} className=\"overflow-hidden rounded-lg border border-border bg-card text-card-foreground\">\n {shown.map((col) => {\n const rowsHere = byColumn.get(col.key) ?? [];\n const head = header(col, rowsHere);\n const headingId = `${uid}-${col.key}`;\n return (\n <Collapsible\n key={col.key}\n variant=\"flush\"\n defaultOpen={opens ? opens.has(col.key) : true}\n title={\n <span className=\"flex min-w-0 items-center gap-2\">\n {head.icon}\n <span id={headingId} className=\"truncate font-semibold\">\n {col.label}\n </span>\n </span>\n }\n meta={\n <span className=\"flex items-center gap-2\">\n {head.total}\n {head.count}\n </span>\n }\n >\n <ul aria-labelledby={headingId} className={cx(\"-mx-4 divide-y divide-border border-t border-border\", !onCreate || col.other ? \"-mb-4\" : \"mb-2\")}>\n {rowsHere.map((row) => {\n const id = keyOf(row);\n const title = titleOf(row);\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <li key={id} data-board-card={id} className={cx(\"flex min-h-11 items-center gap-3 px-4 py-1.5\", accent(row, \"start\"))}>\n <div className=\"min-w-0 flex-1\">\n {titleButton(row, title, \"truncate\")}\n {metaOf ? <div className=\"truncate text-xs text-muted-foreground\">{metaOf(row)}</div> : null}\n </div>\n {renderDetails ? (\n <div className=\"flex shrink-0 items-center gap-2\">{renderDetails(row, { column: columnOf(row), layout: \"stacked\" })}</div>\n ) : null}\n <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 shrink-0\" />\n </li>\n );\n })}\n {rowsHere.length === 0 ? <li className=\"px-4 py-3 text-xs text-muted-foreground\">{emptyLabel}</li> : null}\n </ul>\n {adder(col, headingId, \"-mx-2\")}\n </Collapsible>\n );\n })}\n </div>\n {live}\n </div>\n );\n }\n\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div\n ref={boardRef}\n role=\"region\"\n aria-label={label}\n tabIndex={0}\n className=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-lg pb-2 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:snap-none\"\n >\n {shown.map((col) => {\n const cards = byColumn.get(col.key) ?? [];\n const head = header(col, cards);\n const target = drag && drag.over === col.key && !col.other && columnOf(drag.row) !== col.key;\n const headingId = `${uid}-${col.key}`;\n return (\n <section\n key={col.key}\n data-board-column={col.other ? undefined : col.key}\n aria-labelledby={headingId}\n className={cx(\n \"flex max-h-[75dvh] w-[min(18rem,85vw)] shrink-0 snap-start flex-col rounded-lg border border-border bg-muted/50\",\n target && \"bg-accent ring-2 ring-ring\",\n )}\n >\n <header className=\"flex min-h-11 items-center gap-2 px-3\">\n {head.icon}\n <h3 id={headingId} className=\"min-w-0 flex-1 truncate text-sm font-semibold\">\n {col.label}\n </h3>\n {head.total}\n {head.count}\n </header>\n <ul className=\"flex min-h-16 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2\">\n {cards.map((row) => {\n const id = keyOf(row);\n const lifted = drag?.id === id;\n return (\n <li\n key={id}\n data-board-card={id}\n onPointerDown={(e) => onPointerDown(e, row)}\n onPointerMove={onPointerMove}\n onPointerUp={(e) => finish(e, true)}\n onPointerCancel={(e) => finish(e, false)}\n onClickCapture={(e) => {\n if (suppressClick.current) {\n e.preventDefault();\n e.stopPropagation();\n }\n }}\n className={cx(\n \"rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm\",\n accent(row),\n movable && \"sm:cursor-grab\",\n lifted && \"opacity-40\",\n )}\n >\n {card(row)}\n </li>\n );\n })}\n {cards.length === 0 ? (\n <li className=\"rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground\">{emptyLabel}</li>\n ) : null}\n </ul>\n {adder(col, headingId)}\n </section>\n );\n })}\n </div>\n {live}\n {drag && portal\n ? createPortal(\n <div\n aria-hidden=\"true\"\n className={cx(\n \"pointer-events-none fixed z-50 rotate-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg\",\n accent(drag.row),\n )}\n style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}\n >\n {card(drag.row, { ghost: true })}\n </div>,\n portal,\n )\n : null}\n </div>\n );\n}\n"
23
23
  },
24
24
  {
25
25
  "path": "registry/blocks/board/README.md",
26
26
  "type": "registry:file",
27
27
  "target": "@components/makersclaw/blocks/board/README.md",
28
- "content": "# Board\n\nRecords as cards in columns, one column per stage. Drag a card to another\ncolumn — with a mouse from anywhere on the card, on a phone from its grip,\nfrom the keyboard through the card's menu (\"Move to …\") — and the stage is\nsaved. The move shows at once; when the write fails the card goes back and\nthe board says why.\n\n```jsx\nimport { Board } from \"../components/blocks/board\"; // a Makersclaw app (\"./blocks/board\" from components/)\nimport { Board } from \"@/components/makersclaw/blocks/board\"; // 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\nOne table, one column that holds the stage (`groupBy`). Each row is a card.\n`columns` is the stages in order — the values that column holds. A row\nwhose stage is not in `columns` lands in a last \"No stage\" column, so no\nrecord goes missing; it can be dragged out of it, not into it.\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n example cards move on screen without saving (they are not rows).\n- `groupBy` — the column that holds the stage, e.g. `\"stage\"`.\n- `columns` — the stages in order: `[\"lead\", \"call\", \"won\"]` (labels are\n made from the keys) or `[{ key: \"lead\", label: \"New lead\" }, …]`.\n- `onMove(row, to, from)` — save the move; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app it\n is one line over `moveRow` (lib/actions.js — the activity row included):\n `onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}`.\n Without `onMove` the board only shows; nothing can be moved.\n- `idColumn` — default `\"id\"`.\n- `titleOf(row)` — the card's title, default `row.title ?? row.name`. It\n also names the card in its menu, in announcements and in errors.\n- `onOpen(row)` — the title becomes a button: open your `Sheet` to edit\n the record. The block draws that button; you never style one.\n- `renderDetails(row, { column })` — what the card says under its title:\n the part of the board that is yours. Kit parts and tokens only.\n- `metaOf(row)` — a plain muted line under the title, when that is all\n the card needs (ignored when `renderDetails` is set).\n- `actionsOf(row)` — more menu items for a card, kit `Menu` shape:\n `[{ label, onSelect, icon?, tone?: \"danger\" }]`. The \"Move to\" items\n follow them.\n- `onCreate(title, column)` — an \"Add\" at every column's foot opens an\n inline field; Enter calls this (insert the row with `insertRow`), the\n field stays open for the next one. Return `{ ok: false, error }` to show\n why it failed.\n- `addLabel` · `createPlaceholder` · `emptyLabel` · `otherLabel` — words.\n- `limits` — a work-in-progress limit per stage: `{ doing: 3 }` shows\n `4/3` in the header, in the warning tone, when it is exceeded.\n- `label` — what the board is called to a screen reader.\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// surfaces/pipeline.jsx — server: load, hand over\nimport { loadRows } from \"../lib/data\";\nimport Pipeline from \"../components/Pipeline\";\n\nexport default async function PipelineSurface() {\n const { rows, error } = await loadRows({ source: \"public.deals\", orderBy: \"updated_at desc\" });\n return <Pipeline deals={rows} error={error} />;\n}\n```\n\n```jsx\n// components/Pipeline.jsx — client: the board, the card, the edit sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { insertRow, moveRow } from \"../lib/actions\";\nimport { Badge, Notice } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { Board } from \"./blocks/board\";\n\nconst STAGES = [\n { key: \"lead\", label: \"Lead\" },\n { key: \"call\", label: \"Call booked\" },\n { key: \"proposal\", label: \"Proposal\" },\n { key: \"won\", label: \"Won\" },\n];\nconst SAMPLE = [\n { name: \"Northwind\", stage: \"call\", value: 12000, owner: \"Maya\" },\n { name: \"Acme Ltd\", stage: \"lead\", value: 4000, owner: \"Leon\" },\n];\n\nexport default function Pipeline({ deals, error }) {\n const fixture = useFixture(deals, SAMPLE);\n const [editing, setEditing] = useState(null);\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 <Board\n rows={fixture.rows}\n groupBy=\"stage\"\n columns={STAGES}\n onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}\n titleOf={(d) => d.name}\n limits={{ proposal: 5 }}\n onCreate={(name, stage) => insertRow({ source: \"public.deals\", values: { name, stage }, detail: name })}\n onOpen={setEditing}\n renderDetails={(d) => (\n <div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n <span>{d.owner ?? \"Unowned\"}</span>\n <Badge>{Number(d.value ?? 0).toLocaleString()}</Badge>\n </div>\n )}\n label=\"Deals by stage\"\n />\n {/* <Sheet open={Boolean(editing)} …> the record's fields </Sheet> */}\n </div>\n );\n}\n```\n\n## What it will not do\n\n- Order cards inside a column: they keep the order of `rows` — sort in\n the surface's `loadRows`.\n- Create a stage: `columns` is the surface's; a new stage is a new entry.\n- Colour a column: stages are not status. Put a `Badge` on the card when a\n stage means something the founder must see.\n\nA board it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add board` restores them.\n"
28
+ "content": "# Board\n\nRecords as cards in columns, one column per stage. Drag a card to another\ncolumn — with a mouse from anywhere on the card, on a phone from its grip,\nfrom the keyboard through the card's menu (\"Move to …\") — and the stage is\nsaved. The move shows at once; when the write fails the card goes back and\nthe board says why.\n\n```jsx\nimport { Board } from \"../components/blocks/board\"; // a Makersclaw app (\"./blocks/board\" from components/)\nimport { Board } from \"@/components/makersclaw/blocks/board\"; // 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\nOne table, one column that holds the stage (`groupBy`). Each row is a card.\n`columns` is the stages in order — the values that column holds. A row\nwhose stage is not in `columns` lands in a last \"No stage\" column, so no\nrecord goes missing; it can be dragged out of it, not into it.\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n example cards move on screen without saving (they are not rows).\n- `groupBy` — the column that holds the stage, e.g. `\"stage\"`.\n- `columns` — the stages in order: `[\"lead\", \"call\", \"won\"]` (labels are\n made from the keys) or `[{ key: \"lead\", label: \"New lead\", icon? }, …]`.\n `icon` is a kit `Icon` name, drawn before the stage's label.\n- `onMove(row, to, from)` — save the move; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app it\n is one line over `moveRow` (lib/actions.js — the activity row included):\n `onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}`.\n Without `onMove` the board only shows; nothing can be moved.\n- `idColumn` — default `\"id\"`.\n- `titleOf(row)` — the card's title, default `row.title ?? row.name`. It\n also names the card in its menu, in announcements and in errors.\n- `onOpen(row)` — the title becomes a button: open your `Sheet` to edit\n the record. The block draws that button; you never style one.\n- `renderDetails(row, { column, layout })` — what the card says under its\n title: the part of the board that is yours. Kit parts and tokens only.\n In `layout=\"stacked\"` it sits at the row's end instead, so keep it short\n there (a badge, an avatar); `layout` tells you which.\n- `metaOf(row)` — a plain muted line under the title, when that is all\n the card needs (ignored on cards when `renderDetails` is set; a stacked\n row shows both).\n- `actionsOf(row)` — more menu items for a card, kit `Menu` shape:\n `[{ label, onSelect, icon?, tone?: \"danger\" }]`. The \"Move to\" items\n follow them.\n- `onCreate(title, column)` — an \"Add\" at every column's foot opens an\n inline field; Enter calls this (insert the row with `insertRow`), the\n field stays open for the next one. Return `{ ok: false, error }` to show\n why it failed.\n- `addLabel` · `createPlaceholder` · `emptyLabel` · `otherLabel` — words.\n- `limits` — a work-in-progress limit per stage: `{ doing: 3 }` shows\n `4/3` in the header, in the warning tone, when it is exceeded.\n- `aggregate(rows, column)` — a figure beside each stage's count, from the\n records in it: ``(list) => `${list.reduce((s, r) => s + r.points, 0)}pt` ``\n → \"13pt\". A string or a node; `column` is null for \"No stage\".\n- `accentOf(row)` — the record's category as a colour on one edge of its\n card: `1` … `5` (the chart tokens, `chart-1` … `chart-5`), or `null` for\n none. A category, never a status: show what it means in words too (a\n legend, or the type on the card).\n- `accentEdge` — `\"top\"` (default) or `\"start\"` (the left edge, in a\n right-to-left page the right). Stacked rows always use the start edge.\n- `layout` — `\"columns\"` (default: side by side, dragged) or `\"stacked\"`:\n the stages one under another as folding sections (icon, label, count,\n chevron), each record a compact row. Rows move through their menu\n (\"Move to …\"); nothing drags, so on a phone the page just scrolls.\n- `defaultOpen` — stacked: the stage keys open at first, e.g.\n `[\"todo\"]`. Default: every stage open.\n- `label` — what the board is called to a screen reader.\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// surfaces/pipeline.jsx — server: load, hand over\nimport { loadRows } from \"../lib/data\";\nimport Pipeline from \"../components/Pipeline\";\n\nexport default async function PipelineSurface() {\n const { rows, error } = await loadRows({ source: \"public.deals\", orderBy: \"updated_at desc\" });\n return <Pipeline deals={rows} error={error} />;\n}\n```\n\n```jsx\n// components/Pipeline.jsx — client: the board, the card, the edit sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { insertRow, moveRow } from \"../lib/actions\";\nimport { Badge, Notice } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { Board } from \"./blocks/board\";\n\nconst STAGES = [\n { key: \"lead\", label: \"Lead\" },\n { key: \"call\", label: \"Call booked\" },\n { key: \"proposal\", label: \"Proposal\" },\n { key: \"won\", label: \"Won\" },\n];\nconst SAMPLE = [\n { name: \"Northwind\", stage: \"call\", value: 12000, owner: \"Maya\" },\n { name: \"Acme Ltd\", stage: \"lead\", value: 4000, owner: \"Leon\" },\n];\n\nexport default function Pipeline({ deals, error }) {\n const fixture = useFixture(deals, SAMPLE);\n const [editing, setEditing] = useState(null);\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 <Board\n rows={fixture.rows}\n groupBy=\"stage\"\n columns={STAGES}\n onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}\n titleOf={(d) => d.name}\n limits={{ proposal: 5 }}\n onCreate={(name, stage) => insertRow({ source: \"public.deals\", values: { name, stage }, detail: name })}\n onOpen={setEditing}\n renderDetails={(d) => (\n <div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n <span>{d.owner ?? \"Unowned\"}</span>\n <Badge>{Number(d.value ?? 0).toLocaleString()}</Badge>\n </div>\n )}\n label=\"Deals by stage\"\n />\n {/* <Sheet open={Boolean(editing)} …> the record's fields </Sheet> */}\n </div>\n );\n}\n```\n\nA sprint (icons, points per stage, a top edge by type) and a stacked list:\n\n```jsx\nconst TYPE_HUE = { feature: 1, bug: 4, task: 2 };\n\n<Board\n rows={stories}\n groupBy=\"status\"\n columns={[{ key: \"todo\", label: \"To do\", icon: \"inbox\" }, { key: \"doing\", label: \"In progress\", icon: \"zap\" }, { key: \"done\", label: \"Done\", icon: \"circle-check\" }]}\n onMove={(s, to) => moveRow({ source: \"public.stories\", id: s.id, stateColumn: \"status\", to })}\n aggregate={(list) => `${list.reduce((n, s) => n + (s.points ?? 0), 0)}pt`}\n accentOf={(s) => TYPE_HUE[s.type] ?? null}\n renderDetails={(s) => <Badge>{s.points}pt</Badge>}\n label=\"Stories by status\"\n/>\n\n<Board\n layout=\"stacked\"\n rows={chores}\n groupBy=\"state\"\n columns={[{ key: \"todo\", label: \"To do\", icon: \"inbox\" }, { key: \"done\", label: \"Done\", icon: \"circle-check\" }]}\n defaultOpen={[\"todo\"]}\n onMove={(c, to) => moveRow({ source: \"public.chores\", id: c.id, stateColumn: \"state\", to })}\n metaOf={(c) => c.room}\n renderDetails={(c) => <Badge tone={c.every === \"daily\" ? \"warning\" : \"info\"}>{c.every}</Badge>}\n label=\"Chores by state\"\n/>\n```\n\n## What it will not do\n\n- Order cards inside a column: they keep the order of `rows` — sort in\n the surface's `loadRows`.\n- Create a stage: `columns` is the surface's; a new stage is a new entry.\n- Colour a column: stages are not status. Put a `Badge` on the card when a\n stage means something the founder must see. (`accentOf` colours a card\n by its category, not its stage.)\n- Drag in `layout=\"stacked\"`: rows move from their menu.\n\nA board it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add board` restores them.\n"
29
29
  }
30
30
  ],
31
31
  "categories": [
@@ -39,12 +39,16 @@
39
39
  "drop",
40
40
  "status",
41
41
  "workflow",
42
- "funnel"
42
+ "funnel",
43
+ "sprint",
44
+ "accent",
45
+ "stacked",
46
+ "collapsible"
43
47
  ],
44
48
  "docs": "Installed to components/makersclaw/blocks/board. Import: import { Board } from \"@/components/makersclaw/blocks/board\". Props and an example: components/makersclaw/blocks/board/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
45
49
  "meta": {
46
50
  "makersclaw": {
47
- "version": "0.2.0",
51
+ "version": "0.3.1",
48
52
  "useWhen": "A process where each record sits in one stage and moves forward: a pipeline, a hiring funnel, a sprint, a content calendar by status, outreach by reply state. Not for records with no stages (a table) or items placed on a timeline."
49
53
  }
50
54
  }