@pylonsync/create-pylon 0.3.343 → 0.3.345

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 (422) hide show
  1. package/bin/create-pylon.js +35 -0
  2. package/package.json +1 -1
  3. package/templates/agency/components/ui/badge.tsx +37 -0
  4. package/templates/agency/components/ui/dialog.tsx +110 -0
  5. package/templates/agency/components/ui/input.tsx +30 -0
  6. package/templates/agency/components/ui/label.tsx +21 -0
  7. package/templates/agency/components/ui/select.tsx +39 -0
  8. package/templates/agency/components/ui/table.tsx +124 -0
  9. package/templates/agency/components/ui/textarea.tsx +24 -0
  10. package/templates/agency/package.json +1 -0
  11. package/templates/ai-chat/components/ui/badge.tsx +37 -0
  12. package/templates/ai-chat/components/ui/button.tsx +56 -0
  13. package/templates/ai-chat/components/ui/card.tsx +90 -0
  14. package/templates/ai-chat/components/ui/dialog.tsx +110 -0
  15. package/templates/ai-chat/components/ui/input.tsx +30 -0
  16. package/templates/ai-chat/components/ui/label.tsx +21 -0
  17. package/templates/ai-chat/components/ui/select.tsx +39 -0
  18. package/templates/ai-chat/components/ui/table.tsx +124 -0
  19. package/templates/ai-chat/components/ui/textarea.tsx +24 -0
  20. package/templates/ai-chat/package.json +1 -0
  21. package/templates/ai-studio/components/ui/badge.tsx +37 -0
  22. package/templates/ai-studio/components/ui/button.tsx +56 -0
  23. package/templates/ai-studio/components/ui/card.tsx +90 -0
  24. package/templates/ai-studio/components/ui/dialog.tsx +110 -0
  25. package/templates/ai-studio/components/ui/input.tsx +30 -0
  26. package/templates/ai-studio/components/ui/label.tsx +21 -0
  27. package/templates/ai-studio/components/ui/select.tsx +39 -0
  28. package/templates/ai-studio/components/ui/table.tsx +124 -0
  29. package/templates/ai-studio/components/ui/textarea.tsx +24 -0
  30. package/templates/ai-studio/package.json +1 -0
  31. package/templates/barebones/components/ui/badge.tsx +37 -0
  32. package/templates/barebones/components/ui/dialog.tsx +110 -0
  33. package/templates/barebones/components/ui/input.tsx +30 -0
  34. package/templates/barebones/components/ui/label.tsx +21 -0
  35. package/templates/barebones/components/ui/select.tsx +39 -0
  36. package/templates/barebones/components/ui/table.tsx +124 -0
  37. package/templates/barebones/components/ui/textarea.tsx +24 -0
  38. package/templates/barebones/package.json +1 -0
  39. package/templates/chat/components/ui/badge.tsx +37 -0
  40. package/templates/chat/components/ui/dialog.tsx +110 -0
  41. package/templates/chat/components/ui/input.tsx +30 -0
  42. package/templates/chat/components/ui/label.tsx +21 -0
  43. package/templates/chat/components/ui/select.tsx +39 -0
  44. package/templates/chat/components/ui/table.tsx +124 -0
  45. package/templates/chat/components/ui/textarea.tsx +24 -0
  46. package/templates/chat/package.json +1 -0
  47. package/templates/consumer/components/ui/badge.tsx +37 -0
  48. package/templates/consumer/components/ui/dialog.tsx +110 -0
  49. package/templates/consumer/components/ui/input.tsx +30 -0
  50. package/templates/consumer/components/ui/label.tsx +21 -0
  51. package/templates/consumer/components/ui/select.tsx +39 -0
  52. package/templates/consumer/components/ui/table.tsx +124 -0
  53. package/templates/consumer/components/ui/textarea.tsx +24 -0
  54. package/templates/consumer/package.json +1 -0
  55. package/templates/creator/components/ui/badge.tsx +37 -0
  56. package/templates/creator/components/ui/dialog.tsx +110 -0
  57. package/templates/creator/components/ui/input.tsx +30 -0
  58. package/templates/creator/components/ui/label.tsx +21 -0
  59. package/templates/creator/components/ui/select.tsx +39 -0
  60. package/templates/creator/components/ui/table.tsx +124 -0
  61. package/templates/creator/components/ui/textarea.tsx +24 -0
  62. package/templates/creator/package.json +1 -0
  63. package/templates/crm/AGENTS.md +116 -0
  64. package/templates/crm/CLAUDE.md +7 -0
  65. package/templates/crm/README.md +96 -0
  66. package/templates/crm/app/companies/companies-view.tsx +129 -0
  67. package/templates/crm/app/companies/page.tsx +17 -0
  68. package/templates/crm/app/contacts/contacts-view.tsx +139 -0
  69. package/templates/crm/app/contacts/page.tsx +17 -0
  70. package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
  71. package/templates/crm/app/deals/[id]/page.tsx +23 -0
  72. package/templates/crm/app/error.tsx +43 -0
  73. package/templates/crm/app/globals.css +201 -0
  74. package/templates/crm/app/layout.tsx +31 -0
  75. package/templates/crm/app/login/auth-form.tsx +122 -0
  76. package/templates/crm/app/login/page.tsx +38 -0
  77. package/templates/crm/app/not-found.tsx +29 -0
  78. package/templates/crm/app/page.tsx +38 -0
  79. package/templates/crm/app/pipeline-view.tsx +99 -0
  80. package/templates/crm/app/robots.ts +12 -0
  81. package/templates/crm/app/sitemap.ts +27 -0
  82. package/templates/crm/app/workspace.tsx +231 -0
  83. package/templates/crm/app.ts +171 -0
  84. package/templates/crm/bunfig +4 -0
  85. package/templates/crm/components/activity-timeline.tsx +133 -0
  86. package/templates/crm/components/avatar.tsx +40 -0
  87. package/templates/crm/components/board-skeleton.tsx +44 -0
  88. package/templates/crm/components/command-palette.tsx +162 -0
  89. package/templates/crm/components/data-table.tsx +90 -0
  90. package/templates/crm/components/deal-card.tsx +91 -0
  91. package/templates/crm/components/deal-dialog.tsx +169 -0
  92. package/templates/crm/components/empty-state.tsx +43 -0
  93. package/templates/crm/components/kbd.tsx +23 -0
  94. package/templates/crm/components/metrics-bar.tsx +46 -0
  95. package/templates/crm/components/page-header.tsx +36 -0
  96. package/templates/crm/components/pipeline-board.tsx +105 -0
  97. package/templates/crm/components/record-dialog.tsx +126 -0
  98. package/templates/crm/components/sidebar.tsx +112 -0
  99. package/templates/crm/components/stage-badge.tsx +33 -0
  100. package/templates/crm/components/ui/badge.tsx +37 -0
  101. package/templates/crm/components/ui/button.tsx +56 -0
  102. package/templates/crm/components/ui/card.tsx +90 -0
  103. package/templates/crm/components/ui/dialog.tsx +110 -0
  104. package/templates/crm/components/ui/input.tsx +30 -0
  105. package/templates/crm/components/ui/label.tsx +21 -0
  106. package/templates/crm/components/ui/select.tsx +39 -0
  107. package/templates/crm/components/ui/table.tsx +124 -0
  108. package/templates/crm/components/ui/textarea.tsx +24 -0
  109. package/templates/crm/components.json +20 -0
  110. package/templates/crm/functions/createDeal.ts +59 -0
  111. package/templates/crm/functions/logActivity.ts +49 -0
  112. package/templates/crm/functions/moveDeal.ts +53 -0
  113. package/templates/crm/functions/seedWorkspace.ts +63 -0
  114. package/templates/crm/gitignore +10 -0
  115. package/templates/crm/lib/pipeline.ts +221 -0
  116. package/templates/crm/lib/search.ts +78 -0
  117. package/templates/crm/lib/seed.ts +130 -0
  118. package/templates/crm/lib/utils.ts +10 -0
  119. package/templates/crm/package.json +39 -0
  120. package/templates/crm/tests/components.test.tsx +245 -0
  121. package/templates/crm/tests/pipeline.test.ts +219 -0
  122. package/templates/crm/tests/search.test.ts +83 -0
  123. package/templates/crm/tests/setup.ts +5 -0
  124. package/templates/crm/tsconfig.json +33 -0
  125. package/templates/default/components/ui/badge.tsx +37 -0
  126. package/templates/default/components/ui/dialog.tsx +110 -0
  127. package/templates/default/components/ui/input.tsx +30 -0
  128. package/templates/default/components/ui/label.tsx +21 -0
  129. package/templates/default/components/ui/select.tsx +39 -0
  130. package/templates/default/components/ui/table.tsx +124 -0
  131. package/templates/default/components/ui/textarea.tsx +24 -0
  132. package/templates/default/package.json +1 -0
  133. package/templates/directory/components/ui/badge.tsx +37 -0
  134. package/templates/directory/components/ui/dialog.tsx +110 -0
  135. package/templates/directory/components/ui/input.tsx +30 -0
  136. package/templates/directory/components/ui/label.tsx +21 -0
  137. package/templates/directory/components/ui/select.tsx +39 -0
  138. package/templates/directory/components/ui/table.tsx +124 -0
  139. package/templates/directory/components/ui/textarea.tsx +24 -0
  140. package/templates/directory/package.json +1 -0
  141. package/templates/helpdesk/AGENTS.md +116 -0
  142. package/templates/helpdesk/CLAUDE.md +7 -0
  143. package/templates/helpdesk/README.md +90 -0
  144. package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
  145. package/templates/helpdesk/app/customers/page.tsx +17 -0
  146. package/templates/helpdesk/app/error.tsx +43 -0
  147. package/templates/helpdesk/app/globals.css +201 -0
  148. package/templates/helpdesk/app/inbox-view.tsx +147 -0
  149. package/templates/helpdesk/app/layout.tsx +31 -0
  150. package/templates/helpdesk/app/login/auth-form.tsx +122 -0
  151. package/templates/helpdesk/app/login/page.tsx +38 -0
  152. package/templates/helpdesk/app/not-found.tsx +29 -0
  153. package/templates/helpdesk/app/page.tsx +38 -0
  154. package/templates/helpdesk/app/robots.ts +12 -0
  155. package/templates/helpdesk/app/sitemap.ts +27 -0
  156. package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
  157. package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
  158. package/templates/helpdesk/app/workspace.tsx +210 -0
  159. package/templates/helpdesk/app.ts +142 -0
  160. package/templates/helpdesk/bunfig +4 -0
  161. package/templates/helpdesk/components/avatar.tsx +40 -0
  162. package/templates/helpdesk/components/command-palette.tsx +162 -0
  163. package/templates/helpdesk/components/data-table.tsx +90 -0
  164. package/templates/helpdesk/components/empty-state.tsx +43 -0
  165. package/templates/helpdesk/components/kbd.tsx +23 -0
  166. package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
  167. package/templates/helpdesk/components/page-header.tsx +36 -0
  168. package/templates/helpdesk/components/priority-badge.tsx +61 -0
  169. package/templates/helpdesk/components/record-dialog.tsx +126 -0
  170. package/templates/helpdesk/components/sidebar.tsx +111 -0
  171. package/templates/helpdesk/components/sla-indicator.tsx +47 -0
  172. package/templates/helpdesk/components/thread.tsx +143 -0
  173. package/templates/helpdesk/components/ticket-list.tsx +78 -0
  174. package/templates/helpdesk/components/ui/badge.tsx +37 -0
  175. package/templates/helpdesk/components/ui/button.tsx +56 -0
  176. package/templates/helpdesk/components/ui/card.tsx +90 -0
  177. package/templates/helpdesk/components/ui/dialog.tsx +110 -0
  178. package/templates/helpdesk/components/ui/input.tsx +30 -0
  179. package/templates/helpdesk/components/ui/label.tsx +21 -0
  180. package/templates/helpdesk/components/ui/select.tsx +39 -0
  181. package/templates/helpdesk/components/ui/table.tsx +124 -0
  182. package/templates/helpdesk/components/ui/textarea.tsx +24 -0
  183. package/templates/helpdesk/components.json +20 -0
  184. package/templates/helpdesk/functions/createTicket.ts +62 -0
  185. package/templates/helpdesk/functions/replyToTicket.ts +55 -0
  186. package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
  187. package/templates/helpdesk/functions/setTicketState.ts +58 -0
  188. package/templates/helpdesk/gitignore +10 -0
  189. package/templates/helpdesk/lib/format.ts +53 -0
  190. package/templates/helpdesk/lib/search.ts +78 -0
  191. package/templates/helpdesk/lib/seed.ts +185 -0
  192. package/templates/helpdesk/lib/tickets.ts +184 -0
  193. package/templates/helpdesk/lib/utils.ts +10 -0
  194. package/templates/helpdesk/package.json +39 -0
  195. package/templates/helpdesk/tests/components.test.tsx +202 -0
  196. package/templates/helpdesk/tests/search.test.ts +83 -0
  197. package/templates/helpdesk/tests/setup.ts +5 -0
  198. package/templates/helpdesk/tests/tickets.test.ts +215 -0
  199. package/templates/helpdesk/tsconfig.json +33 -0
  200. package/templates/inventory/AGENTS.md +116 -0
  201. package/templates/inventory/CLAUDE.md +7 -0
  202. package/templates/inventory/README.md +103 -0
  203. package/templates/inventory/app/error.tsx +43 -0
  204. package/templates/inventory/app/globals.css +201 -0
  205. package/templates/inventory/app/layout.tsx +31 -0
  206. package/templates/inventory/app/login/auth-form.tsx +122 -0
  207. package/templates/inventory/app/login/page.tsx +38 -0
  208. package/templates/inventory/app/movements/movements-view.tsx +103 -0
  209. package/templates/inventory/app/movements/page.tsx +17 -0
  210. package/templates/inventory/app/not-found.tsx +29 -0
  211. package/templates/inventory/app/page.tsx +34 -0
  212. package/templates/inventory/app/products/[id]/page.tsx +23 -0
  213. package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
  214. package/templates/inventory/app/products-view.tsx +213 -0
  215. package/templates/inventory/app/robots.ts +12 -0
  216. package/templates/inventory/app/sitemap.ts +27 -0
  217. package/templates/inventory/app/workspace.tsx +172 -0
  218. package/templates/inventory/app.ts +154 -0
  219. package/templates/inventory/bunfig +4 -0
  220. package/templates/inventory/components/avatar.tsx +40 -0
  221. package/templates/inventory/components/command-palette.tsx +162 -0
  222. package/templates/inventory/components/data-table.tsx +90 -0
  223. package/templates/inventory/components/empty-state.tsx +43 -0
  224. package/templates/inventory/components/kbd.tsx +23 -0
  225. package/templates/inventory/components/movement-dialog.tsx +182 -0
  226. package/templates/inventory/components/page-header.tsx +36 -0
  227. package/templates/inventory/components/record-dialog.tsx +126 -0
  228. package/templates/inventory/components/sidebar.tsx +111 -0
  229. package/templates/inventory/components/stock-level.tsx +47 -0
  230. package/templates/inventory/components/summary-bar.tsx +56 -0
  231. package/templates/inventory/components/ui/badge.tsx +37 -0
  232. package/templates/inventory/components/ui/button.tsx +56 -0
  233. package/templates/inventory/components/ui/card.tsx +90 -0
  234. package/templates/inventory/components/ui/dialog.tsx +110 -0
  235. package/templates/inventory/components/ui/input.tsx +30 -0
  236. package/templates/inventory/components/ui/label.tsx +21 -0
  237. package/templates/inventory/components/ui/select.tsx +39 -0
  238. package/templates/inventory/components/ui/table.tsx +124 -0
  239. package/templates/inventory/components/ui/textarea.tsx +24 -0
  240. package/templates/inventory/components.json +20 -0
  241. package/templates/inventory/functions/recordMovement.ts +72 -0
  242. package/templates/inventory/functions/seedWorkspace.ts +40 -0
  243. package/templates/inventory/gitignore +10 -0
  244. package/templates/inventory/lib/format.ts +53 -0
  245. package/templates/inventory/lib/search.ts +78 -0
  246. package/templates/inventory/lib/seed.ts +102 -0
  247. package/templates/inventory/lib/stock.ts +207 -0
  248. package/templates/inventory/lib/utils.ts +10 -0
  249. package/templates/inventory/package.json +39 -0
  250. package/templates/inventory/tests/components.test.tsx +155 -0
  251. package/templates/inventory/tests/search.test.ts +83 -0
  252. package/templates/inventory/tests/setup.ts +5 -0
  253. package/templates/inventory/tests/stock.test.ts +255 -0
  254. package/templates/inventory/tsconfig.json +33 -0
  255. package/templates/invoices/AGENTS.md +116 -0
  256. package/templates/invoices/CLAUDE.md +7 -0
  257. package/templates/invoices/README.md +99 -0
  258. package/templates/invoices/app/clients/clients-view.tsx +130 -0
  259. package/templates/invoices/app/clients/page.tsx +17 -0
  260. package/templates/invoices/app/error.tsx +43 -0
  261. package/templates/invoices/app/globals.css +201 -0
  262. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
  263. package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
  264. package/templates/invoices/app/invoices-view.tsx +183 -0
  265. package/templates/invoices/app/layout.tsx +31 -0
  266. package/templates/invoices/app/login/auth-form.tsx +122 -0
  267. package/templates/invoices/app/login/page.tsx +38 -0
  268. package/templates/invoices/app/not-found.tsx +29 -0
  269. package/templates/invoices/app/page.tsx +36 -0
  270. package/templates/invoices/app/robots.ts +12 -0
  271. package/templates/invoices/app/sitemap.ts +27 -0
  272. package/templates/invoices/app/workspace.tsx +174 -0
  273. package/templates/invoices/app.ts +169 -0
  274. package/templates/invoices/bunfig +4 -0
  275. package/templates/invoices/components/avatar.tsx +40 -0
  276. package/templates/invoices/components/command-palette.tsx +162 -0
  277. package/templates/invoices/components/data-table.tsx +90 -0
  278. package/templates/invoices/components/empty-state.tsx +43 -0
  279. package/templates/invoices/components/kbd.tsx +23 -0
  280. package/templates/invoices/components/line-items.tsx +172 -0
  281. package/templates/invoices/components/page-header.tsx +36 -0
  282. package/templates/invoices/components/payment-dialog.tsx +132 -0
  283. package/templates/invoices/components/record-dialog.tsx +126 -0
  284. package/templates/invoices/components/sidebar.tsx +111 -0
  285. package/templates/invoices/components/status-badge.tsx +35 -0
  286. package/templates/invoices/components/summary-bar.tsx +65 -0
  287. package/templates/invoices/components/totals-panel.tsx +70 -0
  288. package/templates/invoices/components/ui/badge.tsx +37 -0
  289. package/templates/invoices/components/ui/button.tsx +56 -0
  290. package/templates/invoices/components/ui/card.tsx +90 -0
  291. package/templates/invoices/components/ui/dialog.tsx +110 -0
  292. package/templates/invoices/components/ui/input.tsx +30 -0
  293. package/templates/invoices/components/ui/label.tsx +21 -0
  294. package/templates/invoices/components/ui/select.tsx +39 -0
  295. package/templates/invoices/components/ui/table.tsx +124 -0
  296. package/templates/invoices/components/ui/textarea.tsx +24 -0
  297. package/templates/invoices/components.json +20 -0
  298. package/templates/invoices/functions/createInvoice.ts +49 -0
  299. package/templates/invoices/functions/recordPayment.ts +72 -0
  300. package/templates/invoices/functions/seedWorkspace.ts +57 -0
  301. package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
  302. package/templates/invoices/gitignore +10 -0
  303. package/templates/invoices/lib/billing.ts +259 -0
  304. package/templates/invoices/lib/format.ts +53 -0
  305. package/templates/invoices/lib/search.ts +78 -0
  306. package/templates/invoices/lib/seed.ts +190 -0
  307. package/templates/invoices/lib/utils.ts +10 -0
  308. package/templates/invoices/package.json +39 -0
  309. package/templates/invoices/tests/billing.test.ts +283 -0
  310. package/templates/invoices/tests/components.test.tsx +172 -0
  311. package/templates/invoices/tests/search.test.ts +83 -0
  312. package/templates/invoices/tests/setup.ts +5 -0
  313. package/templates/invoices/tsconfig.json +33 -0
  314. package/templates/local-service/components/ui/badge.tsx +37 -0
  315. package/templates/local-service/components/ui/dialog.tsx +110 -0
  316. package/templates/local-service/components/ui/input.tsx +30 -0
  317. package/templates/local-service/components/ui/label.tsx +21 -0
  318. package/templates/local-service/components/ui/select.tsx +39 -0
  319. package/templates/local-service/components/ui/table.tsx +124 -0
  320. package/templates/local-service/components/ui/textarea.tsx +24 -0
  321. package/templates/local-service/package.json +1 -0
  322. package/templates/marketplace/components/ui/badge.tsx +37 -0
  323. package/templates/marketplace/components/ui/button.tsx +56 -0
  324. package/templates/marketplace/components/ui/card.tsx +90 -0
  325. package/templates/marketplace/components/ui/dialog.tsx +110 -0
  326. package/templates/marketplace/components/ui/input.tsx +30 -0
  327. package/templates/marketplace/components/ui/label.tsx +21 -0
  328. package/templates/marketplace/components/ui/select.tsx +39 -0
  329. package/templates/marketplace/components/ui/table.tsx +124 -0
  330. package/templates/marketplace/components/ui/textarea.tsx +24 -0
  331. package/templates/marketplace/lib/utils.ts +10 -0
  332. package/templates/marketplace/package.json +1 -0
  333. package/templates/projects/AGENTS.md +116 -0
  334. package/templates/projects/CLAUDE.md +7 -0
  335. package/templates/projects/README.md +96 -0
  336. package/templates/projects/app/clients/clients-view.tsx +126 -0
  337. package/templates/projects/app/clients/page.tsx +17 -0
  338. package/templates/projects/app/error.tsx +43 -0
  339. package/templates/projects/app/globals.css +201 -0
  340. package/templates/projects/app/layout.tsx +31 -0
  341. package/templates/projects/app/login/auth-form.tsx +122 -0
  342. package/templates/projects/app/login/page.tsx +38 -0
  343. package/templates/projects/app/not-found.tsx +29 -0
  344. package/templates/projects/app/page.tsx +30 -0
  345. package/templates/projects/app/projects/[id]/page.tsx +23 -0
  346. package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
  347. package/templates/projects/app/projects-view.tsx +181 -0
  348. package/templates/projects/app/robots.ts +12 -0
  349. package/templates/projects/app/sitemap.ts +27 -0
  350. package/templates/projects/app/workspace.tsx +190 -0
  351. package/templates/projects/app.ts +186 -0
  352. package/templates/projects/bunfig +4 -0
  353. package/templates/projects/components/avatar.tsx +40 -0
  354. package/templates/projects/components/board-skeleton.tsx +37 -0
  355. package/templates/projects/components/budget-bar.tsx +61 -0
  356. package/templates/projects/components/command-palette.tsx +162 -0
  357. package/templates/projects/components/data-table.tsx +90 -0
  358. package/templates/projects/components/empty-state.tsx +43 -0
  359. package/templates/projects/components/kbd.tsx +23 -0
  360. package/templates/projects/components/page-header.tsx +36 -0
  361. package/templates/projects/components/record-dialog.tsx +126 -0
  362. package/templates/projects/components/sidebar.tsx +111 -0
  363. package/templates/projects/components/task-board.tsx +109 -0
  364. package/templates/projects/components/task-card.tsx +77 -0
  365. package/templates/projects/components/time-dialog.tsx +114 -0
  366. package/templates/projects/components/ui/badge.tsx +37 -0
  367. package/templates/projects/components/ui/button.tsx +56 -0
  368. package/templates/projects/components/ui/card.tsx +90 -0
  369. package/templates/projects/components/ui/dialog.tsx +110 -0
  370. package/templates/projects/components/ui/input.tsx +30 -0
  371. package/templates/projects/components/ui/label.tsx +21 -0
  372. package/templates/projects/components/ui/select.tsx +39 -0
  373. package/templates/projects/components/ui/table.tsx +124 -0
  374. package/templates/projects/components/ui/textarea.tsx +24 -0
  375. package/templates/projects/components.json +20 -0
  376. package/templates/projects/functions/logTime.ts +49 -0
  377. package/templates/projects/functions/moveTask.ts +40 -0
  378. package/templates/projects/functions/seedWorkspace.ts +59 -0
  379. package/templates/projects/gitignore +10 -0
  380. package/templates/projects/lib/format.ts +53 -0
  381. package/templates/projects/lib/search.ts +78 -0
  382. package/templates/projects/lib/seed.ts +140 -0
  383. package/templates/projects/lib/utils.ts +10 -0
  384. package/templates/projects/lib/work.ts +233 -0
  385. package/templates/projects/package.json +39 -0
  386. package/templates/projects/tests/components.test.tsx +200 -0
  387. package/templates/projects/tests/search.test.ts +83 -0
  388. package/templates/projects/tests/setup.ts +5 -0
  389. package/templates/projects/tests/work.test.ts +238 -0
  390. package/templates/projects/tsconfig.json +33 -0
  391. package/templates/restaurant/components/ui/badge.tsx +37 -0
  392. package/templates/restaurant/components/ui/dialog.tsx +110 -0
  393. package/templates/restaurant/components/ui/input.tsx +30 -0
  394. package/templates/restaurant/components/ui/label.tsx +21 -0
  395. package/templates/restaurant/components/ui/select.tsx +39 -0
  396. package/templates/restaurant/components/ui/table.tsx +124 -0
  397. package/templates/restaurant/components/ui/textarea.tsx +24 -0
  398. package/templates/restaurant/package.json +1 -0
  399. package/templates/shop/components/ui/badge.tsx +37 -0
  400. package/templates/shop/components/ui/dialog.tsx +110 -0
  401. package/templates/shop/components/ui/input.tsx +30 -0
  402. package/templates/shop/components/ui/label.tsx +21 -0
  403. package/templates/shop/components/ui/select.tsx +39 -0
  404. package/templates/shop/components/ui/table.tsx +124 -0
  405. package/templates/shop/components/ui/textarea.tsx +24 -0
  406. package/templates/shop/package.json +1 -0
  407. package/templates/todo/components/ui/badge.tsx +37 -0
  408. package/templates/todo/components/ui/dialog.tsx +110 -0
  409. package/templates/todo/components/ui/input.tsx +30 -0
  410. package/templates/todo/components/ui/label.tsx +21 -0
  411. package/templates/todo/components/ui/select.tsx +39 -0
  412. package/templates/todo/components/ui/table.tsx +124 -0
  413. package/templates/todo/components/ui/textarea.tsx +24 -0
  414. package/templates/todo/package.json +1 -0
  415. package/templates/waitlist/components/ui/badge.tsx +37 -0
  416. package/templates/waitlist/components/ui/dialog.tsx +110 -0
  417. package/templates/waitlist/components/ui/input.tsx +30 -0
  418. package/templates/waitlist/components/ui/label.tsx +21 -0
  419. package/templates/waitlist/components/ui/select.tsx +39 -0
  420. package/templates/waitlist/components/ui/table.tsx +124 -0
  421. package/templates/waitlist/components/ui/textarea.tsx +24 -0
  422. package/templates/waitlist/package.json +1 -0
