makersclaw 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (478) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -1
  3. package/THIRD-PARTY-NOTICES.md +180 -0
  4. package/package.json +15 -8
  5. package/r/annotate.json +62 -0
  6. package/r/approval-request.json +52 -0
  7. package/r/audio-player.json +74 -0
  8. package/r/auth-form.json +55 -0
  9. package/r/board.json +55 -0
  10. package/r/calendar-view.json +105 -0
  11. package/r/cart.json +62 -0
  12. package/r/chart.json +83 -0
  13. package/r/chat.json +95 -0
  14. package/r/code-editor.json +94 -0
  15. package/r/comment-box.json +64 -0
  16. package/r/contact-section.json +53 -0
  17. package/r/core.json +341 -0
  18. package/r/csv-import.json +63 -0
  19. package/r/data-table.json +69 -0
  20. package/r/diagram.json +74 -0
  21. package/r/doc-editor.json +82 -0
  22. package/r/doc-page.json +61 -0
  23. package/r/email-render.json +73 -0
  24. package/r/faq.json +50 -0
  25. package/r/file-upload.json +54 -0
  26. package/r/flow-canvas.json +93 -0
  27. package/r/footer.json +52 -0
  28. package/r/gantt.json +70 -0
  29. package/r/job-panel.json +56 -0
  30. package/r/json-editor.json +66 -0
  31. package/r/map.json +89 -0
  32. package/r/menubar.json +52 -0
  33. package/r/mind-map.json +67 -0
  34. package/r/navigation-menu.json +52 -0
  35. package/r/order-detail.json +54 -0
  36. package/r/org-chart.json +66 -0
  37. package/r/page-builder.json +105 -0
  38. package/r/payment-methods.json +51 -0
  39. package/r/pdf-document.json +79 -0
  40. package/r/pdf-viewer.json +88 -0
  41. package/r/product-detail.json +76 -0
  42. package/r/product-grid.json +65 -0
  43. package/r/record-form.json +67 -0
  44. package/r/record-notes.json +55 -0
  45. package/r/registry.json +3612 -0
  46. package/r/search-dialog.json +51 -0
  47. package/r/settings-list.json +66 -0
  48. package/r/setup-checklist.json +51 -0
  49. package/r/share-dialog.json +52 -0
  50. package/r/sidebar.json +69 -0
  51. package/r/signature-pad.json +72 -0
  52. package/r/sketch-canvas.json +99 -0
  53. package/r/spreadsheet.json +90 -0
  54. package/r/stat-cards.json +66 -0
  55. package/r/task-list.json +63 -0
  56. package/r/task-model.json +98 -0
  57. package/r/testimonials.json +52 -0
  58. package/r/transaction-list.json +53 -0
  59. package/r/whiteboard.json +66 -0
  60. package/registry/blocks/annotate/README.md +179 -0
  61. package/registry/blocks/annotate/annotate-data.js +250 -0
  62. package/registry/blocks/annotate/annotate.jsx +646 -0
  63. package/registry/blocks/annotate/block.json +10 -0
  64. package/registry/blocks/annotate/demo.jsx +135 -0
  65. package/registry/blocks/annotate/index.js +15 -0
  66. package/registry/blocks/approval-request/README.md +116 -0
  67. package/registry/blocks/approval-request/approval-request.jsx +386 -0
  68. package/registry/blocks/approval-request/block.json +10 -0
  69. package/registry/blocks/approval-request/demo.jsx +127 -0
  70. package/registry/blocks/approval-request/index.js +4 -0
  71. package/registry/blocks/audio-player/README.md +167 -0
  72. package/registry/blocks/audio-player/audio-player.jsx +623 -0
  73. package/registry/blocks/audio-player/block.json +14 -0
  74. package/registry/blocks/audio-player/colors.js +109 -0
  75. package/registry/blocks/audio-player/demo.jsx +209 -0
  76. package/registry/blocks/audio-player/index.js +6 -0
  77. package/registry/blocks/audio-player/time.js +67 -0
  78. package/registry/blocks/audio-player/wavesurfer.js +9 -0
  79. package/registry/blocks/auth-form/README.md +126 -0
  80. package/registry/blocks/auth-form/auth-form.jsx +568 -0
  81. package/registry/blocks/auth-form/block.json +10 -0
  82. package/registry/blocks/auth-form/demo.jsx +131 -0
  83. package/registry/blocks/auth-form/index.js +3 -0
  84. package/registry/blocks/board/README.md +64 -13
  85. package/registry/blocks/board/block.json +2 -2
  86. package/registry/blocks/board/board.jsx +237 -94
  87. package/registry/blocks/board/demo.jsx +103 -24
  88. package/registry/blocks/calendar-view/README.md +180 -0
  89. package/registry/blocks/calendar-view/agenda.jsx +147 -0
  90. package/registry/blocks/calendar-view/block.json +76 -0
  91. package/registry/blocks/calendar-view/calendar-model.js +442 -0
  92. package/registry/blocks/calendar-view/calendar.jsx +627 -0
  93. package/registry/blocks/calendar-view/demo.jsx +195 -0
  94. package/registry/blocks/calendar-view/draft.jsx +49 -0
  95. package/registry/blocks/calendar-view/index.js +39 -0
  96. package/registry/blocks/calendar-view/month.jsx +346 -0
  97. package/registry/blocks/calendar-view/paint.js +69 -0
  98. package/registry/blocks/calendar-view/time-grid.jsx +527 -0
  99. package/registry/blocks/calendar-view/tray.jsx +87 -0
  100. package/registry/blocks/cart/README.md +146 -0
  101. package/registry/blocks/cart/block.json +10 -0
  102. package/registry/blocks/cart/cart.jsx +597 -0
  103. package/registry/blocks/cart/demo.jsx +98 -0
  104. package/registry/blocks/cart/index.js +4 -0
  105. package/registry/blocks/cart/money.js +28 -0
  106. package/registry/blocks/chart/README.md +316 -0
  107. package/registry/blocks/chart/block.json +10 -0
  108. package/registry/blocks/chart/cartesian.jsx +642 -0
  109. package/registry/blocks/chart/core.jsx +262 -0
  110. package/registry/blocks/chart/demo.jsx +357 -0
  111. package/registry/blocks/chart/heatmap.jsx +336 -0
  112. package/registry/blocks/chart/index.js +8 -0
  113. package/registry/blocks/chart/pie.jsx +245 -0
  114. package/registry/blocks/chart/sparkline.jsx +88 -0
  115. package/registry/blocks/chat/README.md +273 -0
  116. package/registry/blocks/chat/attachment.jsx +95 -0
  117. package/registry/blocks/chat/block.json +10 -0
  118. package/registry/blocks/chat/chat.jsx +132 -0
  119. package/registry/blocks/chat/composer.jsx +364 -0
  120. package/registry/blocks/chat/demo.jsx +578 -0
  121. package/registry/blocks/chat/header.jsx +34 -0
  122. package/registry/blocks/chat/index.js +9 -0
  123. package/registry/blocks/chat/message.jsx +450 -0
  124. package/registry/blocks/chat/scroller.jsx +247 -0
  125. package/registry/blocks/chat/text.jsx +104 -0
  126. package/registry/blocks/code-editor/README.md +210 -0
  127. package/registry/blocks/code-editor/block.json +22 -0
  128. package/registry/blocks/code-editor/code-editor.jsx +534 -0
  129. package/registry/blocks/code-editor/codemirror.js +38 -0
  130. package/registry/blocks/code-editor/demo.jsx +205 -0
  131. package/registry/blocks/code-editor/index.js +8 -0
  132. package/registry/blocks/code-editor/json.js +317 -0
  133. package/registry/blocks/code-editor/sql.js +136 -0
  134. package/registry/blocks/code-editor/text.js +101 -0
  135. package/registry/blocks/code-editor/theme.js +186 -0
  136. package/registry/blocks/comment-box/README.md +123 -0
  137. package/registry/blocks/comment-box/block.json +10 -0
  138. package/registry/blocks/comment-box/comment-body.jsx +217 -0
  139. package/registry/blocks/comment-box/comment-box.jsx +198 -0
  140. package/registry/blocks/comment-box/comment-time.jsx +77 -0
  141. package/registry/blocks/comment-box/demo.jsx +141 -0
  142. package/registry/blocks/comment-box/index.js +8 -0
  143. package/registry/blocks/contact-section/README.md +152 -0
  144. package/registry/blocks/contact-section/block.json +11 -0
  145. package/registry/blocks/contact-section/contact-section.jsx +509 -0
  146. package/registry/blocks/contact-section/demo.jsx +93 -0
  147. package/registry/blocks/contact-section/index.js +4 -0
  148. package/registry/blocks/csv-import/README.md +133 -0
  149. package/registry/blocks/csv-import/block.json +13 -0
  150. package/registry/blocks/csv-import/csv-import.jsx +479 -0
  151. package/registry/blocks/csv-import/demo.jsx +106 -0
  152. package/registry/blocks/csv-import/import.js +200 -0
  153. package/registry/blocks/csv-import/index.js +7 -0
  154. package/registry/blocks/data-table/README.md +386 -0
  155. package/registry/blocks/data-table/block.json +10 -0
  156. package/registry/blocks/data-table/data-table.jsx +1164 -0
  157. package/registry/blocks/data-table/demo.jsx +588 -0
  158. package/registry/blocks/data-table/index.js +5 -0
  159. package/registry/blocks/data-table/model.js +169 -0
  160. package/registry/blocks/diagram/README.md +108 -0
  161. package/registry/blocks/diagram/block.json +13 -0
  162. package/registry/blocks/diagram/demo.jsx +113 -0
  163. package/registry/blocks/diagram/diagram.jsx +481 -0
  164. package/registry/blocks/diagram/index.js +7 -0
  165. package/registry/blocks/diagram/mermaid.js +52 -0
  166. package/registry/blocks/diagram/palette.js +313 -0
  167. package/registry/blocks/diagram/source.js +98 -0
  168. package/registry/blocks/doc-editor/README.md +215 -0
  169. package/registry/blocks/doc-editor/block.json +15 -0
  170. package/registry/blocks/doc-editor/demo.jsx +225 -0
  171. package/registry/blocks/doc-editor/doc-editor.jsx +291 -0
  172. package/registry/blocks/doc-editor/doc.js +309 -0
  173. package/registry/blocks/doc-editor/index.js +7 -0
  174. package/registry/blocks/doc-editor/schema.jsx +56 -0
  175. package/registry/blocks/doc-editor/shadcn-classes.js +110 -0
  176. package/registry/blocks/doc-editor/theme.js +82 -0
  177. package/registry/blocks/doc-page/README.md +119 -0
  178. package/registry/blocks/doc-page/autosave.js +104 -0
  179. package/registry/blocks/doc-page/block.json +10 -0
  180. package/registry/blocks/doc-page/demo.jsx +137 -0
  181. package/registry/blocks/doc-page/doc-page.jsx +276 -0
  182. package/registry/blocks/doc-page/index.js +4 -0
  183. package/registry/blocks/email-render/README.md +151 -0
  184. package/registry/blocks/email-render/block.json +14 -0
  185. package/registry/blocks/email-render/demo.jsx +153 -0
  186. package/registry/blocks/email-render/doc-email.jsx +210 -0
  187. package/registry/blocks/email-render/email.jsx +221 -0
  188. package/registry/blocks/email-render/index.js +10 -0
  189. package/registry/blocks/email-render/render.js +50 -0
  190. package/registry/blocks/email-render/theme.js +215 -0
  191. package/registry/blocks/faq/README.md +101 -0
  192. package/registry/blocks/faq/block.json +11 -0
  193. package/registry/blocks/faq/demo.jsx +89 -0
  194. package/registry/blocks/faq/faq.jsx +220 -0
  195. package/registry/blocks/faq/index.js +4 -0
  196. package/registry/blocks/file-upload/README.md +138 -0
  197. package/registry/blocks/file-upload/block.json +10 -0
  198. package/registry/blocks/file-upload/demo.jsx +204 -0
  199. package/registry/blocks/file-upload/file-upload.jsx +635 -0
  200. package/registry/blocks/file-upload/index.js +4 -0
  201. package/registry/blocks/flow-canvas/README.md +420 -0
  202. package/registry/blocks/flow-canvas/block.json +13 -0
  203. package/registry/blocks/flow-canvas/board.js +819 -0
  204. package/registry/blocks/flow-canvas/chrome.jsx +649 -0
  205. package/registry/blocks/flow-canvas/context.jsx +35 -0
  206. package/registry/blocks/flow-canvas/demo.jsx +249 -0
  207. package/registry/blocks/flow-canvas/edges.jsx +107 -0
  208. package/registry/blocks/flow-canvas/flow-canvas.jsx +1329 -0
  209. package/registry/blocks/flow-canvas/index.js +41 -0
  210. package/registry/blocks/flow-canvas/nodes.jsx +392 -0
  211. package/registry/blocks/flow-canvas/theme.js +174 -0
  212. package/registry/blocks/footer/README.md +133 -0
  213. package/registry/blocks/footer/block.json +11 -0
  214. package/registry/blocks/footer/demo.jsx +119 -0
  215. package/registry/blocks/footer/footer.jsx +406 -0
  216. package/registry/blocks/footer/index.js +4 -0
  217. package/registry/blocks/gantt/README.md +220 -0
  218. package/registry/blocks/gantt/block.json +10 -0
  219. package/registry/blocks/gantt/demo.jsx +148 -0
  220. package/registry/blocks/gantt/gantt.jsx +923 -0
  221. package/registry/blocks/gantt/index.js +7 -0
  222. package/registry/blocks/gantt/rows.jsx +468 -0
  223. package/registry/blocks/gantt/schedule.js +278 -0
  224. package/registry/blocks/job-panel/README.md +86 -2
  225. package/registry/blocks/job-panel/block.json +3 -3
  226. package/registry/blocks/job-panel/demo.jsx +230 -3
  227. package/registry/blocks/job-panel/job-panel.jsx +169 -3
  228. package/registry/blocks/json-editor/README.md +133 -0
  229. package/registry/blocks/json-editor/block.json +10 -0
  230. package/registry/blocks/json-editor/demo.jsx +92 -0
  231. package/registry/blocks/json-editor/index.js +7 -0
  232. package/registry/blocks/json-editor/json-editor.jsx +305 -0
  233. package/registry/blocks/json-editor/json-tree.jsx +393 -0
  234. package/registry/blocks/json-editor/tree.js +225 -0
  235. package/registry/blocks/map/README.md +221 -0
  236. package/registry/blocks/map/block.json +14 -0
  237. package/registry/blocks/map/demo.jsx +201 -0
  238. package/registry/blocks/map/geo.js +198 -0
  239. package/registry/blocks/map/index.js +8 -0
  240. package/registry/blocks/map/list.jsx +128 -0
  241. package/registry/blocks/map/map-view.jsx +715 -0
  242. package/registry/blocks/map/maplibre.js +30 -0
  243. package/registry/blocks/map/marks.jsx +146 -0
  244. package/registry/blocks/map/theme.js +142 -0
  245. package/registry/blocks/menubar/README.md +109 -0
  246. package/registry/blocks/menubar/block.json +10 -0
  247. package/registry/blocks/menubar/demo.jsx +128 -0
  248. package/registry/blocks/menubar/index.js +4 -0
  249. package/registry/blocks/menubar/menubar.jsx +199 -0
  250. package/registry/blocks/mind-map/README.md +206 -0
  251. package/registry/blocks/mind-map/block.json +10 -0
  252. package/registry/blocks/mind-map/demo.jsx +103 -0
  253. package/registry/blocks/mind-map/index.js +31 -0
  254. package/registry/blocks/mind-map/mind-map.jsx +1125 -0
  255. package/registry/blocks/mind-map/topic.jsx +143 -0
  256. package/registry/blocks/mind-map/tree.js +645 -0
  257. package/registry/blocks/navigation-menu/README.md +124 -0
  258. package/registry/blocks/navigation-menu/block.json +10 -0
  259. package/registry/blocks/navigation-menu/demo.jsx +84 -0
  260. package/registry/blocks/navigation-menu/index.js +4 -0
  261. package/registry/blocks/navigation-menu/navigation-menu.jsx +448 -0
  262. package/registry/blocks/order-detail/README.md +109 -0
  263. package/registry/blocks/order-detail/block.json +10 -0
  264. package/registry/blocks/order-detail/demo.jsx +88 -0
  265. package/registry/blocks/order-detail/index.js +4 -0
  266. package/registry/blocks/order-detail/order-detail.jsx +354 -0
  267. package/registry/blocks/org-chart/README.md +145 -0
  268. package/registry/blocks/org-chart/block.json +10 -0
  269. package/registry/blocks/org-chart/demo.jsx +94 -0
  270. package/registry/blocks/org-chart/index.js +7 -0
  271. package/registry/blocks/org-chart/org-chart.jsx +631 -0
  272. package/registry/blocks/org-chart/org.js +245 -0
  273. package/registry/blocks/org-chart/person.jsx +96 -0
  274. package/registry/blocks/page-builder/README.md +257 -0
  275. package/registry/blocks/page-builder/block.json +14 -0
  276. package/registry/blocks/page-builder/demo.jsx +145 -0
  277. package/registry/blocks/page-builder/grapes.js +18 -0
  278. package/registry/blocks/page-builder/index.js +11 -0
  279. package/registry/blocks/page-builder/page-builder.jsx +704 -0
  280. package/registry/blocks/page-builder/page-view.jsx +33 -0
  281. package/registry/blocks/page-builder/page.js +118 -0
  282. package/registry/blocks/page-builder/panels.jsx +588 -0
  283. package/registry/blocks/page-builder/sections.js +209 -0
  284. package/registry/blocks/page-builder/theme.js +87 -0
  285. package/registry/blocks/page-builder/thumbs.jsx +189 -0
  286. package/registry/blocks/page-builder/tokens.js +329 -0
  287. package/registry/blocks/payment-methods/README.md +104 -0
  288. package/registry/blocks/payment-methods/block.json +10 -0
  289. package/registry/blocks/payment-methods/demo.jsx +71 -0
  290. package/registry/blocks/payment-methods/index.js +4 -0
  291. package/registry/blocks/payment-methods/payment-methods.jsx +302 -0
  292. package/registry/blocks/pdf-document/README.md +213 -0
  293. package/registry/blocks/pdf-document/block.json +13 -0
  294. package/registry/blocks/pdf-document/demo.jsx +136 -0
  295. package/registry/blocks/pdf-document/index.js +33 -0
  296. package/registry/blocks/pdf-document/money.js +83 -0
  297. package/registry/blocks/pdf-document/pdf-document.jsx +144 -0
  298. package/registry/blocks/pdf-document/primitives.jsx +271 -0
  299. package/registry/blocks/pdf-document/quote.jsx +179 -0
  300. package/registry/blocks/pdf-document/theme.js +193 -0
  301. package/registry/blocks/pdf-viewer/README.md +191 -0
  302. package/registry/blocks/pdf-viewer/block.json +14 -0
  303. package/registry/blocks/pdf-viewer/demo.jsx +252 -0
  304. package/registry/blocks/pdf-viewer/helpers.js +270 -0
  305. package/registry/blocks/pdf-viewer/index.js +8 -0
  306. package/registry/blocks/pdf-viewer/notes.jsx +195 -0
  307. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1368 -0
  308. package/registry/blocks/pdf-viewer/pdfjs.js +37 -0
  309. package/registry/blocks/pdf-viewer/ssr-shim-end.js +6 -0
  310. package/registry/blocks/pdf-viewer/ssr-shim.js +44 -0
  311. package/registry/blocks/product-detail/README.md +217 -0
  312. package/registry/blocks/product-detail/block.json +10 -0
  313. package/registry/blocks/product-detail/demo.jsx +233 -0
  314. package/registry/blocks/product-detail/index.js +7 -0
  315. package/registry/blocks/product-detail/price.jsx +115 -0
  316. package/registry/blocks/product-detail/product-detail.jsx +348 -0
  317. package/registry/blocks/product-detail/product-media.jsx +102 -0
  318. package/registry/blocks/product-detail/product-options.jsx +144 -0
  319. package/registry/blocks/product-grid/README.md +162 -0
  320. package/registry/blocks/product-grid/block.json +10 -0
  321. package/registry/blocks/product-grid/demo.jsx +255 -0
  322. package/registry/blocks/product-grid/index.js +5 -0
  323. package/registry/blocks/product-grid/product-card.jsx +232 -0
  324. package/registry/blocks/product-grid/product-grid.jsx +235 -0
  325. package/registry/blocks/record-form/README.md +196 -0
  326. package/registry/blocks/record-form/block.json +10 -0
  327. package/registry/blocks/record-form/demo.jsx +525 -0
  328. package/registry/blocks/record-form/index.js +4 -0
  329. package/registry/blocks/record-form/record-fields.jsx +457 -0
  330. package/registry/blocks/record-form/record-form.jsx +415 -0
  331. package/registry/blocks/record-form/rules.js +81 -0
  332. package/registry/blocks/record-notes/README.md +139 -0
  333. package/registry/blocks/record-notes/block.json +10 -0
  334. package/registry/blocks/record-notes/demo.jsx +179 -0
  335. package/registry/blocks/record-notes/index.js +4 -0
  336. package/registry/blocks/record-notes/record-notes.jsx +266 -0
  337. package/registry/blocks/search-dialog/README.md +108 -0
  338. package/registry/blocks/search-dialog/block.json +10 -0
  339. package/registry/blocks/search-dialog/demo.jsx +116 -0
  340. package/registry/blocks/search-dialog/index.js +3 -0
  341. package/registry/blocks/search-dialog/search-dialog.jsx +235 -0
  342. package/registry/blocks/settings-list/README.md +171 -0
  343. package/registry/blocks/settings-list/block.json +10 -0
  344. package/registry/blocks/settings-list/demo.jsx +333 -0
  345. package/registry/blocks/settings-list/index.js +4 -0
  346. package/registry/blocks/settings-list/row-state.jsx +93 -0
  347. package/registry/blocks/settings-list/settings-list.jsx +349 -0
  348. package/registry/blocks/settings-list/settings-matrix.jsx +118 -0
  349. package/registry/blocks/setup-checklist/README.md +142 -0
  350. package/registry/blocks/setup-checklist/block.json +10 -0
  351. package/registry/blocks/setup-checklist/demo.jsx +159 -0
  352. package/registry/blocks/setup-checklist/index.js +4 -0
  353. package/registry/blocks/setup-checklist/setup-checklist.jsx +446 -0
  354. package/registry/blocks/share-dialog/README.md +113 -0
  355. package/registry/blocks/share-dialog/block.json +10 -0
  356. package/registry/blocks/share-dialog/demo.jsx +140 -0
  357. package/registry/blocks/share-dialog/index.js +3 -0
  358. package/registry/blocks/share-dialog/share-dialog.jsx +384 -0
  359. package/registry/blocks/sidebar/README.md +190 -0
  360. package/registry/blocks/sidebar/block.json +43 -0
  361. package/registry/blocks/sidebar/demo.jsx +577 -0
  362. package/registry/blocks/sidebar/index.js +4 -0
  363. package/registry/blocks/sidebar/sidebar-panel.jsx +149 -0
  364. package/registry/blocks/sidebar/sidebar.jsx +864 -0
  365. package/registry/blocks/signature-pad/README.md +137 -0
  366. package/registry/blocks/signature-pad/block.json +13 -0
  367. package/registry/blocks/signature-pad/colors.js +115 -0
  368. package/registry/blocks/signature-pad/demo.jsx +94 -0
  369. package/registry/blocks/signature-pad/index.js +6 -0
  370. package/registry/blocks/signature-pad/pad.js +7 -0
  371. package/registry/blocks/signature-pad/signature-pad.jsx +434 -0
  372. package/registry/blocks/signature-pad/signature.js +68 -0
  373. package/registry/blocks/sketch-canvas/README.md +280 -0
  374. package/registry/blocks/sketch-canvas/block.json +13 -0
  375. package/registry/blocks/sketch-canvas/demo.jsx +159 -0
  376. package/registry/blocks/sketch-canvas/excalidraw.js +40 -0
  377. package/registry/blocks/sketch-canvas/index.js +9 -0
  378. package/registry/blocks/sketch-canvas/palette.js +226 -0
  379. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1188 -0
  380. package/registry/blocks/sketch-canvas/sketch.js +179 -0
  381. package/registry/blocks/sketch-canvas/ssr-shim-end.js +6 -0
  382. package/registry/blocks/sketch-canvas/ssr-shim.js +45 -0
  383. package/registry/blocks/sketch-canvas/stamps.js +104 -0
  384. package/registry/blocks/sketch-canvas/theme.js +131 -0
  385. package/registry/blocks/spreadsheet/README.md +307 -0
  386. package/registry/blocks/spreadsheet/block.json +13 -0
  387. package/registry/blocks/spreadsheet/cells.jsx +455 -0
  388. package/registry/blocks/spreadsheet/chrome.jsx +325 -0
  389. package/registry/blocks/spreadsheet/demo.jsx +177 -0
  390. package/registry/blocks/spreadsheet/grid.js +387 -0
  391. package/registry/blocks/spreadsheet/index.js +34 -0
  392. package/registry/blocks/spreadsheet/spreadsheet.jsx +1732 -0
  393. package/registry/blocks/spreadsheet/types.js +473 -0
  394. package/registry/blocks/stat-cards/README.md +173 -0
  395. package/registry/blocks/stat-cards/block.json +10 -0
  396. package/registry/blocks/stat-cards/demo.jsx +257 -0
  397. package/registry/blocks/stat-cards/index.js +4 -0
  398. package/registry/blocks/stat-cards/stat-cards.jsx +307 -0
  399. package/registry/blocks/stat-cards/usage.jsx +243 -0
  400. package/registry/blocks/task-list/README.md +176 -0
  401. package/registry/blocks/task-list/block.json +10 -0
  402. package/registry/blocks/task-list/demo.jsx +134 -0
  403. package/registry/blocks/task-list/index.js +5 -0
  404. package/registry/blocks/task-list/task-list.jsx +907 -0
  405. package/registry/blocks/task-model/README.md +455 -0
  406. package/registry/blocks/task-model/block.json +167 -0
  407. package/registry/blocks/task-model/changes.js +89 -0
  408. package/registry/blocks/task-model/dates.js +371 -0
  409. package/registry/blocks/task-model/demo.jsx +215 -0
  410. package/registry/blocks/task-model/dnd.jsx +240 -0
  411. package/registry/blocks/task-model/index.js +97 -0
  412. package/registry/blocks/task-model/model.js +919 -0
  413. package/registry/blocks/task-model/parts.jsx +449 -0
  414. package/registry/blocks/task-model/toolbar.jsx +271 -0
  415. package/registry/blocks/testimonials/README.md +139 -0
  416. package/registry/blocks/testimonials/block.json +11 -0
  417. package/registry/blocks/testimonials/demo.jsx +137 -0
  418. package/registry/blocks/testimonials/index.js +4 -0
  419. package/registry/blocks/testimonials/testimonials.jsx +430 -0
  420. package/registry/blocks/transaction-list/README.md +126 -0
  421. package/registry/blocks/transaction-list/block.json +10 -0
  422. package/registry/blocks/transaction-list/demo.jsx +79 -0
  423. package/registry/blocks/transaction-list/index.js +4 -0
  424. package/registry/blocks/transaction-list/transaction-list.jsx +383 -0
  425. package/registry/blocks/whiteboard/README.md +179 -0
  426. package/registry/blocks/whiteboard/block.json +10 -0
  427. package/registry/blocks/whiteboard/demo.jsx +169 -0
  428. package/registry/blocks/whiteboard/index.js +6 -0
  429. package/registry/blocks/whiteboard/templates.js +150 -0
  430. package/registry/blocks/whiteboard/whiteboard.jsx +721 -0
  431. package/src/install.mjs +18 -0
  432. package/src/shadcn.mjs +171 -0
  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/parts/fixture.jsx +1 -1
  437. package/templates/next/files/components/parts/sample.js +15 -0
  438. package/templates/next/files/components/ui/avatar.jsx +141 -0
  439. package/templates/next/files/components/ui/breadcrumb.jsx +149 -0
  440. package/templates/next/files/components/ui/button-group.jsx +70 -0
  441. package/templates/next/files/components/ui/button.jsx +132 -12
  442. package/templates/next/files/components/ui/calendar.jsx +609 -0
  443. package/templates/next/files/components/ui/carousel.jsx +281 -0
  444. package/templates/next/files/components/ui/collapsible.jsx +208 -0
  445. package/templates/next/files/components/ui/combobox.jsx +608 -0
  446. package/templates/next/files/components/ui/command.jsx +577 -0
  447. package/templates/next/files/components/ui/context-menu.jsx +191 -0
  448. package/templates/next/files/components/ui/date-picker.jsx +360 -0
  449. package/templates/next/files/components/ui/distribution.jsx +117 -0
  450. package/templates/next/files/components/ui/feedback.jsx +143 -0
  451. package/templates/next/files/components/ui/field.jsx +115 -3
  452. package/templates/next/files/components/ui/file-input.jsx +234 -0
  453. package/templates/next/files/components/ui/icon.jsx +130 -8
  454. package/templates/next/files/components/ui/image.jsx +143 -0
  455. package/templates/next/files/components/ui/index.js +47 -2
  456. package/templates/next/files/components/ui/input-group.jsx +213 -0
  457. package/templates/next/files/components/ui/input-otp.jsx +170 -0
  458. package/templates/next/files/components/ui/item.jsx +206 -0
  459. package/templates/next/files/components/ui/menu.jsx +454 -82
  460. package/templates/next/files/components/ui/notice.jsx +126 -14
  461. package/templates/next/files/components/ui/pagination.jsx +156 -0
  462. package/templates/next/files/components/ui/popover.jsx +284 -0
  463. package/templates/next/files/components/ui/radio.jsx +193 -0
  464. package/templates/next/files/components/ui/rating.jsx +131 -0
  465. package/templates/next/files/components/ui/resizable.jsx +338 -0
  466. package/templates/next/files/components/ui/scroll-area.jsx +123 -0
  467. package/templates/next/files/components/ui/separator.jsx +119 -0
  468. package/templates/next/files/components/ui/sheet.jsx +96 -11
  469. package/templates/next/files/components/ui/slider.jsx +174 -0
  470. package/templates/next/files/components/ui/stat.jsx +574 -0
  471. package/templates/next/files/components/ui/steps.jsx +338 -0
  472. package/templates/next/files/components/ui/swatch.jsx +33 -0
  473. package/templates/next/files/components/ui/table.jsx +130 -0
  474. package/templates/next/files/components/ui/toast.jsx +440 -0
  475. package/templates/next/files/components/ui/toggle.jsx +195 -0
  476. package/templates/next/files/components/ui/tooltip.jsx +270 -0
  477. package/templates/next/files/lib/sample.js +4 -12
  478. package/templates/next/template.json +264 -8
package/r/core.json ADDED
@@ -0,0 +1,341 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "core",
4
+ "type": "registry:ui",
5
+ "title": "Makersclaw kit",
6
+ "description": "The kit every Makersclaw block is built from — Button, Field, Select, Sheet, Tabs, Menu, Notice, Icon, Depiction — and the parts bound to data (RecordSwitcher, useFixture). Native elements under the project's own theme tokens.",
7
+ "files": [
8
+ {
9
+ "path": "templates/next/files/components/parts/fixture.jsx",
10
+ "type": "registry:ui",
11
+ "target": "@components/makersclaw/parts/fixture.jsx",
12
+ "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { markSample } from \"./sample\";\nimport { Button, Notice } from \"../ui\";\n\n// The worked example, never persisted. A surface opens populated (README\n// §3: the template test) — but populated is not persisted. The example is\n// rows in the app's code, shown while the real collection is empty and\n// never INSERTed: the workspace database is shared by every app, so an\n// invented row there is a real row somewhere else. Clearing writes\n// nothing; the first real write is the user's.\n//\n// const { rows, isFixture, clear } = useFixture(realRows, SAMPLE);\n// {isFixture ? <FixtureNotice onClear={clear} /> : null}\n//\n// A fixture row carries `_sample: true` and a `sample-N` id. Editing one\n// promotes it: the surface copies its values into a new draft and saves\n// THAT — it never updateRow()s a sample id (the row does not exist).\n//\n// `rows` keeps its identity between renders (an effect may depend on it);\n// declare SAMPLE at module level, not inside the component, so it does too.\n\nconst NONE = [];\n\nexport function useFixture(rows, sample, { idColumn = \"id\" } = {}) {\n const [cleared, setCleared] = useState(false);\n const isFixture = !cleared && (!rows || rows.length === 0) && Array.isArray(sample) && sample.length > 0;\n const fixtureRows = useMemo(() => markSample(sample, idColumn), [sample, idColumn]);\n return {\n rows: isFixture ? fixtureRows : rows ?? NONE,\n isFixture,\n clear: () => setCleared(true),\n restore: () => setCleared(false),\n };\n}\n\nexport function isSample(row) {\n return Boolean(row && row._sample);\n}\n\n/** The line above a fixture, with the one control it earns. */\nexport function FixtureNotice({ onClear, children, className }) {\n return (\n <Notice\n tone=\"info\"\n title=\"Example only\"\n className={className}\n action={\n onClear ? (\n <Button variant=\"quiet\" size=\"sm\" onClick={onClear}>\n Clear the example\n </Button>\n ) : null\n }\n >\n {children ?? \"Edit it or start your own. Nothing here is saved until you save it.\"}\n </Notice>\n );\n}\n"
13
+ },
14
+ {
15
+ "path": "templates/next/files/components/parts/index.js",
16
+ "type": "registry:ui",
17
+ "target": "@components/makersclaw/parts/index.js",
18
+ "content": "// Parts: the kit's pieces bound to this template's data layer. Each\n// exists because an app got it wrong once; each carries the behaviour\n// that makes it right. README §3b.\nexport { useFixture, isSample, FixtureNotice } from \"./fixture\";\nexport { RecordSwitcher } from \"./record-switcher\";\n"
19
+ },
20
+ {
21
+ "path": "templates/next/files/components/parts/record-switcher.jsx",
22
+ "type": "registry:ui",
23
+ "target": "@components/makersclaw/parts/record-switcher.jsx",
24
+ "content": "\"use client\";\n\nimport { useId } from \"react\";\nimport { Button, Select } from \"../ui\";\n\n// Pick a saved record, or make a new one. With nothing saved there is\n// nothing to pick, so it shows the one control that has a job: New. A\n// disabled dropdown reading \"No saved…\" is a control that cannot be used\n// drawn at full weight — the thing this part exists to prevent.\n//\n// <RecordSwitcher rows={drafts} value={draft.id} onChange={choose}\n// labelOf={(r) => r.title} onNew={newDraft} newLabel=\"New draft\" />\n\nexport function RecordSwitcher({ rows, value, onChange, labelOf = (r) => r.title ?? r.name ?? r.id, onNew, newLabel = \"New\", label = \"Saved\", idColumn = \"id\", className }) {\n const id = useId();\n const live = (rows ?? []).filter((r) => !r._sample);\n if (live.length === 0) {\n return onNew ? (\n <div className={className}>\n <Button variant=\"secondary\" icon=\"plus\" onClick={onNew}>\n {newLabel}\n </Button>\n </div>\n ) : null;\n }\n return (\n <div className={[\"flex items-end gap-2\", className].filter(Boolean).join(\" \")}>\n <label htmlFor={id} className=\"sr-only\">\n {label}\n </label>\n <div className=\"min-w-0 flex-1\">\n <Select\n id={id}\n value={value ?? \"\"}\n onChange={(e) => onChange?.(e.target.value)}\n options={[\n ...(value ? [] : [{ value: \"\", label: `${label}…` }]),\n ...live.map((r) => ({ value: String(r[idColumn]), label: labelOf(r) })),\n ]}\n />\n </div>\n {onNew ? (\n <Button variant=\"secondary\" icon=\"plus\" onClick={onNew}>\n {newLabel}\n </Button>\n ) : null}\n </div>\n );\n}\n"
25
+ },
26
+ {
27
+ "path": "templates/next/files/components/parts/sample.js",
28
+ "type": "registry:ui",
29
+ "target": "@components/makersclaw/parts/sample.js",
30
+ "content": "// Pure: importable from either side. The parts use it in the browser\n// (fixture.jsx); lib/sample.js re-exports it for server code (lib/data.js).\n// It lives beside the parts, not in lib/, so the parts travel as one folder\n// — into an app's components/parts, or a shadcn project's\n// components/makersclaw/parts — and import nothing outside it but the kit.\n\n/** Sample rows get stable ids and a `_sample: true` marker, so a surface\n * can label them until real rows exist and refuse to edit them. */\nexport function markSample(sample, idColumn = \"id\") {\n return (sample ?? []).map((r, i) => ({\n ...r,\n [idColumn]: r[idColumn] ?? `sample-${i + 1}`,\n _sample: true,\n }));\n}\n"
31
+ },
32
+ {
33
+ "path": "templates/next/files/components/ui/avatar.jsx",
34
+ "type": "registry:ui",
35
+ "target": "@components/makersclaw/ui/avatar.jsx",
36
+ "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Avatar — a person's face, or their initials while it loads and when it\n// fails. The initials are always drawn; the image lays over them only\n// once it has loaded, so a slow, missing or broken `src` never shows a\n// hole or the browser's broken-image glyph. No name either: a user icon.\n//\n// name \"Jane Cooper\" → JC; also the accessible name\n// src the image; optional\n// size xs (24px) · sm (32px) · md (40px) · lg (56px)\n// shape round · square (a company, a bot, a workspace)\n// status online · away · busy · offline — a dot, named to a reader\n// label overrides what a screen reader says\n//\n// AvatarGroup below is the overlapping stack with \"+N\" after `max`.\n\nconst SIZES = {\n xs: { box: \"h-6 w-6 text-2xs\", dot: \"h-1.5 w-1.5\", icon: 12, overlap: \"-space-x-1.5\" },\n sm: { box: \"h-8 w-8 text-xs\", dot: \"h-2 w-2\", icon: 14, overlap: \"-space-x-2\" },\n md: { box: \"h-10 w-10 text-sm\", dot: \"h-2.5 w-2.5\", icon: 18, overlap: \"-space-x-2.5\" },\n lg: { box: \"h-14 w-14 text-lg\", dot: \"h-3.5 w-3.5\", icon: 24, overlap: \"-space-x-3\" },\n};\n\nconst SHAPES = { round: \"rounded-full\", square: \"rounded-md\" };\n\n// Colour is the signal: the status tones are the workspace's status tokens.\nconst STATUS = {\n online: { dot: \"bg-success\", say: \"online\" },\n away: { dot: \"bg-warning\", say: \"away\" },\n busy: { dot: \"bg-destructive\", say: \"busy\" },\n offline: { dot: \"bg-muted-foreground\", say: \"offline\" },\n};\n\n/** \"Jane Cooper\" → \"JC\", \"jane@acme.com\" → \"J\", \"Priya\" → \"P\". First and\n * last word, by code point, so an accented or non-Latin name survives. */\nfunction initials(name) {\n const words = String(name ?? \"\").split(\"@\")[0].trim().split(/[\\s._-]+/).filter(Boolean);\n if (words.length === 0) return \"\";\n const first = Array.from(words[0])[0] ?? \"\";\n const last = words.length > 1 ? Array.from(words[words.length - 1])[0] ?? \"\" : \"\";\n return (first + last).toUpperCase();\n}\n\nexport function Avatar({ name, src, size = \"md\", shape = \"round\", status, label, className }) {\n const s = SIZES[size] ?? SIZES.md;\n const round = SHAPES[shape] ?? SHAPES.round;\n const st = STATUS[status];\n const img = useRef(null);\n // Both hold the src they are about, so a new src starts fresh unreset.\n const [loaded, setLoaded] = useState(null);\n const [failed, setFailed] = useState(null);\n\n // A server-rendered <img> can load or fail before React is listening.\n useEffect(() => {\n const i = img.current;\n if (!i || !src || !i.complete) return;\n if (i.naturalWidth > 0) setLoaded(src);\n else setFailed(src);\n }, [src]);\n\n const text = initials(name);\n const said = label ?? ([name, st?.say].filter(Boolean).join(\", \") || undefined);\n const showImg = src && failed !== src;\n\n return (\n <span\n role={said ? \"img\" : undefined}\n aria-label={said}\n aria-hidden={said ? undefined : true}\n title={name || undefined}\n className={cx(\"relative inline-flex shrink-0 select-none\", round, s.box, className)}\n >\n <span className={cx(\"relative flex h-full w-full items-center justify-center overflow-hidden bg-muted font-medium text-muted-foreground\", round)}>\n {/* Hidden, not removed, once the image is in: a transparent PNG must not show letters through it. */}\n <span aria-hidden=\"true\" className={cx(\"inline-flex leading-none\", loaded === src && showImg && \"invisible\")}>\n {text || <Icon name=\"user\" size={s.icon} />}\n </span>\n {showImg ? (\n <img\n ref={img}\n src={src}\n alt=\"\"\n onLoad={() => setLoaded(src)}\n onError={() => setFailed(src)}\n className={cx(\"absolute inset-0 h-full w-full object-cover\", loaded === src ? \"opacity-100\" : \"opacity-0\")}\n />\n ) : null}\n </span>\n {st ? (\n <span\n aria-hidden=\"true\"\n className={cx(\n \"absolute rounded-full ring-2 ring-background\",\n shape === \"square\" ? \"-bottom-0.5 -right-0.5\" : \"bottom-0 right-0\",\n s.dot,\n st.dot,\n )}\n />\n ) : null}\n </span>\n );\n}\n\n/** Faces overlapping, the first `max` of `people`, then \"+N\" naming the\n * rest on hover and to a reader. No status dots: the next face covers\n * them. Empty `people` renders nothing.\n *\n * people [{ name, src? }]\n * max how many faces before \"+N\" (default 4)\n */\nexport function AvatarGroup({ people, max = 4, size = \"sm\", shape = \"round\", label, className }) {\n const list = (people ?? []).filter(Boolean);\n if (list.length === 0) return null;\n const s = SIZES[size] ?? SIZES.sm;\n const round = SHAPES[shape] ?? SHAPES.round;\n const shown = list.slice(0, Math.max(1, max));\n const rest = list.slice(shown.length);\n const restNames = rest.map((p) => p.name).filter(Boolean).join(\", \");\n\n return (\n <div role=\"group\" aria-label={label ?? `${list.length} people`} className={cx(\"flex items-center\", s.overlap, className)}>\n {shown.map((p, i) => (\n <Avatar key={i} name={p.name} src={p.src} size={size} shape={shape} className=\"ring-2 ring-background\" />\n ))}\n {rest.length > 0 ? (\n <span\n role=\"img\"\n aria-label={`and ${rest.length} more${restNames ? `: ${restNames}` : \"\"}`}\n title={restNames || undefined}\n className={cx(\"relative inline-flex shrink-0 items-center justify-center bg-muted font-medium text-muted-foreground ring-2 ring-background\", round, s.box)}\n >\n <span data-nums aria-hidden=\"true\" className=\"leading-none\">+{rest.length}</span>\n </span>\n ) : null}\n </div>\n );\n}\n"
37
+ },
38
+ {
39
+ "path": "templates/next/files/components/ui/breadcrumb.jsx",
40
+ "type": "registry:ui",
41
+ "target": "@components/makersclaw/ui/breadcrumb.jsx",
42
+ "content": "\"use client\";\n\nimport { useId, useRef } from \"react\";\nimport Link from \"next/link\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\nimport { usePopoverPosition } from \"./popover\";\n\n// Breadcrumb — where this page sits: Deals › Acme Corp › Renewal. Items\n// are data; each with an `href` is a next/link (base path applied), the\n// last is the page you are on (aria-current) and is never a link.\n// A client part (a server surface renders it as it is; items are plain\n// data): the \"…\" that stands in for the middle is a native popover\n// (popovertarget), kept under its button by usePopoverPosition — CSS\n// anchoring where the browser has it, measured where it does not.\n//\n// items [{ label, href? }] — first is the root, last is this page\n// max how many slots on a wide window before the middle folds\n// into \"…\" (default 4, the \"…\" counts as one); a narrow\n// window shows first › … › last once there are four or more\n// separator chevron (default) · slash\n//\n// Long labels truncate, the full text on hover. Earlier crumbs are capped\n// (max-w-40, 56 on sm+); the current page takes the rest of the row. One item or none: nothing\n// to place the page within, so nothing is drawn.\n\nconst SEP = {\n chevron: <Icon name=\"chevron-right\" size={14} />,\n slash: <span className=\"px-0.5\">/</span>,\n};\n\n// Shown at one width and not the other: the \"…\", each crumb, each entry\n// in the popover. Whole class names, so Tailwind sees them.\nconst SHOW = {\n flex: { both: \"flex\", narrow: \"flex sm:hidden\", wide: \"hidden sm:flex\", none: \"hidden\" },\n block: { both: \"block\", narrow: \"block sm:hidden\", wide: \"hidden sm:block\", none: \"hidden\" },\n};\n\n// Every crumb truncates; only the earlier ones carry a cap. The current\n// page is the one worth reading, so it takes whatever the row has left\n// and cuts off only when the row really runs out. It is clamped to one\n// line rather than kept on one (nowrap), so its narrowest size is a\n// letter, not the whole title: a card or grid cell sized by its content\n// still shrinks to the window instead of being pushed wide by the title.\nconst CRUMB = \"min-w-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\";\nconst CAP = \"block max-w-40 truncate sm:max-w-56\";\nconst REST = \"line-clamp-1 [overflow-wrap:anywhere]\";\n\nexport function Breadcrumb({ items, max = 4, separator = \"chevron\", label = \"Breadcrumb\", className }) {\n const auto = useId();\n const more = useRef(null);\n const panel = useRef(null);\n usePopoverPosition(more, panel, { side: \"bottom\", align: \"start\", offset: 4 });\n const list = (items ?? []).filter(Boolean);\n if (list.length < 2) return null;\n\n const id = `crumbs-${auto.replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const n = list.length;\n const keep = Math.max(1, max - 2); // trailing items kept beside the first and the \"…\"\n // Middle item i folds on a wide window when there are more than `max`;\n // on a narrow one every middle item folds once there are four or more\n // (three fit, truncated).\n const foldsWide = (i) => n > max && i > 0 && i < n - keep;\n const foldsNarrow = n > 3;\n const middle = list.slice(1, -1).map((item, k) => ({ item, i: k + 1 }));\n const wideFold = middle.some(({ i }) => foldsWide(i));\n const at = (narrow, wide, display = \"flex\") => SHOW[display][narrow ? (wide ? \"both\" : \"narrow\") : wide ? \"wide\" : \"none\"];\n const sep = (\n <span aria-hidden=\"true\" className=\"shrink-0 text-muted-foreground/70\">\n {SEP[separator] ?? SEP.chevron}\n </span>\n );\n\n const crumb = (item, current) =>\n current || !item.href ? (\n <span aria-current={current ? \"page\" : undefined} title={item.label} className={cx(CRUMB, current ? cx(REST, \"font-medium text-foreground\") : CAP)}>\n {item.label}\n </span>\n ) : (\n <Link href={item.href} title={item.label} className={cx(CRUMB, CAP, \"transition-colors hover:text-foreground\")}>\n {item.label}\n </Link>\n );\n\n return (\n <nav aria-label={label} className={cx(\"min-w-0\", className)}>\n <ol className=\"flex min-w-0 items-center gap-1.5 text-sm text-muted-foreground\">\n <li className=\"flex min-w-0 items-center\">{crumb(list[0], false)}</li>\n\n {middle.length > 0 ? (\n <li className={cx(\"shrink-0 items-center gap-1.5\", at(foldsNarrow, wideFold))}>\n {sep}\n <button\n type=\"button\"\n ref={more}\n popoverTarget={id}\n aria-label=\"Show the full path\"\n title=\"Show the full path\"\n className=\"-mx-1 inline-flex h-8 w-8 items-center justify-center rounded-md outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Icon name=\"more\" size={18} />\n </button>\n {/* Keyed by the page, so a client navigation lands with it closed. */}\n <div\n key={list[n - 1].href ?? list[n - 1].label}\n ref={panel}\n id={id}\n popover=\"auto\"\n className=\"min-w-44 max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg\"\n >\n <ul>\n {middle.map(({ item, i }) => (\n <li key={i} className={at(foldsNarrow, foldsWide(i), \"block\")}>\n {item.href ? (\n <Link\n href={item.href}\n className=\"flex min-h-10 items-center rounded-sm px-2 text-sm outline-none hover:bg-accent focus-visible:bg-accent\"\n >\n <span className=\"truncate\">{item.label}</span>\n </Link>\n ) : (\n <span className=\"flex min-h-10 items-center px-2 text-sm text-muted-foreground\">\n <span className=\"truncate\">{item.label}</span>\n </span>\n )}\n </li>\n ))}\n </ul>\n </div>\n </li>\n ) : null}\n\n {middle.map(({ item, i }) => (\n <li key={i} className={cx(\"min-w-0 items-center gap-1.5\", at(!foldsNarrow, !foldsWide(i)))}>\n {sep}\n {crumb(item, false)}\n </li>\n ))}\n\n {/* The rest of the row, and never less than 10rem (or half a narrow\n row): long earlier crumbs give way before this page's title. */}\n <li className=\"flex min-w-[min(10rem,50%)] flex-1 items-center gap-1.5\">\n {sep}\n {crumb(list[n - 1], true)}\n </li>\n </ol>\n </nav>\n );\n}\n"
43
+ },
44
+ {
45
+ "path": "templates/next/files/components/ui/button-group.jsx",
46
+ "type": "registry:ui",
47
+ "target": "@components/makersclaw/ui/button-group.jsx",
48
+ "content": "import { cx } from \"./cx\";\n\n// ButtonGroup — buttons that belong together drawn as one attached\n// piece: a split \"Save | ▾\", a pager \"‹ 2 of 9 ›\", a zoom \"− 100% +\".\n// It only reshapes its children (square inner corners, one shared border\n// between neighbours); each child stays the Button, IconButton or Menu\n// it is, with its own behaviour and focus. Server-safe (no hooks).\n//\n// orientation horizontal (the default) · vertical\n// size md · sm — match the buttons inside\n// variant secondary (the default) · primary · quiet — how a Menu's\n// trigger inside is drawn, to match its neighbours (the\n// Buttons keep their own variant)\n// aria-label what the group is for (\"Pagination\", \"Text alignment\")\n//\n// Secondary buttons share their borders, so they need no separator.\n// Primary and quiet buttons have no visible border: put a\n// <ButtonGroupSeparator /> between them. A group is for a few related\n// actions — a toolbar with sections is several groups with a gap.\n\nconst ORIENT = {\n horizontal: cx(\n \"inline-flex flex-row items-stretch\",\n \"[&>:where(:not([popover]))]:rounded-none [&>:first-child]:rounded-l-md [&>:last-child]:rounded-r-md [&>:has(+[popover]:last-child)]:rounded-r-md\",\n \"[&>:not(:first-child):not([popover])]:border-l-0 [&>[role=separator]]:w-px\",\n ),\n vertical: cx(\n \"inline-flex flex-col items-stretch\",\n \"[&>:where(:not([popover]))]:rounded-none [&>:first-child]:rounded-t-md [&>:last-child]:rounded-b-md [&>:has(+[popover]:last-child)]:rounded-b-md\",\n \"[&>:not(:first-child):not([popover])]:border-t-0 [&>[role=separator]]:h-px\",\n ),\n};\n\n// A Menu renders a bare trigger; inside a group it wears its neighbours' look.\nconst MENU = {\n secondary: \"[&>[aria-haspopup]]:border [&>[aria-haspopup]]:border-border [&>[aria-haspopup]]:bg-secondary [&>[aria-haspopup]]:text-secondary-foreground [&>[aria-haspopup]]:hover:bg-accent\",\n primary: \"[&>[aria-haspopup]]:border [&>[aria-haspopup]]:border-transparent [&>[aria-haspopup]]:bg-primary [&>[aria-haspopup]]:text-primary-foreground [&>[aria-haspopup]]:hover:bg-primary [&>[aria-haspopup]]:hover:opacity-90\",\n quiet: \"[&>[aria-haspopup]]:border [&>[aria-haspopup]]:border-transparent\",\n};\nconst MENU_SIZE = {\n md: \"[&>[aria-haspopup]]:min-h-11 [&>[aria-haspopup]]:min-w-11\",\n sm: \"[&>[aria-haspopup]]:min-h-9 [&>[aria-haspopup]]:min-w-9\",\n};\n\nexport function ButtonGroup({ orientation = \"horizontal\", size = \"md\", variant = \"secondary\", className, children, ...rest }) {\n return (\n <div\n role=\"group\"\n aria-orientation={orientation === \"vertical\" ? \"vertical\" : undefined}\n className={cx(\n ORIENT[orientation] ?? ORIENT.horizontal,\n MENU[variant] ?? MENU.secondary,\n MENU_SIZE[size] ?? MENU_SIZE.md,\n // The focused child's ring draws over its neighbours.\n \"[&>:not([popover])]:relative [&>:focus-visible]:z-10\",\n className,\n )}\n {...rest}\n >\n {children}\n </div>\n );\n}\n\n/** The line between two buttons whose borders do not show (primary,\n * quiet). The group sizes it; in a vertical group pass\n * orientation=\"horizontal\" so a screen reader hears it right. */\nexport function ButtonGroupSeparator({ orientation = \"vertical\", className }) {\n return <div role=\"separator\" aria-orientation={orientation} className={cx(\"shrink-0 self-stretch bg-border\", className)} />;\n}\n"
49
+ },
50
+ {
51
+ "path": "templates/next/files/components/ui/button.jsx",
52
+ "type": "registry:ui",
53
+ "target": "@components/makersclaw/ui/button.jsx",
54
+ "content": "import Link from \"next/link\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\nimport { Spinner } from \"./feedback\";\n\n// Button — every action in the app. One shape, 44px targets\n// (mobile first: a thumb hits it). `href` makes it a next/link (base\n// path applied); `icon` names one from ./icon. Never style a <button>\n// by hand in a surface: the look is the workspace's, the behaviour is\n// the browser's, and this is the only place both meet.\n//\n// variant primary (the one main action per surface) · secondary ·\n// quiet (no fill) · danger (destructive, red text) ·\n// link (text only: inline, no box, padding or min-height — it\n// wraps and truncates with the sentence it sits in) · nav (a navigation row: quiet, full\n// width, start-aligned, normal weight; aria-current=\"page\"\n// fills it and sets it medium)\n// size md (44px) · sm (36px, for dense rows and rails); a link has\n// no height, so for it md and sm are text-sm and `inherit`\n// takes the surrounding text's size and weight\n// align center (the default) · start (label at the leading edge, for\n// a full-width row of your own; nav is always start)\n// loading true while its action runs: a spinner over the label (the\n// width does not jump), aria-busy, and disabled so a second\n// click cannot fire it again\n// tone a link's colour: primary (the default, text-primary) · muted\n// (text-muted-foreground, foreground on hover — a secondary\n// link beside a heading, \"What's new\", \"View all\"); only a\n// link reads it\n// iconOnly a square at the size (IconButton sets it; a Button whose\n// only child is an <Icon> should too)\n//\n// cx does not merge classes: a className can add, never override, what a\n// variant or size already sets. Anything a surface needs to vary is a\n// prop here, not a className fight.\n\nconst VARIANTS = {\n primary: \"bg-primary text-primary-foreground hover:opacity-90 border-transparent\",\n secondary: \"bg-secondary text-secondary-foreground border-border hover:bg-accent\",\n quiet: \"bg-transparent text-foreground border-transparent hover:bg-accent\",\n danger: \"bg-transparent text-destructive border-border hover:bg-accent\",\n link: \"underline-offset-4 hover:underline\",\n nav:\n \"w-full min-w-0 bg-transparent text-foreground border-transparent hover:bg-accent \" +\n \"[&[aria-current]:not([aria-current=false])]:bg-accent [&[aria-current]:not([aria-current=false])]:text-accent-foreground\",\n};\n\n// A link's colour, apart from its variant so a secondary link is a prop\n// and not a text-muted-foreground laid over text-primary.\nconst LINK_TONES = {\n primary: \"text-primary\",\n muted: \"text-muted-foreground hover:text-foreground\",\n};\n\n// The shape each variant is drawn in. A nav row spaces its icon wider.\n// A link is text in a line: inline, not a flex box — no border, padding\n// or height, it wraps where the sentence wraps and truncates with it. Its\n// icons sit on the text's line: `icon` leads, an <Icon> child trails.\nconst SHAPE = {\n box: \"inline-flex items-center gap-2 rounded-md border whitespace-nowrap\",\n nav: \"inline-flex items-center gap-3 rounded-md border whitespace-nowrap\",\n link: \"inline rounded-sm [&>svg]:ml-1 [&>svg]:inline-block [&>svg]:align-[-0.125em]\",\n};\n\n// Weight lives apart from the shape so a nav row can be normal weight\n// (its current row medium) without laying one font weight over another.\nconst WEIGHT = {\n box: \"font-medium\",\n nav: \"font-normal [&[aria-current]:not([aria-current=false])]:font-medium\",\n};\n\nconst SIZES = {\n md: \"min-h-11 px-4 text-sm\",\n sm: \"min-h-9 px-3 text-sm\",\n};\n\n// A link's sizes are only its text: md and sm as every other button,\n// inherit for a link inside a sentence.\nconst LINK_SIZES = {\n md: \"text-sm font-medium\",\n sm: \"text-sm font-medium\",\n inherit: \"\",\n};\n\n// IconButton's squares. Their own entries, never a px-0 laid over a\n// size's padding: cx does not merge classes, so which of two paddings\n// wins is CSS order, not the order they were written in.\nconst ICON_SIZES = {\n md: \"size-11 px-0 text-sm\",\n sm: \"size-9 px-0 text-sm\",\n};\n\nconst ALIGN = {\n center: \"justify-center\",\n start: \"justify-start text-left\",\n};\n\nexport function Button({\n variant = \"secondary\",\n size = \"md\",\n icon,\n href,\n className,\n children,\n type = \"button\",\n loading = false,\n disabled,\n iconOnly = false,\n align = \"center\",\n tone = \"primary\",\n ...rest\n}) {\n const look = VARIANTS[variant] ? variant : \"secondary\";\n const link = look === \"link\";\n const nav = look === \"nav\";\n const sizeClass = link\n ? LINK_SIZES[size] ?? LINK_SIZES.md\n : iconOnly\n ? ICON_SIZES[size] ?? ICON_SIZES.md\n : SIZES[size] ?? SIZES.md;\n const classes = cx(\n SHAPE[link ? \"link\" : nav ? \"nav\" : \"box\"],\n !link && (nav ? WEIGHT.nav : WEIGHT.box),\n !link && !iconOnly && (ALIGN[nav ? \"start\" : align] ?? ALIGN.center),\n iconOnly && ALIGN.center,\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n VARIANTS[look],\n link && (LINK_TONES[tone] ?? LINK_TONES.primary),\n sizeClass,\n // Busy is not unavailable: the button keeps its full colour.\n loading && \"relative disabled:aria-busy:opacity-100 disabled:aria-busy:cursor-progress\",\n loading && href && \"pointer-events-none opacity-50\",\n className,\n );\n const lead = icon ? (\n link ? (\n <span className=\"mr-1 inline-block align-[-0.125em]\">\n <Icon name={icon} />\n </span>\n ) : (\n <Icon name={icon} />\n )\n ) : null;\n const content = (\n <>\n {lead}\n {children}\n </>\n );\n // Loading: the label stays in place (transparent, still the accessible\n // name) so the button keeps its width; the spinner sits on top of it.\n const body = loading ? (\n <>\n <span className=\"inline-flex items-center gap-2 opacity-0\">{content}</span>\n <span className=\"absolute inset-0 grid place-items-center\">\n <Spinner size=\"sm\" label={null} />\n </span>\n </>\n ) : (\n content\n );\n if (href) {\n return (\n <Link\n href={href}\n className={classes}\n aria-busy={loading || undefined}\n aria-disabled={loading || undefined}\n tabIndex={loading ? -1 : undefined}\n disabled={disabled}\n {...rest}\n >\n {body}\n </Link>\n );\n }\n return (\n <button type={type} className={classes} disabled={loading || disabled} aria-busy={loading || undefined} {...rest}>\n {body}\n </button>\n );\n}\n\n/** A square button holding only an icon; `label` is required and is what\n * a screen reader says. */\nexport function IconButton({ icon, label, size = \"md\", variant = \"quiet\", className, ...rest }) {\n return (\n <Button\n variant={variant}\n size={size}\n aria-label={label}\n title={label}\n iconOnly\n className={cx(\"shrink-0\", className)}\n {...rest}\n >\n <Icon name={icon} size={size === \"sm\" ? 16 : 18} />\n </Button>\n );\n}\n"
55
+ },
56
+ {
57
+ "path": "templates/next/files/components/ui/calendar.jsx",
58
+ "type": "registry:ui",
59
+ "target": "@components/makersclaw/ui/calendar.jsx",
60
+ "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cx } from \"./cx\";\nimport { IconButton } from \"./button\";\nimport { Select } from \"./field\";\n\n// Calendar — a month grid to pick a day, several days, or a span of days.\n// It is the WAI-ARIA date grid: one Tab stop (the focused day), arrows\n// move a day or a week, Page Up/Down a month (with Shift, a year), Home\n// and End the week's edges, Enter or Space picks. Names, week start and\n// digits come from Intl for the reader's locale. No date library.\n//\n// mode single (value \"2026-03-12\" | null) · multiple (value\n// [\"2026-03-12\", …], kept sorted) · range (value { from, to }\n// | null; `to` is null until the second click; a second click\n// before `from` swaps them)\n// value, onChange(value) — controlled; or `defaultValue`\n// required single: clicking the chosen day again keeps it (default:\n// it clears)\n// month the month shown, \"2026-03\" (controlled, with onMonthChange);\n// or `defaultMonth`; else the first chosen day's, else today's\n// numberOfMonths side by side from 640px, stacked on a phone\n// min, max \"YYYY-MM-DD\", inclusive; days past them are disabled and\n// the arrows stop there\n// disabled (date) → true for a day that cannot be picked (weekends,\n// booked days); true disables every day\n// captionLayout label (default) · dropdown (month and year Selects for\n// far jumps; the arrows stay) · none (no caption and no\n// arrows: the parent shows the month and moves it, passing a\n// controlled `month`; a screen reader still hears the month)\n// fromYear, toYear the dropdown's years (default: min/max, else today\n// −100 … +10)\n// showOutsideDays the neighbouring months' days, muted (true)\n// showWeekNumbers ISO 8601 week numbers down the side\n// marked days that hold something (posts, bookings): a list of\n// \"YYYY-MM-DD\", or { \"2026-03-04\": { tone, label } } — a dot\n// under the number in `tone` (primary, the default · neutral ·\n// success · warning · danger · info); `label` joins the day's\n// name for a screen reader (\"Wednesday, March 4, 2026, 3\n// posts\"). Selection is unchanged: open the day's items from\n// onChange\n// emphasizeMarked marked days drawn bold, the rest muted — a calendar\n// that exists to show where the posts are (false)\n// onlyMarked unmarked days cannot be picked, with no strike-through: they\n// are empty, not forbidden (that stays `disabled`'s look).\n// Keys still walk them (false)\n// weekStartsOn 0 (Sunday) … 6; default: the locale's\n// locale a BCP 47 tag; default: the browser's\n// today \"YYYY-MM-DD\", drawn ringed; default: the browser's today.\n// Pass it where a screen must not change from day to day (a\n// demo, a test)\n// size md (44px days) · sm (36px days from 640px, 44px on a phone)\n// autoFocus when it turns true, focus moves to the grid (a picker\n// passes its open state)\n// label what a screen reader calls the whole calendar (\"Calendar\")\n//\n// Dates are plain \"YYYY-MM-DD\" strings in and out, never Date objects. A\n// date is a day on a wall calendar, not an instant: a Date is an instant,\n// and \"2026-03-12\" parsed as one is midnight UTC — the 11th in New York.\n// Strings compare in order (\"2026-03-02\" < \"2026-03-12\"), survive JSON\n// and a database's `date` column unchanged, and mean the same day in\n// every timezone. Inside, arithmetic runs on UTC midnights, where no\n// daylight-saving shift can add or lose a day. The helpers below are\n// exported for the same work in a surface.\n\n// ── Dates as strings ────────────────────────────────────────────────────\n\nconst ISO = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\nconst DAY = 86400000;\nconst pad = (n, w = 2) => String(n).padStart(w, \"0\");\n\n/** \"YYYY-MM-DD\" → a Date at that day's UTC midnight; null when it is not\n * a real day (\"2026-02-30\", \"\", undefined). */\nexport function parseDate(s) {\n const m = typeof s === \"string\" ? ISO.exec(s) : null;\n if (!m) return null;\n const d = new Date(0);\n d.setUTCFullYear(+m[1], +m[2] - 1, +m[3]);\n return d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3] ? d : null;\n}\n\nconst iso = (d) => `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;\n\n/** True for a real \"YYYY-MM-DD\" day. */\nexport const isDateString = (s) => parseDate(s) !== null;\n\n/** A JS Date → the \"YYYY-MM-DD\" it is on the reader's own calendar\n * (local time: new Date() at 23:30 in Tokyo is Tokyo's day). */\nexport function toDateString(date = new Date()) {\n return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;\n}\n\n/** Today on the reader's calendar, \"YYYY-MM-DD\". */\nexport const todayString = () => toDateString(new Date());\n\n/** The day `n` days on (negative: back). */\nexport function addDays(s, n) {\n const d = parseDate(s);\n return d ? iso(new Date(d.getTime() + n * DAY)) : null;\n}\n\n/** The same day `n` months on, clamped to the month's end (Jan 31 + 1 →\n * Feb 28). */\nexport function addMonths(s, n) {\n const d = parseDate(s);\n if (!d) return null;\n const y = d.getUTCFullYear();\n const m = d.getUTCMonth() + n;\n const last = new Date(Date.UTC(2000, 0, 1));\n last.setUTCFullYear(y, m + 1, 0);\n const out = new Date(0);\n out.setUTCFullYear(y, m, Math.min(d.getUTCDate(), last.getUTCDate()));\n return iso(out);\n}\n\n/** The first day of `s`'s month; \"2026-03\" works as well as a full date. */\nexport const startOfMonth = (s) => (typeof s === \"string\" && /^\\d{4}-\\d{2}/.test(s) ? `${s.slice(0, 7)}-01` : null);\n\n/** The last day of `s`'s month. */\nexport const endOfMonth = (s) => {\n const first = startOfMonth(s);\n return first ? addDays(addMonths(first, 1), -1) : null;\n};\n\n/** The day of the week, 0 (Sunday) … 6. */\nexport const dayOfWeek = (s) => parseDate(s)?.getUTCDay() ?? null;\n\n// Intl's spacing differs between ICU builds (Node's thin and no-break\n// spaces around a range's dash, a browser's plain ones): the server's\n// HTML and the browser's first render must be the same text, so every\n// formatted string is written with plain spaces.\nexport const plainSpaces = (s) => s.replace(/[\\u00a0\\u2009\\u202f]/g, \" \");\n\n/** A day in words for the locale: formatDate(\"2026-03-12\") → \"Mar 12,\n * 2026\"; `options` are Intl.DateTimeFormat's ({ dateStyle: \"full\" }). */\nexport function formatDate(s, options = { dateStyle: \"medium\" }, locale) {\n const d = parseDate(s);\n return d ? plainSpaces(new Intl.DateTimeFormat(locale, { ...options, timeZone: \"UTC\" }).format(d)) : \"\";\n}\n\n// The week's first day for a locale: Intl's own answer where the browser\n// has it (getWeekInfo), else CLDR's regions that start on Sunday or\n// Saturday; everyone else starts on Monday.\nconst SUNDAY = new Set(\"AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW\".split(\" \"));\nconst SATURDAY = new Set(\"AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY\".split(\" \"));\n\n/** 0 (Sunday) … 6: the day a week starts on in `locale`. */\nexport function firstDayOfWeek(locale) {\n try {\n const loc = new Intl.Locale(locale ?? Intl.DateTimeFormat().resolvedOptions().locale);\n const info = loc.getWeekInfo?.() ?? loc.weekInfo;\n if (info?.firstDay) return info.firstDay % 7;\n const region = loc.maximize().region;\n return SUNDAY.has(region) ? 0 : SATURDAY.has(region) ? 6 : 1;\n } catch {\n return 1;\n }\n}\n\n// ISO 8601 week number of the week holding `thursday` (a Thursday's\n// week number is its day of the year, in sevens, rounded up).\nfunction isoWeek(thursday) {\n const d = parseDate(thursday);\n const jan1 = Date.UTC(d.getUTCFullYear(), 0, 1);\n return Math.floor((d.getTime() - jan1) / DAY / 7) + 1;\n}\n\n// ── The reader's locale and today, without a hydration mismatch ────────\n// The server does not know either. useSyncExternalStore renders the\n// server's answer (null) while hydrating, then the browser's — so the\n// first paint matches the HTML and nothing warns.\n\nconst never = () => () => {};\nfunction useBrowser(get) {\n return useSyncExternalStore(never, get, () => null);\n}\n\n/** The locale to format in: `locale`, else the browser's (after\n * hydration; \"en-US\" in the server's HTML). */\nexport function useLocale(locale) {\n const browser = useBrowser(() => navigator.language);\n return locale ?? browser ?? \"en-US\";\n}\n\n/** `today`, else the browser's today once hydrated (null before). */\nexport function useToday(today) {\n const browser = useBrowser(todayString);\n return today ?? browser;\n}\n\n// ── The grid ────────────────────────────────────────────────────────────\n\n// A marked day's dot. On a picked day (the primary fill) the primary and\n// neutral dots turn to the fill's own foreground so they still show.\nconst DOT = {\n primary: \"bg-primary\",\n neutral: \"bg-muted-foreground\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-destructive\",\n info: \"bg-info\",\n};\n\nconst SIZES = {\n md: { cell: \"size-11\", caption: \"h-11\", nav: \"md\" },\n sm: { cell: \"size-11 sm:size-9\", caption: \"h-11 sm:h-9\", nav: \"sm\" },\n};\n\nfunction useControlled(value, fallback) {\n const [own, setOwn] = useState(fallback);\n return value !== undefined ? [value, false] : [own, setOwn];\n}\n\nfunction listOf(mode, value) {\n if (!value) return [];\n if (mode === \"multiple\") return Array.isArray(value) ? value : [];\n if (mode === \"range\") return [value.from, value.to].filter(Boolean);\n return [value];\n}\n\nexport function Calendar({\n mode = \"single\",\n value: valueProp,\n defaultValue,\n onChange,\n required = false,\n month: monthProp,\n defaultMonth,\n onMonthChange,\n numberOfMonths = 1,\n min,\n max,\n disabled,\n captionLayout = \"label\",\n fromYear,\n toYear,\n showOutsideDays = true,\n showWeekNumbers = false,\n marked,\n emphasizeMarked = false,\n onlyMarked = false,\n weekStartsOn,\n locale: localeProp,\n today: todayProp,\n size = \"md\",\n autoFocus = false,\n label = \"Calendar\",\n className,\n}) {\n const uid = useId();\n const root = useRef(null);\n const pending = useRef(false);\n const locale = useLocale(localeProp);\n const today = useToday(todayProp);\n const s = SIZES[size] ?? SIZES.md;\n const n = Math.max(1, numberOfMonths | 0);\n\n const [own, setOwnValue] = useControlled(valueProp, defaultValue ?? (mode === \"multiple\" ? [] : null));\n const value = valueProp !== undefined ? valueProp : own;\n const chosen = listOf(mode, value);\n\n const [ownMonth, setOwnMonth] = useState(() => startOfMonth(defaultMonth) ?? startOfMonth(chosen[0]));\n const first = startOfMonth(monthProp) ?? ownMonth ?? startOfMonth(today);\n const last = first ? addMonths(first, n - 1) : null;\n const [focus, setFocus] = useState(null);\n const [hover, setHover] = useState(null);\n\n const marks = useMemo(() => {\n if (Array.isArray(marked)) return Object.fromEntries(marked.map((d) => [d, {}]));\n return marked && typeof marked === \"object\" ? marked : {};\n }, [marked]);\n\n const ws = weekStartsOn ?? firstDayOfWeek(locale);\n const fmt = useMemo(() => {\n const f = (o) => {\n const intl = new Intl.DateTimeFormat(locale, { ...o, timeZone: \"UTC\" });\n return { format: (d) => plainSpaces(intl.format(d)) };\n };\n return {\n caption: f({ month: \"long\", year: \"numeric\" }),\n day: f({ day: \"numeric\" }),\n full: f({ dateStyle: \"full\" }),\n monthShort: f({ month: \"short\" }),\n year: f({ year: \"numeric\" }),\n weekShort: f({ weekday: \"short\" }),\n weekLong: f({ weekday: \"long\" }),\n };\n }, [locale]);\n // 2023-01-01 was a Sunday: the seven names from the week's first day.\n const weekdays = useMemo(\n () =>\n Array.from({ length: 7 }, (_, i) => {\n const d = parseDate(addDays(\"2023-01-01\", (ws + i) % 7));\n return { short: fmt.weekShort.format(d), long: fmt.weekLong.format(d) };\n }),\n [fmt, ws],\n );\n\n // Focus follows the roving day after the render that shows it.\n useEffect(() => {\n if (!pending.current) return;\n pending.current = false;\n root.current?.querySelector('button[data-day][tabindex=\"0\"]')?.focus();\n });\n useEffect(() => {\n if (!autoFocus) return;\n const go = () => root.current?.querySelector('button[data-day][tabindex=\"0\"]')?.focus({ preventScroll: true });\n // Inside a popover not shown yet (one open on load), wait for it: the\n // Popover focuses its first control on toggle, and the day wins after.\n const pop = root.current?.closest(\"[popover]\");\n if (pop && !pop.matches(\":popover-open\")) {\n let t;\n const on = () => (t = setTimeout(go));\n pop.addEventListener(\"toggle\", on, { once: true });\n return () => {\n pop.removeEventListener(\"toggle\", on);\n clearTimeout(t);\n };\n }\n go();\n }, [autoFocus]);\n\n function setMonth(m) {\n if (monthProp === undefined) setOwnMonth(m);\n onMonthChange?.(m.slice(0, 7));\n }\n\n function setValue(v) {\n if (setOwnValue) setOwnValue(v);\n onChange?.(v);\n }\n\n const isDisabled = (d) =>\n disabled === true || (min && d < min) || (max && d > max) || (typeof disabled === \"function\" && !!disabled(d));\n const inView = (d) => !!(d && first && startOfMonth(d) >= first && startOfMonth(d) <= last);\n\n const isEmpty = (d) => onlyMarked && !marks[d];\n\n function pick(d) {\n if (isDisabled(d) || isEmpty(d)) return;\n if (mode === \"multiple\") {\n setValue(chosen.includes(d) ? chosen.filter((x) => x !== d) : [...chosen, d].sort());\n } else if (mode === \"range\") {\n const { from, to } = value ?? {};\n if (!from || to) setValue({ from: d, to: null });\n else if (d < from) setValue({ from: d, to: from });\n else setValue({ from, to: d });\n } else {\n setValue(value === d && !required ? null : d);\n }\n }\n\n function moveTo(d) {\n if (min && d < min) d = min;\n if (max && d > max) d = max;\n const m = startOfMonth(d);\n if (m < first) setMonth(m);\n else if (m > last) setMonth(addMonths(m, -(n - 1)));\n setFocus(d);\n pending.current = true;\n }\n\n function onKeyDown(e) {\n const day = e.target.dataset?.day;\n if (!day || e.altKey || e.ctrlKey || e.metaKey) return;\n const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n const back = (dayOfWeek(day) - ws + 7) % 7;\n const step = {\n ArrowLeft: () => addDays(day, rtl ? 1 : -1),\n ArrowRight: () => addDays(day, rtl ? -1 : 1),\n ArrowUp: () => addDays(day, -7),\n ArrowDown: () => addDays(day, 7),\n PageUp: () => addMonths(day, e.shiftKey ? -12 : -1),\n PageDown: () => addMonths(day, e.shiftKey ? 12 : 1),\n Home: () => addDays(day, -back),\n End: () => addDays(day, 6 - back),\n }[e.key];\n if (!step) return;\n e.preventDefault();\n moveTo(step());\n }\n\n // Range display: the committed span, or — between the two clicks —\n // a preview from `from` to the day under the pointer or focus.\n const range = mode === \"range\" ? value ?? {} : {};\n const previewing = !!(range.from && !range.to && hover);\n const ends = range.from ? [range.from, range.to ?? (previewing ? hover : range.from)].sort() : null;\n\n const tabbable = [focus, ...chosen, today].find((d) => inView(d) && !(min && d < min) && !(max && d > max)) ?? (inView(min) ? min : first);\n\n if (!first) {\n // Hydrating with no month to show yet: hold the space.\n return <div ref={root} role=\"group\" aria-label={label} aria-busy=\"true\" className={cx(\"min-h-80 min-w-72\", className)} />;\n }\n\n const months = Array.from({ length: n }, (_, i) => addMonths(first, i));\n const prevOff = !!(min && startOfMonth(min) >= first);\n const nextOff = !!(max && startOfMonth(max) <= last);\n\n function Caption({ m, i }) {\n const id = `${uid}-cap-${i}`;\n if (captionLayout === \"none\") {\n return (\n <span id={id} className=\"sr-only\" aria-live={i === 0 ? \"polite\" : undefined}>\n {fmt.caption.format(parseDate(m))}\n </span>\n );\n }\n if (captionLayout === \"dropdown\" && i === 0) {\n const y = +m.slice(0, 4);\n const ty = +(today ?? m).slice(0, 4);\n const lo = Math.min(fromYear ?? (min ? +min.slice(0, 4) : ty - 100), y);\n const hi = Math.max(toYear ?? (max ? +max.slice(0, 4) : ty + 10), y);\n const monthOff = (k) => {\n const mm = `${pad(y, 4)}-${pad(k + 1)}-01`;\n return (min && endOfMonth(mm) < min) || (max && mm > max);\n };\n return (\n <div className={cx(\"flex items-center justify-center gap-1 px-12\", s.caption)}>\n <span id={id} className=\"sr-only\" aria-live=\"polite\">\n {fmt.caption.format(parseDate(m))}\n </span>\n <span className=\"min-w-0 flex-[3]\">\n <Select aria-label=\"Month\" value={String(+m.slice(5, 7))} onChange={(e) => setMonth(`${pad(y, 4)}-${pad(+e.target.value)}-01`)}>\n {Array.from({ length: 12 }, (_, k) => (\n <option key={k} value={k + 1} disabled={monthOff(k)}>\n {fmt.monthShort.format(parseDate(`2023-${pad(k + 1)}-01`))}\n </option>\n ))}\n </Select>\n </span>\n <span className=\"min-w-0 flex-[2]\">\n <Select aria-label=\"Year\" value={String(y)} onChange={(e) => setMonth(`${pad(+e.target.value, 4)}${m.slice(4)}`)}>\n {Array.from({ length: hi - lo + 1 }, (_, k) => (\n <option key={k} value={lo + k}>\n {fmt.year.format(parseDate(`${pad(lo + k, 4)}-06-15`))}\n </option>\n ))}\n </Select>\n </span>\n </div>\n );\n }\n return (\n <div id={id} aria-live={i === 0 ? \"polite\" : undefined} className={cx(\"flex items-center justify-center px-12 font-medium\", s.caption)}>\n {fmt.caption.format(parseDate(m))}\n </div>\n );\n }\n\n function Month({ m, i }) {\n const lead = (dayOfWeek(m) - ws + 7) % 7;\n const start = addDays(m, -lead);\n const dim = +endOfMonth(m).slice(8);\n const rows = Math.ceil((lead + dim) / 7);\n return (\n <div className=\"grid content-start gap-2\">\n {Caption({ m, i })}\n <table role=\"grid\" aria-labelledby={`${uid}-cap-${i}`} className=\"border-collapse\" onKeyDown={onKeyDown}>\n <thead>\n <tr>\n {showWeekNumbers ? (\n <th scope=\"col\" className=\"w-8 text-xs font-normal text-muted-foreground\">\n <span className=\"sr-only\">Week</span>\n </th>\n ) : null}\n {weekdays.map((w) => (\n <th key={w.long} scope=\"col\" abbr={w.long} className=\"h-8 p-0 text-xs font-normal text-muted-foreground\">\n <span aria-hidden=\"true\">{w.short}</span>\n <span className=\"sr-only\">{w.long}</span>\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {Array.from({ length: rows }, (_, r) => {\n const rowStart = addDays(start, r * 7);\n return (\n <tr key={rowStart}>\n {showWeekNumbers ? (\n <th scope=\"row\" className=\"w-8 p-0 text-center text-xs font-normal tabular-nums text-muted-foreground\">\n {isoWeek(addDays(rowStart, (4 - dayOfWeek(rowStart) + 7) % 7))}\n </th>\n ) : null}\n {Array.from({ length: 7 }, (_, c) => Day({ d: addDays(rowStart, c), m }))}\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\n );\n }\n\n function Day({ d, m }) {\n const outside = startOfMonth(d) !== m;\n if (outside && !showOutsideDays) return <td key={d} className=\"p-0\" />;\n const off = isDisabled(d);\n const empty = !off && isEmpty(d);\n const picked = mode === \"range\" ? d === range.from || d === range.to : chosen.includes(d);\n // Beside another month, an outside day repeats a day shown there: only\n // the real one carries the band.\n const band = ends && ends[0] < ends[1] && d >= ends[0] && d <= ends[1] && !(outside && n > 1);\n const isToday = d === today;\n const mark = marks[d];\n const tone = mark ? (DOT[mark.tone] ? mark.tone : \"primary\") : null;\n // cx does not merge: one text colour and one weight per day.\n const quiet = !picked && !band && !outside && ((emphasizeMarked && !mark) || empty);\n return (\n <td\n key={d}\n aria-selected={picked || (band && !previewing) || undefined}\n className={cx(\n \"p-0 text-center\",\n band && (previewing ? \"bg-accent/50\" : \"bg-accent\"),\n band && d === ends[0] && \"rounded-l-md\",\n band && d === ends[1] && \"rounded-r-md\",\n )}\n >\n <button\n type=\"button\"\n data-day={d}\n data-outside={outside || undefined}\n tabIndex={!outside && d === tabbable ? 0 : -1}\n aria-label={[fmt.full.format(parseDate(d)), isToday && \"today\", mark?.label].filter(Boolean).join(\", \")}\n aria-current={isToday ? \"date\" : undefined}\n aria-disabled={off || empty || undefined}\n onClick={() => {\n if (off || empty) return;\n pick(d);\n setFocus(d);\n if (!inView(d)) setMonth(startOfMonth(d) < first ? startOfMonth(d) : addMonths(startOfMonth(d), -(n - 1)));\n }}\n onMouseEnter={() => mode === \"range\" && setHover(d)}\n onFocus={() => mode === \"range\" && setHover(d)}\n className={cx(\n \"relative inline-flex items-center justify-center rounded-md text-sm tabular-nums outline-none\",\n \"focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring\",\n s.cell,\n picked\n ? \"bg-primary font-medium text-primary-foreground\"\n : band\n ? \"text-accent-foreground hover:bg-accent\"\n : quiet\n ? \"text-muted-foreground\"\n : \"text-foreground\",\n !picked && !band && !empty && \"hover:bg-accent\",\n emphasizeMarked && mark && !picked && !isToday && \"font-semibold\",\n outside && !picked && \"text-muted-foreground opacity-60\",\n outside && picked && \"opacity-70\",\n off && \"cursor-not-allowed line-through opacity-40 hover:bg-transparent\",\n empty && \"cursor-default\",\n // Today: a ring and a heavier number — never colour alone.\n isToday && \"font-semibold ring-1 ring-inset ring-muted-foreground/60\",\n )}\n >\n {fmt.day.format(parseDate(d))}\n {tone ? (\n <span\n aria-hidden=\"true\"\n className={cx(\n \"absolute bottom-1 left-1/2 size-1.5 -translate-x-1/2 rounded-full\",\n picked && (tone === \"primary\" || tone === \"neutral\") ? \"bg-primary-foreground\" : DOT[tone],\n )}\n />\n ) : null}\n </button>\n </td>\n );\n }\n\n return (\n <div\n ref={root}\n role=\"group\"\n aria-label={label}\n onMouseLeave={() => setHover(null)}\n onBlur={(e) => !root.current?.contains(e.relatedTarget) && setHover(null)}\n className={cx(\"relative inline-block max-w-full text-sm\", className)}\n >\n {captionLayout === \"none\" ? null : (\n <>\n <IconButton\n icon=\"chevron-left\"\n label=\"Previous month\"\n size={s.nav}\n disabled={prevOff}\n onClick={() => setMonth(addMonths(first, -1))}\n className=\"absolute left-0 top-0 z-10\"\n />\n <IconButton\n icon=\"chevron-right\"\n label=\"Next month\"\n size={s.nav}\n disabled={nextOff}\n onClick={() => setMonth(addMonths(first, 1))}\n className=\"absolute right-0 top-0 z-10\"\n />\n </>\n )}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:gap-6\">\n {months.map((m, i) => (\n <div key={m}>{Month({ m, i })}</div>\n ))}\n </div>\n </div>\n );\n}\n"
61
+ },
62
+ {
63
+ "path": "templates/next/files/components/ui/carousel.jsx",
64
+ "type": "registry:ui",
65
+ "target": "@components/makersclaw/ui/carousel.jsx",
66
+ "content": "\"use client\";\n\nimport { Children, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { IconButton } from \"./button\";\n\n// Carousel — a row of slides the user moves through: testimonials, plan\n// cards, screenshots of a template. The browser's own scrolling is the\n// engine (CSS scroll-snap), so a swipe, a trackpad and a scrollbar-less\n// flick all work with nothing to install; the buttons, dots and arrow\n// keys only ask it to scroll. Each child is one slide.\n//\n// <Carousel label=\"Customer quotes\" itemsPerView={{ sm: 2, lg: 3 }}>\n// {quotes.map((q) => <QuoteCard key={q.id} {...q} />)}\n// </Carousel>\n//\n// label what a screen reader calls the region (required)\n// itemsPerView slides side by side: a number, or { sm, md, lg }. Always\n// 1 on a phone; a number n is min(n, 2) from sm, n from md\n// indicator dots (the default) · counter (\"2 / 5\") · none\n// loop previous on the first goes to the last, and back (off)\n// autoplay ms between slides (true = 5000). Off by default; paused\n// on hover and focus, while hidden, and never runs for a\n// reader who prefers reduced motion. Adds a pause button.\n// defaultIndex the slide it opens on\n// onChange (index) → the first visible slide, after it settles\n//\n// A carousel hides all but one thing, so it is for things of a kind\n// where seeing one at a time is fine. Content the user must compare\n// (plans, options) reads better as a grid.\n\nconst GAP = \"calc(var(--spacing) * 4)\";\n// The per-breakpoint count arrives as CSS variables; these classes pick\n// the one for the window's width. Written out whole: Tailwind reads\n// class strings literally.\nconst PER = \"[--per:1] sm:[--per:var(--per-sm)] md:[--per:var(--per-md)] lg:[--per:var(--per-lg)]\";\n\nfunction perView(n) {\n if (typeof n === \"number\") return { sm: Math.min(n, 2), md: n, lg: n };\n const sm = n?.sm ?? 1;\n const md = n?.md ?? sm;\n return { sm, md, lg: n?.lg ?? md };\n}\n\nexport function Carousel({\n label,\n itemsPerView = 1,\n indicator = \"dots\",\n loop = false,\n autoplay,\n defaultIndex = 0,\n onChange,\n className,\n children,\n}) {\n const id = useId();\n const track = useRef(null);\n const slides = Children.toArray(children).filter(Boolean);\n const count = slides.length;\n const per = perView(itemsPerView);\n const [index, setIndex] = useState(defaultIndex);\n const [shown, setShown] = useState(1);\n const [held, setHeld] = useState(false);\n const [stopped, setStopped] = useState(false);\n const [calm, setCalm] = useState(false);\n const [announce, setAnnounce] = useState(false);\n const reported = useRef(defaultIndex);\n const last = Math.max(0, count - shown);\n const interval = autoplay === true ? 5000 : typeof autoplay === \"number\" ? autoplay : 0;\n const playing = interval > 0 && !stopped && !held && !calm;\n\n const go = useCallback(\n (i, smooth = true) => {\n const t = track.current;\n const el = t?.children[i];\n if (!t || !el) return;\n const reduce = typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n t.scrollTo({ left: el.offsetLeft, behavior: smooth && !reduce ? \"smooth\" : \"auto\" });\n },\n [],\n );\n\n // Where the scroll rests → the index. Read from the scroll position,\n // so a swipe, a trackpad and the buttons all land in the same place.\n const read = useCallback(() => {\n const t = track.current;\n if (!t || !t.children.length) return;\n const n = Number.parseFloat(getComputedStyle(t).getPropertyValue(\"--per\")) || 1;\n setShown(n);\n const end = t.scrollWidth - t.clientWidth;\n const lastPos = Math.max(0, t.children.length - Math.round(n));\n let i = 0;\n if (end > 0 && t.scrollLeft >= end - 2) i = lastPos;\n else {\n let best = Infinity;\n [...t.children].forEach((c, k) => {\n const d = Math.abs(c.offsetLeft - t.scrollLeft);\n if (d < best) {\n best = d;\n i = k;\n }\n });\n }\n setIndex(Math.min(i, lastPos));\n }, []);\n\n useLayoutEffect(() => {\n if (defaultIndex) go(defaultIndex, false);\n read();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n const t = track.current;\n if (!t) return;\n let frame = 0;\n const onScroll = () => {\n cancelAnimationFrame(frame);\n frame = requestAnimationFrame(read);\n };\n t.addEventListener(\"scroll\", onScroll, { passive: true });\n const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(read);\n ro?.observe(t);\n return () => {\n cancelAnimationFrame(frame);\n t.removeEventListener(\"scroll\", onScroll);\n ro?.disconnect();\n };\n }, [read]);\n\n // onChange once the index settles, not on every frame of a swipe.\n useEffect(() => {\n if (index === reported.current) return;\n const h = setTimeout(() => {\n reported.current = index;\n onChange?.(index);\n }, 120);\n return () => clearTimeout(h);\n }, [index, onChange]);\n\n useEffect(() => {\n if (!interval || typeof matchMedia !== \"function\") return;\n const mq = matchMedia(\"(prefers-reduced-motion: reduce)\");\n const on = () => setCalm(mq.matches || document.visibilityState === \"hidden\");\n on();\n mq.addEventListener(\"change\", on);\n document.addEventListener(\"visibilitychange\", on);\n return () => {\n mq.removeEventListener(\"change\", on);\n document.removeEventListener(\"visibilitychange\", on);\n };\n }, [interval]);\n\n useEffect(() => {\n if (!playing || last === 0) return;\n const h = setTimeout(() => go(index >= last ? 0 : index + 1), interval);\n return () => clearTimeout(h);\n }, [playing, index, last, interval, go]);\n\n const prev = () => {\n setAnnounce(true);\n go(index > 0 ? index - 1 : loop ? last : 0);\n };\n const next = () => {\n setAnnounce(true);\n go(index < last ? index + 1 : loop ? 0 : last);\n };\n\n function onKeyDown(e) {\n if (e.target.closest(\"input, textarea, select, [contenteditable]\")) return;\n if (e.key === \"ArrowLeft\") prev();\n else if (e.key === \"ArrowRight\") next();\n else if (e.key === \"Home\") go(0);\n else if (e.key === \"End\") go(last);\n else return;\n e.preventDefault();\n }\n\n if (count === 0) return null;\n const positions = last + 1;\n\n return (\n <section\n aria-roledescription=\"carousel\"\n aria-label={label}\n onKeyDown={onKeyDown}\n onPointerEnter={() => setHeld(true)}\n onPointerLeave={() => setHeld(false)}\n onFocus={() => setHeld(true)}\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget)) setHeld(false);\n }}\n className={cx(\"grid min-w-0 gap-3\", className)}\n >\n <div\n ref={track}\n id={`${id}-track`}\n tabIndex={0}\n aria-live={announce && !playing ? \"polite\" : \"off\"}\n className={cx(\n \"relative flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain rounded-lg outline-none\",\n \"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n PER,\n )}\n style={{ gap: GAP, \"--per-sm\": per.sm, \"--per-md\": per.md, \"--per-lg\": per.lg }}\n >\n {slides.map((slide, i) => (\n <div\n key={slide.key ?? i}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${i + 1} of ${count}`}\n className=\"min-w-0 shrink-0 snap-start\"\n style={{ flexBasis: `calc((100% - (var(--per) - 1) * ${GAP}) / var(--per))` }}\n >\n {slide}\n </div>\n ))}\n </div>\n {positions > 1 ? (\n <div className=\"flex items-center justify-between gap-2\">\n <IconButton\n icon=\"chevron-left\"\n label=\"Previous slide\"\n variant=\"secondary\"\n aria-controls={`${id}-track`}\n disabled={!loop && index === 0}\n onClick={prev}\n />\n <div className=\"flex min-w-0 flex-1 items-center justify-center gap-1\">\n {indicator === \"counter\" || (indicator === \"dots\" && positions > 12) ? (\n <p data-nums className=\"text-sm text-muted-foreground\">\n {index + 1} / {positions}\n </p>\n ) : indicator === \"dots\" ? (\n Array.from({ length: positions }, (_, i) => (\n <button\n key={i}\n type=\"button\"\n aria-label={`Go to slide ${i + 1}`}\n aria-current={i === index ? \"true\" : undefined}\n aria-controls={`${id}-track`}\n onClick={() => {\n setAnnounce(true);\n go(i);\n }}\n className=\"group grid size-6 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span\n className={cx(\n \"block h-2 rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none\",\n i === index ? \"w-5 bg-foreground\" : \"w-2 bg-muted-foreground/40 group-hover:bg-muted-foreground\",\n )}\n />\n </button>\n ))\n ) : null}\n {interval > 0 && !calm ? (\n <IconButton\n icon={stopped ? \"play\" : \"pause\"}\n label={stopped ? \"Play slides\" : \"Pause slides\"}\n size=\"sm\"\n className=\"ml-1\"\n onClick={() => setStopped((s) => !s)}\n />\n ) : null}\n </div>\n <IconButton\n icon=\"chevron-right\"\n label=\"Next slide\"\n variant=\"secondary\"\n aria-controls={`${id}-track`}\n disabled={!loop && index >= last}\n onClick={next}\n />\n </div>\n ) : null}\n </section>\n );\n}\n"
67
+ },
68
+ {
69
+ "path": "templates/next/files/components/ui/collapsible.jsx",
70
+ "type": "registry:ui",
71
+ "target": "@components/makersclaw/ui/collapsible.jsx",
72
+ "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Collapsible — a row that shows or hides what is under it: advanced\n// fields, a long note, last week's runs. A native <details>/<summary>\n// underneath, so the browser supplies the keyboard, the expanded state a\n// screen reader announces, and find-in-page opening a closed one. The\n// click is taken over so `open` can be controlled; a toggle the browser\n// makes on its own (find-in-page) is reported through `onOpenChange`.\n// Height animates where the browser can (::details-content) and never\n// when the viewer asks for reduced motion. Never a raw <details> in a\n// surface: `npm run check` refuses it.\n//\n// title the trigger's text (the whole row is the target)\n// meta a count or a date on the right, muted\n// open controlled; with `onOpenChange(next)`. Leave it out and\n// `defaultOpen` sets where it starts\n// variant plain (a row in the flow) · outline (a bordered panel)\n// · flush (edge to edge inside a Card or any bordered\n// box: 16px side padding, a rule under each row, none\n// under the last — a stack of them is a divided list).\n// `accordion-outline` is flush's older name; it still works\n// chevron down (default: points down, turns up when open) ·\n// right (the disclosure triangle: points right, turns\n// down when open — a tree, a list of rows that drill in)\n// disabled the row stays readable but does not toggle\n//\n// Accordion below is a list of them: `type` single (one open at a time)\n// or multiple; `collapsible` lets the open one close when single.\n\nconst MOTION =\n \"[interpolate-size:allow-keywords] [&::details-content]:h-0 [&::details-content]:overflow-clip \" +\n \"[&[open]::details-content]:h-auto [&::details-content]:transition-[height,content-visibility] \" +\n \"[&::details-content]:transition-discrete [&::details-content]:duration-200 [&::details-content]:ease-out \" +\n \"motion-reduce:[&::details-content]:transition-none\";\n\nconst LOOKS = {\n plain: { box: \"\", row: \"-mx-2 px-2 rounded-md\", body: \"pb-3 pt-1\" },\n outline: { box: \"rounded-lg border border-border bg-card text-card-foreground\", row: \"px-4 rounded-lg\", body: \"px-4 pb-4\" },\n // An Accordion's items: the list draws the edges.\n \"accordion-plain\": { box: \"border-b border-border\", row: \"-mx-2 px-2 rounded-md\", body: \"pb-4 pt-1\" },\n \"accordion-outline\": { box: \"border-b border-border last:border-b-0\", row: \"px-4\", body: \"px-4 pb-4\" },\n};\nLOOKS.flush = LOOKS[\"accordion-outline\"];\n\n// Where the chevron points: closed, then open.\nconst CHEVRON = {\n down: { name: \"chevron-down\", open: \"rotate-180\" },\n right: { name: \"chevron-right\", open: \"rotate-90\" },\n};\n\nexport function Collapsible({\n title,\n meta,\n open,\n defaultOpen = false,\n onOpenChange,\n variant = \"plain\",\n chevron = \"down\",\n disabled,\n className,\n children,\n ...rest\n}) {\n const [inner, setInner] = useState(defaultOpen);\n const controlled = open !== undefined;\n const isOpen = controlled ? open : inner;\n const look = LOOKS[variant] ?? LOOKS.plain;\n const turn = CHEVRON[chevron] ?? CHEVRON.down;\n\n function set(next) {\n if (!controlled) setInner(next);\n onOpenChange?.(next);\n }\n\n return (\n <details\n open={isOpen}\n onToggle={(e) => {\n if (e.currentTarget.open !== isOpen) set(e.currentTarget.open);\n }}\n className={cx(MOTION, look.box, className)}\n {...rest}\n >\n <summary\n aria-disabled={disabled ? true : undefined}\n onClick={(e) => {\n e.preventDefault();\n if (!disabled) set(!isOpen);\n }}\n className={cx(\n \"flex min-h-11 list-none items-center gap-3 py-2 text-sm font-medium text-foreground outline-none [&::-webkit-details-marker]:hidden\",\n \"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n disabled ? \"cursor-not-allowed opacity-50\" : \"cursor-pointer hover:bg-accent\",\n look.row,\n )}\n >\n <span className=\"min-w-0 flex-1 text-left\">{title}</span>\n {meta !== undefined && meta !== null ? (\n <span data-nums className=\"shrink-0 text-xs font-normal text-muted-foreground\">\n {meta}\n </span>\n ) : null}\n <Icon\n name={turn.name}\n className={cx(\n \"shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none\",\n isOpen && turn.open,\n )}\n />\n </summary>\n <div className={cx(\"text-sm\", look.body)}>{children}</div>\n </details>\n );\n}\n\n/**\n * A list of Collapsibles. Arrow keys move between the rows, Home/End jump.\n *\n * items [{ key, title, meta?, content, disabled? }]\n * type single (opening one closes the rest) · multiple\n * collapsible single only: the open row can close (default true)\n * value controlled: a key (or null) when single, an array of keys\n * when multiple; with `onChange(next)`. `defaultValue` for\n * where it starts when uncontrolled\n * variant plain (rows divided by rules) · outline (one bordered box)\n * chevron down · right, as Collapsible\n */\nexport function Accordion({\n items,\n type = \"single\",\n collapsible = true,\n value,\n defaultValue,\n onChange,\n variant = \"plain\",\n chevron,\n className,\n \"aria-label\": ariaLabel,\n}) {\n const root = useRef(null);\n const multiple = type === \"multiple\";\n const [inner, setInner] = useState(defaultValue ?? (multiple ? [] : null));\n const controlled = value !== undefined;\n const current = controlled ? value : inner;\n const live = (items ?? []).filter(Boolean);\n if (live.length === 0) return null;\n\n const isOpen = (key) => (multiple ? (current ?? []).includes(key) : current === key);\n\n function toggle(key, next) {\n let out;\n if (multiple) {\n const list = current ?? [];\n out = next ? [...list.filter((k) => k !== key), key] : list.filter((k) => k !== key);\n } else {\n if (!next && !collapsible) return;\n out = next ? key : null;\n }\n if (!controlled) setInner(out);\n onChange?.(out);\n }\n\n function onKeyDown(e) {\n const heads = [...(root.current?.querySelectorAll(\":scope > details > summary:not([aria-disabled='true'])\") ?? [])];\n const i = heads.indexOf(e.target);\n if (i < 0) return;\n let next = null;\n if (e.key === \"ArrowDown\") next = (i + 1) % heads.length;\n else if (e.key === \"ArrowUp\") next = (i - 1 + heads.length) % heads.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = heads.length - 1;\n if (next === null) return;\n e.preventDefault();\n heads[next]?.focus();\n }\n\n return (\n <div\n ref={root}\n role=\"group\"\n aria-label={ariaLabel}\n onKeyDown={onKeyDown}\n className={cx(\n variant === \"outline\" ? \"overflow-hidden rounded-lg border border-border bg-card text-card-foreground\" : \"\",\n className,\n )}\n >\n {live.map((item) => (\n <Collapsible\n key={item.key}\n title={item.title}\n meta={item.meta}\n disabled={item.disabled}\n open={isOpen(item.key)}\n onOpenChange={(next) => toggle(item.key, next)}\n variant={variant === \"outline\" ? \"flush\" : \"accordion-plain\"}\n chevron={chevron}\n >\n {item.content}\n </Collapsible>\n ))}\n </div>\n );\n}\n"
73
+ },
74
+ {
75
+ "path": "templates/next/files/components/ui/combobox.jsx",
76
+ "type": "registry:ui",
77
+ "target": "@components/makersclaw/ui/combobox.jsx",
78
+ "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\nimport { Button } from \"./button\";\nimport { Sheet } from \"./sheet\";\nimport { panelClass, usePopoverPosition } from \"./popover\";\nimport {\n CommandInput,\n CommandList,\n ItemIcon,\n commandFilter,\n listKeyDown,\n optionId,\n resolveActive,\n useAsyncSearch,\n useIsPhone,\n withValues,\n} from \"./command\";\n\n// Combobox — pick one or many from a list you can search: a person, a\n// company, a country, tags. A field-shaped trigger shows the choice; the\n// panel beside it holds a search input and the options — Command's list and\n// filter (./command), so the keys and the matching are the same everywhere.\n// With `searchable={false}` it is the rich Select: the same trigger and\n// options (icons, descriptions, groups) with no input, for a short list the\n// native <select> cannot draw.\n//\n// options [{ value, label, description?, icon?, group?, keywords?,\n// count?, disabled? }] — `icon` a glyph name or a node (an\n// Avatar); `count` a number or short text at the option's\n// right, muted (\"15\" beside a facet's value)\n// value, defaultValue, onValueChange(value, option) — a value (null for\n// none), or with `multiple` an array of values (and the\n// options chosen)\n// multiple chips in the trigger, each with a remove button; the\n// panel stays open while you pick; Backspace in an empty\n// search (or on the trigger) removes the last\n// searchable true (default): a search input in the panel · false: the\n// rich Select — focus stays on the trigger, arrows move,\n// typing a letter jumps to the next option starting with it\n// creatable offer `Create \"typed\"` when nothing matches exactly; true,\n// or (text) => its label. onCreate(text) may return (or\n// resolve) the new option; else it is { value: text, label: text }\n// onSearch async (query, { signal }) → options, debounced; the list\n// shows what it answers (a spinner meanwhile). A chosen\n// option stays named after the results move on\n// loading true while you fetch `options` yourself\n// clearable an × that empties the choice\n// disabled, invalid, name (hidden inputs, so a <form> posts the value)\n// placeholder, searchPlaceholder, empty (shown when nothing matches)\n// open, defaultOpen, onOpenChange(open) — the panel's state, controlled\n// or not (default closed); light dismiss, Escape and a\n// pick that closes report onOpenChange(false)\n// label the name when it is not in a <Field> (a Field's label\n// names it through `id`, and titles the phone sheet)\n// side, align the panel's place, as Popover (default bottom · start);\n// it is the trigger's width, 14rem at the least\n// mobile sheet (default) · popover — on a phone (under 640px) the\n// panel opens as a bottom Sheet: a popover under a field\n// there is squeezed by the on-screen keyboard and half\n// hidden; a sheet is the full width, within thumb reach,\n// and its search sits above the keyboard. Multiple adds a\n// Done button to it\n//\n// Every prop not named goes to the trigger button (aria-describedby for a\n// Field's hint, onBlur…). In a Field, pass the same `id`:\n//\n// <Field id=\"owner\" label=\"Owner\"><Combobox id=\"owner\" options={people} /></Field>\n//\n// ARIA: the trigger is role=combobox (its text is the value, the Field's\n// label its name); the search input inside is a combobox too, pointing at\n// the listbox with aria-activedescendant. Without a search, the trigger\n// itself carries aria-activedescendant — the APG \"select-only combobox\".\n\nconst CREATE = \"\\u0000create\";\nconst same = (a, b) => String(a).trim().toLowerCase() === String(b).trim().toLowerCase();\n\nexport function Combobox({\n id,\n options,\n value,\n defaultValue,\n onValueChange,\n multiple = false,\n searchable = true,\n creatable = false,\n onCreate,\n onSearch,\n loading = false,\n clearable = false,\n disabled = false,\n invalid = false,\n name,\n placeholder = \"Select…\",\n searchPlaceholder = \"Search…\",\n empty = \"No results.\",\n label,\n side = \"bottom\",\n align = \"start\",\n mobile = \"sheet\",\n open,\n defaultOpen = false,\n onOpenChange,\n className,\n onKeyDown,\n onKeyUp,\n onBlur,\n ...rest\n}) {\n const uid = useId().replace(/[^\\w-]/g, \"\");\n const panelId = `${uid}-panel`;\n const listId = `${uid}-list`;\n const wrap = useRef(null);\n const btn = useRef(null);\n const panel = useRef(null);\n const input = useRef(null);\n const list = useRef(null);\n const sheetBody = useRef(null);\n const phone = useIsPhone() && mobile === \"sheet\";\n\n const [isOpen, setIsOpen] = useState(false);\n const lastOpen = useRef(false);\n // Every change of the panel's state passes here, so onOpenChange hears\n // each one once, whoever made it (the trigger, a pick, light dismiss).\n const setOpen = (now) => {\n setIsOpen(now);\n if (now !== lastOpen.current) {\n lastOpen.current = now;\n onOpenChange?.(now);\n }\n };\n const [query, setQuery] = useState(\"\");\n const [active, setActive] = useState(null);\n const [title, setTitle] = useState(label ?? placeholder);\n const [width, setWidth] = useState(0);\n const [created, setCreated] = useState([]);\n const seed = useRef(\"\");\n const wasOpen = useRef(false);\n const swallowSpace = useRef(false);\n const typed = useRef({ text: \"\", at: 0 });\n\n // The choice, as an array of string values either way.\n const controlled = value !== undefined;\n const [own, setOwn] = useState(defaultValue ?? (multiple ? [] : null));\n const raw = controlled ? value : own;\n const chosen = multiple ? (Array.isArray(raw) ? raw.map(String) : []) : raw == null || raw === \"\" ? [] : [String(raw)];\n const key = chosen.join(\"\\u0001\");\n const chosenSet = useMemo(() => new Set(key ? key.split(\"\\u0001\") : []), [key]);\n\n // Every option seen, so a chosen one keeps its label (and icon) after a\n // search replaced the list it came from.\n const known = useRef(new Map());\n const search = useAsyncSearch(onSearch, query, { enabled: isOpen });\n const base = withValues(onSearch ? search.items ?? options : options);\n const source = [...base, ...created.filter((c) => !base.some((o) => o.value === c.value))];\n for (const o of source) known.current.set(o.value, o);\n const optionOf = (v) => known.current.get(v) ?? { value: v, label: v };\n\n let visible = searchable && !onSearch ? commandFilter(source, query) : source;\n const text = query.trim();\n if (creatable && text && !source.some((o) => same(o.label, text))) {\n visible = [\n ...visible,\n { value: CREATE, label: typeof creatable === \"function\" ? creatable(text) : `Create \"${text}\"`, icon: \"plus\", create: true },\n ];\n }\n const current = resolveActive(visible, active);\n const activeIndex = visible.findIndex((o) => o.value === current);\n const activeId = activeIndex >= 0 ? optionId(listId, activeIndex) : null;\n const busy = loading || search.loading;\n // The list draws a count at an option's right inside its label: the\n // filter, the typeahead and the chips keep reading the plain label.\n const shown = visible.some((o) => o.count != null && o.count !== \"\")\n ? visible.map((o) =>\n o.count == null || o.count === \"\"\n ? o\n : {\n ...o,\n label: (\n <span className=\"flex items-center gap-2\">\n <span className=\"min-w-0 flex-1 truncate\">{o.label}</span>\n <span className=\"sr-only\">, </span>\n <span className=\"shrink-0 text-xs tabular-nums text-muted-foreground\">{o.count}</span>\n </span>\n ),\n },\n )\n : visible;\n\n usePopoverPosition(wrap, panel, { side, align });\n\n // The panel is the trigger's width, kept as the field resizes.\n useEffect(() => {\n const el = wrap.current;\n if (!el) return;\n const ro = new ResizeObserver(() => setWidth(el.offsetWidth));\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // `open` drives the panel; `defaultOpen` opens it once, on mount.\n const want = open ?? null;\n useEffect(() => {\n if (want === null) return;\n if (want && !lastOpen.current) show();\n else if (!want && lastOpen.current) hide();\n }, [want]); // eslint-disable-line react-hooks/exhaustive-deps\n useEffect(() => {\n if (defaultOpen && open === undefined) show();\n }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n // Focus into the panel once it shows: the search, or (a phone sheet with\n // no search) the list itself.\n useEffect(() => {\n if (!isOpen) return;\n if (searchable) input.current?.focus({ preventScroll: true });\n else if (phone) list.current?.focus({ preventScroll: true });\n }, [isOpen, searchable, phone]);\n\n // ─── open and close ────────────────────────────────────────────────\n\n function fieldLabel() {\n if (!id || typeof document === \"undefined\") return null;\n const el = document.querySelector(`label[for=\"${CSS.escape(id)}\"]`);\n return el?.textContent?.replace(/\\s*\\*$/, \"\").trim() || null;\n }\n\n // Every opening starts here: the typed seed (or nothing), the choice\n // active, the title read from the Field.\n function begin() {\n setQuery(searchable ? seed.current : \"\");\n seed.current = \"\";\n setActive(chosen[0] ?? null);\n setTitle(label ?? fieldLabel() ?? placeholder);\n }\n\n function show() {\n if (disabled) return;\n // Read the width live: a `defaultOpen` shows from the first effect,\n // before the hydrated render has learnt it is on a phone.\n if (phone || (mobile === \"sheet\" && window.matchMedia(\"(max-width: 639.98px)\").matches)) {\n begin();\n setOpen(true);\n } else if (!panel.current?.matches(\":popover-open\")) {\n panel.current?.showPopover();\n }\n }\n\n function hide() {\n if (phone) {\n // Close the <dialog> itself: it hands focus back to the trigger.\n const dialog = sheetBody.current?.closest(\"dialog\");\n if (dialog?.open) dialog.close();\n else setOpen(false);\n } else if (panel.current?.matches(\":popover-open\")) {\n panel.current.hidePopover();\n }\n }\n\n // The popover's own opens and closes (the trigger, light dismiss, Esc).\n function onToggle(e) {\n const now = e.newState === \"open\";\n if (now) begin();\n else {\n const at = document.activeElement;\n if (!at || at === document.body || panel.current?.contains(at)) btn.current?.focus({ preventScroll: true });\n }\n setOpen(now);\n }\n\n // ─── choosing ──────────────────────────────────────────────────────\n\n function commit(next) {\n if (multiple) {\n if (!controlled) setOwn(next);\n onValueChange?.(next, next.map(optionOf));\n } else {\n const v = next[0] ?? null;\n if (!controlled) setOwn(v);\n onValueChange?.(v, v == null ? null : optionOf(v));\n }\n }\n\n function choose(v) {\n if (multiple) {\n commit(chosenSet.has(v) ? chosen.filter((x) => x !== v) : [...chosen, v]);\n setActive(v);\n } else {\n commit([v]);\n hide();\n }\n }\n\n async function create(typedText) {\n let made = null;\n try {\n made = await onCreate?.(typedText);\n } catch {\n return; // the caller refused it; its own error shows where it chose\n }\n const option = made && typeof made === \"object\" ? withValues([made])[0] : { value: typedText, label: typedText };\n known.current.set(option.value, option);\n setCreated((c) => [...c.filter((o) => o.value !== option.value), option]);\n setQuery(\"\");\n if (multiple) {\n commit([...chosen.filter((x) => x !== option.value), option.value]);\n setActive(option.value);\n } else {\n commit([option.value]);\n hide();\n }\n }\n\n function pick(item) {\n if (item.create) create(text);\n else choose(item.value);\n }\n\n function remove(v) {\n commit(chosen.filter((x) => x !== v));\n btn.current?.focus({ preventScroll: true });\n }\n\n // ─── keys ──────────────────────────────────────────────────────────\n\n const printable = (e) => e.key.length === 1 && e.key !== \" \" && !e.metaKey && !e.ctrlKey && !e.altKey;\n\n // Select-only: a letter jumps to the next option starting with it;\n // letters typed quickly spell a prefix; one letter repeated cycles.\n function typeahead(ch) {\n const now = Date.now();\n const t = typed.current;\n t.text = now - t.at > 700 ? ch : t.text + ch;\n t.at = now;\n const repeat = [...t.text].every((c) => c === t.text[0]);\n const q = (repeat ? t.text[0] : t.text).toLowerCase();\n const live = visible.filter((o) => !o.disabled);\n const at = Math.max(0, live.findIndex((o) => o.value === current));\n const from = repeat ? at + 1 : at;\n const order = [...live.slice(from), ...live.slice(0, from)];\n const hit = order.find((o) => String(o.label).toLowerCase().startsWith(q));\n if (hit) setActive(hit.value);\n }\n\n function onTriggerKey(e) {\n onKeyDown?.(e);\n if (e.defaultPrevented || disabled) return;\n const open = isOpen && !phone;\n if (!open) {\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n show();\n } else if (e.key === \"Backspace\" && multiple && chosen.length) {\n e.preventDefault();\n commit(chosen.slice(0, -1));\n } else if (printable(e)) {\n e.preventDefault();\n if (searchable) seed.current = e.key;\n show();\n }\n return;\n }\n // Open without a search: the keys work the list from the trigger.\n if (e.key === \"Tab\") return hide();\n if (e.key === \" \" || e.key === \"Enter\") {\n e.preventDefault();\n swallowSpace.current = e.key === \" \";\n const item = visible.find((o) => o.value === current);\n if (item && !item.disabled) pick(item);\n return;\n }\n if (listKeyDown(e, visible, current, setActive, pick)) return;\n if (printable(e)) {\n e.preventDefault();\n typeahead(e.key);\n }\n }\n\n // A Space that picked must not also click the trigger open again.\n function onTriggerKeyUp(e) {\n onKeyUp?.(e);\n if (e.key === \" \" && swallowSpace.current) {\n e.preventDefault();\n swallowSpace.current = false;\n }\n }\n\n function onSearchKey(e) {\n if (listKeyDown(e, visible, current, setActive, pick)) return;\n if (e.key === \"Backspace\" && multiple && !query && chosen.length) {\n e.preventDefault();\n commit(chosen.slice(0, -1));\n }\n }\n\n // A phone sheet without a search: the focused listbox takes the keys.\n function onListKey(e) {\n if (e.key === \" \") {\n e.preventDefault();\n const item = visible.find((o) => o.value === current);\n if (item && !item.disabled) pick(item);\n return;\n }\n if (listKeyDown(e, visible, current, setActive, pick)) return;\n if (printable(e)) typeahead(e.key);\n }\n\n // Focus leaving for anything outside the field and its panel closes it.\n function leaving(e) {\n const to = e.relatedTarget;\n if (!to || phone) return;\n if (panel.current?.contains(to) || wrap.current?.contains(to)) return;\n hide();\n }\n\n // ─── the trigger ───────────────────────────────────────────────────\n\n const one = !multiple && chosen.length ? optionOf(chosen[0]) : null;\n const showClear = clearable && chosen.length > 0 && !disabled;\n\n const body = (\n <>\n {searchable ? (\n <CommandInput\n inputRef={input}\n listId={listId}\n activeId={activeId}\n busy={busy && visible.length > 0}\n aria-label={searchPlaceholder || `Search ${title}`}\n placeholder={searchPlaceholder}\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setActive(null);\n }}\n onKeyDown={onSearchKey}\n />\n ) : null}\n <CommandList\n id={listId}\n label={title}\n items={shown}\n active={current}\n onActive={setActive}\n onPick={pick}\n selected={chosenSet}\n multiple={multiple}\n empty={empty}\n loading={busy}\n error={search.error}\n focusable={phone && !searchable}\n onKeyDown={phone && !searchable ? onListKey : undefined}\n listRef={list}\n // On a phone the list scrolls, not the sheet, so the search stays put.\n className={phone ? \"max-h-[55dvh]\" : undefined}\n />\n </>\n );\n\n return (\n <>\n <div\n ref={wrap}\n onPointerDown={() => {\n wasOpen.current = isOpen;\n }}\n // A press on the field (not on one of its buttons) opens it; a\n // press while open was the light dismiss that closed it.\n onClick={(e) => {\n if (disabled || e.target.closest(\"button\")) return;\n btn.current?.focus({ preventScroll: true });\n if (!wasOpen.current) show();\n }}\n className={cx(\n \"relative flex w-full min-h-11 cursor-pointer flex-wrap items-center gap-1 rounded-md border border-input bg-background pl-3 text-sm text-foreground\",\n \"has-[[data-combobox-trigger]:focus-visible]:ring-2 has-[[data-combobox-trigger]:focus-visible]:ring-ring\",\n \"has-[[data-combobox-trigger][aria-invalid=true]]:border-destructive\",\n showClear ? \"pr-16\" : \"pr-9\",\n multiple && chosen.length > 0 && \"py-1.5 pl-1.5\",\n disabled && \"cursor-not-allowed opacity-50\",\n className,\n )}\n >\n {multiple\n ? chosen.map((v) => {\n const o = optionOf(v);\n return (\n <span\n key={v}\n className=\"inline-flex min-h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-secondary pl-2 pr-0.5 text-xs text-secondary-foreground\"\n >\n {typeof o.icon === \"string\" ? <Icon name={o.icon} size={12} className=\"shrink-0 text-muted-foreground\" /> : null}\n <span className=\"truncate\">{o.label}</span>\n {disabled ? (\n <span className=\"w-1.5\" />\n ) : (\n <button\n type=\"button\"\n aria-label={`Remove ${o.label}`}\n onClick={() => remove(v)}\n className=\"grid size-6 shrink-0 place-items-center rounded-sm text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Icon name=\"x\" size={12} />\n </button>\n )}\n </span>\n );\n })\n : null}\n <button\n ref={btn}\n id={id}\n type=\"button\"\n role=\"combobox\"\n data-combobox-trigger=\"\"\n disabled={disabled}\n aria-label={label}\n aria-haspopup={searchable ? \"dialog\" : \"listbox\"}\n aria-expanded={isOpen}\n aria-controls={isOpen ? (searchable && !phone ? panelId : listId) : undefined}\n aria-activedescendant={isOpen && !searchable && !phone ? activeId ?? undefined : undefined}\n aria-invalid={invalid || undefined}\n popoverTarget={phone ? undefined : panelId}\n onClick={phone ? () => show() : undefined}\n onKeyDown={onTriggerKey}\n onKeyUp={onTriggerKeyUp}\n onBlur={(e) => {\n onBlur?.(e);\n if (!searchable) leaving(e);\n }}\n className={cx(\n \"flex min-h-9 min-w-12 flex-1 items-center gap-2 self-stretch text-left outline-none disabled:cursor-not-allowed\",\n multiple && chosen.length > 0 && \"pl-1\",\n )}\n {...rest}\n >\n {multiple ? (\n chosen.length ? (\n <span className=\"sr-only\">{chosen.map((v) => optionOf(v).label).join(\", \")}</span>\n ) : (\n <span className=\"truncate text-muted-foreground\">{placeholder}</span>\n )\n ) : one ? (\n <>\n <ItemIcon icon={one.icon} />\n <span className=\"truncate\">{one.label}</span>\n </>\n ) : (\n <span className=\"truncate text-muted-foreground\">{placeholder}</span>\n )}\n </button>\n {showClear ? (\n <button\n type=\"button\"\n aria-label=\"Clear\"\n onClick={() => {\n commit([]);\n btn.current?.focus({ preventScroll: true });\n }}\n className=\"absolute right-8 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-sm text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Icon name=\"x\" size={14} />\n </button>\n ) : null}\n <Icon name=\"chevrons-up-down\" className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\" />\n {name ? chosen.map((v) => <input key={v} type=\"hidden\" name={name} value={v} />) : null}\n </div>\n\n {phone ? (\n isOpen ? (\n <Sheet\n open\n side=\"bottom\"\n title={title}\n onClose={() => setOpen(false)}\n footer={\n multiple ? (\n <Button variant=\"primary\" onClick={hide} className=\"w-full\">\n Done{chosen.length ? ` · ${chosen.length} chosen` : \"\"}\n </Button>\n ) : null\n }\n >\n <div ref={sheetBody} className=\"-mx-4 -my-4\">\n {body}\n </div>\n </Sheet>\n ) : null\n ) : (\n <div\n ref={panel}\n id={panelId}\n popover=\"auto\"\n role={searchable ? \"dialog\" : undefined}\n aria-label={searchable ? title : undefined}\n onToggle={onToggle}\n onBlur={leaving}\n className={panelClass(\"auto\", \"overflow-hidden p-0 text-sm\")}\n style={width ? { width: `max(${width}px, 14rem)` } : undefined}\n >\n {body}\n </div>\n )}\n </>\n );\n}\n"
79
+ },
80
+ {
81
+ "path": "templates/next/files/components/ui/command.jsx",
82
+ "type": "registry:ui",
83
+ "target": "@components/makersclaw/ui/command.jsx",
84
+ "content": "\"use client\";\n\nimport { isValidElement, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\nimport { KbdGroup } from \"./separator\";\nimport { Spinner } from \"./feedback\";\nimport { Sheet } from \"./sheet\";\n\n// Command — a searchable list of actions: type to narrow, arrows to move,\n// Enter to run. The inline list of a \"jump to…\" panel, and (CommandDialog)\n// the ⌘K palette. No command package: the list is plain data, the filter\n// is commandScore below, and the ARIA is the combobox + listbox pattern —\n// focus stays in the input, aria-activedescendant names the active option,\n// so a screen reader follows the highlight while the caret stays put.\n//\n// items [{ value?, label, description?, icon?, shortcut?, keywords?,\n// group?, disabled?, onSelect? }] — `value` defaults to the\n// label and must be unique; `icon` a glyph name from ./icon or\n// a node (an Avatar); `shortcut` \"⌘N\" or [\"⌘\", \"N\"] (a hint\n// only, shown from md up: bind the key yourself); `keywords`\n// are matched but never shown; `group` is the heading items\n// sit under, in the order groups first appear\n// onSelect (item) — after the item's own onSelect\n// label what a screen reader calls the input and the list\n// placeholder the input's; empty — shown when nothing matches (a string\n// or a node)\n// query, defaultQuery, onQueryChange(query) — the typed text,\n// controlled or not\n// onSearch async (query, { signal }) → items: results come from you\n// (a server search), debounced; the spinner shows while it\n// runs, a stale answer is dropped. `items` show until the\n// first answer\n// loading true while you fetch `items` yourself\n// filter false when `items` arrive already filtered\n// autoFocus focus the input on mount\n//\n// Keys: ↑ ↓ move (disabled items are skipped), Home · End jump to the\n// first · last, PageUp · PageDown by eight, Enter runs the active item. The\n// first match is always active, so typing then Enter runs the best hit.\n//\n// CommandDialog — the same list in the kit's Sheet (center; a bottom sheet\n// on a phone). `open` + `onOpenChange` or uncontrolled; `hotkey=\"k\"` binds\n// ⌘K / Ctrl+K to open and close it. Running an item closes it.\n//\n// commandScore(text, query) → 0…1 (0 = no match) and commandFilter(items,\n// query) → the matching items, best first within their group, are exported\n// for the parts built on this (Combobox) and for a list of your own.\n//\n// The one rule: the list is data. Never map items into your own markup —\n// the ids, roles and keys here are what make it work for everyone.\n\n// ─── matching ──────────────────────────────────────────────────────────\n\nconst fold = (s) =>\n String(s ?? \"\")\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .toLowerCase();\n\nconst BOUNDARY = /[\\s\\-_/.,:;()@&+]/;\n\n/**\n * How well `text` matches `query`, 0 (not at all) to 1 (exactly). Accents\n * and case are ignored. Best to worst: equal, a prefix, a substring at a\n * word start, a substring inside a word, then the query's letters in order\n * (\"nyc\" in \"New York City\"), scored by how many land on word starts.\n */\nexport function commandScore(text, query) {\n const t = fold(text);\n const q = fold(query).trim();\n if (!q) return 1;\n if (!t) return 0;\n if (t === q) return 1;\n if (t.startsWith(q)) return 0.9;\n const at = t.indexOf(q);\n if (at > 0) return (BOUNDARY.test(t[at - 1]) ? 0.8 : 0.6) - Math.min(at, 40) * 0.002;\n // The letters in order.\n let from = 0;\n let prev = -2;\n let points = 0;\n let count = 0;\n for (const ch of q) {\n if (ch === \" \") continue;\n const found = t.indexOf(ch, from);\n if (found < 0) return 0;\n points += found === 0 || BOUNDARY.test(t[found - 1]) ? 1 : found === prev + 1 ? 0.6 : 0.2;\n count++;\n prev = found;\n from = found + 1;\n }\n return 0.1 + 0.3 * (points / count);\n}\n\n// An item's best field: the label counts most, keywords nearly as much,\n// the description least.\nfunction scoreItem(item, q) {\n const best = (query) =>\n Math.max(\n commandScore(item.label, query),\n ...(item.keywords ?? []).map((k) => commandScore(k, query) * 0.95),\n item.description && typeof item.description === \"string\" ? commandScore(item.description, query) * 0.7 : 0,\n );\n const whole = best(q);\n if (whole > 0) return whole;\n // Words in any order (\"lovelace ada\"): every word must match somewhere.\n const words = fold(q).split(/\\s+/).filter(Boolean);\n if (words.length < 2) return 0;\n let sum = 0;\n for (const w of words) {\n const s = best(w);\n if (!s) return 0;\n sum += s;\n }\n return (sum / words.length) * 0.9;\n}\n\n/** Every item with a `value` (the label when it has none). */\nexport function withValues(items) {\n return (items ?? []).filter(Boolean).map((it) => (it.value === undefined ? { ...it, value: String(it.label) } : { ...it, value: String(it.value) }));\n}\n\n/**\n * The items that match `query`, best first within their group; the groups\n * keep the order they first appear in. An empty query returns them all,\n * in order.\n */\nexport function commandFilter(items, query) {\n const all = withValues(items);\n if (!fold(query).trim()) return all;\n const order = [];\n const byGroup = new Map();\n all.forEach((item, i) => {\n const score = scoreItem(item, query);\n if (!score) return;\n const g = item.group ?? \"\";\n if (!byGroup.has(g)) {\n byGroup.set(g, []);\n order.push(g);\n }\n byGroup.get(g).push({ item, score, i });\n });\n return order.flatMap((g) => byGroup.get(g).sort((a, b) => b.score - a.score || a.i - b.i).map((x) => x.item));\n}\n\n// ─── pieces shared with Combobox ───────────────────────────────────────\n\n/** The option's DOM id: stable for a list id and an index. */\nexport const optionId = (listId, i) => `${listId}-o${i}`;\n\n/** The active value as the list shows it: the asked one while it is\n * visible and enabled, else the first enabled item. */\nexport function resolveActive(items, active) {\n if (active != null && items.some((it) => it.value === active && !it.disabled)) return active;\n return items.find((it) => !it.disabled)?.value ?? null;\n}\n\n/**\n * The list keys. Returns true (and prevents the default) when it handled\n * the key: ↑ ↓ Home End PageUp PageDown move, Enter picks.\n */\nexport function listKeyDown(e, items, active, setActive, onPick) {\n const live = items.filter((it) => !it.disabled);\n if (!live.length) return false;\n const i = live.findIndex((it) => it.value === active);\n const last = live.length - 1;\n let next;\n switch (e.key) {\n case \"ArrowDown\":\n next = i < 0 ? 0 : Math.min(i + 1, last);\n break;\n case \"ArrowUp\":\n next = i < 0 ? last : Math.max(i - 1, 0);\n break;\n case \"Home\":\n next = 0;\n break;\n case \"End\":\n next = last;\n break;\n case \"PageDown\":\n next = Math.min((i < 0 ? 0 : i) + 8, last);\n break;\n case \"PageUp\":\n next = Math.max((i < 0 ? 0 : i) - 8, 0);\n break;\n case \"Enter\":\n // An IME composing a word owns Enter.\n if (i < 0 || e.nativeEvent?.isComposing) return false;\n e.preventDefault();\n onPick(live[i]);\n return true;\n default:\n return false;\n }\n e.preventDefault();\n setActive(live[next].value);\n return true;\n}\n\n/**\n * Results from an async `onSearch(query, { signal })`, debounced by\n * `delay` ms (none for the empty query). A newer query aborts the older\n * one; a failed search keeps the last results and sets `error`.\n */\nexport function useAsyncSearch(onSearch, query, { enabled = true, delay = 200 } = {}) {\n const fn = useRef(onSearch);\n fn.current = onSearch;\n const has = Boolean(onSearch);\n const [state, setState] = useState({ items: null, loading: false, error: null });\n useEffect(() => {\n if (!has || !enabled) return;\n const ctl = new AbortController();\n setState((s) => (s.loading ? s : { ...s, loading: true }));\n const timer = setTimeout(async () => {\n try {\n const items = await fn.current(query, { signal: ctl.signal });\n if (!ctl.signal.aborted) setState({ items: items ?? [], loading: false, error: null });\n } catch (error) {\n if (!ctl.signal.aborted) setState((s) => ({ items: s.items ?? [], loading: false, error }));\n }\n }, query.trim() ? delay : 0);\n return () => {\n clearTimeout(timer);\n ctl.abort();\n };\n }, [has, enabled, query, delay]);\n return state;\n}\n\nconst PHONE = \"(max-width: 639.98px)\";\nconst subscribePhone = (cb) => {\n const m = window.matchMedia(PHONE);\n m.addEventListener(\"change\", cb);\n return () => m.removeEventListener(\"change\", cb);\n};\n/** True under the kit's `sm` breakpoint (640px), where Sheet turns every\n * overlay into a bottom sheet. False on the server. */\nexport function useIsPhone() {\n return useSyncExternalStore(\n subscribePhone,\n () => window.matchMedia(PHONE).matches,\n () => false,\n );\n}\n\n/** An item's icon: a glyph name, or a node (an Avatar) as it is. */\nexport function ItemIcon({ icon, className }) {\n if (!icon) return null;\n if (typeof icon === \"string\") return <Icon name={icon} className={cx(\"shrink-0 text-muted-foreground\", className)} />;\n return isValidElement(icon) ? <span className={cx(\"flex shrink-0 items-center\", className)}>{icon}</span> : null;\n}\n/**\n * The search bar at the top of a Command or a Combobox panel: a glyph, the\n * input (role=combobox, pointing at `listId`), a spinner while `busy`.\n * Every other prop goes to the <input>.\n */\nexport function CommandInput({ listId, activeId, busy, inputRef, className, ...rest }) {\n return (\n <div className={cx(\"flex items-center gap-2 border-b border-border px-3\", className)}>\n <Icon name=\"search\" className=\"shrink-0 text-muted-foreground\" />\n <input\n ref={inputRef}\n type=\"text\"\n role=\"combobox\"\n aria-expanded=\"true\"\n aria-controls={listId}\n aria-activedescendant={activeId ?? undefined}\n aria-autocomplete=\"list\"\n autoComplete=\"off\"\n autoCorrect=\"off\"\n spellCheck={false}\n // 16px on a phone, or iOS zooms the page when it takes focus.\n className=\"min-h-11 min-w-0 flex-1 bg-transparent py-2 text-base text-foreground outline-none placeholder:text-muted-foreground sm:text-sm\"\n {...rest}\n />\n {busy ? <Spinner size=\"sm\" label=\"Searching\" className=\"text-muted-foreground\" /> : null}\n </div>\n );\n}\n\n/**\n * The listbox: groups under their headings, options with icon, label,\n * description and shortcut; the empty, loading and error lines around it.\n *\n * id, label the listbox's id and name\n * items already filtered, in the order shown\n * active the active value; onActive(value) on hover\n * onPick(item) click, or Enter through listKeyDown\n * selected a Set of chosen values (a Combobox): each option shows a\n * check (`multiple`: a box at the start) and aria-selected\n * says chosen; without it aria-selected marks the active\n * className the scroll box's; its max height (default max-h-72)\n * focusable the listbox takes focus itself (aria-activedescendant on\n * it, `onKeyDown` for the keys) — when there is no input\n */\nexport function CommandList({\n id,\n label,\n items,\n active,\n onActive,\n onPick,\n selected,\n multiple = false,\n empty = \"No results.\",\n loading = false,\n error,\n focusable = false,\n onKeyDown,\n listRef,\n className,\n}) {\n const box = useRef(null);\n const activeIndex = items.findIndex((it) => it.value === active);\n\n // Keep the active option in view as the keys move it.\n useEffect(() => {\n if (activeIndex < 0) return;\n document.getElementById(optionId(id, activeIndex))?.scrollIntoView({ block: \"nearest\" });\n }, [id, activeIndex]);\n\n // Consecutive items with one group share a heading.\n const sections = [];\n items.forEach((item, i) => {\n const g = item.group ?? \"\";\n const tail = sections[sections.length - 1];\n if (tail && tail.group === g) tail.rows.push([item, i]);\n else sections.push({ group: g, rows: [[item, i]] });\n });\n\n function option([item, i]) {\n const isActive = item.value === active;\n const chosen = selected ? selected.has(item.value) : false;\n const keys = item.shortcut ? (Array.isArray(item.shortcut) ? item.shortcut : [item.shortcut]) : null;\n return (\n <div\n key={item.value}\n id={optionId(id, i)}\n role=\"option\"\n aria-selected={selected ? chosen : isActive}\n aria-disabled={item.disabled || undefined}\n data-active={isActive || undefined}\n // The input keeps focus; a press on an option must not take it.\n onMouseDown={(e) => e.preventDefault()}\n onMouseMove={() => {\n if (!item.disabled && !isActive) onActive?.(item.value);\n }}\n onClick={() => {\n if (!item.disabled) onPick?.(item);\n }}\n className={cx(\n \"flex min-h-11 cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-popover-foreground md:min-h-9\",\n \"data-[active]:bg-accent data-[active]:text-accent-foreground\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50\",\n )}\n >\n {selected && multiple ? (\n <span\n aria-hidden=\"true\"\n className={cx(\n \"grid size-4 shrink-0 place-items-center rounded-sm border\",\n chosen ? \"border-primary bg-primary text-primary-foreground\" : \"border-input bg-background\",\n )}\n >\n {chosen ? <Icon name=\"check\" size={12} /> : null}\n </span>\n ) : null}\n <ItemIcon icon={item.icon} />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{item.label}</span>\n {item.description ? <span className=\"block truncate text-xs text-muted-foreground\">{item.description}</span> : null}\n </span>\n {keys ? (\n <span className=\"hidden shrink-0 md:inline-flex\" aria-hidden=\"true\">\n <KbdGroup keys={keys} />\n </span>\n ) : null}\n {selected && !multiple ? (\n <Icon name=\"check\" className={cx(\"shrink-0 text-foreground\", !chosen && \"invisible\")} />\n ) : null}\n </div>\n );\n }\n\n const status = error ? \"Search failed. Try again.\" : loading ? null : items.length === 0 ? empty : null;\n\n return (\n <div className={cx(\"overflow-y-auto overscroll-contain\", className ?? \"max-h-72\")}>\n {loading && items.length === 0 ? (\n <div role=\"status\" className=\"flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground\">\n <Spinner size=\"sm\" label={null} />\n Searching…\n </div>\n ) : null}\n {status && !(loading && items.length === 0) ? (\n <div role=\"status\" className=\"px-3 py-6 text-center text-sm text-muted-foreground\">\n {status}\n </div>\n ) : null}\n <div\n ref={(el) => {\n box.current = el;\n if (listRef) listRef.current = el;\n }}\n id={id}\n role=\"listbox\"\n aria-label={label}\n aria-multiselectable={multiple || undefined}\n tabIndex={focusable ? 0 : undefined}\n aria-activedescendant={focusable && activeIndex >= 0 ? optionId(id, activeIndex) : undefined}\n onKeyDown={onKeyDown}\n className={cx(\"scroll-py-1 outline-none\", items.length ? \"p-1\" : \"hidden\")}\n >\n {sections.map((s, n) =>\n s.group ? (\n <div\n key={`${s.group}-${n}`}\n role=\"group\"\n aria-labelledby={`${id}-g${n}`}\n className={cx(n > 0 && \"mt-1 border-t border-border pt-1\")}\n >\n <div id={`${id}-g${n}`} className=\"px-2 pb-1 pt-1.5 text-xs font-medium text-muted-foreground\">\n {s.group}\n </div>\n {s.rows.map(option)}\n </div>\n ) : (\n <div key={`-${n}`} role=\"presentation\" className={cx(n > 0 && \"mt-1 border-t border-border pt-1\")}>\n {s.rows.map(option)}\n </div>\n ),\n )}\n </div>\n </div>\n );\n}\n\n// ─── Command ───────────────────────────────────────────────────────────\n\nexport function Command({\n items,\n onSelect,\n label = \"Commands\",\n placeholder = \"Type a command or search…\",\n empty = \"No results.\",\n query,\n defaultQuery = \"\",\n onQueryChange,\n onSearch,\n loading = false,\n filter = true,\n autoFocus = false,\n className,\n listClassName,\n bare = false,\n}) {\n const listId = `${useId().replace(/[^\\w-]/g, \"\")}-list`;\n const input = useRef(null);\n const [own, setOwn] = useState(defaultQuery);\n const text = query ?? own;\n const [active, setActive] = useState(null);\n const search = useAsyncSearch(onSearch, text);\n\n const source = onSearch ? search.items ?? items : items;\n const visible = useMemo(\n () => (filter && !onSearch ? commandFilter(source, text) : withValues(source)),\n [source, text, filter, onSearch],\n );\n const current = resolveActive(visible, active);\n const activeIndex = visible.findIndex((it) => it.value === current);\n\n useEffect(() => {\n if (autoFocus) input.current?.focus({ preventScroll: true });\n }, [autoFocus]);\n\n function setText(v) {\n if (query === undefined) setOwn(v);\n onQueryChange?.(v);\n // A new query starts from its best match.\n setActive(null);\n }\n\n function pick(item) {\n item.onSelect?.(item);\n onSelect?.(item);\n }\n\n const busy = loading || search.loading;\n\n return (\n <div\n className={cx(\n \"flex min-w-0 flex-col overflow-hidden bg-popover text-popover-foreground\",\n !bare && \"rounded-md border border-border has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring\",\n className,\n )}\n >\n <CommandInput\n inputRef={input}\n listId={listId}\n activeId={activeIndex >= 0 ? optionId(listId, activeIndex) : null}\n busy={busy && visible.length > 0}\n aria-label={label}\n placeholder={placeholder}\n value={text}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => listKeyDown(e, visible, current, setActive, pick)}\n />\n <CommandList\n id={listId}\n label={label}\n items={visible}\n active={current}\n onActive={setActive}\n onPick={pick}\n empty={empty}\n loading={busy}\n error={search.error}\n className={listClassName ?? \"max-h-80\"}\n />\n </div>\n );\n}\n\nexport function CommandDialog({ open, defaultOpen = false, onOpenChange, hotkey, label = \"Command menu\", onSelect, className, ...command }) {\n const controlled = open !== undefined;\n const [own, setOwn] = useState(defaultOpen);\n const isOpen = controlled ? open : own;\n const openRef = useRef(isOpen);\n openRef.current = isOpen;\n\n const change = useCallback(\n (next) => {\n if (next === openRef.current) return;\n openRef.current = next;\n if (!controlled) setOwn(next);\n onOpenChange?.(next);\n },\n [controlled, onOpenChange],\n );\n const changeRef = useRef(change);\n changeRef.current = change;\n\n // ⌘K / Ctrl+K opens it, and closes it again.\n useEffect(() => {\n if (!hotkey) return;\n function onKey(e) {\n if (e.key?.toLowerCase() !== String(hotkey).toLowerCase() || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;\n e.preventDefault();\n changeRef.current(!openRef.current);\n }\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [hotkey]);\n\n // A bare Sheet: no header (Esc and a press outside close it), a flush\n // body so the input sits at the top edge, the dialog named by `label`.\n return (\n <Sheet bare label={label} open={isOpen} onClose={() => change(false)} side=\"center\" className={cx(\"sm:mt-[12dvh]\", className)}>\n {/* Mounted only while open, so each opening starts empty. */}\n {isOpen ? (\n <Command\n bare\n label={label}\n autoFocus={isOpen}\n listClassName=\"max-h-[min(24rem,60dvh)]\"\n onSelect={(item) => {\n change(false);\n onSelect?.(item);\n }}\n {...command}\n />\n ) : null}\n </Sheet>\n );\n}\n"
85
+ },
86
+ {
87
+ "path": "templates/next/files/components/ui/context-menu.jsx",
88
+ "type": "registry:ui",
89
+ "target": "@components/makersclaw/ui/context-menu.jsx",
90
+ "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { cx } from \"./cx\";\nimport { MenuPanel } from \"./menu\";\nimport { panelClass, usePopoverPosition } from \"./popover\";\n\n// ContextMenu — the menu a right-click opens on a region: a row, a card,\n// a canvas. The same `items` as Menu (actions, groups, checkboxes,\n// radios, shortcuts, submenus) and the same keys, opened at the pointer\n// and flipped to stay on screen. It opens on:\n//\n// a right-click (or Ctrl-click on a Mac) at the pointer\n// a long press on touch under the finger\n// Shift+F10 or the Menu key on a focused thing under that thing\n//\n// <ContextMenu label=\"Actions for Acme\" items={[{ label: \"Edit\", onSelect }]}>\n// <Row … />\n// </ContextMenu>\n//\n// items as Menu's (./menu); none (or []) leaves the browser's own\n// right-click menu alone\n// label what a reader calls the menu (default \"Actions\")\n// disabled true: the region behaves as if there were no menu\n// className the region's (a <div> around children)\n//\n// The one rule: a context menu is a shortcut, never the only door. Every\n// action in it is also somewhere a tap or a Tab can reach — a Menu on the\n// row, a button in its page — because nobody discovers a right-click.\n// Focus goes to the first item when it opens and back to what had it\n// when it closes.\n//\n// The panel is a manual popover with its own light dismiss: an auto one\n// would be closed by the very release of the button (or the finger) that\n// opened it, since a right-click opens on press on a Mac.\n\nconst LONG_PRESS = 550; // ms; just past the OS's own, which wins where it fires\n\n// A Menu open inside the region (a row's ⋯) keeps its own keys and clicks.\nconst inMenu = (e) => Boolean(e.target.closest?.('[role=\"menu\"]'));\n\nexport function ContextMenu({ items, label = \"Actions\", disabled = false, className, children }) {\n const pop = useRef(null);\n const point = useRef(null);\n const back = useRef(null);\n const press = useRef({ timer: 0, x: 0, y: 0, at: 0 });\n const opened = useRef(0);\n const unwatch = useRef(null);\n usePopoverPosition(point, pop, { side: \"bottom\", align: \"start\", offset: 2 });\n const live = (items ?? []).filter(Boolean);\n useEffect(() => () => unwatch.current?.(), []);\n\n if (live.length === 0 || disabled) return <div className={className}>{children}</div>;\n\n function hide() {\n if (pop.current?.matches(\":popover-open\")) pop.current.hidePopover();\n }\n\n // Light dismiss: a press anywhere outside the panel (and its submenus,\n // which sit inside it) closes it; so does Escape wherever focus is.\n function watch() {\n if (unwatch.current) return;\n const down = (e) => {\n if (!pop.current?.contains(e.target)) hide();\n };\n const key = (e) => {\n if (e.key === \"Escape\" && !pop.current?.contains(e.target)) {\n hide();\n restore();\n }\n };\n document.addEventListener(\"pointerdown\", down, true);\n document.addEventListener(\"keydown\", key);\n window.addEventListener(\"blur\", hide);\n unwatch.current = () => {\n document.removeEventListener(\"pointerdown\", down, true);\n document.removeEventListener(\"keydown\", key);\n window.removeEventListener(\"blur\", hide);\n unwatch.current = null;\n };\n }\n\n // `other` marks an open by a long press or a key, after which the\n // browser's own contextmenu event (Android, Windows) is a duplicate.\n function openAt(x, y, other = false) {\n const p = pop.current;\n const s = point.current;\n if (!p || !s) return;\n // The point is a fixed, empty box the panel is anchored to. Set it, then\n // correct for a transformed ancestor that moves what \"fixed\" means;\n // its width flips 0/1px so a measured placement sees a change.\n s.style.left = `${x}px`;\n s.style.top = `${y}px`;\n const r = s.getBoundingClientRect();\n s.style.left = `${2 * x - r.left}px`;\n s.style.top = `${2 * y - r.top}px`;\n s.style.width = s.style.width === \"1px\" ? \"0px\" : \"1px\";\n if (!p.matches(\":popover-open\")) {\n if (!back.current || !back.current.isConnected || p.contains(back.current)) back.current = document.activeElement;\n } else p.hidePopover();\n p.showPopover();\n watch();\n if (other) opened.current = Date.now();\n p.querySelector('[role^=\"menuitem\"]:not([disabled])')?.focus({ preventScroll: true });\n }\n\n function restore() {\n const to = back.current;\n back.current = null;\n if (to && to !== document.body && to.isConnected) to.focus({ preventScroll: true });\n }\n\n function cancelPress() {\n clearTimeout(press.current.timer);\n press.current.timer = 0;\n }\n\n return (\n <>\n <div\n className={cx(\"[-webkit-touch-callout:none]\", className)}\n onContextMenu={(e) => {\n if (inMenu(e)) return;\n e.preventDefault();\n cancelPress();\n // A long press or Shift+F10 opened it just now.\n if (Date.now() - opened.current < 800) return;\n openAt(e.clientX, e.clientY);\n }}\n onKeyDown={(e) => {\n if ((e.key !== \"ContextMenu\" && !(e.shiftKey && e.key === \"F10\")) || inMenu(e)) return;\n e.preventDefault();\n const r = e.target.getBoundingClientRect();\n openAt(r.left + Math.min(16, r.width / 2), r.bottom, true);\n }}\n onPointerDown={(e) => {\n if (e.pointerType === \"mouse\" || inMenu(e)) return;\n cancelPress();\n const { clientX: x, clientY: y } = e;\n press.current = {\n x,\n y,\n at: 0,\n timer: setTimeout(() => {\n press.current.timer = 0;\n press.current.at = Date.now();\n openAt(x, y, true);\n }, LONG_PRESS),\n };\n }}\n onPointerMove={(e) => {\n const p = press.current;\n if (p.timer && Math.hypot(e.clientX - p.x, e.clientY - p.y) > 10) cancelPress();\n }}\n onPointerUp={cancelPress}\n onPointerCancel={cancelPress}\n onClickCapture={(e) => {\n // The finger lifting after a long press is not a tap on what is under it.\n if (Date.now() - press.current.at < 800) {\n e.preventDefault();\n e.stopPropagation();\n }\n }}\n >\n <span ref={point} aria-hidden=\"true\" className=\"pointer-events-none fixed top-0 left-0 h-0 w-0\" />\n {children}\n </div>\n <MenuPanel\n items={live}\n label={label}\n panelRef={pop}\n popover=\"manual\"\n onToggle={(e) => {\n // A submenu's toggle bubbles here; a close already followed by an\n // open (a right-click elsewhere) is not a close.\n if (e.target !== e.currentTarget || e.newState !== \"closed\" || pop.current?.matches(\":popover-open\")) return;\n unwatch.current?.();\n const a = document.activeElement;\n if (!a || a === document.body || pop.current?.contains(a)) restore();\n else back.current = null;\n }}\n onExit={() => {\n hide();\n restore();\n }}\n onClose={hide}\n className={panelClass(\"auto\", \"min-w-44 max-h-[calc(100vh-1rem)] overflow-y-auto p-1\")}\n />\n </>\n );\n}\n"
91
+ },
92
+ {
93
+ "path": "templates/next/files/components/ui/cx.js",
94
+ "type": "registry:ui",
95
+ "target": "@components/makersclaw/ui/cx.js",
96
+ "content": "/** Join class names, dropping falsy entries. The only helper the kit needs. */\nexport function cx(...parts) {\n return parts.flat().filter(Boolean).join(\" \");\n}\n"
97
+ },
98
+ {
99
+ "path": "templates/next/files/components/ui/date-picker.jsx",
100
+ "type": "registry:ui",
101
+ "target": "@components/makersclaw/ui/date-picker.jsx",
102
+ "content": "\"use client\";\n\nimport { useState, useSyncExternalStore } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\nimport { Button, IconButton } from \"./button\";\nimport { Input } from \"./field\";\nimport { Popover } from \"./popover\";\nimport {\n Calendar,\n addDays,\n addMonths,\n endOfMonth,\n formatDate,\n parseDate,\n plainSpaces,\n startOfMonth,\n useLocale,\n useToday,\n} from \"./calendar\";\n\n// DatePicker — a field that reads like an input and opens a Calendar in a\n// Popover: a due date, a report's span. The trigger shows the day in the\n// reader's words (\"Mar 12, 2026\"); the value stays a \"YYYY-MM-DD\" string\n// (calendar.jsx says why). Put it in <Field id> with the same `id`: the\n// label points at the trigger.\n//\n// mode single (value \"2026-03-12\" | null) · range (value\n// { from, to } | null)\n// value, onChange(value) — controlled; or `defaultValue`\n// placeholder \"Pick a date\" · \"Pick a range\"\n// presets true (the defaults for the mode: Today, Tomorrow, In a\n// week… · Today, Last 7 days, This month…) or your own\n// [{ label, value }] — `value` a date, a { from, to }, or\n// (today) → one. A list beside the calendar; a row above\n// it on a phone\n// clearable an × in the field once it holds a date (true)\n// format Intl.DateTimeFormat options for the field ({ dateStyle:\n// \"medium\" }); a range is written with formatRange\n// disabled true: the whole field; a function (date) → true: those days\n// min, max, numberOfMonths (range: 2 from 640px; always 1 on a phone),\n// captionLayout, showWeekNumbers, weekStartsOn, locale, today — as\n// Calendar\n// name a hidden input for a form; a range submits two under it\n// invalid the red edge — pair it with <Field error>\n// size md (44px) · sm (36px)\n// label what a screen reader calls the panel (\"Choose a date\")\n// defaultOpen open on first render (a demo; a form never needs it)\n//\n// It closes when a day, a finished range or a preset is picked; the focus\n// lands on the chosen day when it opens and back on the field when it\n// closes.\n//\n// DateInput below is the typed way in: the browser's own date field\n// (segments in the reader's order, digits only, never a free-text parse)\n// with this Calendar one button away. Typing a date into a text box and\n// parsing it is not robust across locales (\"03/04\" is two different\n// days), which is why there is no text DateInput.\n\nconst FIELD =\n \"flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 text-left text-sm text-foreground \" +\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 \" +\n \"aria-[invalid=true]:border-destructive\";\nconst HEIGHT = { md: \"min-h-11\", sm: \"min-h-9\" };\n// Presets: on a phone a scrolling row exactly as wide as the calendar (w-0\n// plus a min width of its box: it never widens the panel); a column beside\n// the calendar from 640px.\nconst PRESETS =\n \"-mx-3 flex w-0 min-w-[calc(100%+1.5rem)] gap-1 overflow-x-auto px-3 pb-1 \" +\n \"sm:m-0 sm:w-36 sm:min-w-0 sm:flex-col sm:overflow-visible sm:border-r sm:border-border sm:p-0 sm:pr-3\";\n\nfunction useWide() {\n return useSyncExternalStore(\n (cb) => {\n const q = window.matchMedia(\"(min-width: 640px)\");\n q.addEventListener(\"change\", cb);\n return () => q.removeEventListener(\"change\", cb);\n },\n () => window.matchMedia(\"(min-width: 640px)\").matches,\n () => false,\n );\n}\n\n/** The default presets. Each value is (today) → a date or a { from, to }. */\nexport const DATE_PRESETS = {\n single: [\n { label: \"Today\", value: (t) => t },\n { label: \"Tomorrow\", value: (t) => addDays(t, 1) },\n { label: \"In a week\", value: (t) => addDays(t, 7) },\n { label: \"In a month\", value: (t) => addMonths(t, 1) },\n ],\n range: [\n { label: \"Today\", value: (t) => ({ from: t, to: t }) },\n { label: \"Last 7 days\", value: (t) => ({ from: addDays(t, -6), to: t }) },\n { label: \"Last 30 days\", value: (t) => ({ from: addDays(t, -29), to: t }) },\n { label: \"This month\", value: (t) => ({ from: startOfMonth(t), to: endOfMonth(t) }) },\n { label: \"Last month\", value: (t) => ({ from: startOfMonth(addMonths(t, -1)), to: endOfMonth(addMonths(t, -1)) }) },\n { label: \"This year\", value: (t) => ({ from: `${t.slice(0, 4)}-01-01`, to: `${t.slice(0, 4)}-12-31` }) },\n ],\n};\n\nconst same = (a, b) => (a && b && typeof a === \"object\" ? a.from === b.from && a.to === b.to : a === b);\n\n/** The value in words: a day, or a span through formatRange. */\nfunction words(mode, value, format, locale) {\n if (mode !== \"range\") return value ? formatDate(value, format, locale) : \"\";\n const { from, to } = value ?? {};\n if (!from) return \"\";\n if (!to) return `${formatDate(from, format, locale)} – …`;\n const f = new Intl.DateTimeFormat(locale, { ...format, timeZone: \"UTC\" });\n return plainSpaces(f.formatRange ? f.formatRange(parseDate(from), parseDate(to)) : `${f.format(parseDate(from))} – ${f.format(parseDate(to))}`);\n}\n\nexport function DatePicker({\n id,\n mode = \"single\",\n value: valueProp,\n defaultValue = null,\n onChange,\n placeholder,\n presets = false,\n clearable = true,\n format = { dateStyle: \"medium\" },\n disabled,\n min,\n max,\n numberOfMonths,\n captionLayout,\n showWeekNumbers,\n weekStartsOn,\n locale: localeProp,\n today: todayProp,\n name,\n invalid,\n size = \"md\",\n align = \"start\",\n label,\n defaultOpen = false,\n className,\n}) {\n const locale = useLocale(localeProp);\n const today = useToday(todayProp);\n const wide = useWide();\n const [own, setOwn] = useState(defaultValue);\n const value = valueProp !== undefined ? valueProp : own;\n const [open, setOpen] = useState(defaultOpen);\n const [month, setMonth] = useState(null);\n const range = mode === \"range\";\n const off = disabled === true;\n\n const first = range ? value?.from : value;\n const text = words(mode, value, format, locale);\n const list = presets === true ? DATE_PRESETS[range ? \"range\" : \"single\"] : Array.isArray(presets) ? presets : [];\n const shown = list.map((p) => ({ label: p.label, value: typeof p.value === \"function\" ? (today ? p.value(today) : null) : p.value }));\n\n function set(v) {\n if (valueProp === undefined) setOwn(v);\n onChange?.(v);\n }\n\n // Each opening starts on the chosen day's month (or today's).\n function onOpenChange(next) {\n if (next) setMonth(startOfMonth(first) ?? startOfMonth(today));\n setOpen(next);\n }\n\n function choose(v) {\n set(v);\n if (!range || (v?.from && v?.to)) setOpen(false);\n }\n\n const months = wide ? numberOfMonths ?? (range ? 2 : 1) : 1;\n const showClear = clearable && !off && (range ? !!value?.from : !!value);\n\n return (\n <div className={cx(\"relative w-full\", className)}>\n <Popover\n open={open}\n onOpenChange={onOpenChange}\n align={align}\n width=\"auto\"\n className=\"p-3\"\n label={label ?? (range ? \"Choose a date range\" : \"Choose a date\")}\n trigger={(props) => (\n <button\n {...props}\n id={id}\n type=\"button\"\n disabled={off}\n aria-invalid={invalid ? true : undefined}\n className={cx(FIELD, HEIGHT[size] ?? HEIGHT.md, showClear && \"pr-11\")}\n >\n <Icon name=\"calendar\" className=\"shrink-0 text-muted-foreground\" />\n <span className={cx(\"min-w-0 flex-1 truncate\", !text && \"text-muted-foreground\")}>\n {text || placeholder || (range ? \"Pick a range\" : \"Pick a date\")}\n </span>\n </button>\n )}\n >\n <div className=\"flex flex-col gap-3 sm:flex-row\">\n {shown.length ? (\n <div\n role=\"group\"\n aria-label=\"Presets\"\n className={PRESETS}\n >\n {shown.map((p) => {\n const on = !!p.value && same(p.value, value);\n return (\n <Button\n key={p.label}\n size={wide ? \"sm\" : \"md\"}\n variant={on ? \"secondary\" : \"quiet\"}\n aria-pressed={on}\n disabled={!p.value}\n className=\"shrink-0 justify-start\"\n onClick={() => {\n choose(p.value);\n setMonth(startOfMonth(range ? p.value.from : p.value));\n }}\n >\n {p.label}\n </Button>\n );\n })}\n </div>\n ) : null}\n <Calendar\n mode={range ? \"range\" : \"single\"}\n value={value}\n onChange={choose}\n month={month ?? undefined}\n onMonthChange={(m) => setMonth(`${m}-01`)}\n numberOfMonths={months}\n min={min}\n max={max}\n disabled={typeof disabled === \"function\" ? disabled : undefined}\n captionLayout={captionLayout}\n showWeekNumbers={showWeekNumbers}\n weekStartsOn={weekStartsOn}\n locale={locale}\n today={today ?? undefined}\n autoFocus={open}\n label={label ?? (range ? \"Choose a date range\" : \"Choose a date\")}\n />\n </div>\n </Popover>\n {showClear ? (\n <IconButton\n icon=\"x\"\n label=\"Clear date\"\n size=\"sm\"\n onClick={() => set(null)}\n className={cx(\"absolute right-1\", size === \"sm\" ? \"top-0\" : \"top-1\")}\n />\n ) : null}\n {name && !range ? <input type=\"hidden\" name={name} value={value ?? \"\"} /> : null}\n {name && range ? (\n <>\n <input type=\"hidden\" name={name} value={value?.from ?? \"\"} />\n <input type=\"hidden\" name={name} value={value?.to ?? \"\"} />\n </>\n ) : null}\n </div>\n );\n}\n\n/**\n * A date typed or picked. The browser's own date field — segments in the\n * reader's order, digits only, the value a \"YYYY-MM-DD\" string or \"\" while\n * incomplete — with its built-in picker swapped for a button that opens\n * this Calendar. onChange(date | null). Chrome and Safari hide their own\n * picker icon; Firefox keeps its own beside ours.\n *\n * value, onChange(date | null), defaultValue, min, max, disabled\n * (true, or a function for the Calendar's days), name, invalid, size,\n * locale (the Calendar's; the field itself follows the browser), today\n */\nexport function DateInput({\n id,\n value: valueProp,\n defaultValue = null,\n onChange,\n min,\n max,\n disabled,\n name,\n invalid,\n size = \"md\",\n locale,\n today,\n weekStartsOn,\n className,\n ...rest\n}) {\n const [own, setOwn] = useState(defaultValue);\n const value = valueProp !== undefined ? valueProp : own;\n const [open, setOpen] = useState(false);\n const [month, setMonth] = useState(null);\n const off = disabled === true;\n const out = !!value && ((min && value < min) || (max && value > max));\n\n function set(v) {\n if (valueProp === undefined) setOwn(v);\n onChange?.(v);\n }\n\n return (\n <div className={cx(\"relative w-full\", className)}>\n <Input\n id={id}\n type=\"date\"\n name={name}\n value={value ?? \"\"}\n min={min}\n max={max}\n disabled={off}\n invalid={invalid || out}\n onChange={(e) => set(e.target.value || null)}\n className={cx(\n \"pr-12 [&::-webkit-calendar-picker-indicator]:hidden tabular-nums\",\n size === \"sm\" && \"min-h-9\",\n )}\n {...rest}\n />\n <span className={cx(\"absolute right-1\", size === \"sm\" ? \"top-0\" : \"top-1\")}>\n <Popover\n open={open}\n onOpenChange={(next) => {\n if (next) setMonth(startOfMonth(value) ?? null);\n setOpen(next);\n }}\n align=\"end\"\n width=\"auto\"\n className=\"p-3\"\n label=\"Choose a date\"\n trigger={(props) => <IconButton {...props} icon=\"calendar\" label=\"Open calendar\" size=\"sm\" disabled={off} />}\n >\n <Calendar\n value={value}\n onChange={(v) => {\n set(v);\n setOpen(false);\n }}\n month={month ?? undefined}\n onMonthChange={(m) => setMonth(`${m}-01`)}\n min={min}\n max={max}\n disabled={typeof disabled === \"function\" ? disabled : undefined}\n locale={locale}\n today={today}\n weekStartsOn={weekStartsOn}\n autoFocus={open}\n label=\"Choose a date\"\n />\n </Popover>\n </span>\n </div>\n );\n}\n"
103
+ },
104
+ {
105
+ "path": "templates/next/files/components/ui/depiction.jsx",
106
+ "type": "registry:ui",
107
+ "target": "@components/makersclaw/ui/depiction.jsx",
108
+ "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\n// Depiction — the frame around a picture of something that exists\n// elsewhere: a tweet as X will show it, a LinkedIn post in the feed, the\n// row an email makes in Gmail's inbox. The picture itself is a file in\n// components/depictions/<thing>.jsx and wears THAT place's colours, face\n// and widths (README §3c); this frame is the boundary where the\n// workspace theme stops. app/globals.css resets the inherited face, size,\n// letter-spacing and density on [data-depiction] and sets color-scheme\n// from data-scheme, so nothing of the workspace reaches the picture.\n//\n// <Depiction of=\"x-post\" scheme=\"dark\" width={600} label=\"On X · Dim\">\n// <XPost text={draft.text} name={me.name} handle={me.handle} mode=\"dim\" />\n// </Depiction>\n//\n// of the depiction file's basename; `npm run check` ties them\n// scheme the TARGET's light or dark — never the workspace's\n// width the target's real column in px (X 600, LinkedIn 555, phone 375)\n// label the caption, chrome, in the workspace's tokens\n//\n// Scale, never reflow: line breaks and the \"…more\" fold ARE the preview,\n// so a narrow column shrinks the picture (transform) instead of\n// re-wrapping it. One ResizeObserver, no dependency. The caption sits\n// outside the reset boundary; the frame paints no ground inside it — the\n// depiction's root paints its target's page colour.\n\nexport function Depiction({ of, scheme, width, label, children }) {\n const outer = useRef(null);\n const inner = useRef(null);\n const [fit, setFit] = useState({ k: 1, height: undefined });\n\n useLayoutEffect(() => {\n const o = outer.current;\n const i = inner.current;\n if (!o || !i || typeof ResizeObserver === \"undefined\") return;\n const measure = () => {\n const k = Math.min(1, o.clientWidth / width);\n setFit({ k, height: Math.round(i.offsetHeight * k) });\n };\n measure();\n const ro = new ResizeObserver(measure);\n ro.observe(o);\n ro.observe(i);\n return () => ro.disconnect();\n }, [width]);\n\n return (\n <figure className=\"min-w-0\">\n <figcaption className=\"mb-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground\">\n {label ?? `How it will look · ${of}`}\n </figcaption>\n <div ref={outer} className=\"overflow-hidden\" style={{ height: fit.height }}>\n <div\n ref={inner}\n data-depiction={of}\n data-scheme={scheme}\n style={{ width, transform: fit.k === 1 ? undefined : `scale(${fit.k})`, transformOrigin: \"top left\" }}\n >\n {children}\n </div>\n </div>\n </figure>\n );\n}\n"
109
+ },
110
+ {
111
+ "path": "templates/next/files/components/ui/distribution.jsx",
112
+ "type": "registry:ui",
113
+ "target": "@components/makersclaw/ui/distribution.jsx",
114
+ "content": "import { cx } from \"./cx\";\n\n// Parts of one whole as a single stacked bar: traffic by device, tickets\n// by channel, spend by category. Server-safe (no hooks). Meter is one\n// amount against a limit; this is N shares of a total, which always fill\n// the bar — unless a `total` above their sum leaves a rest of track.\n//\n// Colour: each segment takes a categorical hue by default (segment i gets\n// hue i+1, round 1..5, from --color-chart-N) — the parts are KINDS of a\n// thing. A segment's status `tone` overrides it, for sentiment (positive ·\n// neutral · negative feedback). Tone is status, hue is category.\n\n// Literal class names, so Tailwind sees every one.\nconst HUE_FILLS = { 1: \"bg-chart-1\", 2: \"bg-chart-2\", 3: \"bg-chart-3\", 4: \"bg-chart-4\", 5: \"bg-chart-5\" };\nconst TONE_FILLS = {\n neutral: \"bg-muted-foreground\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-destructive\",\n info: \"bg-info\",\n};\nconst SIZES = { thin: \"h-1.5\", md: \"h-2.5\" };\n\nconst EN = \"en-US\";\nfunction shareText(pct) {\n if (pct > 0 && pct < 1) return \"<1%\";\n return `${Math.round(pct).toLocaleString(EN)}%`;\n}\n\n/**\n * segments [{ label, value, tone?, hue? }] — value is a number in any\n * unit; the bar shows each one's share of their total. A zero\n * (or negative, or missing) value is skipped\n * size thin (6px, a list row) · md (10px)\n * legend a row under the bar: dot, label, share %\n * label the bar's name, read to a screen reader with every\n * segment's label and share: \"Traffic by device: Desktop 62%, …\"\n * total the whole the shares are of, in the segments' unit: 100 GB of\n * storage, a $1,000 budget. When the segments sum to less, the\n * rest is drawn as unfilled track at the end and every share is\n * of `total`. Left out (or not above the sum), the segments fill\n * the bar, as before\n * restLabel names that rest — \"Free\", \"Remaining\" — in the legend (a\n * hollow dot) and to a screen reader; left out, the rest is\n * track only\n * formatValue (value) → string — the legend shows each value in it\n * (\"$450\") instead of the share; a reader hears both\n * formatLegend ({ label, value, pct, share, rest }) → the words after a\n * legend label, whole (\"$450 · 52%\"); wins over formatValue.\n * `rest` is true for the restLabel entry\n *\n * tone success · warning · danger · info · neutral — sentiment,\n * overrides the hue\n * hue 1..5 — pin a category's colour (so \"Email\" is the same hue\n * in every chart); default the segment's place in the list\n *\n * An empty total (no positive values) draws the empty track and reads as\n * \"no data\". Thin gaps separate the segments; the ends are round.\n */\nexport function Distribution({ segments = [], size = \"md\", legend = false, label, total: whole, restLabel, formatValue, formatLegend, className }) {\n const parts = segments\n .map((s, i) => ({ ...s, i }))\n .filter((s) => typeof s.value === \"number\" && Number.isFinite(s.value) && s.value > 0);\n const sum = parts.reduce((n, s) => n + s.value, 0);\n // A `total` above the sum leaves a rest: unfilled track, optionally named.\n const rest = typeof whole === \"number\" && Number.isFinite(whole) && whole > sum ? whole - sum : 0;\n const total = sum + rest;\n const shown = parts.map((s) => {\n const fill = TONE_FILLS[s.tone] ?? HUE_FILLS[s.hue] ?? HUE_FILLS[(s.i % 5) + 1];\n const pct = total > 0 ? (s.value / total) * 100 : 0;\n return { key: s.key ?? `${s.label}-${s.i}`, label: s.label, value: s.value, fill, pct, share: shareText(pct) };\n });\n const restPct = total > 0 ? (rest / total) * 100 : 0;\n const restPart = rest > 0 ? { key: \"__rest\", label: restLabel, value: rest, pct: restPct, share: shareText(restPct), rest: true } : null;\n const named = [...shown, ...(restPart && restLabel ? [restPart] : [])];\n const words = (s) => (formatValue ? `${s.label} ${formatValue(s.value)}, ${s.share}` : `${s.label} ${s.share}`);\n const said = shown.length\n ? `${label ? `${label}: ` : \"\"}${named.map(words).join(\", \")}`\n : `${label ? `${label}: ` : \"\"}no data`;\n const legendText = (s) =>\n formatLegend ? formatLegend({ label: s.label, value: s.value, pct: s.pct, share: s.share, rest: Boolean(s.rest) }) : formatValue ? formatValue(s.value) : s.share;\n const track = SIZES[size] ?? SIZES.md;\n\n return (\n <div className={cx(\"grid gap-2\", className)}>\n <div role=\"img\" aria-label={said} className={cx(\"flex w-full gap-0.5 overflow-hidden rounded-full\", track, !shown.length && \"bg-accent\")}>\n {shown.map((s) => (\n <span\n key={s.key}\n className={cx(\"h-full min-w-0.5 transition-[flex-grow] duration-300 ease-out motion-reduce:transition-none\", s.fill)}\n style={{ flexGrow: s.value, flexShrink: 1, flexBasis: 0 }}\n />\n ))}\n {restPart ? <span className=\"h-full min-w-0.5 bg-accent\" style={{ flexGrow: rest, flexShrink: 1, flexBasis: 0 }} /> : null}\n </div>\n {legend && shown.length ? (\n <ul aria-hidden=\"true\" className={cx(\"flex flex-wrap items-center gap-x-3 gap-y-1\", size === \"thin\" ? \"text-xs\" : \"text-sm\")}>\n {named.map((s) => (\n <li key={s.key} className=\"inline-flex min-w-0 items-center gap-1.5 text-muted-foreground\">\n <span\n className={cx(\n \"shrink-0 rounded-full\",\n size === \"thin\" ? \"size-1.5\" : \"size-2\",\n s.rest ? \"border border-muted-foreground bg-accent\" : s.fill,\n )}\n />\n <span className=\"min-w-0 truncate\">{s.label}</span>\n <span data-nums className=\"font-medium text-foreground\">\n {legendText(s)}\n </span>\n </li>\n ))}\n </ul>\n ) : null}\n </div>\n );\n}\n"
115
+ },
116
+ {
117
+ "path": "templates/next/files/components/ui/feedback.jsx",
118
+ "type": "registry:ui",
119
+ "target": "@components/makersclaw/ui/feedback.jsx",
120
+ "content": "import { cx } from \"./cx\";\n\n// Waiting signals: a spinner, a skeleton, a progress bar. Server-safe (no\n// hooks). Each says to a screen reader what the eye sees — a spinner is\n// a status, a skeleton is nothing (the real content announces itself), a\n// bar is a progressbar with a name. Motion respects the reader's\n// prefers-reduced-motion: the spinner slows, the skeleton and the\n// indeterminate bar stop moving.\n\nconst SPINNER_SIZES = { sm: 16, md: 24, lg: 36 };\n\n/**\n * A turning arc in the text colour around it (currentColor), so it takes\n * the tone of whatever holds it.\n *\n * size sm (16px, inline with text and in a Button) · md (24) · lg (36)\n * label what a screen reader hears, default \"Loading\"; null when the\n * container already says it (a Button with `loading`, aria-busy)\n */\nexport function Spinner({ size = \"md\", label = \"Loading\", className }) {\n const px = SPINNER_SIZES[size] ?? SPINNER_SIZES.md;\n return (\n <svg\n width={px}\n height={px}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2.5\"\n strokeLinecap=\"round\"\n role={label ? \"status\" : undefined}\n aria-label={label || undefined}\n aria-hidden={label ? undefined : true}\n className={cx(\"shrink-0 animate-spin motion-reduce:[animation-duration:2.4s]\", className)}\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" className=\"opacity-20\" />\n <path d=\"M21 12a9 9 0 0 0-9-9\" />\n </svg>\n );\n}\n\nconst SKELETON_SHAPES = {\n line: \"h-[1em] rounded-sm\",\n circle: \"aspect-square rounded-full\",\n rect: \"rounded-lg\",\n};\n\n/**\n * Where content will be, drawn before it arrives — the size of the real\n * thing, so nothing jumps when it lands. Hidden from screen readers; put\n * aria-busy on the region it stands in for.\n *\n * shape line (text height; give a width: w-2/3) · circle (give\n * one side: size-10) · rect (give both: h-32 w-full)\n * className the size, and nothing else\n */\nexport function Skeleton({ shape = \"line\", className }) {\n return (\n <span\n aria-hidden=\"true\"\n className={cx(\n \"block animate-pulse bg-accent motion-reduce:animate-none\",\n SKELETON_SHAPES[shape] ?? SKELETON_SHAPES.line,\n className,\n )}\n />\n );\n}\n\nconst PROGRESS_TONES = {\n neutral: \"bg-primary\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-destructive\",\n info: \"bg-info\",\n};\n\n// The indeterminate sweep. Tailwind ships no such keyframes and the kit\n// does not edit globals.css, so the bar carries its own; identical copies\n// are harmless.\nconst SWEEP = \"@keyframes kit-progress-sweep{from{transform:translateX(-100%)}to{transform:translateX(250%)}}\";\n\n/**\n * How far along a known job is (value of max), or that an unknown one is\n * running (value omitted). A role=progressbar the browser and screen\n * readers understand, drawn from tokens — the native <progress> cannot be\n * themed the same way in every engine.\n *\n * value 0…max; null/undefined → indeterminate (a sweeping bar)\n * max default 100\n * label the bar's name, shown above it and read to a screen reader\n * valueText what the right side says and a reader hears (\"3 of 5\n * steps\"); default the percent. Omitted when indeterminate\n * tone neutral · success · warning · danger · info — status only\n */\nexport function Progress({ value, max = 100, label, valueText, tone = \"neutral\", className, ...rest }) {\n const known = typeof value === \"number\" && Number.isFinite(value);\n const pct = known && max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;\n const text = known ? valueText ?? `${Math.round(pct)}%` : undefined;\n const fill = PROGRESS_TONES[tone] ?? PROGRESS_TONES.neutral;\n return (\n <div className={cx(\"grid gap-1.5\", className)}>\n {label ? (\n <div aria-hidden=\"true\" className=\"flex items-baseline justify-between gap-3 text-sm\">\n <span className=\"min-w-0 truncate font-medium text-foreground\">{label}</span>\n {text ? (\n <span data-nums className=\"shrink-0 text-xs text-muted-foreground\">\n {text}\n </span>\n ) : null}\n </div>\n ) : null}\n <div\n role=\"progressbar\"\n aria-label={label}\n aria-valuemin={0}\n aria-valuemax={known ? max : undefined}\n aria-valuenow={known ? value : undefined}\n aria-valuetext={text}\n className=\"relative h-2 w-full overflow-hidden rounded-full bg-accent\"\n {...rest}\n >\n {known ? (\n <div\n className={cx(\"h-full rounded-full transition-[width] duration-300 ease-out motion-reduce:transition-none\", fill)}\n style={{ width: `${pct}%` }}\n />\n ) : (\n <>\n <style>{SWEEP}</style>\n <div\n className={cx(\n \"h-full w-2/5 rounded-full animate-[kit-progress-sweep_1.4s_ease-in-out_infinite]\",\n \"motion-reduce:w-full motion-reduce:animate-pulse\",\n fill,\n )}\n />\n </>\n )}\n </div>\n </div>\n );\n}\n"
121
+ },
122
+ {
123
+ "path": "templates/next/files/components/ui/field.jsx",
124
+ "type": "registry:ui",
125
+ "target": "@components/makersclaw/ui/field.jsx",
126
+ "content": "\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef } from \"react\";\nimport { cx } from \"./cx\";\n\n// Form parts. Each is the native element with the workspace's look and a\n// 44px target; the browser supplies the behaviour (focus, keyboard,\n// validation, autofill). Wrap one in <Field> for its label, hint and\n// error — never a bare <input> with a <div> label beside it.\n\nconst CONTROL =\n \"w-full min-h-11 rounded-md border border-input bg-background px-3 text-sm text-foreground \" +\n \"placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring \" +\n \"disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive\";\n\n/** Label, control, hint and error in one stack. Pass `id`; the label\n * points at it. `error` (a string) marks the control invalid. */\nexport function Field({ id, label, hint, error, required, className, children }) {\n return (\n <div className={cx(\"grid gap-1.5\", className)}>\n {label ? (\n <label htmlFor={id} className=\"text-sm font-medium text-foreground\">\n {label}\n {required ? <span aria-hidden=\"true\" className=\"text-destructive\"> *</span> : null}\n </label>\n ) : null}\n {children}\n {error ? (\n <p id={id ? `${id}-error` : undefined} role=\"alert\" className=\"text-xs text-destructive\">\n {error}\n </p>\n ) : hint ? (\n <p id={id ? `${id}-hint` : undefined} className=\"text-xs text-muted-foreground\">\n {hint}\n </p>\n ) : null}\n </div>\n );\n}\n\nexport function Input({ className, invalid, ...rest }) {\n return <input className={cx(CONTROL, className)} aria-invalid={invalid ? true : undefined} {...rest} />;\n}\n\n/**\n * A time of day: the native <input type=\"time\"> (the browser's segments,\n * keyboard and picker; `value` is \"HH:MM\", or \"HH:MM:SS\" with `step` under\n * 60), in the workspace's look. The browser's own clock button is drawn\n * by the engine in its own grey and ignores the theme, so it is laid\n * invisibly over the kit's clock glyph: the glyph is what shows, the\n * native button is what a press opens. min, max, step, required and every\n * other prop go to the input.\n */\nexport function TimeInput({ className, invalid, ...rest }) {\n return (\n <span className=\"relative block\">\n <input\n type=\"time\"\n className={cx(\n CONTROL,\n \"appearance-none pr-10 tabular-nums [&::-webkit-date-and-time-value]:text-left\",\n \"[&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:right-3 [&::-webkit-calendar-picker-indicator]:top-1/2\",\n \"[&::-webkit-calendar-picker-indicator]:size-5 [&::-webkit-calendar-picker-indicator]:-translate-y-1/2 [&::-webkit-calendar-picker-indicator]:m-0\",\n \"[&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0\",\n className,\n )}\n aria-invalid={invalid ? true : undefined}\n {...rest}\n />\n <svg\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5l3 3\" />\n </svg>\n </span>\n );\n}\n\n/**\n * Keeps a <textarea> as tall as its text, between `minRows` and `maxRows`\n * lines; past `maxRows` it scrolls. Returns the ref callback to put on the\n * textarea (it also sets `outer`, a ref of the caller's). It measures on\n * every value change, typed or set (a composer clearing after send), and\n * when the width changes the wrapping. Textarea autoGrow and InputGroup\n * multiline use it; it is not a surface's tool.\n */\nexport function useAutoGrow({ enabled = true, minRows = 1, maxRows = 8, value, outer }) {\n const node = useRef(null);\n\n const fit = useCallback(() => {\n const el = node.current;\n if (!el || !enabled) return;\n const cs = getComputedStyle(el);\n const line = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.5 || 20;\n const frame =\n parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) +\n (cs.boxSizing === \"border-box\" ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) : 0);\n const low = line * Math.max(1, minRows) + frame;\n const high = Number.isFinite(maxRows) && maxRows > 0 ? line * Math.max(minRows, maxRows) + frame : Infinity;\n el.style.height = \"auto\";\n const content = el.scrollHeight + (cs.boxSizing === \"border-box\" ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) : 0);\n el.style.height = `${Math.min(Math.max(content, low), high)}px`;\n el.style.overflowY = content > high ? \"auto\" : \"hidden\";\n }, [enabled, minRows, maxRows]);\n\n useLayoutEffect(fit, [fit, value]);\n\n // A width change re-wraps the text; a height change is our own doing.\n useLayoutEffect(() => {\n const el = node.current;\n if (!el || !enabled || typeof ResizeObserver === \"undefined\") return;\n let width = el.clientWidth;\n const ro = new ResizeObserver(() => {\n if (el.clientWidth === width) return;\n width = el.clientWidth;\n fit();\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, [enabled, fit]);\n\n const ref = useCallback(\n (el) => {\n node.current = el;\n if (typeof outer === \"function\") return outer(el);\n if (outer) outer.current = el;\n },\n [outer],\n );\n return { ref, fit };\n}\n\n/**\n * The native <textarea>. `rows` sets its height (default 4) and it\n * resizes by its corner. `autoGrow` makes it as tall as its text instead:\n * from `minRows` (default 1) to `maxRows` (default 8) lines, then it\n * scrolls; no resize corner.\n */\nexport function Textarea({ className, invalid, rows = 4, autoGrow = false, minRows = 1, maxRows = 8, onInput, ref, ...rest }) {\n const grow = useAutoGrow({ enabled: autoGrow, minRows, maxRows, value: rest.value, outer: ref });\n return (\n <textarea\n ref={grow.ref}\n rows={autoGrow ? minRows : rows}\n className={cx(CONTROL, autoGrow ? \"resize-none py-2.5 leading-normal\" : \"min-h-24 resize-y py-2.5 leading-normal\", className)}\n aria-invalid={invalid ? true : undefined}\n onInput={(e) => {\n if (autoGrow) grow.fit();\n onInput?.(e);\n }}\n {...rest}\n />\n );\n}\n\n/**\n * The native <select>, styled. `options` is [{ value, label }]; children\n * work too. A select with nothing to choose from is not a control — when\n * `options` is empty it renders `empty` (a short muted line, or nothing\n * when `empty` is null) rather than a disabled dropdown. For \"pick a\n * saved record or make a new one\" use RecordSwitcher (components/parts).\n */\nexport function Select({ options, empty = \"Nothing to choose yet.\", className, invalid, children, ...rest }) {\n if (options && options.length === 0 && !children) {\n return empty ? <p className=\"text-sm text-muted-foreground\">{empty}</p> : null;\n }\n return (\n <span className=\"relative block\">\n <select\n className={cx(CONTROL, \"appearance-none pr-9\", className)}\n aria-invalid={invalid ? true : undefined}\n {...rest}\n >\n {options ? options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>) : children}\n </select>\n <svg\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </span>\n );\n}\n\n/** An on/off. A checkbox underneath (role=switch), so forms, labels and\n * keyboards work; the track is drawn from tokens. */\nexport function Switch({ id, checked, onChange, label, disabled, className }) {\n return (\n <label htmlFor={id} className={cx(\"inline-flex min-h-11 cursor-pointer items-center gap-3 text-sm\", disabled && \"cursor-not-allowed opacity-50\", className)}>\n <span className=\"relative inline-flex h-6 w-10 shrink-0\">\n <input\n id={id}\n type=\"checkbox\"\n role=\"switch\"\n className=\"peer sr-only\"\n checked={checked}\n onChange={onChange}\n disabled={disabled}\n />\n <span className=\"absolute inset-0 rounded-full border border-input bg-muted transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring\" />\n <span className=\"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-foreground transition-transform peer-checked:translate-x-4 peer-checked:bg-primary-foreground\" />\n </span>\n {label ? <span>{label}</span> : null}\n </label>\n );\n}\n\nexport function Checkbox({ id, label, className, ...rest }) {\n return (\n <label htmlFor={id} className={cx(\"inline-flex min-h-11 cursor-pointer items-center gap-2 text-sm\", className)}>\n <input\n id={id}\n type=\"checkbox\"\n className=\"h-4 w-4 rounded-sm border-input accent-[var(--color-primary)] outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n {...rest}\n />\n {label ? <span>{label}</span> : null}\n </label>\n );\n}\n"
127
+ },
128
+ {
129
+ "path": "templates/next/files/components/ui/file-input.jsx",
130
+ "type": "registry:ui",
131
+ "target": "@components/makersclaw/ui/file-input.jsx",
132
+ "content": "\"use client\";\n\nimport { useId, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Button, IconButton } from \"./button\";\nimport { Icon } from \"./icon\";\n\n// FileInput and FileDrop — how files come in. Both are a real hidden\n// <input type=file> underneath, so the picker is the browser's own (the\n// camera roll on a phone, the Finder on a Mac); the kit draws the door.\n// Neither uploads: the chosen files go out through `onFiles(files)` (an\n// array of File) and the upload is yours.\n//\n// FileInput — a Button that opens the picker.\n// children the button's text (\"Upload a CSV\"); without children it is\n// an IconButton and `label` (required then) is what a reader\n// says (\"Attach files\")\n// icon the glyph (upload; paperclip for a composer)\n// variant, size as Button\n//\n// FileDrop — a drop zone: drag files onto it, or press it (click, Enter,\n// Space) for the picker. It lights up while files are dragged over it.\n// label the line inside it (\"Drop files here or browse\")\n// hint the muted line under it (\"PNG or JPG, up to 10 MB\")\n// size md (a tall box; the default) · sm (one row)\n// children replaces the icon, label and hint inside the zone\n// invalid marks the zone red (a rejected file, a required one missing)\n//\n// Both:\n// accept as the input's (\"image/*\", \".csv,text/csv\"). The picker\n// greys out the rest; a drop is filtered the same way and what\n// does not match goes to `onReject(files)`\n// multiple more than one file (a drop of several with multiple off\n// keeps the first)\n// disabled no picker, no drop\n// onFiles (files) — never called with an empty array\n//\n// Choosing the same file twice in a row fires onFiles twice: the input is\n// emptied after every pick.\n\n/** Does `file` match an accept string (\"image/*,.pdf,text/csv\")? */\nexport function fileMatches(file, accept) {\n if (!accept) return true;\n const name = (file.name ?? \"\").toLowerCase();\n const type = (file.type ?? \"\").toLowerCase();\n return accept\n .split(\",\")\n .map((a) => a.trim().toLowerCase())\n .filter(Boolean)\n .some((a) => {\n if (a.startsWith(\".\")) return name.endsWith(a);\n if (a.endsWith(\"/*\")) return type.startsWith(a.slice(0, -1));\n return type === a;\n });\n}\n\nfunction split(list, accept, multiple) {\n const all = Array.from(list ?? []);\n const ok = all.filter((f) => fileMatches(f, accept));\n const no = all.filter((f) => !fileMatches(f, accept));\n return { ok: multiple ? ok : ok.slice(0, 1), no };\n}\n\nfunction Hidden({ input, accept, multiple, disabled, capture, onPick }) {\n return (\n <input\n ref={input}\n type=\"file\"\n hidden\n tabIndex={-1}\n aria-hidden=\"true\"\n accept={accept}\n multiple={multiple}\n disabled={disabled}\n capture={capture}\n onChange={(e) => {\n const list = Array.from(e.target.files ?? []);\n e.target.value = \"\";\n onPick(list);\n }}\n />\n );\n}\n\nexport function FileInput({\n onFiles,\n onReject,\n accept,\n multiple = false,\n disabled = false,\n capture,\n icon = \"upload\",\n label,\n variant,\n size,\n className,\n children,\n ...rest\n}) {\n const input = useRef(null);\n const pick = (list) => {\n const { ok, no } = split(list, accept, multiple);\n if (no.length) onReject?.(no);\n if (ok.length) onFiles?.(ok);\n };\n const open = () => {\n if (!disabled) input.current?.click();\n };\n return (\n <>\n <Hidden input={input} accept={accept} multiple={multiple} disabled={disabled} capture={capture} onPick={pick} />\n {children ? (\n <Button icon={icon} variant={variant} size={size} disabled={disabled} onClick={open} className={className} {...rest}>\n {children}\n </Button>\n ) : (\n <IconButton icon={icon} label={label ?? \"Choose files\"} variant={variant} size={size} disabled={disabled} onClick={open} className={className} {...rest} />\n )}\n </>\n );\n}\n\nconst ZONE_SIZES = {\n md: \"min-h-40 flex-col justify-center gap-2 px-6 py-8 text-center\",\n sm: \"min-h-11 flex-row justify-start gap-3 px-3 py-2.5 text-left\",\n};\n\nexport function FileDrop({\n onFiles,\n onReject,\n accept,\n multiple = false,\n disabled = false,\n label = \"Drop files here or browse\",\n hint,\n size = \"md\",\n invalid = false,\n className,\n children,\n \"aria-label\": ariaLabel,\n ...rest\n}) {\n const input = useRef(null);\n const id = useId();\n const [over, setOver] = useState(false);\n const depth = useRef(0);\n\n const pick = (list) => {\n const { ok, no } = split(list, accept, multiple);\n if (no.length) onReject?.(no);\n if (ok.length) onFiles?.(ok);\n };\n const open = () => {\n if (!disabled) input.current?.click();\n };\n const hasFiles = (e) => Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n\n return (\n <div\n role=\"button\"\n tabIndex={disabled ? -1 : 0}\n aria-disabled={disabled || undefined}\n aria-invalid={invalid || undefined}\n aria-label={ariaLabel}\n aria-describedby={hint && !children ? `${id}-hint` : undefined}\n data-dragging={over ? \"\" : undefined}\n onClick={(e) => {\n if (e.target.closest(\"button, a, input\")) return;\n open();\n }}\n onKeyDown={(e) => {\n if (e.target !== e.currentTarget) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n open();\n }\n }}\n onDragEnter={(e) => {\n if (disabled || !hasFiles(e)) return;\n e.preventDefault();\n depth.current += 1;\n setOver(true);\n }}\n onDragOver={(e) => {\n if (disabled || !hasFiles(e)) return;\n e.preventDefault();\n e.dataTransfer.dropEffect = \"copy\";\n }}\n onDragLeave={() => {\n depth.current = Math.max(0, depth.current - 1);\n if (depth.current === 0) setOver(false);\n }}\n onDrop={(e) => {\n if (disabled) return;\n e.preventDefault();\n depth.current = 0;\n setOver(false);\n pick(e.dataTransfer?.files);\n }}\n className={cx(\n \"flex w-full cursor-pointer items-center rounded-lg border border-dashed bg-background text-sm transition-colors\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n \"hover:bg-accent data-[dragging]:border-solid data-[dragging]:border-primary data-[dragging]:bg-accent\",\n invalid ? \"border-destructive\" : \"border-input\",\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n ZONE_SIZES[size] ?? ZONE_SIZES.md,\n className,\n )}\n {...rest}\n >\n <Hidden input={input} accept={accept} multiple={multiple} disabled={disabled} onPick={pick} />\n {children ?? (\n <>\n <span\n className={cx(\n \"flex shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground\",\n size === \"sm\" ? \"size-8\" : \"size-10\",\n )}\n >\n <Icon name=\"upload\" size={size === \"sm\" ? 16 : 18} />\n </span>\n <span className=\"grid min-w-0 gap-0.5\">\n <span className=\"font-medium text-foreground\">{over ? (multiple ? \"Drop to add them\" : \"Drop to add it\") : label}</span>\n {hint ? (\n <span id={`${id}-hint`} className={cx(\"text-xs\", invalid ? \"text-destructive\" : \"text-muted-foreground\")}>\n {hint}\n </span>\n ) : null}\n </span>\n </>\n )}\n </div>\n );\n}\n"
133
+ },
134
+ {
135
+ "path": "templates/next/files/components/ui/icon.jsx",
136
+ "type": "registry:ui",
137
+ "target": "@components/makersclaw/ui/icon.jsx",
138
+ "content": "// The kit's icons: one inline sprite, no icon package to install. Every\n// glyph is a 24-box stroke path (1.8 wide, round caps, currentColor); add\n// one here, never an <svg> in a surface. `name` is a key below; unknown\n// names render nothing (and `npm run check` names them).\n//\n// BRANDS holds the brand marks: google, github, apple, microsoft,\n// x-logo (the X / Twitter mark; `x` is the close glyph), linkedin,\n// instagram, youtube, facebook. Unlike PATHS they are FILLED shapes\n// (currentColor, even-odd so letters and holes knock out), one colour,\n// drawn by hand and simplified, never copied from an icon library.\n\nconst PATHS = {\n plus: \"M12 5v14M5 12h14\",\n x: \"M18 6 6 18M6 6l12 12\",\n check: \"m5 12 5 5L20 7\",\n \"chevron-down\": \"m6 9 6 6 6-6\",\n \"chevron-up\": \"m18 15-6-6-6 6\",\n \"chevron-left\": \"m15 18-6-6 6-6\",\n \"chevron-right\": \"m9 18 6-6-6-6\",\n \"chevrons-up-down\": \"m7 15 5 5 5-5M7 9l5-5 5 5\",\n \"arrow-left\": \"M19 12H5m7-7-7 7 7 7\",\n \"arrow-right\": \"M5 12h14m-7-7 7 7-7 7\",\n \"arrow-up\": \"M12 19V5m-7 7 7-7 7 7\",\n \"arrow-down\": \"M12 5v14m7-7-7 7-7-7\",\n search: \"M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14ZM21 21l-4.3-4.3\",\n trash: \"M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14\",\n pencil: \"M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z\",\n copy: \"M8 8h12v12H8zM4 16V4h12\",\n more: \"M5 12h.01M12 12h.01M19 12h.01\",\n external: \"M14 3h7v7m0-7L10 14M21 14v7H3V3h7\",\n refresh: \"M21 12a9 9 0 1 1-2.6-6.4M21 3v6h-6\",\n filter: \"M3 5h18l-7 8v6l-4 2v-8Z\",\n calendar: \"M3 5h18v16H3zM16 3v4M8 3v4M3 10h18\",\n clock: \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5l3 3\",\n user: \"M20 21a8 8 0 0 0-16 0M12 3a5 5 0 1 0 0 10 5 5 0 0 0 0-10Z\",\n users: \"M17 21a4 4 0 0 0-8 0M13 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM23 21a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8M3 21a4 4 0 0 1 3-3.9M8 3.1a4 4 0 0 0 0 7.8\",\n mail: \"M3 5h18v14H3zM3 7l9 6 9-6\",\n link: \"M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1\",\n grip: \"M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01\",\n info: \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 5h.01M12 12v4\",\n warning: \"M12 3 2 20h20L12 3Zm0 7v4m0 3h.01\",\n sparkles: \"m12 3 2 6 6 2-6 2-2 6-2-6-6-2 6-2 2-6ZM19 15l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2Z\",\n play: \"m6 4 14 8-14 8V4Z\",\n pause: \"M6 4h4v16H6zM14 4h4v16h-4z\",\n download: \"M12 3v12m-5-5 5 5 5-5M4 21h16\",\n upload: \"M12 21V9m-5 5 5-5 5 5M4 3h16\",\n eye: \"M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Zm10-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z\",\n \"eye-off\": \"M10.6 5.1A10 10 0 0 1 12 5c6 0 10 7 10 7a17 17 0 0 1-2.4 3.2M6.6 6.6C3.8 8.4 2 12 2 12s4 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2M2 2l20 20\",\n send: \"m22 2-7 20-4-9-9-4 20-7Z\",\n home: \"m3 11 9-8 9 8v10h-6v-6H9v6H3V11Z\",\n settings: \"M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm8 4-1.5-.5.3-1.6 1.4-.9-1-1.7-1.6.5-1.2-1.2.5-1.6-1.7-1-.9 1.4-1.6-.3L12 4l-.5 1.5-1.6.3-.9-1.4-1.7 1 .5 1.6-1.2 1.2-1.6-.5-1 1.7 1.4.9.3 1.6L4 12l1.5.5-.3 1.6-1.4.9 1 1.7 1.6-.5 1.2 1.2-.5 1.6 1.7 1 .9-1.4 1.6.3L12 20l.5-1.5 1.6-.3.9 1.4 1.7-1-.5-1.6 1.2-1.2 1.6.5 1-1.7-1.4-.9-.3-1.6L20 12Z\",\n reply: \"M9 17 4 12l5-5M4 12h10a6 6 0 0 1 6 6v1\",\n repeat: \"m17 2 4 4-4 4M3 11V9a3 3 0 0 1 3-3h15M7 22l-4-4 4-4M21 13v2a3 3 0 0 1-3 3H3\",\n heart: \"M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z\",\n chart: \"M3 3v18h18M7 16l4-5 4 3 5-7\",\n share: \"M12 3v12M7 8l5-5 5 5M5 14v6h14v-6\",\n star: \"m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3 6.4 20.2l1.1-6.2L3 9.6l6.2-.9L12 3Z\",\n bell: \"M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0\",\n tag: \"M20 12 12 20l-9-9V3h8l9 9ZM7 7h.01\",\n folder: \"M3 7V5h6l2 2h10v13H3V7Z\",\n file: \"M14 3H6v18h12V7l-4-4ZM14 3v4h4\",\n image: \"M3 5h18v14H3zM8 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM21 15l-5-5-9 9\",\n phone: \"M22 16.9v3a2 2 0 0 1-2.2 2A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7l.8 3.6a2 2 0 0 1-.5 1.9L8.1 10.5a16 16 0 0 0 5.4 5.4l1.3-1.3a2 2 0 0 1 1.9-.5l3.6.8a2 2 0 0 1 1.7 2Z\",\n globe: \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18\",\n lock: \"M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4\",\n zap: \"M13 2 3 14h8l-1 8 10-12h-8l1-8Z\",\n dollar: \"M12 2v20M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6\",\n map: \"m3 6 6-3 6 3 6-3v15l-6 3-6-3-6 3V6ZM9 3v15M15 6v15\",\n minus: \"M5 12h14\",\n archive: \"M3 4h18v4H3zM5 8v12h14V8M10 12h4\",\n inbox: \"M3 13h5l2 3h4l2-3h5M3 13V5h18v8M3 13v6h18v-6\",\n pin: \"M12 17v5M8 3h8M9 3v7l-3 4v3h12v-3l-3-4V3\",\n bold: \"M7 4h6a4 4 0 0 1 0 8H7zM7 12h7a4 4 0 0 1 0 8H7z\",\n italic: \"M19 4h-9M14 20H5M15 4 9 20\",\n underline: \"M6 4v6a6 6 0 0 0 12 0V4M4 21h16\",\n list: \"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\",\n grid: \"M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z\",\n \"align-left\": \"M3 6h18M3 12h12M3 18h16\",\n \"align-center\": \"M3 6h18M6 12h12M4 18h16\",\n \"align-right\": \"M3 6h18M9 12h12M5 18h16\",\n paperclip: \"m21 11-9 9a6 6 0 0 1-8.5-8.5l8.6-8.6a4 4 0 0 1 5.7 5.7l-8.6 8.5a2 2 0 0 1-2.8-2.8L15 6.9\",\n stop: \"M6 6h12v12H6z\",\n menu: \"M4 6h16M4 12h16M4 18h16\",\n \"panel-left\": \"M3 4h18v16H3zM9 4v16\",\n \"log-out\": \"M9 21H4V3h5M16 17l5-5-5-5M21 12H9\",\n code: \"m16 18 6-6-6-6M8 6l-6 6 6 6\",\n shield: \"M12 3 4 6v6c0 5 3.4 8.2 8 9 4.6-.8 8-4 8-9V6l-8-3Z\",\n \"shield-check\": \"M12 3 4 6v6c0 5 3.4 8.2 8 9 4.6-.8 8-4 8-9V6l-8-3ZM9 12l2 2 4-4\",\n \"circle-check\": \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9 3 3 5-5\",\n // The same glyph under its older name, so either one renders.\n \"check-circle\": \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9 3 3 5-5\",\n \"circle-x\": \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM15 9l-6 6M9 9l6 6\",\n \"message-square\": \"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z\",\n \"git-branch\": \"M6 3v12M18 3a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM18 9a9 9 0 0 1-9 9\",\n \"git-pull-request\": \"M6 3a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM18 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM6 9v12M12 6h4a2 2 0 0 1 2 2v7m-3-12-3 3 3 3\",\n server: \"M3 3h18v8H3zM3 13h18v8H3zM7 7h.01M7 17h.01\",\n leaf: \"M5 19C5 10 10 4 20 4c0 10-6 15-15 15ZM3 21 14 10\",\n monitor: \"M3 4h18v12H3zM12 16v4M8 20h8\",\n smartphone: \"M8 3h8v18H8zM11 17.5h2\",\n video: \"M3 6h12v12H3zM15 10l6-3v10l-6-3\",\n tablet: \"M5 2h14v20H5zM11 18.5h2\",\n sun: \"M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\",\n moon: \"M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z\",\n database: \"M4 6a8 3 0 1 0 16 0 8 3 0 1 0-16 0ZM4 6v12a8 3 0 0 0 16 0V6M4 12a8 3 0 0 0 16 0\",\n \"git-commit\": \"M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM3 12h6M15 12h6\",\n lightbulb: \"M12 3a6 6 0 0 0-3.5 10.9c.6.4.9 1.1.9 1.8V16h5.2v-.3c0-.7.3-1.4.9-1.8A6 6 0 0 0 12 3ZM9.5 19h5M10.5 22h3\",\n \"life-buoy\": \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM5.6 5.6l3.6 3.6M14.8 14.8l3.6 3.6M18.4 5.6l-3.6 3.6M9.2 14.8l-3.6 3.6\",\n \"log-in\": \"M15 3h5v18h-5M10 17l5-5-5-5M15 12H3\",\n \"user-plus\": \"M2 21a7 7 0 0 1 14 0M9 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM19 8v6M16 11h6\",\n palette: \"M12 3a9 9 0 0 0 0 18c1.1 0 2-.9 2-2 0-.5-.2-1-.5-1.3-.3-.4-.5-.8-.5-1.3 0-1.1.9-1.9 2-1.9h2a4 4 0 0 0 4-4C21 6.4 17 3 12 3ZM7.5 11.5h.01M9 7.5h.01M14 6.5h.01M17.5 9.5h.01\",\n layers: \"M12 3 2 8l10 5 10-5-10-5ZM2 12l10 5 10-5M2 16l10 5 10-5\",\n type: \"M4 7V4h16v3M12 4v16M9 20h6\",\n package: \"M12 2 3 7v10l9 5 9-5V7l-9-5ZM3 7l9 5 9-5M12 12v10M7.5 4.5l9 5\",\n // Task states: an empty ring is \"to do\", a broken ring is \"doing\".\n circle: \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z\",\n \"circle-dashed\": \"M13.6 3.1A9 9 0 0 1 17.2 4.6M19.4 6.8A9 9 0 0 1 20.9 10.4M20.9 13.6A9 9 0 0 1 19.4 17.2M17.2 19.4A9 9 0 0 1 13.6 20.9M10.4 20.9A9 9 0 0 1 6.8 19.4M4.6 17.2A9 9 0 0 1 3.1 13.6M3.1 10.4A9 9 0 0 1 4.6 6.8M6.8 4.6A9 9 0 0 1 10.4 3.1\",\n loader: \"M12 2v4M12 18v4M2 12h4M18 12h4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8\",\n \"trending-up\": \"m3 17 6-6 4 4 8-8M15 7h6v6\",\n \"trending-down\": \"m3 7 6 6 4-4 8 8M15 17h6v-6\",\n \"message-circle\": \"M21 12a9 9 0 0 1-13.3 7.9L3 21l1.1-4.7A9 9 0 1 1 21 12Z\",\n \"credit-card\": \"M2 5h20v14H2zM2 10h20M6 15h4\",\n \"shopping-cart\": \"M2 3h3l2.4 12h11.2L21 7H5.8M8 19.5a1.5 1.5 0 1 0 3 0 1.5 1.5 0 1 0-3 0ZM16 19.5a1.5 1.5 0 1 0 3 0 1.5 1.5 0 1 0-3 0Z\",\n key: \"M7.5 11a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM10.7 12.3 21 2M16 7l3 3M18.5 4.5l2 2\",\n quote: \"M4 18h5v-6H4v6Zm0-6c0-4 1.5-6 5-7M15 18h5v-6h-5v6Zm0-6c0-4 1.5-6 5-7\",\n truck: \"M2 5h12v11H2zM14 9h4.5L21 12v4h-7M4 18a2 2 0 1 0 4 0 2 2 0 1 0-4 0ZM15 18a2 2 0 1 0 4 0 2 2 0 1 0-4 0Z\",\n laptop: \"M4 5h16v11H4zM2 19h20\",\n receipt: \"M5 3h14v18l-2.3-1.5-2.4 1.5-2.3-1.5-2.3 1.5-2.4-1.5L5 21V3ZM9 8h6M9 12h6M9 16h3\",\n mic: \"M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3ZM5 11a7 7 0 0 0 14 0M12 18v4M8 22h8\",\n \"thumbs-up\": \"M7 10v11H3V10h4Zm0 0 4-8a3 3 0 0 1 3 3v4h5.5a2 2 0 0 1 2 2.3l-1.4 8a2 2 0 0 1-2 1.7H7\",\n \"thumbs-down\": \"M7 14V3H3v11h4Zm0 0 4 8a3 3 0 0 0 3-3v-4h5.5a2 2 0 0 0 2-2.3l-1.4-8a2 2 0 0 0-2-1.7H7\",\n history: \"M3 12a9 9 0 1 0 2.6-6.4L3 8M3 3v5h5M12 7v5l3 2\",\n wand: \"M3 21 14 10M16 3v4M14 5h4M20 9v3M18.5 10.5h3M9 3v2M8 4h2\",\n building: \"M4 21V3h12v18M16 9h4v12M2 21h20M8 7h4M8 11h4M8 15h4\",\n // Canvas tools (flow-canvas): the rail, the zoom bar, the arrange buttons.\n \"mouse-pointer\": \"M5 3l5.8 16.5 2.4-6.8 6.8-2.4L5 3ZM13.2 12.7 19 18.5\",\n hand: \"M8 12V5.5a1.5 1.5 0 0 1 3 0V11M11 10.5V4a1.5 1.5 0 0 1 3 0v6.5M14 10.5V5.5a1.5 1.5 0 0 1 3 0V14a7 7 0 0 1-7 7h-.4a6 6 0 0 1-4.7-2.3l-2.3-2.9a1.6 1.6 0 0 1 2.4-2.1L8 16\",\n \"sticky-note\": \"M4 4h16v10l-6 6H4V4ZM14 20v-6h6\",\n square: \"M4 4h16v16H4z\",\n frame: \"M7 3v18M17 3v18M3 7h18M3 17h18\",\n connector: \"M4 20C4 11 10 5 19 5m-4-3.5L19 5l-4 3.5M4 20h.01\",\n maximize: \"M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5\",\n undo: \"M9 14 4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3\",\n redo: \"m15 14 5-5-5-5M20 9H9a5 5 0 0 0 0 10h3\",\n keyboard: \"M2 6h20v12H2zM6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10\",\n // Sketch tools (sketch-canvas): the diamond shape and the eraser.\n diamond: \"M12 3 21 12 12 21 3 12Z\",\n eraser: \"M20 20H8.5l-4-4a2 2 0 0 1 0-2.8l8.8-8.8a2 2 0 0 1 2.8 0l3.5 3.5a2 2 0 0 1 0 2.8L12 20M8.5 9.5l6 6\",\n highlighter: \"m14.5 4.5 5 5-7.5 7.5H8.5L7 15.5v-3.5ZM11 8l5 5M7 15.5 4 18.5l2 1.5h5M14 21h6\",\n \"lock-open\": \"M5 11h14v10H5zM8 11V7a4 4 0 0 1 7.7-1.5\",\n \"bring-front\": \"M12 14V3M8 7l4-4 4 4M4 15l8 5 8-5\",\n \"send-back\": \"M12 3v11M8 10l4 4 4-4M4 15l8 5 8-5\",\n // Column types (spreadsheet): a number field, a checkbox field.\n hash: \"M4 9h16M4 15h16M10 3 8 21M16 3l-2 18\",\n \"square-check\": \"M4 4h16v16H4zM8 12l3 3 5-6\",\n target: \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10ZM12 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z\",\n flag: \"M5 21V4m0 0h11l-2 4 2 4H5\",\n \"circle-help\": \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.2a2.6 2.6 0 0 1 5 .9c0 1.7-2.5 2.3-2.5 3.9M12 17h.01\",\n // Playback (audio-player): skip back / forward, sound on / off.\n \"rotate-ccw\": \"M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5\",\n \"rotate-cw\": \"M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5\",\n volume: \"M11 5 6 9H2v6h4l5 4V5ZM15.5 8.5a5 5 0 0 1 0 7M19 5a10 10 0 0 1 0 14\",\n \"volume-x\": \"M11 5 6 9H2v6h4l5 4V5ZM22 9l-6 6M16 9l6 6\",\n navigation: \"M3 11 21 3l-8 18-2-8Z\",\n locate: \"M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14Zm0 4a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM12 2v3M12 19v3M2 12h3M19 12h3\",\n route: \"M6 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM18 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15\",\n // View kinds (work-views): a board's columns, a table's grid, a gantt's bars.\n kanban: \"M4 4h4v14H4zM10 4h4v9h-4zM16 4h4v16h-4z\",\n table: \"M3 4h18v16H3zM3 10h18M3 15h18M9 10v10\",\n gantt: \"M4 4v16h16M8 8h6M11 12h7M9 16h5\",\n};\n\nconst BRANDS = {\n google: \"M18.9 6.2A9 9 0 1 0 21 12v-1.8h-8.8v3.4h4.96A5.4 5.4 0 1 1 16.14 8.53Z\",\n github: \"M12 2.5a9.5 9.5 0 1 0 0 19 9.5 9.5 0 0 0 0-19ZM10 21.3v-2.9c-2.8-.4-4.5-2-4.5-5 0-1.1.4-2.1 1.1-2.9-.2-.7-.2-1.7.2-2.7 1 0 2 .5 2.8 1.1a8 8 0 0 1 4.8 0c.8-.6 1.8-1.1 2.8-1.1.4 1 .4 2 .2 2.7.7.8 1.1 1.8 1.1 2.9 0 3-1.7 4.6-4.5 5v2.9ZM10 20.2c-2.4.4-3.5-.5-4.3-2l1-.4c.6 1.1 1.4 1.7 3.3 1.3Z\",\n apple: \"M12 7.8C10.5 6.8 8.8 6.6 7.4 7.4 4.8 8.9 4.4 12.8 5.9 16.2c1 2.4 2.5 4.4 4.1 4.4.9 0 1.3-.5 2-.5s1.1.5 2 .5c1.6 0 3-1.7 4-3.6.5-.8.9-1.4 1.3-2A3.2 3.2 0 0 1 18.3 9.3c-.8-1.2-2-2.1-3.5-2.2-1 0-1.9.3-2.8.7ZM12.2 6.3c0-1.8 1.3-3.2 3.1-3.3 0 1.8-1.3 3.2-3.1 3.3Z\",\n microsoft: \"M3 3h8.5v8.5H3zM12.5 3H21v8.5h-8.5zM3 12.5h8.5V21H3zM12.5 12.5H21V21h-8.5z\",\n \"x-logo\": \"M2.8 3h6l12.4 18h-6ZM5.3 4.3l10.6 15.4h2.8L8.1 4.3ZM19.3 3h1.9l-6.6 7.4-.8-1ZM4.7 21H2.8l6.6-7.4.8 1Z\",\n linkedin: \"M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2ZM6.4 7.6a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0ZM6.6 10h2.3v7.5H6.6ZM10.8 10H13v1.1c.5-.8 1.4-1.3 2.5-1.3 2 0 3 1.2 3 3.4v4.3h-2.3v-3.9c0-1.1-.4-1.8-1.4-1.8s-1.7.7-1.7 1.8v3.9h-2.3Z\",\n instagram: \"M7.5 2.5h9a5 5 0 0 1 5 5v9a5 5 0 0 1-5 5h-9a5 5 0 0 1-5-5v-9a5 5 0 0 1 5-5ZM7.5 4.3a3.2 3.2 0 0 0-3.2 3.2v9a3.2 3.2 0 0 0 3.2 3.2h9a3.2 3.2 0 0 0 3.2-3.2v-9a3.2 3.2 0 0 0-3.2-3.2ZM12 7.3a4.7 4.7 0 1 0 0 9.4 4.7 4.7 0 0 0 0-9.4Zm0 1.8a2.9 2.9 0 1 1 0 5.8 2.9 2.9 0 0 1 0-5.8ZM16.2 6.7a1.1 1.1 0 1 0 2.2 0 1.1 1.1 0 1 0-2.2 0Z\",\n youtube: \"M2.5 7.5c.2-1.6 1.2-2.6 2.8-2.7 2.1-.2 4.4-.3 6.7-.3s4.6.1 6.7.3c1.6.1 2.6 1.1 2.8 2.7.2 1.5.3 3 .3 4.5s-.1 3-.3 4.5c-.2 1.6-1.2 2.6-2.8 2.7-2.1.2-4.4.3-6.7.3s-4.6-.1-6.7-.3c-1.6-.1-2.6-1.1-2.8-2.7-.2-1.5-.3-3-.3-4.5s.1-3 .3-4.5ZM10 8.8v6.4l5.5-3.2Z\",\n facebook: \"M12 2.5a9.5 9.5 0 1 0 0 19 9.5 9.5 0 0 0 0-19ZM10.3 21.4v-7.6H8.4v-2.5h1.9V9.4c0-2.5 1.4-3.9 3.7-3.9.9 0 1.6.1 1.9.1v2.2h-1.3c-1 0-1.4.6-1.4 1.4v2.1h2.6l-.4 2.5h-2.2v7.6Z\",\n};\n\nexport const ICON_NAMES = [...Object.keys(PATHS), ...Object.keys(BRANDS)];\n\nexport function Icon({ name, size = 16, className, label }) {\n const brand = BRANDS[name];\n const d = PATHS[name] || brand;\n if (!d) return null;\n return (\n <svg\n width={size}\n height={size}\n viewBox=\"0 0 24 24\"\n fill={brand ? \"currentColor\" : \"none\"}\n fillRule={brand ? \"evenodd\" : undefined}\n stroke={brand ? \"none\" : \"currentColor\"}\n strokeWidth={brand ? undefined : \"1.8\"}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden={label ? undefined : true}\n role={label ? \"img\" : undefined}\n aria-label={label}\n className={className}\n >\n <path d={d} />\n </svg>\n );\n}\n"
139
+ },
140
+ {
141
+ "path": "templates/next/files/components/ui/image.jsx",
142
+ "type": "registry:ui",
143
+ "target": "@components/makersclaw/ui/image.jsx",
144
+ "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\nimport { Skeleton } from \"./feedback\";\n\n// Image — a picture of a thing: a file's thumbnail, a product shot, a\n// cover. The browser's own <img> (lazy loading, srcset, decoding) in a box\n// with the workspace's look: rounded, a Skeleton while it loads, an image\n// glyph on a muted fill when it fails or has no `src`. Never a raw <img>\n// in a surface. A person's face is an Avatar.\n//\n// src, alt `alt` is required: what the picture shows, or \"\" when it\n// only decorates what the text beside it already says\n// ratio auto (the image's own shape; give `width` and `height` so\n// the box holds its place while it loads) · square · video\n// (16:9) · portrait (3:4) · photo (4:3) · wide (21:9), or a\n// number (width / height, e.g. 2)\n// fit cover (fills the box, cropped; the default) · contain (all\n// of it, on a muted fill)\n// rounded md (the default) · none · sm · lg · full (a circle, with a\n// square ratio)\n// loading lazy (the default: fetched as it nears the screen) · eager\n// (above the fold, the page's main picture)\n// fallback a node for the failed state, in place of the glyph\n// className sizes the box (size-10, w-48, max-w-sm); every other prop\n// (width, height, srcSet, sizes, onLoad…) goes to the <img>\n//\n// The box is the element that is sized and rounded; the <img> fills it.\n\nconst RATIOS = {\n square: \"aspect-square\",\n video: \"aspect-video\",\n portrait: \"aspect-3/4\",\n photo: \"aspect-4/3\",\n wide: \"aspect-21/9\",\n};\n\nconst ROUNDED = {\n none: \"rounded-none\",\n sm: \"rounded-sm\",\n md: \"rounded-md\",\n lg: \"rounded-lg\",\n full: \"rounded-full\",\n};\n\nconst FITS = {\n cover: \"object-cover\",\n contain: \"object-contain\",\n};\n\nexport function Image({\n src,\n alt,\n ratio = \"auto\",\n fit = \"cover\",\n rounded = \"md\",\n loading = \"lazy\",\n fallback,\n className,\n onLoad,\n onError,\n width,\n height,\n ...rest\n}) {\n const img = useRef(null);\n const noAlt = alt === undefined;\n useEffect(() => {\n if (noAlt) console.warn('Image: `alt` is required — say what the picture shows, or alt=\"\" when it only decorates.');\n }, [noAlt]);\n // \"loading\" · \"loaded\" · \"failed\", reset whenever src changes.\n const [state, setState] = useState({ src, status: src ? \"loading\" : \"failed\" });\n const status = state.src === src ? state.status : src ? \"loading\" : \"failed\";\n if (state.src !== src) setState({ src, status });\n\n // A server-rendered <img> can load or fail before React is listening.\n useEffect(() => {\n const el = img.current;\n if (!el || !src || !el.complete) return;\n setState({ src, status: el.naturalWidth > 0 ? \"loaded\" : \"failed\" });\n }, [src]);\n\n const auto = ratio === \"auto\" || ratio == null;\n const named = typeof ratio === \"string\" ? RATIOS[ratio] : undefined;\n const failed = status === \"failed\";\n // An auto box has no shape of its own until the image arrives: borrow\n // width / height, or a 4:3 box when a failed image has neither.\n let aspect;\n if (typeof ratio === \"number\" && ratio > 0) aspect = ratio;\n else if (auto && width && height) aspect = `${width} / ${height}`;\n else if (auto && failed) aspect = 4 / 3;\n\n return (\n <span\n className={cx(\n \"relative block overflow-hidden bg-muted\",\n ROUNDED[rounded] ?? ROUNDED.md,\n named,\n className,\n )}\n style={aspect ? { aspectRatio: aspect } : undefined}\n >\n {src && !failed ? (\n <img\n ref={img}\n src={src}\n alt={alt ?? \"\"}\n loading={loading}\n decoding=\"async\"\n width={width}\n height={height}\n className={cx(\n \"block transition-opacity duration-200 motion-reduce:transition-none\",\n auto ? \"h-auto w-full\" : \"absolute inset-0 size-full\",\n FITS[fit] ?? FITS.cover,\n status === \"loaded\" ? \"opacity-100\" : \"opacity-0\",\n )}\n onLoad={(e) => {\n setState({ src, status: \"loaded\" });\n onLoad?.(e);\n }}\n onError={(e) => {\n setState({ src, status: \"failed\" });\n onError?.(e);\n }}\n {...rest}\n />\n ) : null}\n {status === \"loading\" ? <Skeleton shape=\"rect\" className=\"absolute inset-0 size-full\" /> : null}\n {failed ? (\n <span\n role={alt ? \"img\" : undefined}\n aria-label={alt || undefined}\n className=\"absolute inset-0 flex items-center justify-center text-muted-foreground\"\n >\n {fallback ?? <Icon name=\"image\" size={20} />}\n </span>\n ) : null}\n </span>\n );\n}\n"
145
+ },
146
+ {
147
+ "path": "templates/next/files/components/ui/index.js",
148
+ "type": "registry:ui",
149
+ "target": "@components/makersclaw/ui/index.js",
150
+ "content": "// The kit. Import from \"../components/ui\" (a surface) or \"./ui\" (a\n// component). Every part is the workspace's look over the browser's own\n// behaviour; none of them takes a colour. README §3b.\nexport { cx } from \"./cx\";\nexport { Icon, ICON_NAMES } from \"./icon\";\nexport { Button, IconButton } from \"./button\";\nexport { Field, Input, TimeInput, Textarea, Select, Switch, Checkbox } from \"./field\";\nexport { Card, Notice, Badge, Kicker, Empty } from \"./notice\";\nexport { Sheet, Confirm } from \"./sheet\";\nexport { Tabs, TabPanel } from \"./tabs\";\nexport { Menu, MenuPanel } from \"./menu\";\nexport { ContextMenu } from \"./context-menu\";\nexport { Popover, usePopoverPosition, panelClass } from \"./popover\";\nexport {\n Calendar,\n parseDate,\n isDateString,\n toDateString,\n todayString,\n addDays,\n addMonths,\n startOfMonth,\n endOfMonth,\n dayOfWeek,\n formatDate,\n firstDayOfWeek,\n} from \"./calendar\";\nexport { DatePicker, DateInput, DATE_PRESETS } from \"./date-picker\";\nexport { Tooltip, HoverCard } from \"./tooltip\";\nexport { Depiction } from \"./depiction\";\nexport { toast, Toaster } from \"./toast\";\nexport { Carousel } from \"./carousel\";\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle } from \"./resizable\";\nexport { Avatar, AvatarGroup } from \"./avatar\";\nexport { Breadcrumb } from \"./breadcrumb\";\nexport { Pagination } from \"./pagination\";\nexport { RadioGroup, Radio } from \"./radio\";\nexport { Toggle, ToggleGroup } from \"./toggle\";\nexport { ButtonGroup, ButtonGroupSeparator } from \"./button-group\";\nexport { Collapsible, Accordion } from \"./collapsible\";\nexport { Item, ItemGroup } from \"./item\";\nexport { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from \"./table\";\nexport { Spinner, Skeleton, Progress } from \"./feedback\";\nexport { Distribution } from \"./distribution\";\nexport { Swatch } from \"./swatch\";\nexport { Stat, StatGroup, Delta, StatusDot, Legend, Meter } from \"./stat\";\nexport { Steps } from \"./steps\";\nexport { Separator, Kbd, KbdGroup, AspectRatio } from \"./separator\";\nexport { ScrollArea } from \"./scroll-area\";\nexport { Slider } from \"./slider\";\nexport { InputGroup, PasswordInput } from \"./input-group\";\nexport { Image } from \"./image\";\nexport { FileInput, FileDrop, fileMatches } from \"./file-input\";\nexport { InputOTP } from \"./input-otp\";\nexport { Command, CommandDialog, commandScore, commandFilter } from \"./command\";\nexport { Combobox } from \"./combobox\";\nexport { Rating } from \"./rating\";\n"
151
+ },
152
+ {
153
+ "path": "templates/next/files/components/ui/input-group.jsx",
154
+ "type": "registry:ui",
155
+ "target": "@components/makersclaw/ui/input-group.jsx",
156
+ "content": "\"use client\";\n\nimport { isValidElement, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Button, IconButton } from \"./button\";\nimport { Icon } from \"./icon\";\nimport { KbdGroup } from \"./separator\";\nimport { useAutoGrow } from \"./field\";\n\n// InputGroup — an Input (or, multiline, a Textarea) with things attached: \"https://\" before it,\n// \"USD\" after it, a search glyph, a ⌘K hint, a copy button. The group is\n// the field: one border, one focus ring, one invalid red, one disabled\n// state, so it reads as a single control and never as an input with\n// stickers beside it. A native <input> inside, every prop not named here\n// goes to it (type, value, onChange, placeholder, autoComplete…); wrap\n// the group in <Field> and give it the same `id`. A press on an addon\n// puts the caret in the input, as a press on the field would.\n//\n// icon a glyph from ./icon at the start (search, mail, globe)\n// prefix text at the start, set close to what is typed (\"https://\")\n// suffix text at the end (\"USD\", \".makersclaw.com\", \"/ month\")\n// kbd a shortcut hint at the end: \"/\" or [\"⌘\", \"K\"]. Shown from\n// md up (a phone has no keyboard) and hidden from readers:\n// pass aria-keyshortcuts=\"Meta+K\" for them\n// action the button at the end: { label, icon?, onClick?, type?,\n// variant? } — with `icon` it is an IconButton and `label` is\n// what a reader says; without, a small text button. Or any\n// node, for something else.\n// invalid marks the input aria-invalid; the whole group turns red\n// multiline true: a <textarea> that grows with its text (from `minRows`,\n// default 1, to `maxRows`, default 8, then it scrolls) — the\n// chat composer, a comment box. The text takes the full width\n// and the addons sit in a bar under it: icon, prefix and\n// `tools` at the start, suffix, kbd and action at the end.\n// `above` is a node inside the border over the text (the\n// attached files). Keys are yours: onKeyDown goes to the\n// textarea (Enter to send — mind e.nativeEvent.isComposing).\n// tools (multiline) nodes at the start of the bar: attach, a model menu\n// above (multiline) a node over the text, inside the one border\n//\n// The input draws no border of its own (it is not composed from <Input>,\n// whose border and ring would draw a box inside the box); the group\n// carries the same classes <Input> does, so the two sit in a form as\n// equals.\n\nconst GROUP =\n \"flex w-full min-h-11 cursor-text items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-foreground \" +\n \"has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring \" +\n \"has-[input[aria-invalid=true]]:border-destructive \" +\n \"has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50\";\n\nconst FIELD =\n \"min-w-0 flex-1 self-stretch border-0 bg-transparent p-0 text-sm text-foreground outline-none \" +\n \"placeholder:text-muted-foreground disabled:cursor-not-allowed\";\n\nconst ADDON = \"shrink-0 select-none whitespace-nowrap text-muted-foreground\";\n\n// The multi-line group: a column — above, the text, then the bar.\nconst MULTI =\n \"flex w-full cursor-text flex-col rounded-md border border-input bg-background text-sm text-foreground \" +\n \"has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-ring \" +\n \"has-[textarea[aria-invalid=true]]:border-destructive \" +\n \"has-[textarea:disabled]:cursor-not-allowed has-[textarea:disabled]:opacity-50\";\n\nconst MULTI_FIELD =\n \"block w-full resize-none border-0 bg-transparent px-3 pt-3 pb-1 text-sm leading-5 text-foreground outline-none \" +\n \"placeholder:text-muted-foreground disabled:cursor-not-allowed\";\n\nexport function InputGroup({ multiline = false, ...props }) {\n if (multiline) return <MultilineGroup {...props} />;\n const { tools, above, minRows, maxRows, ...one } = props; // multiline only\n return <SingleLineGroup {...one} />;\n}\n\n// The trailing button, from `action`.\nfunction actionNode(action, disabled) {\n if (action && typeof action === \"object\" && !isValidElement(action) && \"label\" in action) {\n const { label, icon: glyph, variant, ...props } = action;\n return glyph ? (\n <IconButton icon={glyph} label={label} size=\"sm\" variant={variant ?? \"quiet\"} disabled={disabled} {...props} />\n ) : (\n <Button size=\"sm\" variant={variant ?? \"secondary\"} disabled={disabled} {...props}>\n {label}\n </Button>\n );\n }\n return action || null;\n}\n\nfunction MultilineGroup({ icon, prefix, suffix, kbd, action, tools, above, invalid, disabled, minRows = 1, maxRows = 8, onInput, className, ref, ...rest }) {\n const grow = useAutoGrow({ minRows, maxRows, value: rest.value, outer: ref });\n const area = useRef(null);\n\n function focusText(e) {\n if (e.target.closest(\"button, a, input, select, textarea, [role=button]\")) return;\n e.preventDefault();\n area.current?.focus();\n }\n\n const keys = kbd ? (Array.isArray(kbd) ? kbd : [kbd]) : null;\n const end = actionNode(action, disabled);\n const start = icon || prefix || tools;\n\n return (\n <div className={cx(MULTI, className)} onMouseDown={focusText}>\n {above ? <div className=\"min-w-0 cursor-default\">{above}</div> : null}\n <textarea\n ref={(el) => {\n area.current = el;\n return grow.ref(el);\n }}\n rows={minRows}\n disabled={disabled}\n aria-invalid={invalid ? true : undefined}\n className={MULTI_FIELD}\n onInput={(e) => {\n grow.fit();\n onInput?.(e);\n }}\n {...rest}\n />\n <div className=\"flex min-h-11 items-center gap-1 px-1.5 pb-1.5\">\n {start ? (\n <div className=\"flex min-w-0 items-center gap-1\">\n {icon ? <Icon name={icon} className={cx(ADDON, \"mx-1.5\")} /> : null}\n {prefix ? <span className={cx(ADDON, \"px-1.5\")}>{prefix}</span> : null}\n {tools}\n </div>\n ) : null}\n <div className=\"min-w-0 flex-1\" />\n {suffix ? <span className={cx(ADDON, \"px-1.5\")}>{suffix}</span> : null}\n {keys ? (\n <span className=\"hidden shrink-0 px-1.5 md:inline-flex\" aria-hidden=\"true\">\n <KbdGroup keys={keys} />\n </span>\n ) : null}\n {end ? <span className=\"flex shrink-0 items-center\">{end}</span> : null}\n </div>\n </div>\n );\n}\n\nfunction SingleLineGroup({ icon, prefix, suffix, kbd, action, invalid, disabled, className, ref, ...rest }) {\n const input = useRef(null);\n\n function focusInput(e) {\n if (e.target.closest(\"button, a, input, select, textarea\")) return;\n e.preventDefault();\n input.current?.focus();\n }\n\n const keys = kbd ? (Array.isArray(kbd) ? kbd : [kbd]) : null;\n const end = actionNode(action, disabled);\n\n return (\n <div className={cx(GROUP, end && \"pr-1\", className)} onMouseDown={focusInput}>\n {icon ? <Icon name={icon} className={ADDON} /> : null}\n {prefix ? <span className={cx(ADDON, \"-mr-1.5\")}>{prefix}</span> : null}\n <input\n ref={(el) => {\n input.current = el;\n if (typeof ref === \"function\") ref(el);\n else if (ref) ref.current = el;\n }}\n disabled={disabled}\n aria-invalid={invalid ? true : undefined}\n className={FIELD}\n {...rest}\n />\n {suffix ? <span className={ADDON}>{suffix}</span> : null}\n {keys ? (\n <span className=\"hidden shrink-0 md:inline-flex\" aria-hidden=\"true\">\n <KbdGroup keys={keys} />\n </span>\n ) : null}\n {end ? <span className=\"flex shrink-0 items-center\">{end}</span> : null}\n </div>\n );\n}\n\n/**\n * A password field with a show/hide toggle: an InputGroup whose action is\n * an IconButton (eye / eye-off) with aria-pressed, named \"Show password\"\n * — its pressed state says whether the text is showing. Every other prop\n * goes to the <input> as in InputGroup: `autoComplete` defaults to\n * \"current-password\"; pass \"new-password\" on a sign-up or a reset so the\n * browser offers to generate one. `icon`, `invalid`, `disabled`, `id`\n * work as in InputGroup. `showLabel` renames the toggle.\n */\nexport function PasswordInput({ autoComplete = \"current-password\", showLabel = \"Show password\", disabled, ...rest }) {\n const [shown, setShown] = useState(false);\n return (\n <InputGroup\n {...rest}\n type={shown ? \"text\" : \"password\"}\n autoComplete={autoComplete}\n autoCapitalize=\"off\"\n autoCorrect=\"off\"\n spellCheck={false}\n disabled={disabled}\n action={\n <IconButton\n icon={shown ? \"eye-off\" : \"eye\"}\n label={showLabel}\n aria-pressed={shown}\n size=\"sm\"\n disabled={disabled}\n onClick={() => setShown((v) => !v)}\n />\n }\n />\n );\n}\n"
157
+ },
158
+ {
159
+ "path": "templates/next/files/components/ui/input-otp.jsx",
160
+ "type": "registry:ui",
161
+ "target": "@components/makersclaw/ui/input-otp.jsx",
162
+ "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// InputOTP — the one-time code from an email or a text: N boxes, one\n// character each. The boxes are a view of ONE string, as a single field\n// is: typing fills the box and moves on, Backspace clears and moves\n// back, a cleared box closes the gap, the arrows and Home/End move, and\n// a box past the first empty one sends the caret back to it. Paste, or\n// the phone's \"From Messages\" (autocomplete=\"one-time-code\" on the first\n// box), fills every box at once. Each box is a native <input>, so the\n// keyboard, the numeric keypad and autofill are the browser's.\n//\n// length how many boxes (6)\n// groups box counts split by a dash: [3, 3] → 123–456; their\n// sum is the length\n// value the code so far, a string; onChange(code) on every edit\n// (controlled), or `defaultValue`\n// onComplete (code) → called once the last box fills: verify here\n// digitsOnly true (the default: 0–9 and the number pad) · false\n// (letters too, as typed)\n// label what a screen reader calls the group (\"Verification code\")\n// name a hidden input carries the whole code in a form\n// invalid every box turns red — pair it with <Field error>\n//\n// Wrap it in <Field id> with the same `id`: the label points at the\n// first box. After a wrong code, clear `value` and the caret starts over.\n\nconst SLOT =\n \"h-11 w-10 min-w-0 -ml-px border border-input bg-background text-center text-lg font-medium text-foreground outline-none \" +\n \"first:ml-0 first:rounded-l-md last:rounded-r-md caret-foreground selection:bg-transparent \" +\n \"focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring \" +\n \"aria-[invalid=true]:border-destructive disabled:cursor-not-allowed\";\n\nexport function InputOTP({\n id,\n length = 6,\n groups,\n value,\n defaultValue = \"\",\n onChange,\n onComplete,\n digitsOnly = true,\n label = \"One-time code\",\n name,\n invalid,\n disabled,\n autoFocus,\n className,\n}) {\n const sizes = groups?.length ? groups : [length];\n const total = sizes.reduce((a, b) => a + b, 0);\n const [own, setOwn] = useState(defaultValue);\n const code = (value ?? own).slice(0, total);\n const boxes = useRef([]);\n // The code as of the last edit. Focus moves inside the edit's own\n // event, before React renders it, so the redirect below reads this.\n const latest = useRef(code);\n latest.current = code;\n const allowed = digitsOnly ? /[0-9]/ : /[0-9a-zA-Z]/;\n const clean = (s) => [...s].filter((c) => allowed.test(c)).join(\"\");\n\n function focus(i) {\n const el = boxes.current[Math.max(0, Math.min(i, total - 1))];\n el?.focus();\n el?.select();\n }\n\n function commit(next, caret) {\n next = next.slice(0, total);\n latest.current = next;\n if (value === undefined) setOwn(next);\n if (next !== code) onChange?.(next);\n if (next.length === total && next !== code) onComplete?.(next);\n if (caret !== undefined) focus(caret);\n }\n\n // Put `text` in from box i on, over what is there.\n function place(i, text) {\n commit(code.slice(0, i) + text + code.slice(i + text.length), i + text.length);\n }\n\n function onInput(e, i) {\n const old = code[i] ?? \"\";\n let v = e.target.value;\n // The caret sat beside the old character: keep only the new one.\n if (old && v.length === 2) v = v[0] === old ? v[1] : v[0];\n if (v === \"\") return commit(code.slice(0, i) + code.slice(i + 1), i);\n const typed = clean(v);\n if (typed) place(i, typed);\n }\n\n function onKeyDown(e, i) {\n if (e.key === \"Backspace\" && !code[i] && i > 0) {\n commit(code.slice(0, i - 1) + code.slice(i), i - 1);\n } else if (e.key === \"Delete\" && code[i]) {\n commit(code.slice(0, i) + code.slice(i + 1), i);\n } else if (e.key === \"ArrowLeft\") focus(i - 1);\n else if (e.key === \"ArrowRight\") focus(Math.min(i + 1, code.length));\n else if (e.key === \"Home\") focus(0);\n else if (e.key === \"End\") focus(code.length);\n else return;\n e.preventDefault();\n }\n\n function onPaste(e, i) {\n e.preventDefault();\n const text = clean(e.clipboardData.getData(\"text\"));\n if (!text) return;\n // A whole code replaces the code; a fragment goes in where the caret is.\n place(text.length >= total ? 0 : i, text);\n }\n\n // The code is one string: a box past the first empty one is not a\n // place to type, so the caret goes back to the gap.\n function onFocus(e, i) {\n if (i > latest.current.length) return focus(latest.current.length);\n e.target.select();\n }\n\n let n = 0;\n return (\n <div\n role=\"group\"\n aria-label={label}\n className={cx(\"flex items-center gap-2\", disabled && \"opacity-50\", className)}\n >\n {sizes.map((size, g) => (\n <div key={g} className=\"contents\">\n {g > 0 ? <Icon name=\"minus\" className=\"shrink-0 text-muted-foreground\" /> : null}\n <div className=\"flex\">\n {Array.from({ length: size }, () => {\n const i = n++;\n return (\n <input\n key={i}\n ref={(el) => {\n boxes.current[i] = el;\n }}\n id={i === 0 ? id : undefined}\n type=\"text\"\n inputMode={digitsOnly ? \"numeric\" : \"text\"}\n autoComplete={i === 0 ? \"one-time-code\" : \"off\"}\n autoCorrect=\"off\"\n autoCapitalize=\"off\"\n spellCheck={false}\n autoFocus={i === 0 ? autoFocus : undefined}\n aria-label={`${digitsOnly ? \"Digit\" : \"Character\"} ${i + 1} of ${total}`}\n aria-invalid={invalid ? true : undefined}\n disabled={disabled}\n value={code[i] ?? \"\"}\n onChange={(e) => onInput(e, i)}\n onKeyDown={(e) => onKeyDown(e, i)}\n onPaste={(e) => onPaste(e, i)}\n onFocus={(e) => onFocus(e, i)}\n onClick={(e) => e.currentTarget.select()}\n data-nums\n className={SLOT}\n />\n );\n })}\n </div>\n </div>\n ))}\n {name ? <input type=\"hidden\" name={name} value={code} /> : null}\n </div>\n );\n}\n"
163
+ },
164
+ {
165
+ "path": "templates/next/files/components/ui/item.jsx",
166
+ "type": "registry:ui",
167
+ "target": "@components/makersclaw/ui/item.jsx",
168
+ "content": "import Link from \"next/link\";\nimport { Children } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Item — the list row: a contact, a file, a run, a setting. Media on the\n// left, title and description, meta on the right, then its own actions.\n// With `href` or `onClick` the WHOLE row is the target — the title is the\n// real <a> or <button> and stretches over the row, so the row's own\n// actions stay separate buttons (never a button inside a link) and a\n// screen reader hears the title, not the whole row read out.\n//\n// media a node on the left: an avatar, an <img>, a logo\n// icon or a glyph from ./icon, drawn in a muted square\n// title the one line that names the thing (truncates)\n// description up to two lines under it\n// meta a count, a date, a Badge — muted, on the right\n// actions buttons on the far right (IconButton, Menu); they sit above\n// the row's target, so they click on their own\n// href a next/link row · `onClick` a button row · neither: static\n// size default (56px) · sm (44px, for dense lists and rails) ·\n// lg (64px, roomier: a pipeline's runs, a list read at leisure)\n// variant plain (no fill) · outline (a bordered card) · muted (a filled panel)\n// layout row (default) · stacked — a card for a grid: icon or media on\n// top, then the title (up to two lines), the description (up\n// to three), children, and meta at the foot; actions sit in the\n// top-right corner. It fills its grid cell's height, so a row\n// of cards lines up. Pair it with variant=\"outline\" or \"muted\".\n// The link or button target and what goes where are the row's\n//\n// Where the other props go: `aria-current`, `onClick`, `target` and `rel`\n// belong to the row's target, so they go on its <a> or <button> (with\n// `href`, `onClick` rides on the link: analytics, closing a sheet).\n// aria-current on the target fills the row, so the current page looks\n// like what a screen reader says it is. Every other prop (data-*, id,\n// aria-label…) goes on the row's <div>.\n//\n// ItemGroup stacks plain Items with a rule between each, as a list.\n\nconst VARIANTS = {\n plain: \"\",\n outline: \"border border-border bg-card text-card-foreground\",\n muted: \"bg-muted\",\n};\n\nconst SIZES = {\n default: { row: \"min-h-14 gap-3 px-3 py-2.5\", icon: \"size-10\", glyph: 18, desc: \"line-clamp-2 text-sm\" },\n sm: { row: \"min-h-11 gap-2.5 px-2.5 py-1.5\", icon: \"size-8\", glyph: 16, desc: \"truncate text-xs\" },\n lg: { row: \"min-h-16 gap-3.5 px-4 py-3\", icon: \"size-11\", glyph: 20, desc: \"line-clamp-2 text-sm\" },\n};\n\n// layout=\"stacked\": the card's padding and gaps, and its icon square.\nconst STACKED = {\n default: { box: \"gap-3 p-4\", icon: \"size-10\", glyph: 18 },\n sm: { box: \"gap-2 p-3\", icon: \"size-8\", glyph: 16 },\n lg: { box: \"gap-4 p-5\", icon: \"size-12\", glyph: 22 },\n};\n\nconst TARGET = \"outline-none after:absolute after:inset-0\";\n\n// The row filled while its target (or a static row itself) is current.\nconst CURRENT =\n \"has-[[data-item-target][aria-current]:not([aria-current=false])]:bg-accent \" +\n \"[&[aria-current]:not([aria-current=false])]:bg-accent\";\n\nexport function Item({\n media,\n icon,\n title,\n description,\n meta,\n actions,\n href,\n onClick,\n size = \"default\",\n variant = \"plain\",\n layout = \"row\",\n disabled,\n className,\n children,\n \"aria-current\": ariaCurrent,\n target,\n rel,\n ...rest\n}) {\n const s = SIZES[size] ?? SIZES.default;\n const interactive = Boolean(href || onClick) && !disabled;\n\n let name = title;\n let targeted = false;\n if (href && !disabled) {\n targeted = true;\n name = (\n <Link href={href} onClick={onClick} target={target} rel={rel} aria-current={ariaCurrent} data-item-target className={TARGET}>\n {title}\n </Link>\n );\n } else if (onClick) {\n targeted = true;\n name = (\n <button\n type=\"button\"\n onClick={onClick}\n disabled={disabled}\n aria-current={ariaCurrent}\n data-item-target\n className={cx(TARGET, \"text-left disabled:cursor-not-allowed\")}\n >\n {title}\n </button>\n );\n }\n\n const ring =\n interactive &&\n \"hover:bg-accent has-[[data-item-target]:focus-visible]:ring-2 has-[[data-item-target]:focus-visible]:ring-inset has-[[data-item-target]:focus-visible]:ring-ring\";\n\n if (layout === \"stacked\") {\n const k = STACKED[size] ?? STACKED.default;\n return (\n <div\n className={cx(\"relative flex h-full flex-col items-start rounded-lg text-sm\", VARIANTS[variant] ?? VARIANTS.plain, k.box, ring, CURRENT, disabled && \"opacity-50\", className)}\n aria-current={targeted ? undefined : ariaCurrent}\n {...rest}\n >\n {icon ? (\n <span className={cx(\"flex shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground\", variant === \"muted\" && \"bg-background\", k.icon)}>\n <Icon name={icon} size={k.glyph} />\n </span>\n ) : media ? (\n <span className=\"flex shrink-0 items-center\">{media}</span>\n ) : null}\n <div className={cx(\"w-full min-w-0 flex-1\", actions && !icon && !media && \"pr-10\")}>\n <p className=\"line-clamp-2 font-medium text-foreground\">{name}</p>\n {description ? <p className=\"mt-1 line-clamp-3 text-sm text-muted-foreground\">{description}</p> : null}\n {children ? <div className=\"mt-2\">{children}</div> : null}\n </div>\n {meta !== undefined && meta !== null ? (\n <div data-nums className=\"text-xs text-muted-foreground\">\n {meta}\n </div>\n ) : null}\n {actions ? <div className=\"absolute right-2 top-2 z-10 flex items-center gap-1\">{actions}</div> : null}\n </div>\n );\n }\n\n return (\n <div\n className={cx(\n \"relative flex items-center rounded-lg text-sm\",\n VARIANTS[variant] ?? VARIANTS.plain,\n s.row,\n ring,\n CURRENT,\n disabled && \"opacity-50\",\n className,\n )}\n aria-current={targeted ? undefined : ariaCurrent}\n {...rest}\n >\n {icon ? (\n <span className={cx(\"flex shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground\", variant === \"muted\" && \"bg-background\", s.icon)}>\n <Icon name={icon} size={s.glyph} />\n </span>\n ) : media ? (\n <span className=\"flex shrink-0 items-center\">{media}</span>\n ) : null}\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate font-medium text-foreground\">{name}</p>\n {description ? <p className={cx(\"text-muted-foreground\", s.desc)}>{description}</p> : null}\n {children ? <div className=\"mt-1.5\">{children}</div> : null}\n </div>\n {meta !== undefined && meta !== null ? (\n <div data-nums className=\"shrink-0 text-xs text-muted-foreground\">\n {meta}\n </div>\n ) : null}\n {actions ? <div className=\"relative z-10 flex shrink-0 items-center gap-1\">{actions}</div> : null}\n </div>\n );\n}\n\n/** Items as a list, a rule between each. `variant=\"outline\"` boxes the\n * whole list; put plain Items in it. */\nexport function ItemGroup({ variant = \"plain\", className, children, \"aria-label\": ariaLabel }) {\n const rows = Children.toArray(children).filter(Boolean);\n if (rows.length === 0) return null;\n return (\n <ul\n role=\"list\"\n aria-label={ariaLabel}\n className={cx(\n \"flex flex-col\",\n variant === \"outline\" && \"overflow-hidden rounded-lg border border-border bg-card text-card-foreground [&>li>*]:rounded-none\",\n className,\n )}\n >\n {rows.map((row, i) => (\n <li key={row.key ?? i} className={cx(i > 0 && \"border-t border-border\")}>\n {row}\n </li>\n ))}\n </ul>\n );\n}\n"
169
+ },
170
+ {
171
+ "path": "templates/next/files/components/ui/menu.jsx",
172
+ "type": "registry:ui",
173
+ "target": "@components/makersclaw/ui/menu.jsx",
174
+ "content": "\"use client\";\n\nimport { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\nimport { KbdGroup } from \"./separator\";\nimport { panelClass, usePopoverPosition } from \"./popover\";\n\n// Menu — the \"…\" of a row, the kebab of a card, a \"View\" or \"Account\"\n// dropdown. The Popover API does the hard part (top layer, light dismiss,\n// Escape); usePopoverPosition (./popover) keeps it beside its button,\n// flipping at the window's edge and following scroll. The keys are the\n// WAI-ARIA menu pattern: arrows, Home/End, a first letter jumps to the\n// item it starts, ArrowRight/Left into and out of a submenu, Escape and\n// Tab close it. `items` with nothing in them render nothing: a menu is\n// earned by having actions.\n//\n// items the entries, in order; any mix of\n// { label, onSelect, icon?, tone?: \"danger\", disabled?,\n// shortcut?: \"⌘K\", description? } an action (closes)\n// { type: \"checkbox\", label, checked, onCheckedChange(next),\n// icon?, shortcut?, description?, disabled? } stays open\n// { type: \"radio\", label?, value, onValueChange(value),\n// options: [{ value, label, description?, disabled? }] or\n// [\"a\", \"b\"] } stays open\n// { group: \"Account\", items: [...] } a labelled group\n// { type: \"label\", label } a heading (a reader skips it;\n// `group` is the one it names)\n// { type: \"separator\" } a line\n// { label, icon?, items: [...] } a submenu: to the side on a\n// wide window, inline on a phone\n// a falsy entry is skipped, so `cond && {…}` works\n// trigger a node to use as the button's content; default is the ⋯ icon\n// label what a reader calls the menu, and the button: always the ⋯\n// one's name (default \"More\"); with a `trigger`, the button's\n// name only when given — else its visible text is. Begin it\n// with that text (\"Ada Lovelace, account menu\"), so a voice\n// user who says what they see still hits it\n// side bottom (default) · top · right · left; flips when no room\n// align end (default) · start · center, along that side\n// size the button: sm (36px, the default — a row's or a card's ⋯) ·\n// md (44px, a touch target: a toolbar, a menubar, a phone)\n//\n// A shortcut is only a hint: the menu draws it, the app binds it. The\n// one rule: an action closes the menu, a choice (checkbox, radio) does\n// not — it is a setting, and the next one is usually beside it.\n//\n// MenuPanel is the menu itself without its button — ContextMenu\n// (./context-menu) opens the same `items` at the pointer.\n\nconst Root = createContext({ close() {}, narrow: false });\nconst Level = createContext(null);\n\nconst SYMBOL_KEYS = { \"⌘\": \"Meta\", \"⌥\": \"Alt\", \"⇧\": \"Shift\", \"⌃\": \"Control\", \"↵\": \"Enter\", \"⏎\": \"Enter\", \"⌫\": \"Backspace\", \"⌦\": \"Delete\", \"⎋\": \"Escape\", \"⇥\": \"Tab\", \"↑\": \"ArrowUp\", \"↓\": \"ArrowDown\", \"←\": \"ArrowLeft\", \"→\": \"ArrowRight\" };\nconst SYMBOLS = /[⌘⌥⇧⌃↵⏎⌫⌦⎋⇥↑↓←→]|[^⌘⌥⇧⌃↵⏎⌫⌦⎋⇥↑↓←→]+/g;\n\n// \"⌘K\" → [\"⌘\", \"K\"]; \"Ctrl+Shift+P\" → [\"Ctrl\", \"Shift\", \"P\"]; an array as is.\nfunction keysOf(shortcut) {\n if (Array.isArray(shortcut)) return shortcut;\n const s = String(shortcut).trim();\n return /[+\\s]/.test(s) && s.length > 1 ? s.split(/\\s*\\+\\s*|\\s+/).filter(Boolean) : s.match(SYMBOLS) ?? [];\n}\n\n// Only this menu's items — not a submenu's, which sit inside it.\nfunction own(menu) {\n if (!menu) return [];\n return [...menu.querySelectorAll('[role^=\"menuitem\"]:not([disabled])')].filter((n) => n.closest('[role=\"menu\"]') === menu);\n}\n\nconst nameOf = (n) => (n.dataset.text || n.textContent || \"\").trim().toLowerCase();\n\n// A first letter moves to the next item it starts; letters typed quickly\n// spell a longer prefix; the same letter again walks through its items.\nfunction typeahead(nodes, key, memo) {\n const now = Date.now();\n memo.text = now - memo.at < 500 ? memo.text + key.toLowerCase() : key.toLowerCase();\n memo.at = now;\n const same = [...memo.text].every((c) => c === memo.text[0]);\n const q = same ? memo.text[0] : memo.text;\n const i = nodes.indexOf(document.activeElement);\n const from = i < 0 ? 0 : i + (same ? 1 : 0);\n return [...nodes.slice(from), ...nodes.slice(0, from)].find((n) => nameOf(n).startsWith(q));\n}\n\nfunction useNarrow() {\n const [narrow, setNarrow] = useState(false);\n useEffect(() => {\n const mq = window.matchMedia(\"(max-width: 767.98px)\");\n const on = () => setNarrow(mq.matches);\n on();\n mq.addEventListener(\"change\", on);\n return () => mq.removeEventListener(\"change\", on);\n }, []);\n return narrow;\n}\n\nconst focus = (n) => n?.focus({ preventScroll: true });\n\nconst ITEM =\n \"flex w-full min-h-10 items-center gap-2 rounded-sm px-2 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent disabled:opacity-50\";\n\n/** The menu's list — role=menu, its keys and its items — for a part that\n * opens it its own way (Menu under a button, ContextMenu at the pointer).\n * `onExit` runs on Escape and Tab; `onClose` closes everything after an\n * action. Pass `popover` and a `panelRef` to make it the floating panel. */\nexport function MenuPanel({ items, label, labelledBy, id, panelRef, popover, onToggle, onExit, onClose, onBack, className, style, onPointerEnter }) {\n const root = useContext(Root);\n const narrow = useNarrow();\n const [openSub, setOpenSub] = useState(null);\n const timer = useRef(0);\n const typed = useRef({ text: \"\", at: 0 });\n const live = (items ?? []).filter(Boolean);\n // A level that holds choices keeps a gutter for their marks, so every\n // label in it starts at the same line.\n const gutter = live.some((it) => it.type === \"checkbox\" || it.type === \"radio\" || (it.group && (it.items ?? []).some((c) => c?.type === \"checkbox\" || c?.type === \"radio\")));\n\n useEffect(() => () => clearTimeout(timer.current), []);\n\n const level = useMemo(\n () => ({\n openSub,\n setOpenSub,\n gutter,\n later(fn, ms) {\n clearTimeout(timer.current);\n timer.current = setTimeout(fn, ms);\n },\n cancel: () => clearTimeout(timer.current),\n }),\n [openSub, gutter],\n );\n const ctx = useMemo(() => (onClose ? { close: onClose, narrow } : { ...root, narrow }), [onClose, root, narrow]);\n\n function onKeyDown(e) {\n const menu = e.currentTarget;\n const nodes = own(menu);\n const i = nodes.indexOf(document.activeElement);\n const k = e.key;\n if (k === \"ArrowDown\") focus(nodes[(i + 1) % nodes.length]);\n else if (k === \"ArrowUp\") focus(nodes[(i - 1 + nodes.length) % nodes.length]);\n else if (k === \"Home\" || k === \"PageUp\") focus(nodes[0]);\n else if (k === \"End\" || k === \"PageDown\") focus(nodes[nodes.length - 1]);\n else if (k === \"ArrowLeft\" && onBack) onBack();\n else if (k === \"Escape\") (onBack ?? onExit)?.();\n else if (k === \"Tab\" && onExit) onExit();\n else if (k.length === 1 && k !== \" \" && !e.ctrlKey && !e.metaKey && !e.altKey) focus(typeahead(nodes, k, typed.current));\n else return; // Tab in a submenu reaches the top menu, which closes\n e.preventDefault();\n e.stopPropagation();\n }\n\n return (\n <Root.Provider value={ctx}>\n <Level.Provider value={level}>\n <div\n ref={panelRef}\n id={id}\n popover={popover}\n role=\"menu\"\n aria-label={labelledBy ? undefined : label}\n aria-labelledby={labelledBy}\n aria-orientation=\"vertical\"\n tabIndex={-1}\n onToggle={onToggle}\n onKeyDown={onKeyDown}\n onPointerEnter={onPointerEnter}\n className={className}\n style={style}\n >\n <Entries items={live} />\n </div>\n </Level.Provider>\n </Root.Provider>\n );\n}\n\nfunction Entries({ items }) {\n return items.filter(Boolean).map((item, i) => <Entry key={item.key ?? i} item={item} />);\n}\n\nfunction Heading({ id, children }) {\n const { gutter } = useContext(Level);\n return (\n <div id={id} role={id ? undefined : \"none\"} className={cx(\"px-2 py-1.5 text-xs font-medium text-muted-foreground\", gutter && \"pl-8\")}>\n {children}\n </div>\n );\n}\n\nfunction Entry({ item }) {\n if (item.type === \"separator\") return <div role=\"separator\" className=\"-mx-1 my-1 h-px bg-border\" />;\n if (item.type === \"label\") return <Heading>{item.label}</Heading>;\n if (item.type === \"checkbox\") return <Choice item={item} kind=\"checkbox\" checked={Boolean(item.checked)} onPick={() => item.onCheckedChange?.(!item.checked)} />;\n if (item.type === \"radio\") return <RadioGroup item={item} />;\n if (item.group !== undefined) return <Group item={item} />;\n if (Array.isArray(item.items)) return <Submenu item={item} />;\n return <Action item={item} />;\n}\n\nfunction Group({ item }) {\n const id = useId();\n const live = (item.items ?? []).filter(Boolean);\n if (live.length === 0) return null;\n return (\n <div role=\"group\" aria-labelledby={item.group ? id : undefined}>\n {item.group ? <Heading id={id}>{item.group}</Heading> : null}\n <Entries items={live} />\n </div>\n );\n}\n\nfunction RadioGroup({ item }) {\n const id = useId();\n const options = (item.options ?? []).filter((o) => o !== null && o !== undefined && o !== false).map((o) => (typeof o === \"object\" ? o : { value: o, label: String(o) }));\n return (\n <div role=\"group\" aria-labelledby={item.label ? id : undefined}>\n {item.label ? <Heading id={id}>{item.label}</Heading> : null}\n {options.map((o) => (\n <Choice key={String(o.value)} item={o} kind=\"radio\" checked={o.value === item.value} onPick={() => o.value !== item.value && item.onValueChange?.(o.value)} />\n ))}\n </div>\n );\n}\n\n// One row's inside: the mark or icon, the label (and its description),\n// the shortcut, a submenu's chevron.\nfunction useRow(item) {\n const id = useId();\n const { gutter } = useContext(Level);\n const keys = item.shortcut ? keysOf(item.shortcut) : null;\n const props = {\n \"data-text\": typeof item.label === \"string\" ? item.label : undefined,\n \"aria-labelledby\": item.description ? `${id}l` : undefined,\n \"aria-describedby\": item.description ? `${id}d` : undefined,\n \"aria-keyshortcuts\": keys ? keys.map((k) => SYMBOL_KEYS[k] ?? k).join(\"+\") : undefined,\n };\n const body = (lead, tail) => (\n <>\n {lead ?? (item.icon ? <Icon name={item.icon} /> : gutter ? <span aria-hidden=\"true\" className=\"size-4 shrink-0\" /> : null)}\n {item.description ? (\n <span className=\"min-w-0 flex-1 py-1.5\">\n <span id={`${id}l`} className=\"block\">\n {item.label}\n </span>\n <span id={`${id}d`} className=\"block max-w-64 text-xs text-muted-foreground\">\n {item.description}\n </span>\n </span>\n ) : (\n <span className=\"min-w-0 flex-1\">{item.label}</span>\n )}\n {keys ? (\n <span aria-hidden=\"true\" className=\"ml-auto shrink-0 pl-4\">\n <KbdGroup keys={keys} />\n </span>\n ) : null}\n {tail}\n </>\n );\n return { props, body };\n}\n\n// Hovering an item with a mouse takes the focus to it (so the keys go on\n// from there) and closes a sibling's submenu a moment later.\nfunction useHover() {\n const level = useContext(Level);\n const { narrow } = useContext(Root);\n return (e) => {\n if (e.pointerType !== \"mouse\" || narrow) return;\n focus(e.currentTarget);\n if (level.openSub) level.later(() => level.setOpenSub(null), 200);\n else level.cancel();\n };\n}\n\nfunction Action({ item }) {\n const { close } = useContext(Root);\n const hover = useHover();\n const { props, body } = useRow(item);\n return (\n <button\n type=\"button\"\n role=\"menuitem\"\n disabled={item.disabled}\n {...props}\n onPointerEnter={hover}\n onClick={() => {\n close();\n item.onSelect?.();\n }}\n className={cx(ITEM, item.tone === \"danger\" && \"text-destructive\")}\n >\n {body()}\n </button>\n );\n}\n\nfunction Choice({ item, kind, checked, onPick }) {\n const hover = useHover();\n const { props, body } = useRow(item);\n const mark = (\n <span aria-hidden=\"true\" className=\"grid size-4 shrink-0 place-items-center\">\n {checked ? kind === \"checkbox\" ? <Icon name=\"check\" size={16} /> : <span className=\"size-2 rounded-full bg-current\" /> : null}\n </span>\n );\n return (\n <button\n type=\"button\"\n role={kind === \"checkbox\" ? \"menuitemcheckbox\" : \"menuitemradio\"}\n aria-checked={checked}\n disabled={item.disabled}\n {...props}\n onPointerEnter={hover}\n onClick={onPick}\n className={cx(ITEM, item.tone === \"danger\" && \"text-destructive\")}\n >\n {body(mark)}\n </button>\n );\n}\n\nconst NONE = { current: null };\n\nfunction Submenu({ item }) {\n const key = useId();\n const level = useContext(Level);\n const { narrow } = useContext(Root);\n const btn = useRef(null);\n const sub = useRef(null);\n const wantFocus = useRef(false);\n const open = level.openSub === key;\n const live = (item.items ?? []).filter(Boolean);\n const { props, body } = useRow(item);\n usePopoverPosition(btn, narrow ? NONE : sub, { side: \"right\", align: \"start\", offset: 2 });\n\n // The state drives the panel; a light dismiss comes back through onToggle.\n useEffect(() => {\n const p = sub.current;\n if (p && !narrow) {\n const shown = p.matches(\":popover-open\");\n if (open && !shown) p.showPopover();\n else if (!open && shown) p.hidePopover();\n }\n if (open && wantFocus.current) {\n wantFocus.current = false;\n focus(own(sub.current)[0]);\n }\n }, [open, narrow]);\n\n if (live.length === 0) return null;\n\n function show(withFocus) {\n wantFocus.current = withFocus;\n level.cancel();\n if (open && withFocus) focus(own(sub.current)[0]);\n else level.setOpenSub(key);\n }\n function back() {\n level.setOpenSub(null);\n focus(btn.current);\n }\n\n return (\n <div role=\"none\">\n <button\n ref={btn}\n type=\"button\"\n role=\"menuitem\"\n aria-haspopup=\"menu\"\n aria-expanded={open}\n aria-controls={open ? `${key}m` : undefined}\n id={`${key}t`}\n disabled={item.disabled}\n {...props}\n onPointerEnter={(e) => {\n if (e.pointerType !== \"mouse\" || narrow) return;\n focus(e.currentTarget);\n if (!open) level.later(() => level.setOpenSub(key), 100);\n else level.cancel();\n }}\n onClick={(e) => {\n // A key (Enter, Space) goes in; a click opens it, or closes it\n // again on a phone, where the list unfolds in place.\n if (e.detail === 0) show(true);\n else if (open && narrow) level.setOpenSub(null);\n else show(false);\n }}\n onKeyDown={(e) => {\n if (e.key !== \"ArrowRight\") return;\n e.preventDefault();\n e.stopPropagation();\n show(true);\n }}\n className={cx(ITEM, \"aria-expanded:bg-accent\")}\n >\n {body(null, <Icon name={narrow ? \"chevron-down\" : \"chevron-right\"} size={16} className={cx(\"ml-auto shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none\", narrow && open && \"rotate-180\")} />)}\n </button>\n {narrow ? (\n open ? (\n <MenuPanel id={`${key}m`} items={live} labelledBy={`${key}t`} panelRef={sub} onBack={back} className=\"ml-4 border-l border-border pl-1\" />\n ) : null\n ) : (\n <MenuPanel\n id={`${key}m`}\n items={live}\n labelledBy={`${key}t`}\n panelRef={sub}\n popover=\"auto\"\n onBack={back}\n onPointerEnter={() => level.cancel()}\n onToggle={(e) => {\n if (e.target === e.currentTarget && e.newState === \"closed\" && level.openSub === key) level.setOpenSub(null);\n }}\n className={panelClass(\"auto\", \"min-w-44 max-h-[calc(100vh-1rem)] overflow-y-auto p-1\")}\n />\n )}\n </div>\n );\n}\n\nconst TRIGGER_SIZES = {\n sm: \"min-h-9 min-w-9 px-2\",\n md: \"min-h-11 min-w-11 px-3\",\n};\n\nexport function Menu({ items, label, trigger, align = \"end\", side = \"bottom\", size = \"sm\", className }) {\n const name = label ?? \"More\";\n const id = useId();\n const btn = useRef(null);\n const pop = useRef(null);\n const start = useRef(\"first\");\n const [open, setOpen] = useState(false);\n const live = (items ?? []).filter(Boolean);\n usePopoverPosition(btn, pop, { side, align, offset: 4 });\n\n if (live.length === 0) return null;\n\n function onToggle(e) {\n // React bubbles toggle: a submenu's opening is not this menu's.\n if (e.target !== e.currentTarget) return;\n const now = e.newState === \"open\";\n setOpen(now);\n if (now) {\n const nodes = own(pop.current);\n focus(start.current === \"last\" ? nodes[nodes.length - 1] : nodes[0]);\n start.current = \"first\";\n } else if (!document.activeElement || document.activeElement === document.body || pop.current?.contains(document.activeElement)) {\n focus(btn.current);\n }\n }\n\n function exit() {\n pop.current?.hidePopover();\n focus(btn.current);\n }\n\n return (\n <>\n <button\n ref={btn}\n type=\"button\"\n popoverTarget={id}\n aria-haspopup=\"menu\"\n aria-expanded={open}\n aria-controls={id}\n aria-label={trigger ? label : name}\n title={trigger ? undefined : name}\n onKeyDown={(e) => {\n if (e.key !== \"ArrowDown\" && e.key !== \"ArrowUp\") return;\n e.preventDefault();\n start.current = e.key === \"ArrowUp\" ? \"last\" : \"first\";\n if (!pop.current?.matches(\":popover-open\")) pop.current?.showPopover();\n }}\n className={cx(\n \"inline-flex items-center justify-center gap-2 rounded-md text-sm text-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring\",\n TRIGGER_SIZES[size] ?? TRIGGER_SIZES.sm,\n className,\n )}\n >\n {trigger ?? <Icon name=\"more\" />}\n </button>\n <MenuPanel\n id={id}\n items={live}\n label={name}\n panelRef={pop}\n popover=\"auto\"\n onToggle={onToggle}\n onExit={exit}\n onClose={() => pop.current?.hidePopover()}\n className={panelClass(\"auto\", \"min-w-44 max-h-[calc(100vh-1rem)] overflow-y-auto p-1\")}\n />\n </>\n );\n}\n"
175
+ },
176
+ {
177
+ "path": "templates/next/files/components/ui/notice.jsx",
178
+ "type": "registry:ui",
179
+ "target": "@components/makersclaw/ui/notice.jsx",
180
+ "content": "import { cx } from \"./cx\";\nimport { IconButton } from \"./button\";\nimport { Icon } from \"./icon\";\n\n// Static parts: a card, a notice, a badge, a kicker, the empty state.\n// Server-safe (no hooks), so a server surface can use them directly.\n\nconst TONES = {\n neutral: { box: \"border-border bg-card text-card-foreground\", icon: \"info\" },\n info: { box: \"border-info/40 bg-card text-card-foreground\", icon: \"info\" },\n success: { box: \"border-success/40 bg-card text-card-foreground\", icon: \"check\" },\n warning: { box: \"border-warning/50 bg-card text-card-foreground\", icon: \"warning\" },\n danger: { box: \"border-destructive/50 bg-card text-card-foreground\", icon: \"warning\" },\n};\n\n/** A raised panel with an optional heading row.\n * padding md (default, 16px) · sm (12px, a dense rail) · none (the\n * body runs to the border — a table, a list of rows; the\n * heading row keeps its own 16px inset)\n * titleSize md (default, 16px) · lg (20px, a page-level card's title)\n * Pick these here, never with a className: cx does not merge classes,\n * so a `p-0` laid over the default padding wins only by CSS order. */\nconst CARD_PADDING = { none: \"\", sm: \"p-3\", md: \"p-4\" };\nconst CARD_TITLE = { md: \"text-base\", lg: \"text-xl\" };\n\nexport function Card({\n title,\n description,\n actions,\n padding = \"md\",\n titleSize = \"md\",\n className,\n children,\n as: Tag = \"section\",\n}) {\n const pad = CARD_PADDING[padding] ?? CARD_PADDING.md;\n const bare = padding === \"none\";\n return (\n <Tag className={cx(\"rounded-lg border border-border bg-card text-card-foreground\", pad, bare && \"overflow-hidden\", className)}>\n {title || actions ? (\n <div className={cx(\"flex items-start justify-between gap-3\", bare ? \"px-4 pt-4 pb-3\" : \"mb-3\")}>\n <div className=\"min-w-0\">\n {title ? (\n <h2 className={cx(\"font-semibold leading-tight\", CARD_TITLE[titleSize] ?? CARD_TITLE.md)}>{title}</h2>\n ) : null}\n {description ? <p className=\"mt-0.5 text-sm text-muted-foreground\">{description}</p> : null}\n </div>\n {actions ? <div className=\"flex shrink-0 gap-2\">{actions}</div> : null}\n </div>\n ) : null}\n {children}\n </Tag>\n );\n}\n\n/** One line the user should read, with an optional action. `tone`\n * neutral · info · success · warning · danger. Colour is the signal, so\n * status tones are for status only — never decoration. */\nexport function Notice({ tone = \"neutral\", title, action, onDismiss, className, children }) {\n const t = TONES[tone] ?? TONES.neutral;\n return (\n <div role={tone === \"danger\" ? \"alert\" : \"status\"} className={cx(\"flex items-start gap-3 rounded-lg border p-3 text-sm\", t.box, className)}>\n <Icon name={t.icon} className=\"mt-0.5 shrink-0 text-muted-foreground\" />\n <div className=\"min-w-0 flex-1\">\n {title ? <p className=\"font-medium\">{title}</p> : null}\n {children ? <div className={cx(title && \"mt-0.5\", \"text-muted-foreground\")}>{children}</div> : null}\n </div>\n {action ? <div className=\"shrink-0\">{action}</div> : null}\n {onDismiss ? (\n <IconButton icon=\"x\" label=\"Dismiss\" size=\"sm\" onClick={onDismiss} className=\"-my-1 -mr-1\" />\n ) : null}\n </div>\n );\n}\n\n/** A small label for a state or a count.\n * tone neutral · primary · success · warning · danger · info\n * variant label (default: small caps, wide tracking; primary is a\n * solid fill, every other tone a tint) · soft (the case as\n * written, normal tracking, EVERY tone a tint — primary\n * included: \"Ready\", \"Daily over\", \"Feature\") · outline (the\n * case as written, a tone-coloured hairline on no fill) ·\n * code (the mono face, the case as written, normal tracking\n * — a version tag \"v2.4.0\", a commit \"a3f8c2d\", a path)\n * hue 1..5 — a categorical colour, read from --color-chart-N,\n * for KINDS of things (a DNS type, a platform, a content\n * type). Tone is for status; hue is for category, and a\n * tone given with it wins. With every variant: soft is the\n * case as written on a tint of the hue with a hue dot and\n * foreground text; label (and code) is filled with the hue\n * and black or white text, whichever reads; outline is a\n * hue hairline with foreground text. The text is never the\n * hue itself: a chart colour is not made to be read as type\n * Case is a variant, not its own prop: uppercase needs wide tracking and\n * sentence case needs none, so the two travel together, and a `case`\n * prop would have to be ignored by code. A soft primary lives on `soft`\n * because `label` primary is a solid fill apps already rely on. The\n * outline's ring is inset, so every variant is the same size in a row. */\nconst BADGE_LOOK = {\n label: {\n neutral: \"bg-muted text-muted-foreground\",\n primary: \"bg-primary text-primary-foreground\",\n success: \"bg-success/15 text-success\",\n warning: \"bg-warning/15 text-warning\",\n danger: \"bg-destructive/15 text-destructive\",\n info: \"bg-info/15 text-info\",\n },\n soft: {\n neutral: \"bg-muted text-muted-foreground\",\n primary: \"bg-primary/15 text-primary\",\n success: \"bg-success/15 text-success\",\n warning: \"bg-warning/15 text-warning\",\n danger: \"bg-destructive/15 text-destructive\",\n info: \"bg-info/15 text-info\",\n },\n outline: {\n neutral: \"bg-transparent text-foreground ring-1 ring-inset ring-border\",\n primary: \"bg-transparent text-primary ring-1 ring-inset ring-primary/50\",\n success: \"bg-transparent text-success ring-1 ring-inset ring-success/50\",\n warning: \"bg-transparent text-warning ring-1 ring-inset ring-warning/50\",\n danger: \"bg-transparent text-destructive ring-1 ring-inset ring-destructive/50\",\n info: \"bg-transparent text-info ring-1 ring-inset ring-info/50\",\n },\n};\nBADGE_LOOK.code = BADGE_LOOK.label;\n\nconst BADGE_FACE = {\n label: \"font-medium uppercase tracking-wide\",\n soft: \"font-medium\",\n outline: \"font-medium\",\n code: \"font-mono font-normal\",\n};\n\n// Categorical hues, literal so Tailwind sees every class. A filled hue's\n// text is black or white, picked from the fill's relative luminance (the\n// WCAG formula, on the clipped sRGB channels): what CSS contrast-color()\n// does, which no token can. A chart colour sits mid-lightness (chart-2…5\n// are clamped to OKLCH L 0.55–0.8, often past sRGB), where the theme's\n// light text fails on a light theme and its dark text on a dark primary.\n// Black below Y 0.179, white above: the better of the two is always\n// 4.58:1 or more. The soft tint is light (8%) so foreground text keeps\n// its contrast.\nconst HUE_LOOK = {\n label: {\n 1: \"bg-chart-1 text-[color:color(from_var(--color-chart-1)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 2: \"bg-chart-2 text-[color:color(from_var(--color-chart-2)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 3: \"bg-chart-3 text-[color:color(from_var(--color-chart-3)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 4: \"bg-chart-4 text-[color:color(from_var(--color-chart-4)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n 5: \"bg-chart-5 text-[color:color(from_var(--color-chart-5)_srgb_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1)_clamp(0,(0.179_-_(0.2126*pow(clamp(0,r,1),2.2)_+_0.7152*pow(clamp(0,g,1),2.2)_+_0.0722*pow(clamp(0,b,1),2.2)))*999,1))]\",\n },\n soft: {\n 1: \"bg-chart-1/8 text-foreground\",\n 2: \"bg-chart-2/8 text-foreground\",\n 3: \"bg-chart-3/8 text-foreground\",\n 4: \"bg-chart-4/8 text-foreground\",\n 5: \"bg-chart-5/8 text-foreground\",\n },\n outline: {\n 1: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-1\",\n 2: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-2\",\n 3: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-3\",\n 4: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-4\",\n 5: \"bg-transparent text-foreground ring-1 ring-inset ring-chart-5\",\n },\n};\nHUE_LOOK.code = HUE_LOOK.label;\nconst HUE_DOT = { 1: \"bg-chart-1\", 2: \"bg-chart-2\", 3: \"bg-chart-3\", 4: \"bg-chart-4\", 5: \"bg-chart-5\" };\n\nexport function Badge({ tone, hue, variant = \"label\", className, children }) {\n const v = BADGE_FACE[variant] ? variant : \"label\";\n const h = tone == null && HUE_DOT[hue] ? hue : null;\n const look = h ? HUE_LOOK[v][h] : BADGE_LOOK[v][tone ?? \"neutral\"] ?? BADGE_LOOK[v].neutral;\n return (\n <span className={cx(\"inline-flex items-center rounded-sm px-1.5 py-0.5 text-2xs\", h && v === \"soft\" && \"gap-1\", BADGE_FACE[v], look, className)}>\n {h && v === \"soft\" ? <span aria-hidden=\"true\" className={cx(\"size-1.5 shrink-0 rounded-full\", HUE_DOT[h])} /> : null}\n {children}\n </span>\n );\n}\n\n/** The small uppercase line above a heading. */\nexport function Kicker({ className, children }) {\n return <p className={cx(\"text-2xs font-medium uppercase tracking-wider text-muted-foreground\", className)}>{children}</p>;\n}\n\n/** What a surface shows when a real collection is empty AND there is no\n * fixture to show (a filter with no matches, a search with no results).\n * Says what would appear here and offers the one way to make it. */\nexport function Empty({ icon = \"search\", title, body, action, className }) {\n return (\n <div className={cx(\"grid place-items-center gap-2 rounded-lg border border-dashed border-border px-6 py-10 text-center\", className)}>\n <Icon name={icon} size={22} className=\"text-muted-foreground\" />\n <p className=\"text-sm font-medium\">{title}</p>\n {body ? <p className=\"max-w-prose text-sm text-muted-foreground\">{body}</p> : null}\n {action ? <div className=\"mt-2\">{action}</div> : null}\n </div>\n );\n}\n"
181
+ },
182
+ {
183
+ "path": "templates/next/files/components/ui/pagination.jsx",
184
+ "type": "registry:ui",
185
+ "target": "@components/makersclaw/ui/pagination.jsx",
186
+ "content": "import { cx } from \"./cx\";\nimport { Button } from \"./button\";\nimport { Icon } from \"./icon\";\nimport { Select } from \"./field\";\n\n// Pagination — move through a long list a page at a time. Numbers around\n// the current page with \"…\" for the gaps, first and last always there,\n// previous/next disabled at the ends. Two ways to move, pick one:\n// `hrefFor(page)` makes every page a link (next/link, base path applied;\n// works in a server surface, the URL is the state) or `onPageChange(page)`\n// makes them buttons (a client surface holds the page).\n//\n// page the current page, 1-based\n// pageCount how many pages there are\n// siblings pages shown each side of the current one (default 1)\n// compact always \"Page 3 of 12\" with previous/next; without it\n// a phone gets that and a wider window the numbers\n// pageSize with onPageSizeChange: a \"Rows per page\" Select\n// pageSizes its choices (default 10 · 25 · 50 · 100)\n// size md (44px targets) · sm (36px, for a dense table foot)\n//\n// One page and no page-size choice: nothing to move through, nothing drawn.\n\n/** The slots to draw: page numbers and \"gap\". The row is the same width\n * wherever the current page is, so the buttons never jump under a thumb. */\nfunction slots(page, count, siblings) {\n const width = siblings * 2 + 5; // first, gap, siblings, current, siblings, gap, last\n if (count <= width) return Array.from({ length: count }, (_, i) => i + 1);\n const range = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => a + i);\n const edge = siblings * 2 + 3; // what a run from either end holds before its gap\n if (page <= edge - siblings) return [...range(1, edge), \"gap\", count];\n if (page > count - (edge - siblings)) return [1, \"gap\", ...range(count - edge + 1, count)];\n return [1, \"gap\", ...range(page - siblings, page + siblings), \"gap\", count];\n}\n\nexport function Pagination({\n page,\n pageCount,\n hrefFor,\n onPageChange,\n siblings = 1,\n compact = false,\n pageSize,\n pageSizes = [10, 25, 50, 100],\n onPageSizeChange,\n size = \"md\",\n label = \"Pagination\",\n className,\n}) {\n const count = Math.max(1, Math.floor(pageCount || 1));\n const current = Math.min(Math.max(1, Math.floor(page || 1)), count);\n const sizer = Boolean(onPageSizeChange && pageSize);\n if (count <= 1 && !sizer) return null;\n\n const square = size === \"sm\" ? \"min-w-9 px-2\" : \"min-w-11 px-2\";\n\n // One page control: a link when hrefFor is given, else a button. Past\n // either end it is a disabled button, never a link to nowhere.\n const go = (to, { children, current: isCurrent, className: extra, ...rest }) => {\n const disabled = to < 1 || to > count;\n const common = {\n size,\n variant: isCurrent ? \"secondary\" : \"quiet\",\n className: extra,\n \"aria-current\": isCurrent ? \"page\" : undefined,\n ...rest,\n };\n if (disabled) return <Button {...common} disabled>{children}</Button>;\n if (hrefFor) return <Button {...common} href={hrefFor(to)}>{children}</Button>;\n return (\n <Button {...common} onClick={isCurrent ? undefined : () => onPageChange?.(to)}>\n {children}\n </Button>\n );\n };\n\n const prev = (wide) =>\n go(current - 1, {\n \"aria-label\": \"Previous page\",\n className: wide ? \"gap-1 pl-2 pr-3\" : square,\n children: (\n <>\n <Icon name=\"chevron-left\" />\n {wide ? <span>Previous</span> : null}\n </>\n ),\n });\n const next = (wide) =>\n go(current + 1, {\n \"aria-label\": \"Next page\",\n className: wide ? \"gap-1 pl-3 pr-2\" : square,\n children: (\n <>\n {wide ? <span>Next</span> : null}\n <Icon name=\"chevron-right\" />\n </>\n ),\n });\n\n const status = (\n <span data-nums className=\"whitespace-nowrap px-2 text-sm text-muted-foreground\">\n Page <span className=\"font-medium text-foreground\">{current}</span> of {count}\n </span>\n );\n\n return (\n <nav aria-label={label} className={cx(\"flex flex-wrap items-center justify-between gap-x-4 gap-y-2\", className)}>\n {sizer ? (\n <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground\">\n <span className=\"whitespace-nowrap\">Rows per page</span>\n <span className=\"w-20\">\n <Select\n value={String(pageSize)}\n onChange={(e) => onPageSizeChange(Number(e.target.value))}\n options={pageSizes.map((n) => ({ value: String(n), label: String(n) }))}\n />\n </span>\n </label>\n ) : null}\n\n {count > 1 ? (\n <>\n {/* Compact: every width when asked, else only a phone's. */}\n <div className={cx(\"flex items-center gap-1\", sizer ? \"ml-auto\" : \"mx-auto\", !compact && \"sm:hidden\")}>\n {prev(false)}\n {status}\n {next(false)}\n </div>\n\n {compact ? null : (\n <ul className={cx(\"hidden items-center gap-1 sm:flex\", sizer ? \"ml-auto\" : \"mx-auto\")}>\n <li>{prev(true)}</li>\n {slots(current, count, Math.max(0, siblings)).map((s, i) =>\n s === \"gap\" ? (\n <li key={`gap-${i}`} aria-hidden=\"true\" className={cx(\"inline-flex items-center justify-center text-muted-foreground\", square)}>\n <Icon name=\"more\" />\n </li>\n ) : (\n <li key={s}>\n {go(s, {\n current: s === current,\n \"aria-label\": `Page ${s}`,\n className: square,\n children: <span data-nums>{s}</span>,\n })}\n </li>\n ),\n )}\n <li>{next(true)}</li>\n </ul>\n )}\n </>\n ) : null}\n </nav>\n );\n}\n"
187
+ },
188
+ {
189
+ "path": "templates/next/files/components/ui/popover.jsx",
190
+ "type": "registry:ui",
191
+ "target": "@components/makersclaw/ui/popover.jsx",
192
+ "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Button } from \"./button\";\n\n// Popover — a panel that floats beside the control that opened it: a\n// filter form under its button, a short setting beside a row. The Popover\n// API does the hard part (top layer, light dismiss, Escape, focus back to\n// the trigger); CSS anchor positioning keeps it beside the trigger where\n// the browser has it, and a measured fallback does where it does not.\n// Controlled (`open` + `onOpenChange`) or not (`defaultOpen`).\n//\n// trigger a node → the content of a kit Button (`variant`, `size`\n// apply); or (props) => element for a trigger of your own —\n// spread `props` on a <button> (ref, popoverTarget, aria-*)\n// side top · right · bottom (default) · left; flips to the\n// opposite side when there is no room, and every open tries\n// the asked side first again\n// align start · center (default) · end, along that side (physical:\n// start is the left or top edge)\n// width sm (224px) · md (288px, default) · lg (384px) · auto (its\n// content); never wider than the window less 16px\n// className the panel's; a padding in it (p-0, p-2…) replaces the p-4\n// label what a screen reader calls the panel (role=dialog);\n// defaults to the trigger when it is a string\n// open, defaultOpen, onOpenChange(open) — light dismiss and Escape\n// report onOpenChange(false); the panel follows `open`\n//\n// Focus moves to the panel's first control when it opens and back to the\n// trigger when it closes; Tab past the panel's end closes it.\n//\n// For the parts built on this (Tooltip, HoverCard, a combobox, a date\n// picker): usePopoverPosition(anchorRef, panelRef, { side, align, offset })\n// keeps any [popover] panel beside its anchor — flipping, following scroll\n// and resize — and panelClass(width, className) is the panel's look.\n\nconst WIDTHS = { sm: \"w-56\", md: \"w-72\", lg: \"w-96\", auto: \"w-max\" };\nconst OPPOSITE = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" };\nconst EDGE = 8; // the room kept between a panel and the window's edge\n\n/** The floating panel's look: popover tokens, a border, a shadow, and a\n * quick fade that `prefers-reduced-motion` drops. */\nexport function panelClass(width = \"md\", className) {\n return cx(\n WIDTHS[width] ?? WIDTHS.md,\n \"max-w-[calc(100vw-1rem)] rounded-md border border-border bg-popover text-popover-foreground shadow-lg outline-none\",\n \"opacity-0 scale-95 transition-[opacity,scale,display,overlay] transition-discrete duration-150 open:opacity-100 open:scale-100\",\n \"starting:open:opacity-0 starting:open:scale-95 motion-reduce:transition-none\",\n className,\n );\n}\n\n// Anchor positioning with the fallbacks we name: Chrome 129+, Safari 26.\n// Checked once; anything older takes the measured path.\nlet anchored;\nfunction canAnchor() {\n if (anchored === undefined) {\n anchored =\n typeof CSS !== \"undefined\" &&\n CSS.supports(\"position-area\", \"bottom span-right\") &&\n CSS.supports(\"position-try-fallbacks\", \"top span-left, bottom span-all\");\n }\n return anchored;\n}\n\n// Every place the panel may go, preferred first: the asked side with the\n// asked alignment, then the other alignments, then the opposite side.\n// `span-all` centres on the anchor with the whole window to spread into.\nfunction areas(side, align) {\n const vertical = side === \"top\" || side === \"bottom\";\n const [start, end] = vertical ? [\"span-right\", \"span-left\"] : [\"span-bottom\", \"span-top\"];\n const cross = { start, end, center: \"span-all\" };\n const order = align === \"start\" ? [\"start\", \"end\", \"center\"] : align === \"end\" ? [\"end\", \"start\", \"center\"] : [\"center\", \"start\", \"end\"];\n return [side, OPPOSITE[side]].flatMap((s) => order.map((a) => `${s} ${cross[a]}`));\n}\n\n// Set a panel beside its anchor; returns the undo.\nfunction attach(a, p, name, side, align, offset) {\n const vertical = side === \"top\" || side === \"bottom\";\n p.style.inset = \"auto\";\n\n if (canAnchor()) {\n const [first, ...rest] = areas(side, align);\n // An anchor may hold several panels (a tooltip and a popover): the\n // names are a list, and each panel adds and removes only its own.\n const names = () => a.style.getPropertyValue(\"anchor-name\").split(\",\").map((n) => n.trim()).filter((n) => n.startsWith(\"--\"));\n a.style.setProperty(\"anchor-name\", [...names().filter((n) => n !== name), name].join(\", \"));\n p.style.setProperty(\"position-anchor\", name);\n p.style.setProperty(\"position-area\", first);\n p.style.setProperty(\"position-try-fallbacks\", rest.join(\", \"));\n p.style.setProperty(\"position-visibility\", \"anchors-visible\");\n // Both sides of the axis, so a flipped panel keeps its gap.\n p.style.margin = vertical ? `${offset}px 0` : `0 ${offset}px`;\n // Chrome remembers the last fallback that fitted while the panel is\n // still drawn — and the close fade keeps it drawn — so a panel that\n // once flipped reopens flipped while that side still fits. A changed\n // list makes it forget: every open alternates between the list and\n // the same list with its last place repeated (a no-op try), so each\n // open starts again from the asked side.\n let fresh = false;\n const reprefer = (e) => {\n if (e.newState !== \"open\") return;\n fresh = !fresh;\n p.style.setProperty(\"position-try-fallbacks\", (fresh ? [...rest, rest[rest.length - 1]] : rest).join(\", \"));\n };\n p.addEventListener(\"beforetoggle\", reprefer);\n return () => {\n p.removeEventListener(\"beforetoggle\", reprefer);\n const left = names().filter((n) => n !== name);\n if (left.length) a.style.setProperty(\"anchor-name\", left.join(\", \"));\n else a.style.removeProperty(\"anchor-name\");\n };\n }\n\n let frame = 0;\n function place() {\n frame = 0;\n if (!p.matches(\":popover-open\")) return;\n const r = a.getBoundingClientRect();\n const vw = document.documentElement.clientWidth;\n const vh = document.documentElement.clientHeight;\n // Measure at the origin, where nothing squeezes it.\n p.style.top = \"0px\";\n p.style.left = \"0px\";\n const w = p.offsetWidth;\n const h = p.offsetHeight;\n const room = { top: r.top, bottom: vh - r.bottom, left: r.left, right: vw - r.right };\n const need = (vertical ? h : w) + offset + EDGE;\n const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;\n let x;\n let y;\n if (vertical) {\n y = s === \"bottom\" ? r.bottom + offset : r.top - offset - h;\n x = align === \"start\" ? r.left : align === \"end\" ? r.right - w : r.left + (r.width - w) / 2;\n } else {\n x = s === \"right\" ? r.right + offset : r.left - offset - w;\n y = align === \"start\" ? r.top : align === \"end\" ? r.bottom - h : r.top + (r.height - h) / 2;\n }\n // Shift onto the screen; where neither side had room this may cover\n // the anchor, which beats a panel cut off at the window's edge.\n x = Math.max(EDGE, Math.min(x, vw - w - EDGE));\n y = Math.max(EDGE, Math.min(y, vh - h - EDGE));\n p.style.top = `${Math.round(y)}px`;\n p.style.left = `${Math.round(x)}px`;\n p.dataset.side = s;\n // An anchor scrolled out of the window takes its panel with it.\n p.style.visibility = r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw ? \"hidden\" : \"\";\n }\n const queue = () => {\n if (!frame) frame = requestAnimationFrame(place);\n };\n // A ResizeObserver reports before paint, so the panel is placed in\n // the frame it appears — and again whenever either box changes size.\n const ro = new ResizeObserver(place);\n ro.observe(p);\n ro.observe(a);\n window.addEventListener(\"scroll\", queue, true);\n window.addEventListener(\"resize\", queue);\n return () => {\n ro.disconnect();\n cancelAnimationFrame(frame);\n window.removeEventListener(\"scroll\", queue, true);\n window.removeEventListener(\"resize\", queue);\n };\n}\n\n/**\n * Keep an open [popover] panel beside its anchor. CSS anchor positioning\n * where the browser has it (it follows scroll natively and hides the\n * panel when the anchor scrolls away); else it is measured — placed\n * before the first paint, flipped to the opposite side when the asked one\n * has no room, shifted along the other axis to stay on screen, and moved\n * again on scroll, resize and a change in either element's size.\n */\nexport function usePopoverPosition(anchorRef, panelRef, { side = \"bottom\", align = \"center\", offset = 6 } = {}) {\n const name = `--pop-${useId().replace(/[^\\w-]/g, \"\")}`;\n const live = useRef({ key: null, off: null });\n\n // No dependency list: a panel may mount after its anchor (a portal) or\n // either element be replaced, so every commit looks, and sets up again\n // only when an element or an option changed.\n useLayoutEffect(() => {\n const key = [anchorRef.current, panelRef.current, side, align, offset];\n if (live.current.key?.every((v, i) => v === key[i])) return;\n live.current.off?.();\n const [a, p] = key;\n live.current = { key, off: a && p ? attach(a, p, name, side, align, offset) : null };\n });\n useLayoutEffect(\n () => () => {\n live.current.off?.();\n live.current = { key: null, off: null };\n },\n [],\n );\n}\n\nconst FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\nexport function Popover({\n trigger,\n label,\n side = \"bottom\",\n align = \"center\",\n width = \"md\",\n open,\n defaultOpen = false,\n onOpenChange,\n variant = \"secondary\",\n size = \"md\",\n className,\n children,\n}) {\n const id = useId();\n const btn = useRef(null);\n const pop = useRef(null);\n const controlled = open !== undefined;\n const [own, setOwn] = useState(defaultOpen);\n const isOpen = controlled ? open : own;\n const last = useRef(isOpen);\n last.current = isOpen;\n usePopoverPosition(btn, pop, { side, align });\n\n // The state drives the top layer; the browser's own closes (light\n // dismiss, Escape, the trigger again) come back through onToggle.\n useEffect(() => {\n const p = pop.current;\n if (!p) return;\n const shown = p.matches(\":popover-open\");\n if (isOpen && !shown) p.showPopover();\n else if (!isOpen && shown) p.hidePopover();\n }, [isOpen]);\n\n function onToggle(e) {\n const now = e.newState === \"open\";\n const p = pop.current;\n if (now) (p?.querySelector(FOCUSABLE) ?? p)?.focus({ preventScroll: true });\n else if (!document.activeElement || document.activeElement === document.body || p?.contains(document.activeElement)) {\n btn.current?.focus({ preventScroll: true });\n }\n if (!controlled) setOwn(now);\n if (now !== last.current) onOpenChange?.(now);\n }\n\n // Tab out of the panel's end (to anything but the trigger) closes it.\n function onBlur(e) {\n const to = e.relatedTarget;\n if (to && !pop.current?.contains(to) && to !== btn.current) pop.current?.hidePopover();\n }\n\n const props = {\n ref: btn,\n popoverTarget: id,\n \"aria-haspopup\": \"dialog\",\n \"aria-expanded\": isOpen,\n \"aria-controls\": id,\n };\n\n return (\n <>\n {typeof trigger === \"function\" ? (\n trigger(props)\n ) : (\n <Button variant={variant} size={size} {...props}>\n {trigger}\n </Button>\n )}\n <div\n ref={pop}\n id={id}\n popover=\"auto\"\n role=\"dialog\"\n aria-label={label ?? (typeof trigger === \"string\" ? trigger : undefined)}\n tabIndex={-1}\n onToggle={onToggle}\n onBlur={onBlur}\n className={panelClass(width, cx(!/(^|\\s)p[xytrbl]?-/.test(className ?? \"\") && \"p-4\", \"text-sm\", className))}\n >\n {children}\n </div>\n </>\n );\n}\n"
193
+ },
194
+ {
195
+ "path": "templates/next/files/components/ui/radio.jsx",
196
+ "type": "registry:ui",
197
+ "target": "@components/makersclaw/ui/radio.jsx",
198
+ "content": "\"use client\";\n\nimport { createContext, useContext, useEffect, useId, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// RadioGroup — one choice out of a few, all of them visible. Real\n// <input type=\"radio\"> sharing one name, so the browser supplies the\n// behaviour: arrow keys move AND select, Tab enters at the checked one,\n// forms submit the value. Options as data (`options`) or as <Radio>\n// children. Controlled (`value` + `onChange(value)`) or not (`defaultValue`).\n//\n// options [{ value, label, description?, icon?, disabled? }]\n// variant list (a dot beside each label; the default) · card (each\n// option a selectable card — plans, templates, modes)\n// orientation vertical (the default) · horizontal (wraps; cards stack\n// on a narrow window and share the row from sm up)\n// size md (44px rows) · sm (36px, dense rails); list only\n// invalid marks every option; <Field error> sets the text\n//\n// In a <Field id=\"x\" label hint error>, give the group the same `id`: a\n// <label> cannot name a group, so the group borrows Field's label (and\n// its hint or error) by aria-labelledby / aria-describedby. More than\n// ~6 options, or options that are records: a <Select>.\n\nconst Group = createContext(null);\n\nconst ROW = {\n md: \"min-h-11 py-3\",\n sm: \"min-h-9 py-2\",\n};\n\nexport function RadioGroup({\n id,\n name,\n value,\n defaultValue,\n onChange,\n options,\n variant = \"list\",\n orientation = \"vertical\",\n size = \"md\",\n invalid,\n disabled,\n required,\n className,\n children,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledby,\n}) {\n const auto = useId();\n const [own, setOwn] = useState(defaultValue);\n const [named, setNamed] = useState({});\n const current = value !== undefined ? value : own;\n\n // Field's label and hint point at `id`; wire them to the group.\n useEffect(() => {\n if (!id || typeof document === \"undefined\") return;\n const label = document.querySelector(`label[for=\"${CSS.escape(id)}\"]`);\n const note = document.getElementById(`${id}-error`) ?? document.getElementById(`${id}-hint`);\n if (label && !label.id) label.id = `${id}-label`;\n setNamed({ labelledby: label?.id, describedby: note?.id });\n if (!label) return;\n // Clicking the label lands on the group, as it would on an input.\n const focus = () => {\n const box = document.getElementById(id);\n (box?.querySelector(\"input:checked:not(:disabled)\") ?? box?.querySelector(\"input:not(:disabled)\"))?.focus();\n };\n label.addEventListener(\"click\", focus);\n return () => label.removeEventListener(\"click\", focus);\n }, [id, invalid]);\n\n const ctx = {\n name: name ?? id ?? auto,\n current,\n variant,\n size,\n invalid,\n disabled,\n required,\n select(next) {\n if (value === undefined) setOwn(next);\n onChange?.(next);\n },\n };\n\n const card = variant === \"card\";\n return (\n <Group.Provider value={ctx}>\n <div\n id={id}\n role=\"radiogroup\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabel ? undefined : ariaLabelledby ?? named.labelledby}\n aria-describedby={named.describedby}\n aria-invalid={invalid ? true : undefined}\n aria-required={required ? true : undefined}\n aria-orientation={orientation}\n className={cx(\n card\n ? orientation === \"horizontal\"\n ? \"grid gap-3 sm:auto-cols-fr sm:grid-flow-col\"\n : \"grid gap-3\"\n : orientation === \"horizontal\"\n ? \"flex flex-wrap gap-x-6\"\n : \"grid\",\n className,\n )}\n >\n {options ? options.map((o) => <Radio key={o.value} {...o} />) : children}\n </div>\n </Group.Provider>\n );\n}\n\n/** One option; only inside a <RadioGroup>. */\nexport function Radio({ value, label, description, icon, disabled, className }) {\n const g = useContext(Group);\n const auto = useId();\n if (!g) return null;\n const off = g.disabled || disabled;\n const inputId = `${g.name}-${auto}`;\n const card = g.variant === \"card\";\n\n const dot = (\n <span className=\"relative mt-0.5 flex h-4 w-4 shrink-0\">\n <input\n id={inputId}\n type=\"radio\"\n name={g.name}\n value={value}\n checked={g.current === value}\n onChange={() => g.select(value)}\n disabled={off}\n required={g.required}\n aria-describedby={description ? `${inputId}-d` : undefined}\n className={cx(\n \"peer h-4 w-4 appearance-none rounded-full border bg-background outline-none transition-colors checked:border-primary disabled:cursor-not-allowed\",\n g.invalid ? \"border-destructive\" : \"border-input\",\n !card && \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n )}\n />\n <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 m-auto h-2 w-2 scale-0 rounded-full bg-primary transition-transform peer-checked:scale-100\" />\n </span>\n );\n\n const text = (\n <span className=\"grid min-w-0 flex-1 gap-0.5\">\n <span className={cx(\"leading-5 text-foreground\", card && \"font-medium\")}>{label ?? value}</span>\n {description ? (\n <span id={`${inputId}-d`} className=\"text-sm leading-5 text-muted-foreground\">\n {description}\n </span>\n ) : null}\n </span>\n );\n\n if (card) {\n return (\n <label\n htmlFor={inputId}\n className={cx(\n \"flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border bg-card p-4 text-sm text-card-foreground transition-colors\",\n \"hover:bg-accent/50 has-[:checked]:bg-accent/50 has-[:checked]:ring-1\",\n g.invalid ? \"border-destructive has-[:checked]:ring-destructive\" : \"border-border has-[:checked]:border-primary has-[:checked]:ring-primary\",\n \"has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-ring\",\n off && \"cursor-not-allowed opacity-50 hover:bg-card\",\n className,\n )}\n >\n {icon ? <Icon name={icon} size={18} className=\"mt-px shrink-0 text-muted-foreground\" /> : null}\n {text}\n {dot}\n </label>\n );\n }\n\n return (\n <label\n htmlFor={inputId}\n className={cx(\n \"flex cursor-pointer items-start gap-3 text-sm\",\n ROW[g.size] ?? ROW.md,\n off && \"cursor-not-allowed opacity-50\",\n className,\n )}\n >\n {dot}\n {icon ? <Icon name={icon} className=\"mt-0.5 shrink-0 text-muted-foreground\" /> : null}\n {text}\n </label>\n );\n}\n"
199
+ },
200
+ {
201
+ "path": "templates/next/files/components/ui/rating.jsx",
202
+ "type": "registry:ui",
203
+ "target": "@components/makersclaw/ui/rating.jsx",
204
+ "content": "\"use client\";\n\nimport { useId, useState } from \"react\";\nimport { cx } from \"./cx\";\n\n// Rating — stars. Read-only it shows a score (4.5 of 5, halves drawn);\n// with `onChange` it is an input: a native radio group underneath, one\n// radio per star, so arrow keys, Tab, forms and screen readers are the\n// browser's. The stars are drawn from tokens: filled in `text-primary`,\n// empty in the border colour. Never a colour prop.\n//\n// value the score: a number from 0 to `max`. Read-only it may be\n// fractional (shown to the nearest half, read to one decimal);\n// as an input it is a whole number (0 = none chosen yet)\n// max how many stars (5)\n// onChange (n) → the chosen whole number; not an event. Present = input\n// readOnly true: show it, even when onChange is passed\n// size sm (16px stars) · md (20px stars; as an input each star is a\n// 44px target)\n// label what is rated (\"Support quality\"); a reader hears\n// \"Support quality, 4.5 out of 5\". Without it: \"4.5 out of 5\"\n// name the form field the input submits under\n// disabled the input, greyed and still\n//\n// A reader hears the number, never \"star star star\": the read-only row is\n// one image named \"4.5 out of 5\", and each radio is named \"3 out of 5\".\n\nconst STAR = \"m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3 6.4 20.2l1.1-6.2L3 9.6l6.2-.9L12 3Z\";\n\nconst PX = { sm: 16, md: 20 };\n// Each star's box as an input: 44px at md, the thumb's target.\nconst TARGET = { sm: \"size-8\", md: \"size-11\" };\n\nfunction Star({ size, className }) {\n return (\n <svg\n width={size}\n height={size}\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n className={cx(\"block shrink-0\", className)}\n >\n <path d={STAR} />\n </svg>\n );\n}\n\n// One star filled by `part` (0, 0.5 or 1): an empty star with the filled\n// one laid over it, cut to that width from the start edge.\nfunction PartStar({ part, size }) {\n return (\n <span className=\"relative block shrink-0\" style={{ width: size, height: size }}>\n <Star size={size} className=\"text-border\" />\n {part > 0 ? (\n <span className=\"absolute inset-y-0 start-0 overflow-hidden\" style={{ width: `${part * 100}%` }}>\n <Star size={size} className=\"text-primary\" />\n </span>\n ) : null}\n </span>\n );\n}\n\nconst spoken = (n) => String(Math.round(n * 10) / 10);\n\nexport function Rating({ value = 0, max = 5, onChange, readOnly = false, size = \"md\", label, name, disabled, id, className }) {\n const px = PX[size] ?? PX.md;\n const group = useId();\n const [hover, setHover] = useState(null);\n const count = Math.max(1, Math.round(max));\n const score = Math.min(Math.max(Number(value) || 0, 0), count);\n const stars = Array.from({ length: count }, (_, i) => i + 1);\n\n if (!onChange || readOnly) {\n const halves = Math.round(score * 2) / 2;\n const said = `${spoken(score)} out of ${count}`;\n return (\n <span\n id={id}\n role=\"img\"\n aria-label={label ? `${label}, ${said}` : said}\n className={cx(\"inline-flex items-center gap-0.5 align-middle\", className)}\n >\n {stars.map((n) => (\n <PartStar key={n} size={px} part={Math.min(Math.max(halves - (n - 1), 0), 1)} />\n ))}\n </span>\n );\n }\n\n const chosen = Math.round(score);\n const shown = hover ?? chosen;\n return (\n <div\n id={id}\n role=\"radiogroup\"\n aria-label={label ?? \"Rating\"}\n aria-disabled={disabled || undefined}\n className={cx(\"inline-flex items-center\", size === \"sm\" ? \"-mx-2\" : \"-mx-3\", disabled && \"opacity-50\", className)}\n onMouseLeave={() => setHover(null)}\n >\n {stars.map((n) => (\n <label\n key={n}\n className={cx(\n \"grid shrink-0 place-items-center rounded-md\",\n \"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring\",\n disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n TARGET[size] ?? TARGET.md,\n )}\n onMouseEnter={() => !disabled && setHover(n)}\n >\n <input\n type=\"radio\"\n name={name ?? group}\n value={n}\n checked={chosen === n}\n onChange={() => onChange(n)}\n disabled={disabled}\n aria-label={`${n} out of ${count}`}\n className=\"sr-only\"\n />\n <Star size={px} className={cx(\"transition-colors\", n <= shown ? \"text-primary\" : \"text-border\")} />\n </label>\n ))}\n </div>\n );\n}\n"
205
+ },
206
+ {
207
+ "path": "templates/next/files/components/ui/resizable.jsx",
208
+ "type": "registry:ui",
209
+ "target": "@components/makersclaw/ui/resizable.jsx",
210
+ "content": "\"use client\";\n\nimport { Children, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Resizable — panes the user sizes by dragging the line between them: a\n// list beside its record, an editor above its preview. Sizes are\n// percentages of the group; a handle moves only the two panes either\n// side of it. Mobile first: below md (768px) a horizontal group stacks\n// its panes top to bottom at their natural height and hides the handles\n// (there is no room to share, and a thumb cannot hit a 1px line).\n//\n// <ResizablePanelGroup direction=\"horizontal\" storageKey=\"inbox\" className=\"h-[32rem]\">\n// <ResizablePanel defaultSize={35} minSize={20}>…list…</ResizablePanel>\n// <ResizableHandle grip />\n// <ResizablePanel>…record…</ResizablePanel>\n// </ResizablePanelGroup>\n//\n// ResizablePanelGroup\n// direction horizontal (side by side; the default) · vertical\n// storageKey remember the sizes in this browser (localStorage)\n// onResize (sizes) → percentages, one per panel\n// className its height (h-[32rem]); without one it fills its parent\n// (h-full), so the parent needs a height. A vertical\n// group has no height of its own to fall back on\n// ResizablePanel\n// defaultSize %, of the group; panels without one share what is left\n// minSize % it never shrinks below (10) · maxSize % (100)\n// ResizableHandle\n// grip draw a visible grip on the line\n// label what a screen reader calls it (\"Resize\")\n//\n// Panels and handles are the group's DIRECT children, in order. A handle\n// is a separator a keyboard can move: arrows by 2% (Shift: 10%), Home and\n// End to the limits, Enter to fold the pane before it to its minimum and\n// back; double-click resets the pair.\n//\n// The group never asks what TYPE a child is: from a server surface a\n// child's type is a client reference, not the ResizablePanel function,\n// so a type test finds no panels. Each child gets its slot through\n// context and the panels register themselves (kind, sizes) there.\n\nconst STEP = 2;\nconst BIG = 10;\n\nconst GroupContext = createContext(null);\nconst SlotContext = createContext(-1);\n\n// Register this child in its group under its slot; returns the group's\n// wiring for it (undefined until the group has seen the registration).\nfunction useSlot(entry) {\n const group = useContext(GroupContext);\n const slot = useContext(SlotContext);\n const register = group?.register;\n const key = JSON.stringify(entry);\n useLayoutEffect(() => {\n if (!register || slot < 0) return;\n return register(slot, JSON.parse(key));\n }, [register, slot, key]);\n return group && slot >= 0 ? group.bySlot[slot] : undefined;\n}\n\nfunction initial(panels) {\n const given = panels.reduce((s, p) => s + (p.defaultSize ?? 0), 0);\n const open = panels.filter((p) => p.defaultSize == null).length;\n const share = open ? Math.max(0, 100 - given) / open : 0;\n const raw = panels.map((p) => p.defaultSize ?? share);\n const total = raw.reduce((s, n) => s + n, 0) || 1;\n return raw.map((n) => (n / total) * 100);\n}\n\nexport function ResizablePanelGroup({ direction = \"horizontal\", storageKey, onResize, className, children }) {\n const base = useId();\n const group = useRef(null);\n const items = Children.toArray(children).filter(isValidElement);\n // slot → { kind: \"panel\" | \"handle\", defaultSize?, minSize?, maxSize? }\n const [reg, setReg] = useState({});\n const register = useCallback((slot, entry) => {\n setReg((r) => (JSON.stringify(r[slot]) === JSON.stringify(entry) ? r : { ...r, [slot]: entry }));\n return () =>\n setReg((r) => {\n if (!(slot in r)) return r;\n const { [slot]: _, ...rest } = r;\n return rest;\n });\n }, []);\n const slots = Object.keys(reg).map(Number).sort((a, b) => a - b);\n const panelSlots = slots.filter((k) => reg[k].kind === \"panel\");\n const panels = panelSlots.map((k) => ({ minSize: 10, maxSize: 100, ...reg[k] }));\n const defaults = useRef(null);\n if (!defaults.current || defaults.current.length !== panels.length) defaults.current = initial(panels);\n const [own, setSizes] = useState(null);\n const sizes = own && own.length === panels.length ? own : defaults.current;\n const loaded = useRef(false);\n const horizontal = direction !== \"vertical\";\n\n // The remembered sizes, before first paint, when they still fit (once\n // the panels have registered — their layout effects run before ours,\n // the re-render they cause before paint).\n useLayoutEffect(() => {\n if (!panels.length) return;\n if (storageKey) {\n try {\n const saved = JSON.parse(localStorage.getItem(`resizable:${storageKey}`) ?? \"null\");\n if (Array.isArray(saved) && saved.length === panels.length && saved.every((n) => Number.isFinite(n) && n >= 0)) setSizes(saved);\n } catch {}\n }\n loaded.current = true;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [storageKey, panels.length]);\n\n useEffect(() => {\n if (!loaded.current || !panels.length) return;\n onResize?.(sizes);\n if (!storageKey) return;\n const h = setTimeout(() => {\n try {\n localStorage.setItem(`resizable:${storageKey}`, JSON.stringify(sizes.map((n) => Math.round(n * 100) / 100)));\n } catch {}\n }, 150);\n return () => clearTimeout(h);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [sizes, storageKey]);\n\n // Move the line between panel a and a + 1 so a is `want` %; the pair's\n // total stays put and each side keeps its min and max.\n function place(a, want) {\n setSizes((mine) => {\n const prev = mine && mine.length === panels.length ? mine : defaults.current;\n const b = a + 1;\n const pair = prev[a] + prev[b];\n const pa = panels[a];\n const pb = panels[b];\n const lo = Math.max(pa.minSize, pair - pb.maxSize, 0);\n const hi = Math.min(pa.maxSize, pair - pb.minSize, pair);\n const next = Math.min(hi, Math.max(lo, want));\n if (Math.abs(next - prev[a]) < 0.01) return prev;\n const out = [...prev];\n out[a] = next;\n out[b] = pair - next;\n return out;\n });\n }\n\n function limits(a) {\n const pair = sizes[a] + sizes[a + 1];\n return {\n lo: Math.max(panels[a].minSize, pair - panels[a + 1].maxSize, 0),\n hi: Math.min(panels[a].maxSize, pair - panels[a + 1].minSize, pair),\n };\n }\n\n // The panels' own length along the axis, handles excluded: what 100% is.\n function span() {\n const nodes = group.current?.querySelectorAll(\":scope > [data-panel]\") ?? [];\n let px = 0;\n for (const n of nodes) px += horizontal ? n.offsetWidth : n.offsetHeight;\n return px || 1;\n }\n\n // What each registered slot is given: a panel its id and size, a\n // handle the pair either side of it (none when it is not between two).\n const bySlot = {};\n let p = -1;\n for (const k of slots) {\n if (reg[k].kind === \"panel\") {\n p += 1;\n bySlot[k] = { _id: `${base}-${p}`, _size: sizes[p], _horizontal: horizontal };\n } else if (reg[k].kind === \"handle\") {\n const a = p;\n if (a < 0 || a >= panels.length - 1) {\n bySlot[k] = { _horizontal: horizontal, _orphan: true };\n continue;\n }\n bySlot[k] = {\n _horizontal: horizontal,\n _controls: `${base}-${a}`,\n _value: sizes[a],\n _limits: () => limits(a),\n _start: () => ({ from: sizes[a], span: span() }),\n _place: (want) => place(a, want),\n _reset: () => {\n const d = defaults.current;\n const pair = sizes[a] + sizes[a + 1];\n place(a, (d[a] / (d[a] + d[a + 1])) * pair);\n },\n };\n }\n }\n\n return (\n <div\n ref={group}\n data-direction={horizontal ? \"horizontal\" : \"vertical\"}\n className={cx(\n \"flex w-full min-h-0 min-w-0\",\n !/(?:^|\\s)h-/.test(className ?? \"\") && \"h-full\",\n horizontal ? \"flex-row max-md:h-auto max-md:flex-col\" : \"flex-col\",\n className,\n )}\n >\n <GroupContext.Provider value={{ register, bySlot, horizontal }}>\n {items.map((child, i) => (\n <SlotContext.Provider key={child.key ?? i} value={i}>\n {child}\n </SlotContext.Provider>\n ))}\n </GroupContext.Provider>\n </div>\n );\n}\n\nexport function ResizablePanel({ defaultSize, minSize, maxSize, className, children }) {\n const group = useContext(GroupContext);\n const wired = useSlot({ kind: \"panel\", defaultSize, minSize, maxSize });\n const _horizontal = wired?._horizontal ?? group?.horizontal ?? true;\n // Before the group has counted its panels (the server render, the first\n // client pass) a panel with a defaultSize takes that share, the rest\n // split what is left: the layout the counted sizes will give.\n const style = wired\n ? { flexGrow: wired._size ?? 1, flexShrink: 1, flexBasis: 0 }\n : defaultSize != null\n ? { flexGrow: 0, flexShrink: 1, flexBasis: `${defaultSize}%` }\n : { flexGrow: 1, flexShrink: 1, flexBasis: 0 };\n return (\n <div\n id={wired?._id}\n data-panel=\"\"\n style={style}\n className={cx(\"min-h-0 min-w-0 overflow-auto\", _horizontal && \"max-md:flex-none! max-md:overflow-visible\", className)}\n >\n {children}\n </div>\n );\n}\n\nexport function ResizableHandle({ grip = false, label = \"Resize\", className }) {\n const group = useContext(GroupContext);\n const wired = useSlot({ kind: \"handle\" });\n const [dragging, setDragging] = useState(false);\n const drag = useRef(null);\n const folded = useRef(null);\n const _horizontal = wired?._horizontal ?? group?.horizontal ?? true;\n if (!group || wired?._orphan) return null;\n const { _controls, _value, _limits, _start, _place, _reset } = wired ?? {};\n const live = Boolean(_place);\n const { lo, hi } = live ? _limits() : { lo: 0, hi: 100 };\n\n function onPointerDown(e) {\n if (e.button !== 0 || !live) return;\n e.preventDefault();\n e.currentTarget.setPointerCapture(e.pointerId);\n e.currentTarget.focus();\n drag.current = { at: _horizontal ? e.clientX : e.clientY, ..._start() };\n setDragging(true);\n document.documentElement.style.cursor = _horizontal ? \"col-resize\" : \"row-resize\";\n document.documentElement.style.userSelect = \"none\";\n }\n function onPointerMove(e) {\n const d = drag.current;\n if (!d) return;\n const moved = (_horizontal ? e.clientX : e.clientY) - d.at;\n _place(d.from + (moved / d.span) * 100);\n }\n function end() {\n if (!drag.current) return;\n drag.current = null;\n setDragging(false);\n document.documentElement.style.cursor = \"\";\n document.documentElement.style.userSelect = \"\";\n }\n\n function onKeyDown(e) {\n if (!live) return;\n const step = e.shiftKey ? BIG : STEP;\n const back = _horizontal ? \"ArrowLeft\" : \"ArrowUp\";\n const fwd = _horizontal ? \"ArrowRight\" : \"ArrowDown\";\n if (e.key === back) _place(_value - step);\n else if (e.key === fwd) _place(_value + step);\n else if (e.key === \"Home\") _place(lo);\n else if (e.key === \"End\") _place(hi);\n else if (e.key === \"Enter\") {\n if (folded.current != null && _value <= lo + 0.01) {\n _place(folded.current);\n folded.current = null;\n } else {\n folded.current = _value;\n _place(lo);\n }\n } else return;\n e.preventDefault();\n }\n\n return (\n <div\n // Until the group wires it (the server render), a plain line.\n role={live ? \"separator\" : undefined}\n aria-hidden={live ? undefined : true}\n tabIndex={live ? 0 : undefined}\n aria-label={live ? label : undefined}\n aria-orientation={live ? (_horizontal ? \"vertical\" : \"horizontal\") : undefined}\n aria-controls={_controls}\n aria-valuenow={live ? Math.round(_value) : undefined}\n aria-valuemin={live ? Math.round(lo) : undefined}\n aria-valuemax={live ? Math.round(hi) : undefined}\n data-dragging={dragging || undefined}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={end}\n onPointerCancel={end}\n onLostPointerCapture={end}\n onKeyDown={onKeyDown}\n onDoubleClick={_reset}\n className={cx(\n \"relative z-10 flex shrink-0 touch-none items-center justify-center bg-border outline-none transition-colors\",\n \"hover:bg-ring/60 focus-visible:bg-ring data-[dragging]:bg-ring motion-reduce:transition-none\",\n \"after:absolute after:content-['']\",\n _horizontal\n ? \"w-px cursor-col-resize after:inset-y-0 after:left-1/2 after:w-4 after:-translate-x-1/2 max-md:hidden\"\n : \"h-px w-full cursor-row-resize after:inset-x-0 after:top-1/2 after:h-4 after:-translate-y-1/2\",\n className,\n )}\n >\n {grip ? (\n <span\n aria-hidden=\"true\"\n className={cx(\n \"grid place-items-center rounded-sm border border-border bg-background text-muted-foreground shadow-sm\",\n _horizontal ? \"h-6 w-3.5\" : \"h-3.5 w-6\",\n )}\n >\n <Icon name=\"grip\" size={12} className={_horizontal ? undefined : \"rotate-90\"} />\n </span>\n ) : null}\n </div>\n );\n}\n"
211
+ },
212
+ {
213
+ "path": "templates/next/files/components/ui/scroll-area.jsx",
214
+ "type": "registry:ui",
215
+ "target": "@components/makersclaw/ui/scroll-area.jsx",
216
+ "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\n\n// ScrollArea — a box whose content scrolls inside it: a long list in a\n// rail, a row of cards wider than the screen. The browser's own\n// scrolling (touch, wheel, keys, momentum), with thin scrollbars in the\n// workspace's colours. Give it a bound through className (max-h-80, h-64)\n// or it simply grows.\n//\n// orientation vertical · horizontal · both\n// fade true: the edges with more content past them fade out, so\n// it is plain there is more — only while there is\n// label the region's name for a screen reader (\"Recent activity\")\n// ref the scrolling element itself (not the outer box): read and\n// set scrollTop, call scrollTo — a message list that sticks\n// to the bottom, a feed that keeps its place\n// onScroll and every other prop (aria-busy, id, data-*) go on the\n// scrolling element too\n//\n// When the content overflows, the box joins the tab order (a region with\n// the label) so a keyboard can scroll it — Safari does not do that for a\n// scroller on its own. When it fits, it is an ordinary box.\n\nconst AXES = {\n vertical: \"overflow-y-auto overflow-x-hidden\",\n horizontal: \"overflow-x-auto overflow-y-hidden\",\n both: \"overflow-auto\",\n};\n\n// Each edge fades over 2rem while there is content past it.\nconst FADES =\n \"data-[more-top]:mask-t-from-[calc(100%-2rem)] data-[more-bottom]:mask-b-from-[calc(100%-2rem)] \" +\n \"data-[more-left]:mask-l-from-[calc(100%-2rem)] data-[more-right]:mask-r-from-[calc(100%-2rem)]\";\n\nexport function ScrollArea({ orientation = \"vertical\", fade = false, label, className, children, ref: outer, ...rest }) {\n const ref = useRef(null);\n const setRef = useCallback(\n (el) => {\n ref.current = el;\n if (typeof outer === \"function\") return outer(el);\n if (outer) outer.current = el;\n },\n [outer],\n );\n const [edges, setEdges] = useState({ scrolls: false, top: false, bottom: false, left: false, right: false });\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const measure = () => {\n const x = Math.abs(el.scrollLeft);\n const overX = el.scrollWidth - el.clientWidth > 1;\n const overY = el.scrollHeight - el.clientHeight > 1;\n const next = {\n scrolls: (orientation !== \"horizontal\" && overY) || (orientation !== \"vertical\" && overX),\n top: overY && el.scrollTop > 1,\n bottom: overY && el.scrollTop + el.clientHeight < el.scrollHeight - 1,\n left: overX && x > 1,\n right: overX && x + el.clientWidth < el.scrollWidth - 1,\n };\n setEdges((prev) => (Object.keys(next).every((k) => prev[k] === next[k]) ? prev : next));\n };\n measure();\n el.addEventListener(\"scroll\", measure, { passive: true });\n if (typeof ResizeObserver === \"undefined\") return () => el.removeEventListener(\"scroll\", measure);\n // The box and each child: content growing inside a fixed box changes\n // the overflow without resizing the box.\n const ro = new ResizeObserver(measure);\n const watch = () => {\n ro.disconnect();\n ro.observe(el);\n for (const child of el.children) ro.observe(child);\n };\n watch();\n const mo = new MutationObserver(() => {\n watch();\n measure();\n });\n mo.observe(el, { childList: true });\n return () => {\n el.removeEventListener(\"scroll\", measure);\n ro.disconnect();\n mo.disconnect();\n };\n }, [orientation]);\n\n const show = (edge) => (fade && edges[edge] ? \"\" : undefined);\n\n return (\n // The ring sits on this outer box: a mask on the scroller would cut\n // off a ring drawn around it.\n <div\n className={cx(\n \"flex min-h-0 min-w-0 flex-col rounded-md\",\n \"has-[>[data-scroller]:focus-visible]:ring-2 has-[>[data-scroller]:focus-visible]:ring-ring\",\n className,\n )}\n >\n <div\n {...rest}\n ref={setRef}\n data-scroller=\"\"\n tabIndex={edges.scrolls ? 0 : undefined}\n role={edges.scrolls && label ? \"region\" : undefined}\n aria-label={edges.scrolls ? label : undefined}\n data-more-top={show(\"top\")}\n data-more-bottom={show(\"bottom\")}\n data-more-left={show(\"left\")}\n data-more-right={show(\"right\")}\n className={cx(\n \"min-h-0 flex-1 rounded-[inherit] outline-none\",\n \"scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent\",\n AXES[orientation] ?? AXES.vertical,\n fade && FADES,\n )}\n >\n {children}\n </div>\n </div>\n );\n}\n"
217
+ },
218
+ {
219
+ "path": "templates/next/files/components/ui/separator.jsx",
220
+ "type": "registry:ui",
221
+ "target": "@components/makersclaw/ui/separator.jsx",
222
+ "content": "import { cx } from \"./cx\";\n\n// Small static parts that shape a layout: a separator, a key cap, a box\n// that keeps its ratio. Server-safe (no hooks).\n\n/**\n * A hairline between groups. Decorative by default — most lines only\n * group what spacing already groups, and a screen reader should not\n * announce them. Pass `decorative={false}` when the line divides two\n * sections a reader should know about (role=separator).\n *\n * orientation horizontal (full width) · vertical (full height of a\n * flex row: toolbar groups, breadcrumb-like rows)\n * decorative true (hidden from readers) · false (a separator)\n * label horizontal only: a word on the line (\"or\")\n */\nexport function Separator({ orientation = \"horizontal\", decorative = true, label, className }) {\n const vertical = orientation === \"vertical\";\n const a11y = decorative\n ? { role: \"none\" }\n : { role: \"separator\", \"aria-orientation\": vertical ? \"vertical\" : undefined };\n if (vertical) {\n return <div {...a11y} className={cx(\"w-px shrink-0 self-stretch bg-border\", className)} />;\n }\n if (label) {\n return (\n <div {...a11y} className={cx(\"flex items-center gap-3 text-xs text-muted-foreground\", className)}>\n <span className=\"h-px flex-1 bg-border\" />\n <span>{label}</span>\n <span className=\"h-px flex-1 bg-border\" />\n </div>\n );\n }\n return <div {...a11y} className={cx(\"h-px w-full shrink-0 bg-border\", className)} />;\n}\n\n// Symbols a reader cannot say; each is shown to the eye and spelled out\n// to the ear.\nconst KEY_NAMES = {\n \"⌘\": \"Command\",\n \"⌥\": \"Option\",\n \"⇧\": \"Shift\",\n \"⌃\": \"Control\",\n \"↵\": \"Enter\",\n \"⏎\": \"Return\",\n \"⌫\": \"Backspace\",\n \"⌦\": \"Delete\",\n \"⎋\": \"Escape\",\n \"⇥\": \"Tab\",\n \"↑\": \"Up\",\n \"↓\": \"Down\",\n \"←\": \"Left\",\n \"→\": \"Right\",\n};\n\n/** One key, as printed on the keyboard: ⌘, Esc, K. A native <kbd>. */\nexport function Kbd({ className, children }) {\n const name = typeof children === \"string\" ? KEY_NAMES[children] : undefined;\n return (\n <kbd\n className={cx(\n \"inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-border bg-muted px-1\",\n \"font-sans text-2xs font-medium leading-none text-muted-foreground\",\n className,\n )}\n >\n {name ? (\n <>\n <span aria-hidden=\"true\">{children}</span>\n <span className=\"sr-only\">{name}</span>\n </>\n ) : (\n children\n )}\n </kbd>\n );\n}\n\n/** A combo pressed together (⌘ K). `keys` is [\"⌘\", \"K\"]; children work\n * too. Nested <kbd>s, which is how HTML spells a key combination. */\nexport function KbdGroup({ keys, className, children }) {\n return (\n <kbd className={cx(\"inline-flex items-center gap-1 font-sans\", className)}>\n {keys ? keys.map((k, i) => <Kbd key={i}>{k}</Kbd>) : children}\n </kbd>\n );\n}\n\nconst RATIOS = {\n square: \"aspect-square\",\n video: \"aspect-video\",\n portrait: \"aspect-3/4\",\n photo: \"aspect-4/3\",\n wide: \"aspect-21/9\",\n};\n\n/**\n * A box that keeps its shape as its width changes — a thumbnail, a video,\n * a map. Its one child fills it (an <img> is cropped to cover).\n *\n * ratio square · video (16:9) · portrait (3:4) · photo (4:3) · wide\n * (21:9), or a number (width / height, e.g. 2)\n */\nexport function AspectRatio({ ratio = \"video\", className, children }) {\n const named = typeof ratio === \"string\" ? RATIOS[ratio] : undefined;\n return (\n <div\n className={cx(\n \"relative w-full overflow-hidden\",\n named ?? (typeof ratio === \"number\" ? undefined : RATIOS.video),\n \"[&>*]:absolute [&>*]:inset-0 [&>*]:size-full [&>img]:object-cover\",\n className,\n )}\n style={typeof ratio === \"number\" && ratio > 0 ? { aspectRatio: ratio } : undefined}\n >\n {children}\n </div>\n );\n}\n"
223
+ },
224
+ {
225
+ "path": "templates/next/files/components/ui/sheet.jsx",
226
+ "type": "registry:ui",
227
+ "target": "@components/makersclaw/ui/sheet.jsx",
228
+ "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Button, IconButton } from \"./button\";\nimport { Checkbox, Input } from \"./field\";\n\n// Sheet — the one overlay. A native <dialog> (focus trap, Esc, scroll\n// lock and the top layer come from the browser), docked to the side the\n// surface asks for. Mobile first: on a narrow window every side becomes\n// a bottom sheet the thumb can reach. Controlled: `open` + `onClose`.\n//\n// side end (a rail beside the work; the default) · start (a drawer\n// from the leading edge, as navigation opens) · center (a\n// dialog) · bottom\n// title, description the header; the title names the dialog\n// footer a row of buttons under the body\n// bare true: no header and a flush body (no padding) — for a panel\n// that draws its own top, as CommandDialog does. Esc and a\n// press outside still close it. Give it `label`.\n// label the dialog's name for a screen reader when no `title` shows\n//\n// Confirm below is the \"delete this?\" case — never window.confirm().\n\nconst SIDES = {\n end:\n \"inset-y-0 right-0 left-auto h-dvh max-h-none w-full max-w-md rounded-none border-l \" +\n \"max-sm:inset-x-0 max-sm:top-auto max-sm:bottom-0 max-sm:h-auto max-sm:max-h-[85dvh] max-sm:max-w-none max-sm:rounded-t-xl max-sm:border-l-0 max-sm:border-t\",\n start:\n \"inset-y-0 left-0 right-auto h-dvh max-h-none w-full max-w-md rounded-none border-r \" +\n \"max-sm:inset-x-0 max-sm:top-auto max-sm:bottom-0 max-sm:h-auto max-sm:max-h-[85dvh] max-sm:max-w-none max-sm:rounded-t-xl max-sm:border-r-0 max-sm:border-t\",\n center: \"m-auto w-[calc(100%-2rem)] max-w-lg rounded-lg border max-sm:mb-0 max-sm:mt-auto max-sm:w-full max-sm:max-w-none max-sm:rounded-b-none max-sm:rounded-t-xl\",\n bottom: \"inset-x-0 top-auto bottom-0 w-full max-w-none max-h-[85dvh] rounded-t-xl border-t\",\n};\n\nexport function Sheet({ open, onClose, title, description, side = \"end\", footer, bare = false, label, className, children }) {\n const ref = useRef(null);\n const titleId = useId();\n const named = Boolean(title) && !bare;\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n if (open && !el.open) el.showModal();\n else if (!open && el.open) el.close();\n }, [open]);\n\n return (\n <dialog\n ref={ref}\n onClose={() => onClose?.()}\n onClick={(e) => {\n if (e.target === ref.current) ref.current.close();\n }}\n className={cx(\n \"fixed m-0 flex-col bg-popover p-0 text-popover-foreground shadow-xl border-border open:flex\",\n SIDES[side] ?? SIDES.end,\n className,\n )}\n aria-labelledby={named ? titleId : undefined}\n aria-label={named ? undefined : label}\n >\n <div className=\"flex min-h-0 flex-1 flex-col\">\n {!bare && (title || onClose) ? (\n <header className=\"flex items-start justify-between gap-3 border-b border-border px-4 py-3\">\n <div className=\"min-w-0\">\n {title ? <h2 id={titleId} className=\"text-base font-semibold leading-tight\">{title}</h2> : null}\n {description ? <p className=\"mt-0.5 text-sm text-muted-foreground\">{description}</p> : null}\n </div>\n <IconButton icon=\"x\" label=\"Close\" size=\"sm\" onClick={() => ref.current?.close()} className=\"-mr-2\" />\n </header>\n ) : null}\n <div className={cx(\"min-h-0 flex-1 overflow-y-auto\", !bare && \"px-4 py-4\")}>{children}</div>\n {footer ? <footer className=\"flex flex-wrap justify-end gap-2 border-t border-border px-4 py-3\">{footer}</footer> : null}\n </div>\n </dialog>\n );\n}\n\n/** A yes/no with the consequence spelled out. `tone=\"danger\"` for a\n * destructive confirm. Resolves through `onConfirm`; closes either way.\n *\n * consequences lines that will happen (\"14 contacts are deleted\"), a\n * muted bullet list under the body\n * requireText the exact text to type (\"acme-corp\") before Confirm\n * unlocks — for the one delete that cannot come back\n * acknowledge a checkbox label that must be ticked first (\"I have\n * exported what I need\")\n *\n * Confirm stays disabled until each gate given is met; both reset when\n * the dialog opens again. Without them it is the plain yes/no. */\nexport function Confirm({\n open,\n onClose,\n onConfirm,\n title,\n body,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"neutral\",\n busy,\n consequences,\n requireText,\n acknowledge,\n}) {\n const [typed, setTyped] = useState(\"\");\n const [ticked, setTicked] = useState(false);\n const typeId = useId();\n const tickId = useId();\n\n useEffect(() => {\n if (!open) return;\n setTyped(\"\");\n setTicked(false);\n }, [open]);\n\n const lines = Array.isArray(consequences) ? consequences.filter(Boolean) : [];\n const held = (requireText ? typed !== requireText : false) || (acknowledge ? !ticked : false);\n\n return (\n <Sheet\n open={open}\n onClose={onClose}\n side=\"center\"\n title={title}\n footer={\n <>\n <Button variant=\"quiet\" onClick={onClose} disabled={busy}>\n {cancelLabel}\n </Button>\n <Button variant={tone === \"danger\" ? \"danger\" : \"primary\"} onClick={onConfirm} disabled={busy || held}>\n {busy ? \"Working…\" : confirmLabel}\n </Button>\n </>\n }\n >\n {body ? <p className=\"text-sm text-muted-foreground\">{body}</p> : null}\n {lines.length ? (\n <ul className={cx(\"list-disc space-y-1 ps-5 text-sm text-muted-foreground marker:text-muted-foreground\", body && \"mt-3\")}>\n {lines.map((line, i) => (\n <li key={i}>{line}</li>\n ))}\n </ul>\n ) : null}\n {requireText ? (\n <div className={cx(\"grid gap-1.5\", (body || lines.length) && \"mt-4\")}>\n <label htmlFor={typeId} className=\"text-sm text-foreground\">\n Type <span className=\"break-all font-mono font-medium\">{requireText}</span> to confirm\n </label>\n <Input\n id={typeId}\n value={typed}\n onChange={(e) => setTyped(e.target.value)}\n autoComplete=\"off\"\n autoCapitalize=\"off\"\n autoCorrect=\"off\"\n spellCheck={false}\n disabled={busy}\n />\n </div>\n ) : null}\n {acknowledge ? (\n <Checkbox\n id={tickId}\n checked={ticked}\n onChange={(e) => setTicked(e.target.checked)}\n label={acknowledge}\n disabled={busy}\n className={cx((body || lines.length || requireText) && \"mt-2\")}\n />\n ) : null}\n </Sheet>\n );\n}\n"
229
+ },
230
+ {
231
+ "path": "templates/next/files/components/ui/slider.jsx",
232
+ "type": "registry:ui",
233
+ "target": "@components/makersclaw/ui/slider.jsx",
234
+ "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\n\n// Slider — pick a number (or a span of numbers) by dragging. The native\n// <input type=\"range\"> underneath, so keyboard (arrows, Page Up/Down,\n// Home/End), touch, forms and screen readers are the browser's; the\n// track, the filled part and the thumb are drawn from tokens. The input\n// is 44px tall however thin the track looks: the thumb is what a thumb\n// hits. Controlled (`value` + `onChange`) or not (`defaultValue`).\n//\n// value a number — or [low, high] for a range: two thumbs on one\n// track, each its own native input (labels \"Minimum\" and\n// \"Maximum\"; `thumbLabels` renames them). A range can never\n// cross: low stops at high - `minGap`.\n// onChange (value) → the number, or [low, high]; not an event\n// min, max, step as the native input (0, 100, 1)\n// showValue print the value beside the track, through `format`\n// format (n) → string — \"$40\", \"12 seats\"; also what a screen\n// reader says (aria-valuetext)\n// label the accessible name when no <Field> label points at `id`\n// name the form field; a range submits two values under it\n//\n// Use it where the exact number matters less than its place in a span\n// (budget, seats, a threshold). A value someone will type — a price, a\n// quantity to the unit — is an Input with type=\"number\".\n\n// The native input, stripped to a transparent strip with a drawn thumb.\n// Written out per engine: Tailwind reads classes literally, never a\n// built string. The wrapper sets --rail (the input's height: 44px, the\n// target) and --thumb (the drawn thumb): WebKit puts the thumb at the top\n// of its track, so the margin centres it, and the fill below measures on\n// --thumb to meet the thumb's centre.\nconst RANGE =\n \"absolute inset-0 h-full w-full appearance-none bg-transparent outline-none cursor-pointer disabled:cursor-not-allowed \" +\n \"[&::-webkit-slider-runnable-track]:h-full [&::-webkit-slider-runnable-track]:bg-transparent \" +\n \"[&::-webkit-slider-thumb]:mt-[calc((var(--rail)-var(--thumb))/2)] [&::-webkit-slider-thumb]:size-[var(--thumb)] [&::-webkit-slider-thumb]:appearance-none \" +\n \"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background \" +\n \"[&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-shadow \" +\n \"hover:[&::-webkit-slider-thumb]:ring-4 hover:[&::-webkit-slider-thumb]:ring-ring/15 \" +\n \"active:[&::-webkit-slider-thumb]:ring-4 active:[&::-webkit-slider-thumb]:ring-ring/25 \" +\n \"focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-ring focus-visible:[&::-webkit-slider-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-offset-background \" +\n \"[&::-moz-range-track]:bg-transparent [&::-moz-range-progress]:bg-transparent \" +\n \"[&::-moz-range-thumb]:box-border [&::-moz-range-thumb]:size-[var(--thumb)] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 \" +\n \"[&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-moz-range-thumb]:transition-shadow \" +\n \"hover:[&::-moz-range-thumb]:ring-4 hover:[&::-moz-range-thumb]:ring-ring/15 \" +\n \"active:[&::-moz-range-thumb]:ring-4 active:[&::-moz-range-thumb]:ring-ring/25 \" +\n \"focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-ring focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-moz-range-thumb]:ring-offset-background\";\n\n// Two inputs share one track: neither takes the pointer except at its\n// own thumb, so each thumb drags on its own and the other stays put.\nconst TWIN =\n \"pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto\";\n\nconst clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));\n\nexport function Slider({\n id,\n value,\n defaultValue,\n onChange,\n min = 0,\n max = 100,\n step = 1,\n minGap = 0,\n showValue,\n format = String,\n label,\n thumbLabels = [\"Minimum\", \"Maximum\"],\n name,\n disabled,\n className,\n ...rest\n}) {\n const [own, setOwn] = useState(defaultValue ?? min);\n const current = value ?? own;\n const range = Array.isArray(current);\n const [low, high] = range ? current : [min, current];\n const lowRef = useRef(null);\n const highRef = useRef(null);\n\n function set(next) {\n if (value === undefined) setOwn(next);\n onChange?.(next);\n }\n\n const span = max - min || 1;\n const p = (n) => (clamp(n, min, max) - min) / span;\n // The native thumb travels inside the track, half a thumb in from each\n // end; the fill measures on that same inner span so it meets the\n // thumb's centre at every value.\n const at = (f) => `calc(var(--thumb) / 2 + ${f} * (100% - var(--thumb)))`;\n const fill = range\n ? { left: at(p(low)), width: `calc(${p(high) - p(low)} * (100% - var(--thumb)))` }\n : { left: 0, width: at(p(high)) };\n\n // A press on the bare track (not a thumb) moves the nearer thumb there.\n // A single slider needs none of this: its input takes the press itself.\n function onTrackDown(e) {\n if (!range || disabled || e.target !== e.currentTarget) return;\n const box = e.currentTarget.getBoundingClientRect();\n const thumb = parseFloat(getComputedStyle(e.currentTarget).getPropertyValue(\"--thumb\")) * parseFloat(getComputedStyle(document.documentElement).fontSize) || 20;\n const f = clamp((e.clientX - box.left - thumb / 2) / (box.width - thumb), 0, 1);\n const n = clamp(Math.round((min + f * span - min) / step) * step + min, min, max);\n if (Math.abs(n - low) <= Math.abs(n - high)) {\n set([Math.min(n, high - minGap), high]);\n lowRef.current?.focus();\n } else {\n set([low, Math.max(n, low + minGap)]);\n highRef.current?.focus();\n }\n }\n\n const shown = range ? `${format(low)} – ${format(high)}` : format(high);\n const common = { type: \"range\", min, max, step, disabled, name };\n\n return (\n <div className={cx(\"flex w-full items-center gap-3\", disabled && \"opacity-50\", className)}>\n <div\n className=\"relative h-[var(--rail)] min-w-0 flex-1\"\n style={{ \"--rail\": \"2.75rem\", \"--thumb\": \"1.25rem\" }}\n onPointerDown={onTrackDown}\n >\n <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted\" />\n <span aria-hidden=\"true\" className=\"pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary\" style={fill} />\n {range ? (\n <>\n <input\n {...common}\n ref={lowRef}\n id={id}\n value={low}\n aria-label={label ? `${label}, ${thumbLabels[0].toLowerCase()}` : thumbLabels[0]}\n aria-valuetext={format(low)}\n onChange={(e) => set([Math.min(Number(e.target.value), high - minGap), high])}\n // The upper half of the track belongs to whichever thumb can\n // still move there: when both sit at the top, low must be on\n // top or it could never be dragged back down.\n className={cx(RANGE, TWIN, p(low) > 0.5 ? \"z-20\" : \"z-10\")}\n {...rest}\n />\n <input\n {...common}\n ref={highRef}\n id={id ? `${id}-max` : undefined}\n value={high}\n aria-label={label ? `${label}, ${thumbLabels[1].toLowerCase()}` : thumbLabels[1]}\n aria-valuetext={format(high)}\n onChange={(e) => set([low, Math.max(Number(e.target.value), low + minGap)])}\n className={cx(RANGE, TWIN, \"z-10\")}\n />\n </>\n ) : (\n <input\n {...common}\n id={id}\n value={high}\n aria-label={label}\n aria-valuetext={format === String ? undefined : format(high)}\n onChange={(e) => set(Number(e.target.value))}\n className={RANGE}\n {...rest}\n />\n )}\n </div>\n {showValue ? (\n <output htmlFor={range && id ? `${id} ${id}-max` : id} data-nums className=\"shrink-0 text-right text-sm text-foreground\">\n {shown}\n </output>\n ) : null}\n </div>\n );\n}\n"
235
+ },
236
+ {
237
+ "path": "templates/next/files/components/ui/stat.jsx",
238
+ "type": "registry:ui",
239
+ "target": "@components/makersclaw/ui/stat.jsx",
240
+ "content": "import { Children } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Numbers at a glance: a Stat, the strip of them, the signed change beside\n// one, a status dot, a legend of dots, a meter against a limit.\n// Server-safe (no hooks), so a server surface can use them directly.\n// Every number is tabular (data-nums), so a column of them lines up and a\n// ticking value does not shake. Colour is the signal: tones are named, as\n// Badge does it, and status tones are for status only — never decoration.\n// A dot can take a categorical `hue` (1..5, the chart colours) instead, for\n// KINDS of things: a platform, a content type. Tone is status, hue is\n// category; a tone given with a hue wins.\n// Blocks use these and never draw their own.\n\n// The text colour of a value in each tone.\nconst TEXT_TONES = {\n neutral: \"text-foreground\",\n muted: \"text-muted-foreground\",\n primary: \"text-primary\",\n success: \"text-success\",\n warning: \"text-warning\",\n danger: \"text-destructive\",\n info: \"text-info\",\n};\n\n// The fill of a dot or a bar in each tone. neutral is a quiet grey for a\n// dot; a Meter's neutral bar is bg-primary, the same as Progress, and its\n// muted bar is the grey.\nconst FILL_TONES = {\n neutral: \"bg-muted-foreground\",\n muted: \"bg-muted-foreground\",\n primary: \"bg-primary\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-destructive\",\n info: \"bg-info\",\n};\n\n// The fill of a dot in each categorical hue (--color-chart-N).\nconst HUE_FILLS = { 1: \"bg-chart-1\", 2: \"bg-chart-2\", 3: \"bg-chart-3\", 4: \"bg-chart-4\", 5: \"bg-chart-5\" };\n\nconst EN = \"en-US\"; // one locale on the server and the client: no hydration drift\nconst formatNumber = (n) => (typeof n === \"number\" ? n.toLocaleString(EN, { maximumFractionDigits: 2 }) : String(n ?? \"\"));\n\n/* ------------------------------------------------------------------------\n Delta\n ------------------------------------------------------------------------ */\n\nconst DELTA_SIZES = { sm: { text: \"text-xs\", icon: 12 }, md: { text: \"text-sm\", icon: 14 } };\n\n/**\n * A signed change: an arrow, the value with its sign, an optional suffix —\n * \"↑ +12.4%\". Up is good (success) and down is bad (danger) unless\n * `invert`, for \"lower is better\" (latency, churn, cost). Zero is neutral,\n * with a flat dash and no sign.\n *\n * value the change, a number; its sign sets the arrow and the tone\n * display what to show for the size of it instead of the number\n * (\"$35K\"); the sign is still added. Default the number, to at\n * most one decimal\n * suffix after the number: \"%\", \" pts\", \"ms\"\n * invert lower is better: down turns success, up turns danger\n * label a muted line after it: \"vs last week\"\n * size sm (text-xs) · md (text-sm)\n * variant text (bare) · badge (a tinted pill, for a table cell or a tile corner)\n * arrow default true; false for the bare \"+$35K\" of a dense row\n * hideValue the arrow and its tone alone (a trend in a tight cell);\n * the value is still what a screen reader hears. Always draws\n * the arrow, whatever `arrow` says\n */\nexport function Delta({ value, display, suffix = \"\", invert = false, label, size = \"sm\", variant = \"text\", arrow = true, hideValue = false, className }) {\n const n = typeof value === \"number\" && Number.isFinite(value) ? value : 0;\n const sign = n > 0 ? 1 : n < 0 ? -1 : 0;\n const good = invert ? sign < 0 : sign > 0;\n const tone = sign === 0 ? \"neutral\" : good ? \"success\" : \"danger\";\n const s = DELTA_SIZES[size] ?? DELTA_SIZES.sm;\n const amount = display ?? Math.abs(n).toLocaleString(EN, { maximumFractionDigits: 1 });\n const signed = `${sign > 0 ? \"+\" : sign < 0 ? \"−\" : \"\"}${amount}${suffix}`;\n const look =\n variant === \"badge\"\n ? {\n neutral: \"bg-muted text-muted-foreground\",\n success: \"bg-success/15 text-success\",\n danger: \"bg-destructive/15 text-destructive\",\n }[tone]\n : { neutral: \"text-muted-foreground\", success: \"text-success\", danger: \"text-destructive\" }[tone];\n return (\n <span className={cx(\"inline-flex items-baseline gap-1.5\", s.text, className)}>\n <span\n data-nums\n className={cx(\n \"inline-flex items-center gap-0.5 whitespace-nowrap font-medium\",\n variant === \"badge\" && \"rounded-sm px-1.5 py-0.5\",\n look,\n )}\n >\n {arrow || hideValue ? <Icon name={sign > 0 ? \"arrow-up\" : sign < 0 ? \"arrow-down\" : \"minus\"} size={s.icon} className=\"self-center\" /> : null}\n {/* The sign is a real minus (U+2212) on screen; a reader hears the word. */}\n {hideValue ? null : <span aria-hidden=\"true\">{signed}</span>}\n <span className=\"sr-only\">{sign > 0 ? `up ${amount}${suffix}` : sign < 0 ? `down ${amount}${suffix}` : `no change`}</span>\n </span>\n {label ? <span className=\"text-muted-foreground\">{label}</span> : null}\n </span>\n );\n}\n\n/* ------------------------------------------------------------------------\n StatusDot and Legend\n ------------------------------------------------------------------------ */\n\nconst DOT_SIZES = { sm: \"size-1.5\", md: \"size-2\" };\n\n/**\n * A small round dot in a tone: a row's state, a service's health, a\n * legend key. The dot alone says nothing to a screen reader — the meaning\n * is in `label` (shown), or in `label` + `hideLabel` (read, not shown), or\n * in the text beside it.\n *\n * tone neutral · primary · success · warning · danger · info\n * hue 1..5 — a categorical colour instead (a kind of thing, not\n * a state); ignored when a tone is given\n * size sm (6px, in a dense row) · md (8px)\n * pulse a ring that breathes out, for something live right now\n * (deploying, recording); still under prefers-reduced-motion\n * label the words after it: \"Healthy\", \"3 alerts\"\n * hideLabel keep the label for readers only\n */\nexport function StatusDot({ tone, hue, size = \"md\", pulse = false, label, hideLabel = false, className }) {\n const fill = tone == null && HUE_FILLS[hue] ? HUE_FILLS[hue] : FILL_TONES[tone ?? \"neutral\"] ?? FILL_TONES.neutral;\n const dot = (\n <span aria-hidden=\"true\" className={cx(\"relative inline-flex shrink-0\", DOT_SIZES[size] ?? DOT_SIZES.md)}>\n {pulse ? <span className={cx(\"absolute inset-0 rounded-full opacity-60 animate-ping motion-reduce:hidden\", fill)} /> : null}\n <span className={cx(\"relative inline-flex size-full rounded-full\", fill)} />\n </span>\n );\n if (!label) return <span className={cx(\"inline-flex items-center\", className)}>{dot}</span>;\n return (\n <span className={cx(\"inline-flex items-center gap-1.5 text-sm text-muted-foreground\", className)}>\n {dot}\n <span className={hideLabel ? \"sr-only\" : \"min-w-0 truncate\"}>{label}</span>\n </span>\n );\n}\n\n/**\n * A row of dot · count · label, wrapping when it runs out of room — \"● 2\n * overdue · ● 3 urgent\". The key under a chart, the summary in a card\n * header. A list, so a reader hears how many kinds there are.\n *\n * items [{ tone, label, count }] — count optional; an item with count 0\n * is still drawn (say it, or leave it out of items). `hue` (1..5)\n * in place of `tone` for a key of categories\n * size sm (text-xs, 6px dots) · md (text-sm, 8px dots)\n * label the list's name for readers: \"Tasks by state\"\n */\nexport function Legend({ items = [], size = \"md\", label, className }) {\n if (!items.length) return null;\n return (\n <ul\n aria-label={label}\n className={cx(\"flex flex-wrap items-center gap-x-3 gap-y-1\", size === \"sm\" ? \"text-xs\" : \"text-sm\", className)}\n >\n {items.map((it, i) => (\n <li key={it.key ?? `${it.label}-${i}`} className=\"inline-flex items-center gap-1.5 text-muted-foreground\">\n <StatusDot tone={it.tone} hue={it.hue} size={size === \"sm\" ? \"sm\" : \"md\"} />\n {it.count != null ? (\n <span data-nums className=\"font-medium text-foreground\">\n {formatNumber(it.count)}\n </span>\n ) : null}\n <span>{it.label}</span>\n </li>\n ))}\n </ul>\n );\n}\n\n/* ------------------------------------------------------------------------\n Stat and StatGroup\n ------------------------------------------------------------------------ */\n\nconst STAT_SIZES = {\n sm: { label: \"text-xs\", value: \"text-lg font-semibold leading-tight\", unit: \"text-xs\", icon: 14, delta: \"sm\" },\n md: { label: \"text-sm\", value: \"text-2xl font-semibold leading-tight tracking-tight\", unit: \"text-sm\", icon: 16, delta: \"sm\" },\n lg: { label: \"text-sm\", value: \"font-display text-4xl font-semibold leading-none tracking-tight\", unit: \"text-base\", icon: 18, delta: \"md\" },\n};\n\n/**\n * One number with its name: \"Hit rate 94.8%\". The value is the loudest\n * thing on it; everything else is muted.\n *\n * label what it counts: \"Monthly revenue\"\n * value already formatted — \"$1.37M\", \"42\", \"2.4/4\" (a string or a\n * number; a number gets thousands separators)\n * unit after the value, smaller and muted: \"ms\", \"%\", \"/ 10.3 TB\"\n * caption a muted line under it: \"Unit + integration tests\"\n * tone colours the value, for a threshold state (a miss rate over\n * budget is danger). neutral (default) · primary · success ·\n * warning · danger · info\n * dot a tone for a StatusDot before the label (the state of the\n * thing, when the value itself stays neutral)\n * icon an Icon name, muted, at the end of the label row\n * delta the change beside the value: a number (a percent: 12.4 →\n * \"↑ +12.4%\") or Delta's props ({ value, display, suffix, label })\n * invert lower is better, passed to the Delta\n * size sm (a dense strip) · md · lg (the one number a card is about)\n * align start · end (a stat pinned to the right of a header)\n */\nexport function Stat({ label, value, unit, caption, tone = \"neutral\", dot, icon, delta, invert, size = \"md\", align = \"start\", className }) {\n const s = STAT_SIZES[size] ?? STAT_SIZES.md;\n const d = delta == null ? null : typeof delta === \"number\" ? { value: delta, suffix: \"%\" } : delta;\n return (\n <div className={cx(\"min-w-0\", align === \"end\" && \"text-right\", className)}>\n {label || icon || dot ? (\n <div className={cx(\"flex items-center gap-1.5 text-muted-foreground\", s.label, align === \"end\" && \"justify-end\")}>\n {dot ? <StatusDot tone={dot} size=\"sm\" /> : null}\n {label ? <span className=\"min-w-0 truncate\">{label}</span> : null}\n {icon ? <Icon name={icon} size={s.icon} className={cx(\"shrink-0\", align !== \"end\" && \"ml-auto\")} /> : null}\n </div>\n ) : null}\n <div className={cx(\"mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5\", align === \"end\" && \"justify-end\")}>\n <span className=\"inline-flex min-w-0 items-baseline gap-1\">\n <span data-nums className={cx(s.value, TEXT_TONES[tone] ?? TEXT_TONES.neutral)}>\n {formatNumber(value)}\n </span>\n {unit ? (\n <span data-nums className={cx(\"text-muted-foreground\", s.unit)}>\n {unit}\n </span>\n ) : null}\n </span>\n {d ? <Delta invert={invert} size={s.delta} {...d} /> : null}\n </div>\n {caption ? <p className=\"mt-1 text-xs text-muted-foreground\">{caption}</p> : null}\n </div>\n );\n}\n\n// Literal class names, so Tailwind sees every one.\nconst COLS = { 1: \"sm:grid-cols-1\", 2: \"sm:grid-cols-2\", 3: \"sm:grid-cols-3\", 4: \"sm:grid-cols-4\" };\nconst SPAN_SM = { 1: \"sm:col-span-1\", 2: \"sm:col-span-2\", 3: \"sm:col-span-3\", 4: \"sm:col-span-4\" };\n\n/**\n * The strip of 2 to 4 Stats across the top of a card or a page. Two\n * columns on a phone, all of them from sm up; a last cell that would\n * leave a hole stretches to fill its row.\n *\n * variant card (default: bordered, a rule between every cell) ·\n * divided (no box, a vertical rule between cells — for a strip\n * already inside a Card) · bare (spacing only)\n * columns from sm up; default the number of Stats (at most 4). Six\n * stats in columns={3} make two rows\n * label the strip's name for readers: \"Cache summary\"\n * children the Stats\n *\n * Edge to edge inside a Card: variant=\"card\" with className=\"rounded-none\n * border-x-0\" and the Card's padding taken off (-mx-4).\n */\nexport function StatGroup({ variant = \"card\", columns, label, className, children }) {\n const kids = Children.toArray(children).filter(Boolean);\n const n = kids.length;\n if (!n) return null;\n const cols = Math.min(4, Math.max(1, columns ?? Math.min(n, 4)));\n const phoneCols = Math.min(2, n);\n const card = variant === \"card\";\n const divided = variant === \"divided\";\n return (\n <div\n role={label ? \"group\" : undefined}\n aria-label={label}\n className={cx(\n \"grid\",\n phoneCols === 1 ? \"grid-cols-1\" : \"grid-cols-2\",\n COLS[cols],\n card && \"gap-px overflow-hidden rounded-lg border border-border bg-border\",\n divided && \"gap-y-4\",\n variant === \"bare\" && \"gap-4\",\n className,\n )}\n >\n {kids.map((kid, i) => {\n const last = i === n - 1;\n // A last cell alone on its row spans what is left of it.\n const phoneSpan = last && n % phoneCols !== 0 ? \"col-span-2\" : \"\";\n const remSm = n % cols;\n const smSpan = last ? SPAN_SM[remSm ? cols - remSm + 1 : 1] : phoneSpan ? \"sm:col-span-1\" : \"\";\n // Divided: a rule on every cell that does not start a row.\n const rulePhone = i % phoneCols !== 0;\n const ruleSm = i % cols !== 0;\n const rule = divided\n ? cx(\n \"pr-4\",\n rulePhone && \"border-l border-border pl-4\",\n rulePhone && !ruleSm && \"sm:border-l-0 sm:pl-0\",\n !rulePhone && ruleSm && \"sm:border-l sm:border-border sm:pl-4\",\n )\n : \"\";\n return (\n <div key={kid.key ?? i} className={cx(\"min-w-0\", card && \"bg-card p-4\", rule, phoneSpan, smSpan)}>\n {kid}\n </div>\n );\n })}\n </div>\n );\n}\n\n/* ------------------------------------------------------------------------\n Meter\n ------------------------------------------------------------------------ */\n\nconst METER_SIZES = { thin: { track: \"h-1\", mark: \"h-3\" }, md: { track: \"h-2\", mark: \"h-4\" } };\n\n/**\n * A quantity against a limit: storage used of a plan, spend of a budget,\n * bandwidth of a cap. Drawn like Progress, but it is a role=meter, not a\n * progressbar — Progress is how far a job has got, which only moves\n * forward and ends; a Meter is how full something is, which can fall and\n * can go OVER. Over the limit the bar stays full, turns danger, and a\n * reader hears \"over by …\".\n *\n * value the amount used, a number in the unit of `max`\n * max the limit; default 100\n * label the meter's name, shown above it and read to a screen reader\n * unit after the numbers: \"GB\", \"credits\"\n * format (n) → string for every number shown; default thousands\n * separators (\"$\" + n / 1000 + \"K\" for money)\n * valueText the right side of the header; default \"used / limit unit\";\n * null hides it (a row that shows its own numbers)\n * valueTone the colour of that text: muted (default) · neutral ·\n * primary · success · warning · danger · info · \"bar\" (the\n * bar's own tone). Over the limit it is danger regardless\n * target a mark on the bar at this amount (a goal, a budget line),\n * named in the footer and to a reader\n * targetMark default true; false keeps the target in the footer and for\n * a reader but draws no tick on the bar\n * direction against `target`, when no `tone` is given: higher (a goal —\n * success once reached, warning below it) · lower (a budget —\n * success at or under it, warning past it). Higher also keeps\n * a bar over the limit out of danger: past a quota is good\n * thresholds [{ at, tone }] — `at` is a percent of max; the bar takes the\n * tone of the highest one reached: [{ at: 80, tone: \"warning\" },\n * { at: 95, tone: \"danger\" }]\n * tone the bar below every threshold: neutral (bg-primary, like\n * Progress) · muted (grey, a plain share that signals\n * nothing) · success · warning · danger · info\n * caption a muted line under the bar, left of the target\n * size thin (4px, a list row) · md (8px); a ring: sm (40px, a\n * list row or a stat tile) · md (64px) · lg (96px)\n * shape bar (default) · ring — a circular gauge, the percent in its\n * middle, the label, value text, caption and target beside\n * it. Tone, thresholds, target (a tick across the ring),\n * direction, over-the-limit and what a reader hears are the\n * bar's\n * center a ring's middle instead of the percent: a string or a node\n * (\"4.2 GB\", an Icon); null leaves it empty\n */\nexport function Meter({\n value,\n max = 100,\n label,\n unit,\n format = formatNumber,\n valueText,\n valueTone = \"muted\",\n target,\n targetMark = true,\n direction,\n thresholds,\n tone,\n caption,\n size = \"md\",\n shape = \"bar\",\n center,\n className,\n ...rest\n}) {\n const v = typeof value === \"number\" && Number.isFinite(value) ? value : 0;\n const limit = max > 0 ? max : 100;\n const pct = (v / limit) * 100;\n const over = v > limit;\n const overBad = over && direction !== \"higher\";\n const u = unit ? ` ${unit}` : \"\";\n const hasTarget = typeof target === \"number\" && Number.isFinite(target);\n\n // No tone given: the direction judges the value against the target.\n const met = direction === \"higher\" ? v >= target : v <= target;\n let barTone = tone ?? (hasTarget && (direction === \"higher\" || direction === \"lower\") ? (met ? \"success\" : \"warning\") : \"neutral\");\n for (const t of [...(thresholds ?? [])].sort((a, b) => a.at - b.at)) if (pct >= t.at) barTone = t.tone;\n if (overBad) barTone = \"danger\";\n const fill = barTone === \"neutral\" ? \"bg-primary\" : FILL_TONES[barTone] ?? \"bg-primary\";\n const valueClass = overBad\n ? \"font-medium text-destructive\"\n : valueTone === \"bar\"\n ? cx(\"font-medium\", barTone === \"neutral\" ? \"text-primary\" : TEXT_TONES[barTone] ?? \"text-primary\")\n : valueTone === \"muted\"\n ? \"text-muted-foreground\"\n : cx(\"font-medium\", TEXT_TONES[valueTone] ?? \"text-muted-foreground\");\n\n const targetPct = hasTarget ? Math.min(100, Math.max(0, (target / limit) * 100)) : 0;\n const shown = valueText === undefined ? `${format(v)} / ${format(limit)}${u}` : valueText;\n const said = [\n `${format(v)} of ${format(limit)}${u}`,\n over ? `over by ${format(v - limit)}${u}` : `${Math.round(pct)}%`,\n hasTarget ? `target ${format(target)}${u}` : null,\n ]\n .filter(Boolean)\n .join(\", \");\n const meterProps = {\n role: \"meter\",\n \"aria-label\": label,\n \"aria-valuemin\": 0,\n \"aria-valuemax\": limit,\n \"aria-valuenow\": Math.min(v, limit),\n \"aria-valuetext\": said,\n };\n\n if (shape === \"ring\") {\n return (\n <MeterRing\n {...{ pct, over, overBad, barTone, valueTone, valueClass, label, shown, caption, hasTarget, targetMark, targetPct, center, size, className, meterProps, rest }}\n targetText={hasTarget ? `Target ${format(target)}${u}` : null}\n />\n );\n }\n\n const s = METER_SIZES[size] ?? METER_SIZES.md;\n const footer = caption || (hasTarget && size === \"md\");\n\n return (\n <div className={cx(\"grid gap-1.5\", className)}>\n {label || shown ? (\n <div aria-hidden=\"true\" className=\"flex items-baseline justify-between gap-3 text-sm\">\n <span className=\"min-w-0 truncate font-medium text-foreground\">{label}</span>\n {shown ? (\n <span data-nums className={cx(\"shrink-0 text-xs\", valueClass)}>\n {shown}\n </span>\n ) : null}\n </div>\n ) : null}\n <div className=\"relative\">\n <div\n role=\"meter\"\n aria-label={label}\n aria-valuemin={0}\n aria-valuemax={limit}\n aria-valuenow={Math.min(v, limit)}\n aria-valuetext={said}\n className={cx(\"relative w-full overflow-hidden rounded-full bg-accent\", s.track)}\n {...rest}\n >\n <div\n className={cx(\"h-full rounded-full transition-[width] duration-300 ease-out motion-reduce:transition-none\", fill)}\n style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}\n />\n </div>\n {hasTarget && targetMark ? (\n <span\n aria-hidden=\"true\"\n className={cx(\"absolute top-1/2 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground\", s.mark)}\n style={{ left: `${targetPct}%` }}\n />\n ) : null}\n </div>\n {footer ? (\n <div aria-hidden={caption ? undefined : \"true\"} className=\"flex items-baseline justify-between gap-3 text-xs text-muted-foreground\">\n <span className=\"min-w-0\">{caption}</span>\n {hasTarget ? (\n <span data-nums className=\"shrink-0\">\n Target {format(target)}\n {u}\n </span>\n ) : null}\n </div>\n ) : null}\n </div>\n );\n}\n\n// The ring's stroke in each tone (neutral is bg-primary's stroke, as the bar).\nconst STROKE_TONES = {\n neutral: \"stroke-primary\",\n muted: \"stroke-muted-foreground\",\n primary: \"stroke-primary\",\n success: \"stroke-success\",\n warning: \"stroke-warning\",\n danger: \"stroke-destructive\",\n info: \"stroke-info\",\n};\n\n// px is the ring's box, w its stroke; text the middle's type.\nconst RING_SIZES = {\n sm: { px: 40, w: 4, text: \"text-2xs font-semibold\", side: \"text-sm\", gap: \"gap-2.5\" },\n md: { px: 64, w: 6, text: \"text-sm font-semibold\", side: \"text-sm\", gap: \"gap-3\" },\n lg: { px: 96, w: 8, text: \"text-xl font-semibold tracking-tight\", side: \"text-base\", gap: \"gap-4\" },\n};\nRING_SIZES.thin = RING_SIZES.sm;\n\n// Meter shape=\"ring\": the same numbers, drawn as an arc from twelve o'clock.\nfunction MeterRing({ pct, overBad, barTone, valueTone, valueClass, label, shown, caption, hasTarget, targetMark, targetPct, targetText, center, size, className, meterProps, rest }) {\n const g = RING_SIZES[size] ?? RING_SIZES.md;\n const c = g.px / 2;\n const r = c - g.w / 2;\n const len = 2 * Math.PI * r;\n const frac = Math.min(1, Math.max(0, pct / 100));\n const stroke = STROKE_TONES[barTone] ?? \"stroke-primary\";\n const middleClass = overBad\n ? \"text-destructive\"\n : valueTone === \"bar\"\n ? barTone === \"neutral\"\n ? \"text-primary\"\n : TEXT_TONES[barTone] ?? \"text-primary\"\n : valueTone === \"muted\"\n ? \"text-foreground\"\n : TEXT_TONES[valueTone] ?? \"text-foreground\";\n const middle = center === undefined ? `${Math.round(pct)}%` : center;\n // The target tick: a short radial line across the ring, at its angle.\n const a = (targetPct / 100) * 2 * Math.PI - Math.PI / 2;\n const tick = (d) => [c + Math.cos(a) * d, c + Math.sin(a) * d];\n const [x1, y1] = tick(r - g.w / 2 - 1.5);\n const [x2, y2] = tick(r + g.w / 2 + 1.5);\n const side = label || shown || caption || (hasTarget && size !== \"sm\");\n\n return (\n <div className={cx(\"flex min-w-0 items-center\", g.gap, className)}>\n <div {...meterProps} className=\"relative shrink-0\" style={{ width: g.px, height: g.px }} {...rest}>\n <svg aria-hidden=\"true\" width={g.px} height={g.px} viewBox={`0 0 ${g.px} ${g.px}`} className=\"block overflow-visible\">\n {/* raw: an SVG gauge — the kit has no smaller part to draw an arc. */}\n <circle cx={c} cy={c} r={r} fill=\"none\" strokeWidth={g.w} className=\"stroke-accent\" />\n {frac > 0 ? (\n <circle\n cx={c}\n cy={c}\n r={r}\n fill=\"none\"\n strokeWidth={g.w}\n strokeLinecap=\"round\"\n strokeDasharray={`${len * frac} ${len}`}\n transform={`rotate(-90 ${c} ${c})`}\n className={cx(\"transition-[stroke-dasharray] duration-300 ease-out motion-reduce:transition-none\", stroke)}\n />\n ) : null}\n {hasTarget && targetMark ? (\n <line x1={x1} y1={y1} x2={x2} y2={y2} strokeWidth={2} strokeLinecap=\"round\" className=\"stroke-foreground\" />\n ) : null}\n </svg>\n {middle !== null && middle !== false ? (\n <span aria-hidden=\"true\" data-nums className={cx(\"absolute inset-0 flex items-center justify-center leading-none\", g.text, middleClass)}>\n {middle}\n </span>\n ) : null}\n </div>\n {side ? (\n <div className={cx(\"grid min-w-0 gap-0.5\", g.side)}>\n {label ? <span aria-hidden=\"true\" className=\"min-w-0 truncate font-medium text-foreground\">{label}</span> : null}\n {shown ? (\n <span data-nums aria-hidden=\"true\" className={cx(\"text-xs\", valueClass)}>\n {shown}\n </span>\n ) : null}\n {caption || (hasTarget && size !== \"sm\") ? (\n <span className=\"text-xs text-muted-foreground\">\n {caption}\n {caption && hasTarget && size !== \"sm\" ? \" · \" : null}\n {hasTarget && size !== \"sm\" ? <span aria-hidden=\"true\" data-nums>{targetText}</span> : null}\n </span>\n ) : null}\n </div>\n ) : null}\n </div>\n );\n}\n"
241
+ },
242
+ {
243
+ "path": "templates/next/files/components/ui/steps.jsx",
244
+ "type": "registry:ui",
245
+ "target": "@components/makersclaw/ui/steps.jsx",
246
+ "content": "import { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Steps — where a job with named stages has got to: a CI run's stages, a\n// rollout's phases (5% → 25% → 100%), an onboarding checklist. Server-safe\n// (no hooks). An ordered list: every step is an <li>, the current one has\n// aria-current=\"step\", and a reader hears each step's state in words —\n// the dot or segment says nothing on its own. Status sets every tone;\n// nothing takes a colour.\n//\n// steps [{ key, label, description?, status?, meta? }] — status\n// complete · current · pending · failed · skipped. Plain\n// strings are labels. A step without a status takes it\n// from `current`\n// current the index of the current step, for the simple case:\n// before it complete, after it pending\n// variant dots (markers joined by a line, the line filled up to\n// where the job has got) · bar (one segment per phase, like\n// Progress cut into pieces)\n// size dots only: md (24px markers with a check, a cross, a\n// number) · sm (small plain dots, a dense card header)\n// hideStepLabels bare markers: every step's label, description and meta\n// go to readers only (\"Build, complete\"), none on screen.\n// Pass the labels anyway — a step without one is only\n// \"Step 1\" to a reader. With onSelect each marker is the\n// button\n// orientation horizontal · vertical\n// pulse the current step breathes; still under prefers-reduced-motion\n// label the list's name, shown above it (as Progress does) and read\n// to a screen reader; `hideLabel` keeps it for readers only\n// valueText the right side of that header: \"25% traffic\", \"3/5 stages\"\n// onSelect (step, index) → makes every step a button (open its logs,\n// jump to it). Without it the list takes no clicks\n//\n// On a phone: a horizontal list whose steps carry a description or meta\n// turns vertical below sm (640px) — those words need a line of their own.\n// Bare dots, bare segments and short labels (\"5%\", \"Build\") stay in one\n// row at every width: the spacing compresses, labels truncate, it never\n// wraps and never scrolls.\n//\n// The rule: status is the only thing that colours a step — complete is\n// success, current is primary, failed is danger, pending and skipped are\n// muted. Never use Steps for choices (that is Tabs or RadioGroup).\n\nconst STATUS = {\n complete: { word: \"complete\", icon: \"check\" },\n current: { word: \"current\", icon: null },\n pending: { word: \"not started\", icon: null },\n failed: { word: \"failed\", icon: \"x\" },\n skipped: { word: \"skipped\", icon: \"minus\" },\n};\n\n// Markers for the dots variant.\nconst MARKER_MD = {\n complete: \"bg-success text-card\",\n current: \"border-2 border-primary bg-card text-primary\",\n pending: \"border border-border bg-card text-muted-foreground\",\n failed: \"bg-destructive text-destructive-foreground\",\n skipped: \"border border-dashed border-muted-foreground/50 bg-card text-muted-foreground\",\n};\nconst MARKER_SM = {\n complete: \"size-2.5 bg-success\",\n current: \"size-3 bg-primary\",\n pending: \"size-2.5 bg-muted-foreground/35\",\n failed: \"size-3 bg-destructive\",\n skipped: \"size-2.5 border border-muted-foreground/60\",\n};\n\n// Segments for the bar variant.\nconst SEGMENT = {\n complete: \"bg-success\",\n current: \"bg-primary\",\n pending: \"bg-accent\",\n failed: \"bg-destructive\",\n skipped: \"bg-muted-foreground/25\",\n};\n\nconst LABEL_TONE = {\n complete: \"text-foreground\",\n current: \"text-foreground\",\n pending: \"text-muted-foreground\",\n failed: \"text-destructive\",\n skipped: \"text-muted-foreground line-through decoration-muted-foreground/50\",\n};\n\n// Every layout as literal class names, so Tailwind sees each one:\n// h (always a row), v (always a column), r (a column below sm, a row from sm).\n// The DOM is the same in all three: li > [track: marker + line] [text].\nconst LAYOUT = {\n dots: {\n h: {\n ol: \"flex w-full items-start\",\n li: \"relative flex min-w-0 flex-1 flex-col gap-2\",\n last: \"flex-none\",\n track: \"flex items-center\",\n line: \"mx-2 h-0.5 min-w-2 flex-1 rounded-full\",\n text: \"min-w-0 pr-3\",\n textLast: \"min-w-0\",\n label: \"block truncate text-sm font-medium\",\n rowMeta: \"\",\n },\n v: {\n ol: \"flex flex-col\",\n li: \"relative flex min-w-0 gap-3\",\n last: \"\",\n track: \"flex flex-col items-center\",\n line: \"my-1 min-h-3 w-0.5 flex-1 rounded-full\",\n text: \"min-w-0 flex-1 pb-5\",\n textLast: \"min-w-0 flex-1\",\n label: \"min-w-0 text-sm font-medium\",\n rowMeta: \"flex items-baseline justify-between gap-3\",\n },\n r: {\n ol: \"flex flex-col sm:flex-row sm:items-start\",\n li: \"relative flex min-w-0 gap-3 sm:flex-1 sm:flex-col sm:gap-2\",\n last: \"sm:flex-none\",\n track: \"flex flex-col items-center sm:flex-row\",\n line: \"my-1 min-h-3 w-0.5 flex-1 rounded-full sm:mx-2 sm:my-0 sm:h-0.5 sm:min-h-0 sm:w-auto sm:min-w-2\",\n text: \"min-w-0 flex-1 pb-5 sm:flex sm:flex-none sm:flex-col sm:pb-0 sm:pr-3\",\n textLast: \"min-w-0 flex-1 sm:flex sm:flex-none sm:flex-col\",\n label: \"min-w-0 text-sm font-medium sm:block sm:truncate\",\n rowMeta: \"flex items-baseline justify-between gap-3 sm:contents\",\n meta: \"sm:order-last sm:mt-0.5\",\n },\n },\n bar: {\n h: {\n ol: \"flex w-full items-start gap-1\",\n li: \"relative flex min-w-0 flex-1 flex-col gap-1.5\",\n last: \"\",\n track: \"h-2 w-full rounded-full\",\n text: \"min-w-0 text-center\",\n textLast: \"min-w-0 text-center\",\n label: \"block truncate text-xs\",\n rowMeta: \"\",\n },\n v: {\n ol: \"flex flex-col gap-1\",\n li: \"relative flex min-w-0 gap-3\",\n last: \"\",\n track: \"min-h-6 w-1.5 shrink-0 self-stretch rounded-full\",\n text: \"min-w-0 flex-1 py-1\",\n textLast: \"min-w-0 flex-1 py-1\",\n label: \"min-w-0 text-sm\",\n rowMeta: \"flex items-baseline justify-between gap-3\",\n },\n r: {\n ol: \"flex flex-col gap-1 sm:flex-row sm:items-start\",\n li: \"relative flex min-w-0 gap-3 sm:flex-1 sm:flex-col sm:gap-1.5\",\n last: \"\",\n track: \"min-h-6 w-1.5 shrink-0 self-stretch rounded-full sm:h-2 sm:min-h-0 sm:w-full\",\n text: \"min-w-0 flex-1 py-1 sm:flex sm:flex-col sm:py-0 sm:text-center\",\n textLast: \"min-w-0 flex-1 py-1 sm:flex sm:flex-col sm:py-0 sm:text-center\",\n label: \"min-w-0 text-sm sm:block sm:truncate sm:text-xs\",\n rowMeta: \"flex items-baseline justify-between gap-3 sm:contents\",\n meta: \"sm:order-last sm:mt-0.5\",\n },\n },\n};\n\nfunction normalize(steps, current) {\n const cur = typeof current === \"number\" && Number.isFinite(current) ? current : -1;\n return steps.map((raw, i) => {\n const s = typeof raw === \"string\" ? { label: raw } : raw ?? {};\n const derived = cur < 0 ? \"pending\" : i < cur ? \"complete\" : i === cur ? \"current\" : \"pending\";\n const status = STATUS[s.status] ? s.status : derived;\n return { ...s, key: s.key ?? `${i}`, status };\n });\n}\n\nfunction Marker({ step, index, size, pulse }) {\n const st = step.status;\n if (size === \"sm\") {\n return (\n <span aria-hidden=\"true\" className=\"relative grid size-3 shrink-0 place-items-center\">\n {pulse && st === \"current\" ? (\n <span className={cx(\"absolute inset-0 rounded-full opacity-50 animate-ping bg-primary motion-reduce:hidden\")} />\n ) : null}\n <span className={cx(\"relative rounded-full\", MARKER_SM[st])} />\n </span>\n );\n }\n const glyph = STATUS[st].icon;\n return (\n <span aria-hidden=\"true\" className=\"relative grid size-6 shrink-0 place-items-center\">\n {pulse && st === \"current\" ? (\n <span className={cx(\"absolute inset-0 rounded-full opacity-30 animate-ping bg-primary motion-reduce:hidden\")} />\n ) : null}\n <span className={cx(\"relative grid size-6 place-items-center rounded-full\", MARKER_MD[st])}>\n {glyph ? (\n <Icon name={glyph} size={14} className={st === \"complete\" || st === \"failed\" ? \"[stroke-width:2.6]\" : undefined} />\n ) : st === \"current\" ? (\n <span className=\"size-2 rounded-full bg-primary\" />\n ) : (\n <span data-nums className=\"text-2xs font-medium\">\n {index + 1}\n </span>\n )}\n </span>\n </span>\n );\n}\n\n/**\n * The steps of one job and where it has got to. See the header above.\n */\nexport function Steps({\n steps = [],\n current,\n variant = \"dots\",\n size = \"md\",\n orientation = \"horizontal\",\n pulse = false,\n label,\n hideLabel = false,\n hideStepLabels = false,\n valueText,\n onSelect,\n className,\n}) {\n const list = normalize(steps, current);\n if (!list.length) return null;\n const kind = variant === \"bar\" ? \"bar\" : \"dots\";\n const words = !hideStepLabels && list.some((s) => s.description || s.meta);\n const mode = orientation === \"vertical\" ? \"v\" : words ? \"r\" : \"h\";\n const L = LAYOUT[kind][mode];\n // How far the job has got: the last step that is not pending. The line\n // after every step before it is filled.\n let reached = -1;\n list.forEach((s, i) => {\n if (s.status !== \"pending\") reached = i;\n });\n const n = list.length;\n\n return (\n <div className={cx(\"grid gap-2\", className)}>\n {label && !hideLabel ? (\n <div aria-hidden=\"true\" className=\"flex items-baseline justify-between gap-3 text-sm\">\n <span className=\"min-w-0 truncate text-muted-foreground\">{label}</span>\n {valueText ? (\n <span data-nums className=\"shrink-0 font-medium text-foreground\">\n {valueText}\n </span>\n ) : null}\n </div>\n ) : null}\n <ol aria-label={label ? (valueText ? `${label}, ${valueText}` : label) : undefined} className={L.ol}>\n {list.map((step, i) => {\n const last = i === n - 1;\n const st = step.status;\n const isCurrent = st === \"current\";\n const labelTone = kind === \"bar\" && !isCurrent && st !== \"failed\" ? \"text-muted-foreground\" : LABEL_TONE[st];\n const title = (\n <>\n {step.label ?? `Step ${i + 1}`}\n {/* The state in words; aria-current already says \"current step\". */}\n {isCurrent ? null : <span className=\"sr-only\">, {STATUS[st].word}</span>}\n </>\n );\n // Bare markers: the words a reader would otherwise have seen.\n const hiddenTitle = (\n <>\n {title}\n {step.description ? <>, {step.description}</> : null}\n {step.meta ? <>, {step.meta}</> : null}\n </>\n );\n return (\n <li key={step.key} aria-current={isCurrent ? \"step\" : undefined} className={cx(L.li, last && L.last)}>\n {kind === \"dots\" ? (\n <div className={L.track}>\n <Marker step={step} index={i} size={size} pulse={pulse} />\n {last ? null : (\n <span aria-hidden=\"true\" className={cx(L.line, i < reached ? \"bg-success\" : \"bg-border\")} />\n )}\n </div>\n ) : (\n <span\n aria-hidden=\"true\"\n className={cx(\n L.track,\n SEGMENT[st],\n \"transition-colors duration-300 motion-reduce:transition-none\",\n pulse && isCurrent && \"animate-pulse motion-reduce:animate-none\",\n )}\n />\n )}\n {hideStepLabels ? (\n onSelect ? (\n // raw: a button stretched over the whole step, named by its label.\n <button\n type=\"button\"\n onClick={() => onSelect(step, i)}\n className=\"absolute inset-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span className=\"sr-only\">{hiddenTitle}</span>\n </button>\n ) : (\n <span className=\"sr-only\">{hiddenTitle}</span>\n )\n ) : step.label || step.description || step.meta ? (\n <div className={last ? L.textLast : L.text}>\n <div className={L.rowMeta}>\n {onSelect ? (\n // raw: a stretched button over the whole step, as Item does.\n <button\n type=\"button\"\n onClick={() => onSelect(step, i)}\n className={cx(\n L.label,\n labelTone,\n isCurrent && \"font-semibold\",\n \"rounded-sm text-left outline-none after:absolute after:inset-0 after:content-[''] hover:underline focus-visible:ring-2 focus-visible:ring-ring\",\n )}\n >\n {title}\n </button>\n ) : (\n <span className={cx(L.label, labelTone, kind === \"bar\" && isCurrent && \"font-semibold\")}>{title}</span>\n )}\n {step.meta ? (\n <span data-nums className={cx(\"shrink-0 text-xs text-muted-foreground\", L.meta)}>\n {step.meta}\n </span>\n ) : null}\n </div>\n {step.description ? <p className=\"mt-0.5 text-xs text-muted-foreground\">{step.description}</p> : null}\n </div>\n ) : (\n <span className=\"sr-only\">{title}</span>\n )}\n </li>\n );\n })}\n </ol>\n </div>\n );\n}\n"
247
+ },
248
+ {
249
+ "path": "templates/next/files/components/ui/swatch.jsx",
250
+ "type": "registry:ui",
251
+ "target": "@components/makersclaw/ui/swatch.jsx",
252
+ "content": "import { cx } from \"./cx\";\n\n// A square of a colour that comes from DATA: a brand colour in an audit\n// row, a label's colour a user picked, a product variant. Server-safe.\n//\n// The one place in the kit that sets an inline background from a prop.\n// Every other colour in an app is a token; a colour that IS the record's\n// value cannot be one, so this part owns the inline style and nothing\n// else ever writes one (a block shows a data colour with a Swatch).\n//\n// color a CSS colour string from the data: \"#0053fd\", \"rgb(…)\",\n// \"oklch(…)\", \"rebeccapurple\". An invalid one draws the\n// empty square\n// size sm (16px, inline in a row) · md (24px)\n// label the accessible name: \"Brand blue #0053FD\". Without one the\n// square is decoration and hidden from a screen reader —\n// the text beside it must say the colour\n//\n// A hairline border keeps a colour close to the canvas (white on a light\n// page, black on a dark one) visible.\nconst SIZES = { sm: \"size-4 rounded-sm\", md: \"size-6 rounded-md\" };\n\nexport function Swatch({ color, size = \"md\", label, className }) {\n return (\n <span\n role={label ? \"img\" : undefined}\n aria-label={label || undefined}\n aria-hidden={label ? undefined : \"true\"}\n className={cx(\"inline-block shrink-0 border border-border align-middle\", SIZES[size] ?? SIZES.md, className)}\n style={{ backgroundColor: typeof color === \"string\" ? color : undefined }}\n />\n );\n}\n"
253
+ },
254
+ {
255
+ "path": "templates/next/files/components/ui/table.jsx",
256
+ "type": "registry:ui",
257
+ "target": "@components/makersclaw/ui/table.jsx",
258
+ "content": "import { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Table parts — the native <table> with the workspace's look. Presentation\n// only: the rows, the sort order and the selection are the caller's state;\n// these draw them. Server-safe (no hooks). `Table` is a scroll box, so a\n// wide table scrolls inside itself and never widens a 390px window; cells\n// do not wrap, so a narrow window scrolls rather than squashing columns.\n//\n// <Table>\n// <TableHeader><TableRow>\n// <TableHead sort={dir} onSort={flip}>Name</TableHead>\n// <TableHead numeric>Amount</TableHead>\n// </TableRow></TableHeader>\n// <TableBody>{rows.map((r) => (\n// <TableRow key={r.id} selected={r.id === picked}>\n// <TableCell>{r.name}</TableCell>\n// <TableCell numeric>{r.amount}</TableCell>\n// </TableRow>\n// ))}</TableBody>\n// </Table>\n//\n// Table variant outline (a bordered card; the default) · plain\n// TableRow selected — the row the rest of the surface is showing\n// TableHead sort asc · desc · none makes it a button with aria-sort;\n// `onSort` is called on press and the caller picks the next\n// order. One column is sorted at a time; the rest are none —\n// or, sorting by several, pass each sorted column its\n// `sortIndex` (1 first): the number shows beside the arrow.\n// Pass it only while two or more are sorted\n// numeric TableHead and TableCell: right-aligned, tabular figures\n\nexport function Table({ variant = \"outline\", className, children, ...rest }) {\n return (\n <div\n className={cx(\n \"relative w-full max-w-full overflow-x-auto\",\n variant === \"outline\" && \"rounded-lg border border-border bg-card text-card-foreground\",\n className,\n )}\n >\n <table className=\"w-full caption-bottom border-collapse text-sm\" {...rest}>\n {children}\n </table>\n </div>\n );\n}\n\nexport function TableHeader({ className, ...rest }) {\n return <thead className={cx(\"[&_tr]:border-b [&_tr]:hover:bg-transparent\", className)} {...rest} />;\n}\n\nexport function TableBody({ className, ...rest }) {\n return <tbody className={cx(\"[&_tr:last-child]:border-b-0\", className)} {...rest} />;\n}\n\nexport function TableFooter({ className, ...rest }) {\n return <tfoot className={cx(\"border-t border-border bg-muted/50 font-medium [&_tr]:border-b-0\", className)} {...rest} />;\n}\n\nexport function TableRow({ selected, className, ...rest }) {\n return (\n <tr\n data-selected={selected ? \"\" : undefined}\n className={cx(\"border-b border-border transition-colors hover:bg-muted/50 data-[selected]:bg-muted\", className)}\n {...rest}\n />\n );\n}\n\nconst SORT = {\n asc: { aria: \"ascending\", icon: \"chevron-up\" },\n desc: { aria: \"descending\", icon: \"chevron-down\" },\n none: { aria: undefined, icon: \"chevrons-up-down\" },\n};\n\nexport function TableHead({ sort, onSort, sortIndex, numeric, scope = \"col\", className, children, ...rest }) {\n const s = sort ? SORT[sort] ?? SORT.none : null;\n return (\n <th\n scope={scope}\n aria-sort={s?.aria}\n className={cx(\n \"h-11 whitespace-nowrap px-3 text-left align-middle text-xs font-medium text-muted-foreground\",\n numeric && \"text-right\",\n className,\n )}\n {...rest}\n >\n {s ? (\n <button\n type=\"button\"\n onClick={onSort}\n className={cx(\n \"-mx-2 inline-flex min-h-9 items-center gap-1 rounded-md px-2 outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n sort !== \"none\" && \"text-foreground\",\n numeric && \"flex-row-reverse\",\n )}\n >\n {children}\n {sortIndex != null && sort !== \"none\" ? (\n <span className={cx(\"inline-flex items-center\", numeric && \"flex-row-reverse\")}>\n <Icon name={s.icon} size={14} />\n <span className=\"sr-only\">, sort priority </span>\n <span className=\"font-semibold leading-none tabular-nums\">{sortIndex}</span>\n </span>\n ) : (\n <Icon name={s.icon} size={14} className={sort === \"none\" ? \"opacity-50\" : undefined} />\n )}\n </button>\n ) : (\n children\n )}\n </th>\n );\n}\n\nexport function TableCell({ numeric, className, ...rest }) {\n return (\n <td\n data-nums={numeric ? \"\" : undefined}\n className={cx(\"whitespace-nowrap px-3 py-2.5 align-middle\", numeric && \"text-right\", className)}\n {...rest}\n />\n );\n}\n\nexport function TableCaption({ className, ...rest }) {\n return <caption className={cx(\"px-3 py-3 text-left text-xs text-muted-foreground\", className)} {...rest} />;\n}\n"
259
+ },
260
+ {
261
+ "path": "templates/next/files/components/ui/tabs.jsx",
262
+ "type": "registry:ui",
263
+ "target": "@components/makersclaw/ui/tabs.jsx",
264
+ "content": "\"use client\";\n\nimport { useId, useRef } from \"react\";\nimport { cx } from \"./cx\";\n\n// Tabs — one strip, arrow keys move and select, Home/End jump. Controlled:\n// `value` + `onChange`. The strip is `Tabs`; each panel is a `TabPanel`\n// keyed the same way, so the caller decides what a tab shows. Tabs are\n// for views of ONE thing (a record's notes | history), never for the\n// app's surfaces — those are the shell's nav.\n//\n// tabs [{ key, label, count? }]\n\nexport function Tabs({ tabs, value, onChange, id, className, \"aria-label\": ariaLabel }) {\n const auto = useId();\n const base = id ?? auto;\n const refs = useRef([]);\n\n function onKeyDown(e, index) {\n const last = tabs.length - 1;\n let next = null;\n if (e.key === \"ArrowRight\") next = index === last ? 0 : index + 1;\n else if (e.key === \"ArrowLeft\") next = index === 0 ? last : index - 1;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = last;\n if (next === null) return;\n e.preventDefault();\n refs.current[next]?.focus();\n onChange?.(tabs[next].key);\n }\n\n return (\n <div role=\"tablist\" aria-label={ariaLabel} className={cx(\"flex gap-1 overflow-x-auto border-b border-border\", className)}>\n {tabs.map((t, i) => {\n const selected = t.key === value;\n return (\n <button\n key={t.key}\n ref={(el) => {\n refs.current[i] = el;\n }}\n type=\"button\"\n role=\"tab\"\n id={`${base}-tab-${t.key}`}\n aria-selected={selected}\n aria-controls={`${base}-panel-${t.key}`}\n tabIndex={selected ? 0 : -1}\n onClick={() => onChange?.(t.key)}\n onKeyDown={(e) => onKeyDown(e, i)}\n className={cx(\n \"-mb-px inline-flex min-h-11 items-center gap-2 whitespace-nowrap border-b-2 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n selected ? \"border-primary font-medium text-foreground\" : \"border-transparent text-muted-foreground hover:text-foreground\",\n )}\n >\n {t.label}\n {typeof t.count === \"number\" ? (\n <span data-nums className=\"rounded-sm bg-muted px-1.5 text-2xs text-muted-foreground\">\n {t.count}\n </span>\n ) : null}\n </button>\n );\n })}\n </div>\n );\n}\n\nexport function TabPanel({ tab, value, id, className, children }) {\n const selected = tab === value;\n return (\n <div\n role=\"tabpanel\"\n id={id ? `${id}-panel-${tab}` : undefined}\n aria-labelledby={id ? `${id}-tab-${tab}` : undefined}\n hidden={!selected}\n className={cx(selected && \"pt-4\", className)}\n >\n {selected ? children : null}\n </div>\n );\n}\n"
265
+ },
266
+ {
267
+ "path": "templates/next/files/components/ui/toast.jsx",
268
+ "type": "registry:ui",
269
+ "target": "@components/makersclaw/ui/toast.jsx",
270
+ "content": "\"use client\";\n\nimport { isValidElement, useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport { cx } from \"./cx\";\nimport { Button, IconButton } from \"./button\";\nimport { Icon } from \"./icon\";\nimport { Spinner } from \"./feedback\";\n\n// Toast — a short line about something that just happened (\"Saved\",\n// \"Invite sent\"), which leaves on its own. Call it from any client code;\n// nothing needs mounting: the first toast mounts a host into the page's\n// top layer (a manual popover, above sheets and menus). app/layout.jsx is\n// fixed, so this is how an app gets toasts at all. Put <Toaster /> in a\n// surface only to choose where they stack.\n//\n// toast(\"Saved\") → id\n// toast.success / .error / .info / .warning(title, options)\n// toast.promise(promise, { loading, success, error })\n// success / error: a string, (value) → string, or\n// { title, description, action }; null dismisses. Returns the\n// promise.\n// toast.dismiss(id) one; no id = all\n//\n// options description the line under the title\n// action { label, onClick } — one button; the toast\n// closes after unless onClick calls\n// event.preventDefault()\n// duration ms, default 5000; Infinity stays until closed\n// id reuse one to update a toast in place\n//\n// <Toaster position visible />\n// position bottom-end (the default) · bottom-center ·\n// top-end · top-center. On a phone every position\n// is full width at its edge.\n// visible how many show at once (3); older ones wait behind\n//\n// Newest in front; the rest stack behind and fan out on hover or focus,\n// and every timer pauses while they do (and while the tab is hidden).\n// Close with the ×, Escape, or a sideways swipe. Screen readers hear each\n// toast once through a live region (assertive for .error). A toast is\n// never the only record of something that matters: an error the user\n// must act on is a Notice on the surface.\n\nconst DURATION = 5000;\nconst EXIT_MS = 200;\nconst GAP = 12;\nconst PEEK = 12;\nconst SWIPE = 80;\n\nconst TONES = {\n neutral: { box: \"border-border\", icon: null },\n success: { box: \"border-success/40\", icon: \"check\", color: \"text-success\" },\n info: { box: \"border-info/40\", icon: \"info\", color: \"text-info\" },\n warning: { box: \"border-warning/50\", icon: \"warning\", color: \"text-warning\" },\n danger: { box: \"border-destructive/50\", icon: \"warning\", color: \"text-destructive\" },\n loading: { box: \"border-border\", icon: null },\n};\n\n// Position → where the host sits. Class strings are written out whole:\n// Tailwind reads them literally.\nconst PLACES = {\n \"bottom-end\": \"top-auto bottom-0 left-0 right-0 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:left-auto sm:right-0\",\n \"bottom-center\": \"top-auto bottom-0 left-0 right-0 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:mx-auto\",\n \"top-end\": \"top-0 bottom-auto left-0 right-0 sm:left-auto sm:right-0\",\n \"top-center\": \"top-0 bottom-auto left-0 right-0 sm:mx-auto\",\n};\n\n// ---- The store: module-level, so toast() works from anywhere ----------\n\nconst EMPTY = { toasts: [], host: null };\nconst AUTO = { auto: true };\nlet state = EMPTY;\nlet seq = 0;\nlet auto = null;\nconst hosts = [];\nconst listeners = new Set();\n\nfunction set(next) {\n state = { ...state, ...next };\n for (const l of listeners) l();\n}\n\nfunction subscribe(l) {\n listeners.add(l);\n return () => listeners.delete(l);\n}\n\nconst reduced = () => typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n// No <Toaster /> on the page: lay one in <body>, in its own root.\nfunction mount() {\n if (typeof document === \"undefined\" || state.host || auto) return;\n const el = document.createElement(\"div\");\n el.setAttribute(\"data-toaster\", \"\");\n document.body.appendChild(el);\n auto = { el, root: createRoot(el) };\n set({ host: AUTO });\n auto.root.render(<Host token={AUTO} />);\n}\n\nfunction show(title, options, tone) {\n const { id: wanted, description, action, duration } = options ?? {};\n const id = wanted ?? `toast-${++seq}`;\n const was = state.toasts.find((t) => t.id === id);\n const item = {\n id,\n tone,\n title,\n description,\n action,\n duration: duration ?? (tone === \"loading\" ? Infinity : DURATION),\n version: (was?.version ?? 0) + 1,\n leaving: false,\n };\n set({ toasts: was ? state.toasts.map((t) => (t.id === id ? item : t)) : [item, ...state.toasts] });\n mount();\n return id;\n}\n\nfunction dismiss(id) {\n const hit = (t) => id === undefined || t.id === id;\n if (!state.toasts.some((t) => hit(t) && !t.leaving)) return;\n set({ toasts: state.toasts.map((t) => (hit(t) ? { ...t, leaving: true } : t)) });\n setTimeout(() => set({ toasts: state.toasts.filter((t) => !(hit(t) && t.leaving)) }), reduced() ? 0 : EXIT_MS);\n}\n\nexport function toast(title, options) {\n return show(title, options, \"neutral\");\n}\ntoast.success = (title, options) => show(title, options, \"success\");\ntoast.error = (title, options) => show(title, options, \"danger\");\ntoast.info = (title, options) => show(title, options, \"info\");\ntoast.warning = (title, options) => show(title, options, \"warning\");\ntoast.dismiss = dismiss;\ntoast.promise = (promise, { loading = \"Working…\", success, error } = {}) => {\n const p = typeof promise === \"function\" ? promise() : promise;\n const id = show(loading, {}, \"loading\");\n const settle = (message, value, tone) => {\n const m = typeof message === \"function\" ? message(value) : message;\n if (m == null || m === false) dismiss(id);\n else if (typeof m === \"object\" && !isValidElement(m)) show(m.title, { ...m, id }, tone);\n else show(m, { id }, tone);\n };\n Promise.resolve(p).then(\n (v) => settle(success ?? \"Done\", v, \"success\"),\n (e) => settle(error ?? \"Something went wrong\", e, \"danger\"),\n );\n return p;\n};\n\n// ---- The host ---------------------------------------------------------\n\n/** Where toasts stack. Optional — without one, the first toast mounts a\n * bottom-end host by itself. The newest <Toaster /> on the page wins. */\nexport function Toaster({ position = \"bottom-end\", visible = 3, className }) {\n const token = useRef(null);\n if (!token.current) token.current = {};\n useEffect(() => {\n const own = token.current;\n hosts.push(own);\n set({ host: own });\n if (auto) {\n const a = auto;\n auto = null;\n setTimeout(() => {\n a.root.unmount();\n a.el.remove();\n });\n }\n return () => {\n hosts.splice(hosts.indexOf(own), 1);\n set({ host: hosts[hosts.length - 1] ?? null });\n if (!state.host && state.toasts.length) mount();\n };\n }, []);\n return <Host token={token.current} position={position} visible={visible} className={className} />;\n}\n\nfunction Host({ token, position = \"bottom-end\", visible = 3, className }) {\n const s = useSyncExternalStore(subscribe, () => state, () => EMPTY);\n const pop = useRef(null);\n const said = useRef(new Set());\n const [hovered, setHovered] = useState(false);\n const [focused, setFocused] = useState(false);\n const [hidden, setHidden] = useState(false);\n const [heights, setHeights] = useState({});\n const [say, setSay] = useState({ polite: \"\", assertive: \"\" });\n const mine = s.host === token;\n const items = mine ? s.toasts : [];\n const live = items.filter((t) => !t.leaving);\n const newest = live[0]?.id;\n const top = position.startsWith(\"top\");\n\n // In the top layer while there is something to show. A modal <dialog>\n // opened since would sit above it: show again to rise over it.\n useLayoutEffect(() => {\n const p = pop.current;\n if (!p?.showPopover) return;\n const open = p.matches(\":popover-open\");\n if (items.length && !open) p.showPopover();\n else if (!items.length && open) p.hidePopover();\n else if (open && newest && document.querySelector(\"dialog:modal\") && !p.contains(document.activeElement)) {\n p.hidePopover();\n p.showPopover();\n }\n }, [items.length, newest]);\n\n // An emptied stack cannot report the pointer leaving it.\n useEffect(() => {\n if (items.length === 0) {\n setHovered(false);\n setFocused(false);\n }\n }, [items.length]);\n\n useEffect(() => {\n const on = () => setHidden(document.visibilityState === \"hidden\");\n document.addEventListener(\"visibilitychange\", on);\n return () => document.removeEventListener(\"visibilitychange\", on);\n }, []);\n\n // Each toast is announced once per version, a beat after the live\n // region exists (a region born with its text is not read).\n const timer = useRef(0);\n useEffect(() => () => clearTimeout(timer.current), []);\n useEffect(() => {\n const t = live.find((x) => !said.current.has(`${x.id}:${x.version}`));\n if (!t) return;\n said.current.add(`${t.id}:${t.version}`);\n const text = [t.title, t.description].filter((x) => typeof x === \"string\" || typeof x === \"number\").join(\". \");\n if (!text) return;\n clearTimeout(timer.current);\n timer.current = setTimeout(() => setSay(t.tone === \"danger\" ? { polite: \"\", assertive: text } : { polite: text, assertive: \"\" }), 100);\n });\n\n const measure = useCallback((id, h) => setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h })), []);\n\n if (!mine) return null;\n\n const expanded = hovered || focused;\n const paused = expanded || hidden;\n const shown = live.slice(0, visible);\n let run = 0;\n const offsets = shown.map((t) => {\n const at = run;\n run += (heights[t.id] ?? 0) + GAP;\n return at;\n });\n const front = heights[shown[0]?.id] ?? 0;\n const height = expanded ? Math.max(0, run - GAP) : front + PEEK * Math.max(0, shown.length - 1);\n\n return (\n <>\n <div className=\"sr-only\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n {say.polite}\n </div>\n <div className=\"sr-only\" aria-live=\"assertive\" aria-atomic=\"true\">\n {say.assertive}\n </div>\n <section\n ref={pop}\n popover=\"manual\"\n aria-label=\"Notifications\"\n onPointerEnter={() => setHovered(true)}\n onPointerLeave={() => setHovered(false)}\n onFocus={() => setFocused(true)}\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false);\n }}\n className={cx(\n \"pointer-events-none fixed z-50 m-0 h-auto w-full overflow-visible bg-transparent p-3 sm:w-96 sm:p-4\",\n PLACES[position] ?? PLACES[\"bottom-end\"],\n className,\n )}\n >\n <ol className=\"relative transition-[height] duration-300 ease-out motion-reduce:transition-none\" style={{ height }}>\n {items.map((t) => {\n const i = live.indexOf(t);\n return (\n <ToastItem\n key={t.id}\n t={t}\n index={i}\n top={top}\n beyond={i >= visible}\n offset={offsets[i] ?? 0}\n expanded={expanded}\n clip={!expanded && i > 0 ? front : undefined}\n paused={paused}\n onHeight={measure}\n />\n );\n })}\n </ol>\n </section>\n </>\n );\n}\n\nfunction ToastItem({ t, index, top, beyond, offset, expanded, clip, paused, onHeight }) {\n const inner = useRef(null);\n const left = useRef(t.duration);\n const ver = useRef(0);\n const drag = useRef(null);\n const last = useRef(\"\");\n const [dx, setDx] = useState(0);\n const [exit, setExit] = useState(0);\n const tone = TONES[t.tone] ?? TONES.neutral;\n\n // The timer: counts down only while nothing holds the stack open; a\n // new version (toast.promise settling, an update by id) starts it over.\n useEffect(() => {\n if (ver.current !== t.version) {\n ver.current = t.version;\n left.current = t.duration;\n }\n if (paused || t.leaving || !Number.isFinite(t.duration)) return;\n const start = Date.now();\n const h = setTimeout(() => dismiss(t.id), Math.max(0, left.current));\n return () => {\n clearTimeout(h);\n left.current -= Date.now() - start;\n };\n }, [paused, t.leaving, t.version, t.duration, t.id]);\n\n useLayoutEffect(() => {\n const el = inner.current;\n if (!el || typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(() => onHeight(t.id, el.offsetHeight));\n ro.observe(el);\n onHeight(t.id, el.offsetHeight);\n return () => ro.disconnect();\n }, [t.id, onHeight]);\n\n // Where it sits in the stack. A leaving toast keeps its last place\n // while it fades, so the others close the gap at once.\n const dir = top ? 1 : -1;\n if (!t.leaving) {\n const y = expanded ? offset : index * PEEK;\n const k = expanded ? 1 : 1 - Math.min(index, 3) * 0.05;\n last.current = `translateY(${dir * y}px) scale(${k})`;\n }\n\n function onPointerDown(e) {\n if (e.button !== 0 || e.target.closest(\"button, a, input, textarea, select\")) return;\n drag.current = { x: e.clientX, at: e.timeStamp };\n e.currentTarget.setPointerCapture(e.pointerId);\n }\n function onPointerMove(e) {\n if (drag.current) setDx(e.clientX - drag.current.x);\n }\n function onPointerUp(e) {\n const d = drag.current;\n drag.current = null;\n if (!d) return;\n const moved = e.clientX - d.x;\n const fast = Math.abs(moved) > 10 && Math.abs(moved) / Math.max(1, e.timeStamp - d.at) > 0.5;\n if (Math.abs(moved) > SWIPE || fast) {\n setExit(Math.sign(moved) || 1);\n dismiss(t.id);\n } else setDx(0);\n }\n\n const dragging = drag.current !== null && dx !== 0;\n const style = {\n zIndex: 1000 - Math.max(index, 0),\n transform: last.current,\n transformOrigin: top ? \"bottom center\" : \"top center\",\n height: clip,\n touchAction: \"pan-y\",\n };\n if (dragging) {\n style.translate = `${dx}px 0`;\n style.opacity = Math.max(0, 1 - Math.abs(dx) / 240);\n style.transition = \"none\";\n } else if (exit) style.translate = `${exit * 110}% 0`;\n\n return (\n <li\n style={style}\n inert={beyond || undefined}\n aria-hidden={beyond || undefined}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={() => {\n drag.current = null;\n setDx(0);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Escape\") dismiss(t.id);\n }}\n className={cx(\n \"pointer-events-auto absolute inset-x-0 select-none\",\n \"transition-[opacity,translate,transform,height] duration-300 ease-out motion-reduce:transition-none\",\n top ? \"top-0 starting:-translate-y-full\" : \"bottom-0 starting:translate-y-full\",\n \"starting:opacity-0\",\n clip !== undefined && \"overflow-hidden rounded-lg\",\n (t.leaving || beyond) && \"opacity-0\",\n t.leaving && !exit && (top ? \"-translate-y-2\" : \"translate-y-2\"),\n beyond && \"pointer-events-none\",\n )}\n >\n <div\n ref={inner}\n className={cx(\n \"flex items-start gap-3 rounded-lg border bg-popover py-3 pl-4 pr-2 text-sm text-popover-foreground shadow-lg\",\n \"[&>*]:transition-opacity [&>*]:duration-200 motion-reduce:[&>*]:transition-none\",\n tone.box,\n clip !== undefined && \"[&>*]:opacity-0\",\n )}\n >\n {t.tone === \"loading\" ? (\n <Spinner size=\"sm\" label={null} className=\"mt-0.5 text-muted-foreground\" />\n ) : tone.icon ? (\n <Icon name={tone.icon} className={cx(\"mt-0.5 shrink-0\", tone.color)} />\n ) : null}\n <div className=\"min-w-0 flex-1 py-px\">\n <p className=\"font-medium leading-snug [overflow-wrap:anywhere]\">{t.title}</p>\n {t.description ? <p className=\"mt-0.5 text-muted-foreground [overflow-wrap:anywhere]\">{t.description}</p> : null}\n </div>\n {t.action ? (\n <Button\n size=\"sm\"\n variant=\"secondary\"\n className=\"-my-1 shrink-0\"\n onClick={(e) => {\n t.action.onClick?.(e);\n if (!e.defaultPrevented) dismiss(t.id);\n }}\n >\n {t.action.label}\n </Button>\n ) : null}\n <IconButton icon=\"x\" label=\"Dismiss\" size=\"sm\" className=\"-my-1.5 shrink-0\" onClick={() => dismiss(t.id)} />\n </div>\n </li>\n );\n}\n"
271
+ },
272
+ {
273
+ "path": "templates/next/files/components/ui/toggle.jsx",
274
+ "type": "registry:ui",
275
+ "target": "@components/makersclaw/ui/toggle.jsx",
276
+ "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Toggle — a button that stays down: bold on, pinned, \"show archived\".\n// A real <button aria-pressed>, so Space and Enter work and a screen\n// reader says \"pressed\". Controlled (`pressed` + `onPressedChange(next)`)\n// or not (`defaultPressed`). A setting that saves is a Switch; a toggle\n// changes what THIS view shows or does. Off reads muted, on reads lit\n// (full text, the accent fill, a stronger edge) — never colour alone.\n//\n// variant secondary (bordered; the default) · quiet (no fill until pressed)\n// size md (44px) · sm (36px)\n// icon a name from ./icon; with no children the toggle is square\n// and `label` is required (what a screen reader says)\n//\n// ToggleGroup below is a row of them as one control — the segmented\n// control. Arrow keys move between items (roving focus: the row is one\n// Tab stop), Space or Enter presses.\n\nconst VARIANTS = {\n secondary:\n \"bg-secondary text-muted-foreground border-border hover:bg-accent hover:text-foreground \" +\n \"aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:border-foreground/50\",\n quiet:\n \"bg-transparent text-muted-foreground border-transparent hover:bg-accent hover:text-foreground \" +\n \"aria-pressed:bg-accent aria-pressed:text-accent-foreground\",\n};\n\n// `square` is the icon-only toggle's own box, used INSTEAD of `box` —\n// never a px-0 laid over box's padding: cx does not merge classes, so\n// which of two paddings wins is CSS order, not the order written.\nconst SIZES = {\n md: { box: \"min-h-11 px-3 text-sm\", square: \"size-11 px-0 text-sm\", glyph: 18 },\n sm: { box: \"min-h-9 px-2.5 text-sm\", square: \"size-9 px-0 text-sm\", glyph: 16 },\n};\n\nexport function Toggle({\n pressed,\n defaultPressed = false,\n onPressedChange,\n variant = \"secondary\",\n size = \"md\",\n icon,\n label,\n className,\n children,\n onClick,\n ...rest\n}) {\n const [own, setOwn] = useState(defaultPressed);\n const on = pressed ?? own;\n const s = SIZES[size] ?? SIZES.md;\n const square = icon && !children;\n return (\n <button\n type=\"button\"\n aria-pressed={on}\n aria-label={square ? label : undefined}\n title={square ? label : undefined}\n onClick={(e) => {\n onClick?.(e);\n if (e.defaultPrevented) return;\n if (pressed === undefined) setOwn(!on);\n onPressedChange?.(!on);\n }}\n className={cx(\n \"inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap transition-colors\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n VARIANTS[variant] ?? VARIANTS.secondary,\n square ? s.square : s.box,\n className,\n )}\n {...rest}\n >\n {icon ? <Icon name={icon} size={square ? s.glyph : 16} /> : null}\n {children}\n </button>\n );\n}\n\n// As SIZES: `square` replaces `item` for an icon-only item.\nconst SEG = {\n md: { track: \"p-1\", item: \"min-h-9 px-3\", square: \"min-h-9 min-w-9 px-0\", glyph: 18 },\n sm: { track: \"p-0.5\", item: \"min-h-8 px-2.5\", square: \"min-h-8 min-w-8 px-0\", glyph: 16 },\n};\n\n/**\n * A row of toggles as one control, the segmented look: a muted track,\n * the pressed items raised. `type` single (exactly one pressed — pressing\n * it again keeps it; a filter that can be off is `multiple`) · multiple.\n * `value` is a string (single) or an array (multiple); `onChange` gets\n * the same shape. md is 44px tall, sm 36px. Stretches when given a width\n * (className=\"w-full\"): the items share it.\n *\n * items [{ value, label, icon?, iconOnly?, disabled? }] — `iconOnly`\n * shows the icon alone; `label` is then what a screen reader says\n */\nexport function ToggleGroup({\n type = \"single\",\n items,\n value,\n defaultValue,\n onChange,\n size = \"md\",\n disabled,\n className,\n \"aria-label\": ariaLabel,\n}) {\n const multiple = type === \"multiple\";\n const [own, setOwn] = useState(defaultValue ?? (multiple ? [] : undefined));\n const current = value !== undefined ? value : own;\n const refs = useRef([]);\n const s = SEG[size] ?? SEG.md;\n const live = (items ?? []).filter(Boolean);\n\n const isOn = (v) => (multiple ? (current ?? []).includes(v) : current === v);\n const usable = (i) => !disabled && !live[i]?.disabled;\n\n function press(v) {\n let next;\n if (multiple) next = isOn(v) ? current.filter((x) => x !== v) : [...(current ?? []), v];\n else if (current === v) return;\n else next = v;\n if (value === undefined) setOwn(next);\n onChange?.(next);\n }\n\n // One Tab stop: the first pressed item, else the first usable one.\n let stop = live.findIndex((it, i) => usable(i) && isOn(it.value));\n if (stop < 0) stop = live.findIndex((_, i) => usable(i));\n\n function onKeyDown(e, index) {\n const step = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? -1 : 0;\n let next = null;\n if (step) {\n for (let k = 1; k <= live.length; k++) {\n const i = (index + step * k + live.length) % live.length;\n if (usable(i)) {\n next = i;\n break;\n }\n }\n } else if (e.key === \"Home\") next = live.findIndex((_, i) => usable(i));\n else if (e.key === \"End\") next = live.findLastIndex((_, i) => usable(i));\n if (next === null || next < 0) return;\n e.preventDefault();\n refs.current[next]?.focus();\n }\n\n if (live.length === 0) return null;\n\n return (\n <div\n role=\"group\"\n aria-label={ariaLabel}\n className={cx(\"inline-flex max-w-full gap-1 rounded-md bg-muted\", s.track, disabled && \"opacity-50\", className)}\n >\n {live.map((it, i) => {\n const on = isOn(it.value);\n const square = it.icon && it.iconOnly;\n return (\n <button\n key={it.value}\n ref={(el) => {\n refs.current[i] = el;\n }}\n type=\"button\"\n aria-pressed={on}\n aria-label={square ? it.label : undefined}\n title={square ? it.label : undefined}\n disabled={!usable(i)}\n tabIndex={i === stop ? 0 : -1}\n onClick={() => press(it.value)}\n onKeyDown={(e) => onKeyDown(e, i)}\n className={cx(\n \"inline-flex flex-1 items-center justify-center gap-2 rounded-sm text-sm font-medium whitespace-nowrap transition-colors\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n \"disabled:cursor-not-allowed\",\n !disabled && \"disabled:opacity-50\",\n square ? s.square : s.item,\n on ? \"bg-background text-foreground shadow-sm ring-1 ring-border\" : \"text-muted-foreground hover:text-foreground\",\n )}\n >\n {it.icon ? <Icon name={it.icon} size={square ? s.glyph : 16} /> : null}\n {square ? null : it.label}\n </button>\n );\n })}\n </div>\n );\n}\n"
277
+ },
278
+ {
279
+ "path": "templates/next/files/components/ui/tooltip.jsx",
280
+ "type": "registry:ui",
281
+ "target": "@components/makersclaw/ui/tooltip.jsx",
282
+ "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cx } from \"./cx\";\nimport { panelClass, usePopoverPosition } from \"./popover\";\n\n// Tooltip and HoverCard — what shows while the pointer rests on something,\n// placed by usePopoverPosition (./popover). Both wrap their trigger in an\n// inline span that listens; neither is the only way to learn anything.\n//\n// Tooltip — a short label for a control: the name of an icon button, a\n// shortcut. Hover (after `delay`) or keyboard focus shows it; leaving,\n// clicking, blur or Escape hides it. While one shows (or just after), the\n// next opens at once, so running along a toolbar reads every label. Plain text only:\n// it never holds a link or a button. A `popover=\"hint\"` where the browser\n// has it (one hint at a time, never closing an open Popover), else\n// \"manual\" with the same rules kept here.\n//\n// <Tooltip label=\"Copy link\"><IconButton icon=\"link\" label=\"Copy link\" /></Tooltip>\n//\n// label the text; nothing (or \"\") renders the child alone\n// side top (default) · right · bottom · left; align start · center · end\n// delay ms before a hover shows it (default 500)\n//\n// Touch shows nothing, on purpose: a long press belongs to the OS (text\n// selection, the link menu) and a tap is the action. So what a touch user\n// needs is visible or in the control's label — the tooltip only repeats\n// it for the pointer. The child's `title` is removed while it is wrapped,\n// so the browser's own tooltip does not double this one, and the label is\n// tied to the child's control with aria-describedby unless it already is\n// that control's name.\n//\n// HoverCard — a richer preview of what a link or a name points at: a\n// person's card, a company's summary. Opens after `openDelay` on hover or\n// keyboard focus, stays while the pointer or focus is on the trigger or\n// the card, closes `closeDelay` after both are left, or on Escape. It may\n// hold any content — links, buttons, blocks — because it is portalled to\n// <body>; Tab from the trigger walks into it and out again. Touch shows\n// nothing — the tap follows the link — so the card only previews what the\n// link's own page shows in full.\n//\n// <HoverCard trigger={<a href=\"/people/ada\">Ada Lovelace</a>}>…the card…</HoverCard>\n//\n// trigger the inline thing hovered: a link, a name, an avatar\n// side bottom (default) · top · right · left; align start · center · end\n// width sm · md (default) · lg · auto, as Popover\n// openDelay ms (default 600); closeDelay ms (default 300)\n// className the card's (it has p-4 unless it names a padding)\n\nconst FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])';\n\n// \"hint\" where the browser has it; the attribute reads back \"manual\" where not.\nfunction hintable() {\n const d = document.createElement(\"div\");\n d.popover = \"hint\";\n return d.popover === \"hint\";\n}\n\n// One tooltip at a time, and the warm-up skipped just after one closed.\nlet hideShown = null;\nlet closedAt = 0;\n\n// Show, hide and their timer, made once per mounted part: they read only\n// refs, so the Escape listener added on show is the one removed on hide.\nfunction useFloat(panel, { onHide } = {}) {\n const api = useRef(null);\n if (!api.current) {\n let timer = 0;\n const self = {\n later(fn, ms) {\n clearTimeout(timer);\n timer = setTimeout(fn, ms);\n },\n cancel: () => clearTimeout(timer),\n isOpen: () => Boolean(panel.current?.matches(\":popover-open\")),\n show() {\n clearTimeout(timer);\n if (!panel.current || self.isOpen()) return;\n panel.current.showPopover();\n document.addEventListener(\"keydown\", self.onKey);\n },\n hide() {\n clearTimeout(timer);\n document.removeEventListener(\"keydown\", self.onKey);\n if (!self.isOpen()) return;\n const inside = panel.current.contains(document.activeElement);\n panel.current.hidePopover();\n onHide?.(inside);\n },\n onKey(e) {\n if (e.key === \"Escape\") self.hide();\n },\n };\n api.current = self;\n }\n useEffect(() => () => {\n api.current.cancel();\n document.removeEventListener(\"keydown\", api.current.onKey);\n }, []);\n return api.current;\n}\n\nexport function Tooltip({ label, side = \"top\", align = \"center\", delay = 500, className, children }) {\n const id = useId();\n const anchor = useRef(null);\n const tip = useRef(null);\n usePopoverPosition(anchor, tip, { side, align, offset: 6 });\n const f = useFloat(tip);\n\n useEffect(() => {\n const t = tip.current;\n const control = anchor.current?.querySelector(FOCUSABLE) ?? anchor.current?.firstElementChild;\n if (!t || !control) return;\n if (hintable()) t.setAttribute(\"popover\", \"hint\");\n const title = control.getAttribute(\"title\");\n if (title !== null) control.removeAttribute(\"title\");\n const name = (control.getAttribute(\"aria-label\") ?? control.textContent ?? \"\").trim();\n const describe = name !== String(label).trim();\n if (describe) control.setAttribute(\"aria-describedby\", id);\n return () => {\n if (title !== null) control.setAttribute(\"title\", title);\n if (describe) control.removeAttribute(\"aria-describedby\");\n };\n }, [id, label]);\n\n if (!label) return children;\n\n function show() {\n if (hideShown && hideShown !== f.hide) hideShown();\n f.show();\n hideShown = f.hide;\n }\n\n return (\n <>\n <span\n ref={anchor}\n className=\"inline-flex\"\n onPointerEnter={(e) => e.pointerType !== \"touch\" && f.later(show, hideShown || Date.now() - closedAt < 400 ? 0 : delay)}\n onPointerLeave={() => f.later(f.hide, 80)}\n onPointerDown={f.hide}\n onFocus={(e) => e.target.matches(\":focus-visible\") && show()}\n onBlur={f.hide}\n >\n {children}\n </span>\n <span\n ref={tip}\n id={id}\n role=\"tooltip\"\n popover=\"manual\"\n // However it closed (ours, or a hint's own Escape and light dismiss).\n onToggle={(e) => {\n if (e.newState !== \"closed\") return;\n f.hide();\n closedAt = Date.now();\n if (hideShown === f.hide) hideShown = null;\n }}\n // A pointer crossing the gap onto the label keeps it (WCAG 1.4.13).\n onPointerEnter={f.cancel}\n onPointerLeave={() => f.later(f.hide, 80)}\n className={cx(\n \"max-w-64 rounded-md bg-foreground px-2 py-1 text-xs leading-snug text-background shadow-md text-balance\",\n \"opacity-0 transition-[opacity,display,overlay] transition-discrete duration-100 open:opacity-100 starting:open:opacity-0 motion-reduce:transition-none\",\n className,\n )}\n >\n {label}\n </span>\n </>\n );\n}\n\nexport function HoverCard({\n trigger,\n side = \"bottom\",\n align = \"center\",\n width = \"md\",\n openDelay = 600,\n closeDelay = 300,\n className,\n children,\n}) {\n const anchor = useRef(null);\n const card = useRef(null);\n const hovering = useRef(false);\n const [host, setHost] = useState(null);\n useEffect(() => setHost(document.body), []);\n usePopoverPosition(anchor, card, { side, align, offset: 8 });\n // Focus inside the card goes back to the trigger, not to the page.\n const control = () => anchor.current?.querySelector(FOCUSABLE);\n const f = useFloat(card, { onHide: (inside) => inside && control()?.focus() });\n\n // Pointer or focus on either the trigger or the card keeps it open.\n const stay = {\n onPointerEnter: (e) => {\n if (e.pointerType === \"touch\") return;\n hovering.current = true;\n if (f.isOpen()) f.cancel();\n else f.later(f.show, openDelay);\n },\n onPointerLeave: (e) => {\n if (e.pointerType === \"touch\") return;\n hovering.current = false;\n f.later(f.hide, closeDelay);\n },\n onBlur: (e) => {\n const to = e.relatedTarget;\n if (to ? !anchor.current?.contains(to) && !card.current?.contains(to) : !hovering.current) f.later(f.hide, 0);\n },\n };\n\n // The card lives at the end of <body> (a name in a <p> may not hold a\n // <div>), so Tab is carried by hand: from the trigger into the open card,\n // from the card's first control back to the trigger, and from its last\n // on to whatever follows the trigger on the page.\n function onTriggerKey(e) {\n if (e.key !== \"Tab\" || e.shiftKey || !f.isOpen()) return;\n const first = card.current?.querySelector(FOCUSABLE);\n if (!first) return;\n e.preventDefault();\n first.focus();\n }\n function onCardKey(e) {\n if (e.key !== \"Tab\") return;\n const inCard = [...card.current.querySelectorAll(FOCUSABLE)];\n const back = e.shiftKey && document.activeElement === inCard[0];\n const on = !e.shiftKey && document.activeElement === inCard[inCard.length - 1];\n if (!back && !on) return;\n e.preventDefault();\n const from = control();\n if (back) return from?.focus();\n const page = [...document.querySelectorAll(FOCUSABLE)].filter((n) => !card.current.contains(n) && n.tabIndex >= 0 && n.offsetParent);\n f.hide();\n (page[page.indexOf(from) + 1] ?? from)?.focus();\n }\n\n return (\n <>\n <span\n ref={anchor}\n className=\"inline\"\n {...stay}\n onKeyDown={onTriggerKey}\n onFocus={(e) => {\n if (f.isOpen()) f.cancel();\n else if (e.target.matches(\":focus-visible\")) f.later(f.show, openDelay);\n }}\n >\n {trigger}\n </span>\n {host\n ? createPortal(\n <div\n ref={card}\n popover=\"manual\"\n {...stay}\n onKeyDown={onCardKey}\n onFocus={f.cancel}\n className={panelClass(width, cx(!/(^|\\s)p[xytrbl]?-/.test(className ?? \"\") && \"p-4\", \"text-sm\", className))}\n >\n {children}\n </div>,\n host,\n )\n : null}\n </>\n );\n}\n"
283
+ }
284
+ ],
285
+ "cssVars": {
286
+ "theme": {
287
+ "text-2xs": "0.6875rem",
288
+ "text-2xs--line-height": "1rem",
289
+ "color-chart-1": "var(--chart-1)",
290
+ "color-chart-2": "var(--chart-2)",
291
+ "color-chart-3": "var(--chart-3)",
292
+ "color-chart-4": "var(--chart-4)",
293
+ "color-chart-5": "var(--chart-5)"
294
+ },
295
+ "light": {
296
+ "success": "oklch(0.627 0.194 149.214)",
297
+ "warning": "oklch(0.666 0.179 58.318)",
298
+ "info": "oklch(0.546 0.245 262.881)"
299
+ },
300
+ "dark": {
301
+ "success": "oklch(0.792 0.209 151.711)",
302
+ "warning": "oklch(0.828 0.189 84.429)",
303
+ "info": "oklch(0.707 0.165 254.624)"
304
+ }
305
+ },
306
+ "css": {
307
+ "@layer base": {
308
+ "[data-nums]": {
309
+ "font-variant-numeric": "tabular-nums"
310
+ },
311
+ "dialog": {
312
+ "color": "inherit"
313
+ },
314
+ "dialog::backdrop": {
315
+ "background": "color-mix(in oklab, var(--background) 70%, transparent)"
316
+ },
317
+ "[popover]": {
318
+ "color": "inherit",
319
+ "margin": "0",
320
+ "padding": "0",
321
+ "border": "0",
322
+ "background": "transparent"
323
+ }
324
+ },
325
+ "@layer components": {
326
+ "[data-depiction]": {
327
+ "isolation": "isolate",
328
+ "color-scheme": "light",
329
+ "font": "16px/1.5 -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Apple Color Emoji\", \"Segoe UI Emoji\", sans-serif",
330
+ "letter-spacing": "normal",
331
+ "word-spacing": "normal",
332
+ "text-align": "start",
333
+ "color": "CanvasText"
334
+ },
335
+ "[data-depiction][data-scheme=\"dark\"]": {
336
+ "color-scheme": "dark"
337
+ }
338
+ }
339
+ },
340
+ "docs": "Installed to components/makersclaw/ui and components/makersclaw/parts. Import: import { Button } from \"@/components/makersclaw/ui\". Needs Next.js 15+, React 19 and Tailwind v4. https://www.npmjs.com/package/makersclaw"
341
+ }