@@ -0,0 +1,72 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { totals, type LineItem, type Payment } from "../lib/billing";
3
+
4
+ /**
5
+ * Record money received against an invoice.
6
+ *
7
+ * A mutation rather than a bare insert because a payment can settle the
8
+ * invoice, and the status flip has to happen in the same transaction — a
9
+ * payment that lands without the status changing leaves someone chasing an
10
+ * invoice that's already been paid.
11
+ *
12
+ * The balance is recomputed SERVER-side from the line items and existing
13
+ * payments. Trusting a client-sent balance would let a stale tab overpay or
14
+ * mark an invoice settled that isn't.
15
+ */
16
+ export default mutation<
17
+ { invoiceId: string; amountCents: number; method?: string; reference?: string },
18
+ { ok: true; settled: boolean }
19
+ >({
20
+ auth: "user",
21
+ args: {
22
+ invoiceId: v.id("Invoice"),
23
+ amountCents: v.number(),
24
+ method: v.optional(v.string()),
25
+ reference: v.optional(v.string()),
26
+ },
27
+ async handler(ctx, args) {
28
+ const amountCents = Math.round(Number(args.amountCents) || 0);
29
+ if (amountCents <= 0) {
30
+ throw ctx.error("INVALID_ARGS", "A payment must be a positive amount.");
31
+ }
32
+
33
+ const invoice = await ctx.db.get("Invoice", args.invoiceId);
34
+ if (!invoice) throw ctx.error("NOT_FOUND", "Invoice not found.");
35
+ if (invoice.status === "void") {
36
+ throw ctx.error("INVALID_ARGS", "That invoice is void.");
37
+ }
38
+
39
+ const items = (await ctx.db.query("LineItem", {
40
+ invoiceId: args.invoiceId,
41
+ })) as unknown as LineItem[];
42
+ const payments = (await ctx.db.query("Payment", {
43
+ invoiceId: args.invoiceId,
44
+ })) as unknown as Payment[];
45
+
46
+ const before = totals(invoice as never, items, payments);
47
+ if (amountCents > before.balanceCents) {
48
+ throw ctx.error(
49
+ "INVALID_ARGS",
50
+ "That is more than the outstanding balance.",
51
+ );
52
+ }
53
+
54
+ const now = new Date().toISOString();
55
+ await ctx.db.insert("Payment", {
56
+ invoiceId: args.invoiceId,
57
+ amountCents,
58
+ method: args.method ?? "other",
59
+ reference: args.reference || null,
60
+ paidAt: now,
61
+ recordedBy: ctx.auth.userId,
62
+ });
63
+
64
+ const settled = amountCents >= before.balanceCents;
65
+ await ctx.db.update("Invoice", args.invoiceId, {
66
+ updatedAt: now,
67
+ ...(settled ? { status: "paid" } : {}),
68
+ });
69
+
70
+ return { ok: true, settled } as const;
71
+ },
72
+ });
@@ -0,0 +1,57 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { shapeSeed } from "../lib/seed";
3
+
4
+ /**
5
+ * Fill a brand-new billing workspace once.
6
+ *
7
+ * An empty invoice list shows nothing about totals, ageing, or partial payment.
8
+ * The client calls this after sign-in; it returns immediately if any invoice
9
+ * exists, so it's safe on every load and can never duplicate the fixtures or
10
+ * touch real books.
11
+ *
12
+ * Delete this function and lib/seed.ts once you're billing real clients.
13
+ */
14
+ export default mutation<Record<string, never>, { seeded: boolean }>({
15
+ auth: "user",
16
+ args: {},
17
+ async handler(ctx) {
18
+ const existing = await ctx.db.query("Invoice", { $limit: 1 });
19
+ if (existing.length > 0) return { seeded: false };
20
+
21
+ const seed = shapeSeed();
22
+ const me = ctx.auth.userId;
23
+
24
+ const clientIds = new Map<string, string>();
25
+ for (const client of seed.clients) {
26
+ const id = await ctx.db.insert("Client", client.row);
27
+ clientIds.set(client.key, id as string);
28
+ }
29
+
30
+ const invoiceIds = new Map<string, string>();
31
+ for (const invoice of seed.invoices) {
32
+ const id = await ctx.db.insert("Invoice", {
33
+ ...invoice.row,
34
+ clientId: clientIds.get(invoice.client) ?? null,
35
+ ownerId: me,
36
+ });
37
+ invoiceIds.set(invoice.key, id as string);
38
+ }
39
+
40
+ for (const line of seed.lines) {
41
+ await ctx.db.insert("LineItem", {
42
+ ...line.row,
43
+ invoiceId: invoiceIds.get(line.invoice) ?? null,
44
+ });
45
+ }
46
+
47
+ for (const payment of seed.payments) {
48
+ await ctx.db.insert("Payment", {
49
+ ...payment.row,
50
+ invoiceId: invoiceIds.get(payment.invoice) ?? null,
51
+ recordedBy: me,
52
+ });
53
+ }
54
+
55
+ return { seeded: true };
56
+ },
57
+ });
@@ -0,0 +1,35 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { isValidStatus } from "../lib/billing";
3
+
4
+ /**
5
+ * Move an invoice between draft, sent, paid, and void.
6
+ *
7
+ * "overdue" is deliberately NOT settable — it's derived from the due date and
8
+ * the balance (lib/billing.ts), so there is no column to fall out of date and
9
+ * no nightly job to run.
10
+ *
11
+ * Sending stamps the issue date if it's missing, because "sent" with no issue
12
+ * date is a document that can't be aged.
13
+ */
14
+ export default mutation<{ invoiceId: string; status: string }, { ok: true }>({
15
+ auth: "user",
16
+ args: { invoiceId: v.id("Invoice"), status: v.string() },
17
+ async handler(ctx, args) {
18
+ if (!isValidStatus(args.status)) {
19
+ throw ctx.error("INVALID_ARGS", `Unknown status "${args.status}".`);
20
+ }
21
+
22
+ const invoice = await ctx.db.get("Invoice", args.invoiceId);
23
+ if (!invoice) throw ctx.error("NOT_FOUND", "Invoice not found.");
24
+
25
+ const now = new Date().toISOString();
26
+ const patch: Record<string, unknown> = {
27
+ status: args.status,
28
+ updatedAt: now,
29
+ };
30
+ if (args.status === "sent" && !invoice.issueDate) patch.issueDate = now;
31
+
32
+ await ctx.db.update("Invoice", args.invoiceId, patch);
33
+ return { ok: true } as const;
34
+ },
35
+ });
@@ -0,0 +1,10 @@
1
+ node_modules/
2
+ .pylon/
3
+ pylon.manifest.json
4
+ pylon.client.ts
5
+ web/dist/
6
+ *.db
7
+ *.db-*
8
+ .env
9
+ .env.local
10
+ .DS_Store
@@ -0,0 +1,259 @@
1
+ // Invoice arithmetic and status.
2
+ //
3
+ // MONEY IS INTEGER CENTS everywhere in this app. Floating-point dollars are how
4
+ // an invoice ends up off by a penny after tax, and a penny on an invoice is a
5
+ // support ticket. Values are converted at the edges — `parseAmount` on input,
6
+ // `money` on output — and never in between.
7
+ //
8
+ // Pure: no React, no `db`. The totals a customer is billed from are the last
9
+ // thing that should need a running server to verify.
10
+
11
+ export interface Status {
12
+ id: string;
13
+ label: string;
14
+ }
15
+
16
+ export const STATUSES: Status[] = [
17
+ { id: "draft", label: "Draft" },
18
+ { id: "sent", label: "Sent" },
19
+ { id: "paid", label: "Paid" },
20
+ { id: "void", label: "Void" },
21
+ ];
22
+
23
+ export interface LineItem {
24
+ id: string;
25
+ invoiceId: string;
26
+ description: string;
27
+ /** Thousandths, so 1.5 hours is 1500 — no float quantities either. */
28
+ quantityMilli: number;
29
+ unitPriceCents: number;
30
+ }
31
+
32
+ export interface Payment {
33
+ id: string;
34
+ invoiceId: string;
35
+ amountCents: number;
36
+ paidAt?: string | null;
37
+ method?: string | null;
38
+ }
39
+
40
+ export interface Invoice {
41
+ id: string;
42
+ number: string;
43
+ clientId?: string | null;
44
+ status: string;
45
+ /** Basis points: 875 = 8.75%. Integer, for the same reason as cents. */
46
+ taxRateBps?: number | null;
47
+ issueDate?: string | null;
48
+ dueDate?: string | null;
49
+ notes?: string | null;
50
+ createdAt?: string | null;
51
+ }
52
+
53
+ export function statusById(id: string): Status | undefined {
54
+ return STATUSES.find((s) => s.id === id);
55
+ }
56
+
57
+ export function isValidStatus(id: string): boolean {
58
+ return STATUSES.some((s) => s.id === id);
59
+ }
60
+
61
+ export interface Totals {
62
+ subtotalCents: number;
63
+ taxCents: number;
64
+ totalCents: number;
65
+ paidCents: number;
66
+ balanceCents: number;
67
+ }
68
+
69
+ /**
70
+ * What an invoice is worth, what's been paid, and what's left.
71
+ *
72
+ * Tax is computed on the rounded subtotal rather than per line: that's what a
73
+ * customer can reproduce from the printed invoice, and per-line rounding drifts
74
+ * from the visible total.
75
+ */
76
+ export function totals(
77
+ invoice: Invoice,
78
+ items: LineItem[],
79
+ payments: Payment[],
80
+ ): Totals {
81
+ const lines = items.filter((item) => item.invoiceId === invoice.id);
82
+ const subtotalCents = lines.reduce((sum, item) => sum + lineTotalCents(item), 0);
83
+
84
+ const bps = Math.max(0, Number(invoice.taxRateBps) || 0);
85
+ const taxCents = Math.round((subtotalCents * bps) / 10_000);
86
+ const totalCents = subtotalCents + taxCents;
87
+
88
+ const paidCents = payments
89
+ .filter((payment) => payment.invoiceId === invoice.id)
90
+ .reduce((sum, payment) => sum + (Number(payment.amountCents) || 0), 0);
91
+
92
+ return {
93
+ subtotalCents,
94
+ taxCents,
95
+ totalCents,
96
+ paidCents,
97
+ // Never negative: an overpayment is a credit to handle deliberately, not a
98
+ // negative balance that quietly offsets the next invoice.
99
+ balanceCents: Math.max(0, totalCents - paidCents),
100
+ };
101
+ }
102
+
103
+ /** One line, rounded once. quantityMilli is thousandths of a unit. */
104
+ export function lineTotalCents(item: {
105
+ quantityMilli: number;
106
+ unitPriceCents: number;
107
+ }): number {
108
+ const qty = Number(item.quantityMilli) || 0;
109
+ const unit = Number(item.unitPriceCents) || 0;
110
+ return Math.round((qty * unit) / 1000);
111
+ }
112
+
113
+ /**
114
+ * Overdue is DERIVED, not a stored status — an invoice becomes overdue by the
115
+ * clock passing, and no job runs at midnight to flip a column. A sent invoice
116
+ * with a due date in the past and a balance outstanding is overdue; anything
117
+ * paid, void, or still a draft is not.
118
+ */
119
+ export function isOverdue(
120
+ invoice: Invoice,
121
+ balanceCents: number,
122
+ now: number = Date.now(),
123
+ ): boolean {
124
+ if (invoice.status !== "sent") return false;
125
+ if (balanceCents <= 0) return false;
126
+ const due = Date.parse(invoice.dueDate ?? "");
127
+ if (!Number.isFinite(due)) return false;
128
+ return now > endOfDay(due);
129
+ }
130
+
131
+ /** What to show in the status column, including the derived Overdue. */
132
+ export function displayStatus(
133
+ invoice: Invoice,
134
+ balanceCents: number,
135
+ now: number = Date.now(),
136
+ ): string {
137
+ if (isOverdue(invoice, balanceCents, now)) return "overdue";
138
+ // A sent invoice that's been fully paid reads as paid even before someone
139
+ // remembers to change the field.
140
+ if (invoice.status === "sent" && balanceCents <= 0) return "paid";
141
+ return invoice.status;
142
+ }
143
+
144
+ export function daysOverdue(
145
+ invoice: Invoice,
146
+ now: number = Date.now(),
147
+ ): number | null {
148
+ const due = Date.parse(invoice.dueDate ?? "");
149
+ if (!Number.isFinite(due)) return null;
150
+ return Math.floor((startOfDay(now) - startOfDay(due)) / 86_400_000);
151
+ }
152
+
153
+ export interface Summary {
154
+ outstandingCents: number;
155
+ overdueCents: number;
156
+ paidCents: number;
157
+ draftCount: number;
158
+ overdueCount: number;
159
+ }
160
+
161
+ /** The numbers on the list header: what's owed, what's late, what's landed. */
162
+ export function summarize(
163
+ invoices: Invoice[],
164
+ items: LineItem[],
165
+ payments: Payment[],
166
+ now: number = Date.now(),
167
+ ): Summary {
168
+ const summary: Summary = {
169
+ outstandingCents: 0,
170
+ overdueCents: 0,
171
+ paidCents: 0,
172
+ draftCount: 0,
173
+ overdueCount: 0,
174
+ };
175
+
176
+ for (const invoice of invoices) {
177
+ const t = totals(invoice, items, payments);
178
+ summary.paidCents += t.paidCents;
179
+ if (invoice.status === "draft") summary.draftCount += 1;
180
+ if (invoice.status === "sent") {
181
+ summary.outstandingCents += t.balanceCents;
182
+ if (isOverdue(invoice, t.balanceCents, now)) {
183
+ summary.overdueCents += t.balanceCents;
184
+ summary.overdueCount += 1;
185
+ }
186
+ }
187
+ }
188
+ return summary;
189
+ }
190
+
191
+ /** "$1,240.00" — invoices show exact amounts, never a compacted $1.2K. */
192
+ export function money(cents: number | null | undefined): string {
193
+ const value = Number(cents) || 0;
194
+ const sign = value < 0 ? "-" : "";
195
+ const abs = Math.abs(value);
196
+ const dollars = Math.floor(abs / 100).toLocaleString("en-US");
197
+ const rest = String(abs % 100).padStart(2, "0");
198
+ return `${sign}$${dollars}.${rest}`;
199
+ }
200
+
201
+ /** "1.5" from 1500 thousandths, without a trailing ".000". */
202
+ export function quantity(milli: number | null | undefined): string {
203
+ const value = (Number(milli) || 0) / 1000;
204
+ return String(Number(value.toFixed(3)));
205
+ }
206
+
207
+ /**
208
+ * Parse a typed amount into cents. Accepts "1,240.50", "$1240.5", "1240".
209
+ * Returns null for anything it can't read, so a bad keystroke can't silently
210
+ * become $0.00 on a bill.
211
+ */
212
+ export function parseAmount(input: string): number | null {
213
+ const cleaned = input.replace(/[$,\s]/g, "");
214
+ if (!cleaned || !/^-?\d*\.?\d*$/.test(cleaned) || cleaned === "." || cleaned === "-") {
215
+ return null;
216
+ }
217
+ const value = Number(cleaned);
218
+ if (!Number.isFinite(value)) return null;
219
+ return Math.round(value * 100);
220
+ }
221
+
222
+ /** Parse a typed quantity into thousandths. */
223
+ export function parseQuantity(input: string): number | null {
224
+ const cleaned = input.replace(/[,\s]/g, "");
225
+ if (!cleaned || !/^\d*\.?\d*$/.test(cleaned) || cleaned === ".") return null;
226
+ const value = Number(cleaned);
227
+ if (!Number.isFinite(value)) return null;
228
+ return Math.round(value * 1000);
229
+ }
230
+
231
+ /**
232
+ * The next invoice number, continuing whatever series is already there.
233
+ *
234
+ * Derived from the existing numbers rather than a stored counter: a counter in
235
+ * a synced replica is a race, and gaps in an invoice series get asked about by
236
+ * accountants.
237
+ */
238
+ export function nextNumber(existing: string[], year: number): string {
239
+ const prefix = `INV-${year}-`;
240
+ let highest = 0;
241
+ for (const number of existing) {
242
+ if (!number.startsWith(prefix)) continue;
243
+ const suffix = Number(number.slice(prefix.length));
244
+ if (Number.isFinite(suffix) && suffix > highest) highest = suffix;
245
+ }
246
+ return `${prefix}${String(highest + 1).padStart(4, "0")}`;
247
+ }
248
+
249
+ function startOfDay(ms: number): number {
250
+ const date = new Date(ms);
251
+ date.setHours(0, 0, 0, 0);
252
+ return date.getTime();
253
+ }
254
+
255
+ function endOfDay(ms: number): number {
256
+ const date = new Date(ms);
257
+ date.setHours(23, 59, 59, 999);
258
+ return date.getTime();
259
+ }
@@ -0,0 +1,53 @@
1
+ // Small display helpers shared across views. Pure, so the timeline and the
2
+ // queue can't drift on how they render the same instant.
3
+
4
+ /** Initials for an avatar: "Dana Whitfield" → "DW", "jordan@x.com" → "JO". */
5
+ export function initials(nameOrEmail: string | null | undefined): string {
6
+ const raw = (nameOrEmail ?? "").trim();
7
+ if (!raw) return "?";
8
+ const name = raw.includes("@") ? raw.split("@")[0] : raw;
9
+ const parts = name.split(/[\s._-]+/).filter(Boolean);
10
+ if (parts.length === 0) return "?";
11
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
12
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
13
+ }
14
+
15
+ /** A stable accent per record, so the same person keeps their colour. */
16
+ export function accentIndex(seed: string, buckets = 6): number {
17
+ let hash = 0;
18
+ for (let i = 0; i < seed.length; i += 1) {
19
+ hash = (hash * 31 + seed.charCodeAt(i)) | 0;
20
+ }
21
+ return Math.abs(hash) % buckets;
22
+ }
23
+
24
+ /** "just now" / "4h ago" / "Mar 3". */
25
+ export function relativeTime(
26
+ iso: string | null | undefined,
27
+ now: number = Date.now(),
28
+ ): string {
29
+ if (!iso) return "";
30
+ const then = Date.parse(iso);
31
+ if (!Number.isFinite(then)) return "";
32
+ const seconds = Math.round((now - then) / 1000);
33
+ if (seconds < 45) return "just now";
34
+ const minutes = Math.round(seconds / 60);
35
+ if (minutes < 60) return `${minutes}m ago`;
36
+ const hours = Math.round(minutes / 60);
37
+ if (hours < 24) return `${hours}h ago`;
38
+ const days = Math.round(hours / 24);
39
+ if (days < 7) return `${days}d ago`;
40
+ return new Date(then).toLocaleDateString("en-US", { month: "short", day: "numeric" });
41
+ }
42
+
43
+ /** "3h 20m" / "45m" / "2d" — a duration an agent reads at a glance. */
44
+ export function duration(minutes: number): string {
45
+ const abs = Math.abs(Math.round(minutes));
46
+ if (abs < 60) return `${abs}m`;
47
+ if (abs < 1440) {
48
+ const hours = Math.floor(abs / 60);
49
+ const rest = abs % 60;
50
+ return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
51
+ }
52
+ return `${Math.floor(abs / 1440)}d`;
53
+ }
@@ -0,0 +1,78 @@
1
+ // Client-side search powering the ⌘K palette.
2
+ //
3
+ // A small team's CRM fits in the synced replica, so search runs locally against
4
+ // data already in memory: results appear as you type, with no request per
5
+ // keystroke. Swap in `db.useSearch` (Pylon's FTS) when a workspace outgrows
6
+ // that — the component boundary doesn't change, only this file.
7
+
8
+ export interface SearchItem {
9
+ id: string;
10
+ type: "deal" | "company" | "contact";
11
+ title: string;
12
+ /** Second line: the company for a contact, the domain for a company. */
13
+ subtitle?: string;
14
+ href: string;
15
+ /** Extra text matched but not displayed — an email, say. */
16
+ keywords?: string;
17
+ }
18
+
19
+ export interface Scored extends SearchItem {
20
+ score: number;
21
+ }
22
+
23
+ /**
24
+ * Rank items against a query. Prefix matches beat word-boundary matches, which
25
+ * beat substring matches; ties break on the shorter title, so "Acme" ranks
26
+ * above "Acme Corporation Holdings" for the query "acme".
27
+ */
28
+ export function searchItems(
29
+ items: SearchItem[],
30
+ query: string,
31
+ limit = 8,
32
+ ): SearchItem[] {
33
+ const q = query.trim().toLowerCase();
34
+ if (!q) return items.slice(0, limit);
35
+
36
+ const scored: Scored[] = [];
37
+ for (const item of items) {
38
+ const score = scoreItem(item, q);
39
+ if (score > 0) scored.push({ ...item, score });
40
+ }
41
+ scored.sort(
42
+ (a, b) => b.score - a.score || a.title.length - b.title.length ||
43
+ a.title.localeCompare(b.title),
44
+ );
45
+ return scored.slice(0, limit);
46
+ }
47
+
48
+ function scoreItem(item: SearchItem, q: string): number {
49
+ const title = item.title.toLowerCase();
50
+ if (title.startsWith(q)) return 100;
51
+ if (wordStart(title, q)) return 80;
52
+ if (title.includes(q)) return 60;
53
+
54
+ const haystack = `${item.subtitle ?? ""} ${item.keywords ?? ""}`.toLowerCase();
55
+ if (wordStart(haystack, q)) return 40;
56
+ if (haystack.includes(q)) return 20;
57
+ return 0;
58
+ }
59
+
60
+ function wordStart(haystack: string, needle: string): boolean {
61
+ let from = 0;
62
+ for (;;) {
63
+ const at = haystack.indexOf(needle, from);
64
+ if (at === -1) return false;
65
+ if (at === 0 || /[\s._@/-]/.test(haystack[at - 1])) return true;
66
+ from = at + 1;
67
+ }
68
+ }
69
+
70
+ /** Move through a list with arrow keys, wrapping at both ends. */
71
+ export function moveSelection(
72
+ current: number,
73
+ delta: number,
74
+ length: number,
75
+ ): number {
76
+ if (length === 0) return 0;
77
+ return (current + delta + length) % length;
78
+ }