@storyshelf/app 0.6.2 → 0.7.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 (361) hide show
  1. package/README.md +42 -4
  2. package/dist/app-types.d.mts +1 -3
  3. package/dist/asset-manifest.mjs +13 -5
  4. package/dist/asset-manifest.mjs.map +1 -1
  5. package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
  6. package/dist/assets/htmx.mjs.map +1 -0
  7. package/dist/assets/mark.mjs +10 -0
  8. package/dist/assets/mark.mjs.map +1 -0
  9. package/dist/boot-tasks.mjs +80 -0
  10. package/dist/boot-tasks.mjs.map +1 -0
  11. package/dist/build-state-flags.mjs +18 -0
  12. package/dist/build-state-flags.mjs.map +1 -0
  13. package/dist/build-state-setup.mjs +75 -0
  14. package/dist/build-state-setup.mjs.map +1 -0
  15. package/dist/capture-alerts.mjs +46 -0
  16. package/dist/capture-alerts.mjs.map +1 -0
  17. package/dist/capture-process.mjs +59 -0
  18. package/dist/capture-process.mjs.map +1 -0
  19. package/dist/capture-setup.mjs +95 -117
  20. package/dist/capture-setup.mjs.map +1 -1
  21. package/dist/connections.mjs +34 -0
  22. package/dist/connections.mjs.map +1 -0
  23. package/dist/consumer-setup.mjs +58 -0
  24. package/dist/consumer-setup.mjs.map +1 -0
  25. package/dist/git-status.mjs +46 -0
  26. package/dist/git-status.mjs.map +1 -0
  27. package/dist/hidden-data.mjs +24 -0
  28. package/dist/hidden-data.mjs.map +1 -0
  29. package/dist/index.d.mts +3 -3
  30. package/dist/index.mjs +203 -125
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/insights/budget.mjs +49 -0
  33. package/dist/insights/budget.mjs.map +1 -0
  34. package/dist/insights/deps.mjs +21 -0
  35. package/dist/insights/deps.mjs.map +1 -0
  36. package/dist/insights/errors.mjs +22 -0
  37. package/dist/insights/errors.mjs.map +1 -0
  38. package/dist/insights/health-panel.mjs +40 -0
  39. package/dist/insights/health-panel.mjs.map +1 -0
  40. package/dist/insights/job.mjs +103 -0
  41. package/dist/insights/job.mjs.map +1 -0
  42. package/dist/insights/load-health.mjs +59 -0
  43. package/dist/insights/load-health.mjs.map +1 -0
  44. package/dist/insights/load-triage.mjs +98 -0
  45. package/dist/insights/load-triage.mjs.map +1 -0
  46. package/dist/insights/panel.mjs +28 -0
  47. package/dist/insights/panel.mjs.map +1 -0
  48. package/dist/insights/request.mjs +113 -0
  49. package/dist/insights/request.mjs.map +1 -0
  50. package/dist/insights/stale.mjs +21 -0
  51. package/dist/insights/stale.mjs.map +1 -0
  52. package/dist/insights/usage-report.mjs +60 -0
  53. package/dist/insights/usage-report.mjs.map +1 -0
  54. package/dist/insights/view.mjs +48 -0
  55. package/dist/insights/view.mjs.map +1 -0
  56. package/dist/job-canceller.mjs +14 -0
  57. package/dist/job-canceller.mjs.map +1 -0
  58. package/dist/job-queue.mjs +51 -0
  59. package/dist/job-queue.mjs.map +1 -0
  60. package/dist/jobs-setup.mjs +132 -0
  61. package/dist/jobs-setup.mjs.map +1 -0
  62. package/dist/lifecycle.mjs +91 -19
  63. package/dist/lifecycle.mjs.map +1 -1
  64. package/dist/middleware/auth-gate.mjs +4 -2
  65. package/dist/middleware/auth-gate.mjs.map +1 -1
  66. package/dist/middleware/rate-limit.mjs +41 -33
  67. package/dist/middleware/rate-limit.mjs.map +1 -1
  68. package/dist/middleware/request-log.mjs +3 -2
  69. package/dist/middleware/request-log.mjs.map +1 -1
  70. package/dist/middleware/require-review.mjs +51 -0
  71. package/dist/middleware/require-review.mjs.map +1 -0
  72. package/dist/middleware/require-testing.mjs +41 -0
  73. package/dist/middleware/require-testing.mjs.map +1 -0
  74. package/dist/middleware/setup-gate.mjs.map +1 -1
  75. package/dist/middleware/store-scope.mjs +12 -2
  76. package/dist/middleware/store-scope.mjs.map +1 -1
  77. package/dist/notifications-pause.mjs +33 -0
  78. package/dist/notifications-pause.mjs.map +1 -0
  79. package/dist/notify.d.mts +2 -1
  80. package/dist/notify.mjs +78 -39
  81. package/dist/notify.mjs.map +1 -1
  82. package/dist/pages/a11y-violations.mjs +116 -0
  83. package/dist/pages/a11y-violations.mjs.map +1 -0
  84. package/dist/pages/admin-ai.mjs +78 -0
  85. package/dist/pages/admin-ai.mjs.map +1 -0
  86. package/dist/pages/admin-system-origin.mjs +123 -0
  87. package/dist/pages/admin-system-origin.mjs.map +1 -0
  88. package/dist/pages/admin-system-targets.mjs +53 -0
  89. package/dist/pages/admin-system-targets.mjs.map +1 -0
  90. package/dist/pages/admin-system.mjs +63 -13
  91. package/dist/pages/admin-system.mjs.map +1 -1
  92. package/dist/pages/build-detail.mjs +104 -76
  93. package/dist/pages/build-detail.mjs.map +1 -1
  94. package/dist/pages/build-diff-actions.mjs +87 -33
  95. package/dist/pages/build-diff-actions.mjs.map +1 -1
  96. package/dist/pages/build-diff-comments.mjs +16 -14
  97. package/dist/pages/build-diff-comments.mjs.map +1 -1
  98. package/dist/pages/build-diff-environment.mjs +82 -0
  99. package/dist/pages/build-diff-environment.mjs.map +1 -0
  100. package/dist/pages/build-diff-header.mjs +22 -4
  101. package/dist/pages/build-diff-header.mjs.map +1 -1
  102. package/dist/pages/build-diff-insight.mjs +113 -0
  103. package/dist/pages/build-diff-insight.mjs.map +1 -0
  104. package/dist/pages/build-diff-model.mjs +55 -10
  105. package/dist/pages/build-diff-model.mjs.map +1 -1
  106. package/dist/pages/build-diff-nav.mjs +28 -12
  107. package/dist/pages/build-diff-nav.mjs.map +1 -1
  108. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  109. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  110. package/dist/pages/build-diff-usage.mjs +63 -0
  111. package/dist/pages/build-diff-usage.mjs.map +1 -0
  112. package/dist/pages/build-diff-viewer.mjs +62 -5
  113. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  114. package/dist/pages/build-diff.mjs +34 -13
  115. package/dist/pages/build-diff.mjs.map +1 -1
  116. package/dist/pages/build-tests.mjs +74 -0
  117. package/dist/pages/build-tests.mjs.map +1 -0
  118. package/dist/pages/component-detail.mjs +249 -0
  119. package/dist/pages/component-detail.mjs.map +1 -0
  120. package/dist/pages/component-stories.mjs +168 -0
  121. package/dist/pages/component-stories.mjs.map +1 -0
  122. package/dist/pages/compute-jobs.mjs +13 -26
  123. package/dist/pages/compute-jobs.mjs.map +1 -1
  124. package/dist/pages/design-links.mjs +182 -0
  125. package/dist/pages/design-links.mjs.map +1 -0
  126. package/dist/pages/environment-change.mjs +53 -0
  127. package/dist/pages/environment-change.mjs.map +1 -0
  128. package/dist/pages/health-panel.mjs +126 -0
  129. package/dist/pages/health-panel.mjs.map +1 -0
  130. package/dist/pages/library-grid.mjs +105 -0
  131. package/dist/pages/library-grid.mjs.map +1 -0
  132. package/dist/pages/library-model.mjs +47 -0
  133. package/dist/pages/library-model.mjs.map +1 -0
  134. package/dist/pages/library-snapshots.mjs +130 -0
  135. package/dist/pages/library-snapshots.mjs.map +1 -0
  136. package/dist/pages/library.mjs +112 -218
  137. package/dist/pages/library.mjs.map +1 -1
  138. package/dist/pages/notifications-paused.mjs +22 -0
  139. package/dist/pages/notifications-paused.mjs.map +1 -0
  140. package/dist/pages/profile.mjs +53 -48
  141. package/dist/pages/profile.mjs.map +1 -1
  142. package/dist/pages/project-builds-table.mjs +2 -2
  143. package/dist/pages/project-builds-table.mjs.map +1 -1
  144. package/dist/pages/project-builds.mjs +51 -21
  145. package/dist/pages/project-builds.mjs.map +1 -1
  146. package/dist/pages/project-settings.mjs +51 -12
  147. package/dist/pages/project-settings.mjs.map +1 -1
  148. package/dist/pages/projects-card.mjs +14 -1
  149. package/dist/pages/projects-card.mjs.map +1 -1
  150. package/dist/pages/projects.mjs +6 -2
  151. package/dist/pages/projects.mjs.map +1 -1
  152. package/dist/pages/review-changeset.mjs +116 -0
  153. package/dist/pages/review-changeset.mjs.map +1 -0
  154. package/dist/pages/review-comments.mjs +103 -0
  155. package/dist/pages/review-comments.mjs.map +1 -0
  156. package/dist/pages/review-verdict.mjs +102 -0
  157. package/dist/pages/review-verdict.mjs.map +1 -0
  158. package/dist/pages/settings-ai.mjs +69 -0
  159. package/dist/pages/settings-ai.mjs.map +1 -0
  160. package/dist/pages/settings-capture.mjs +124 -0
  161. package/dist/pages/settings-capture.mjs.map +1 -0
  162. package/dist/pages/settings-design-credential.mjs +99 -0
  163. package/dist/pages/settings-design-credential.mjs.map +1 -0
  164. package/dist/pages/settings-design-links.mjs +179 -0
  165. package/dist/pages/settings-design-links.mjs.map +1 -0
  166. package/dist/pages/settings-design.mjs +48 -0
  167. package/dist/pages/settings-design.mjs.map +1 -0
  168. package/dist/pages/settings-general.mjs +46 -21
  169. package/dist/pages/settings-general.mjs.map +1 -1
  170. package/dist/pages/settings-labels.mjs +43 -2
  171. package/dist/pages/settings-labels.mjs.map +1 -1
  172. package/dist/pages/settings-links.mjs +112 -0
  173. package/dist/pages/settings-links.mjs.map +1 -0
  174. package/dist/pages/settings-notifications-git.mjs +128 -0
  175. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  176. package/dist/pages/settings-notifications.mjs +79 -42
  177. package/dist/pages/settings-notifications.mjs.map +1 -1
  178. package/dist/pages/settings-tests.mjs +33 -2
  179. package/dist/pages/settings-tests.mjs.map +1 -1
  180. package/dist/pages/target-choices.mjs +39 -0
  181. package/dist/pages/target-choices.mjs.map +1 -0
  182. package/dist/publish-setup.mjs +89 -0
  183. package/dist/publish-setup.mjs.map +1 -0
  184. package/dist/retention-timer.mjs +2 -1
  185. package/dist/retention-timer.mjs.map +1 -1
  186. package/dist/review-events.mjs +48 -0
  187. package/dist/review-events.mjs.map +1 -0
  188. package/dist/routers/admin-pages.mjs +45 -3
  189. package/dist/routers/admin-pages.mjs.map +1 -1
  190. package/dist/routers/admin-public-origin.mjs +85 -0
  191. package/dist/routers/admin-public-origin.mjs.map +1 -0
  192. package/dist/routers/assets.mjs +9 -2
  193. package/dist/routers/assets.mjs.map +1 -1
  194. package/dist/routers/auth.mjs +1 -1
  195. package/dist/routers/baseline-guard.mjs +2 -2
  196. package/dist/routers/baseline-guard.mjs.map +1 -1
  197. package/dist/routers/baselines.handlers.mjs +64 -0
  198. package/dist/routers/baselines.handlers.mjs.map +1 -0
  199. package/dist/routers/baselines.mjs +125 -0
  200. package/dist/routers/baselines.mjs.map +1 -0
  201. package/dist/routers/build-cancel.mjs +68 -0
  202. package/dist/routers/build-cancel.mjs.map +1 -0
  203. package/dist/routers/build-changeset.mjs +70 -0
  204. package/dist/routers/build-changeset.mjs.map +1 -0
  205. package/dist/routers/build-reviews.handlers.mjs +86 -0
  206. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  207. package/dist/routers/build-reviews.mjs +74 -0
  208. package/dist/routers/build-reviews.mjs.map +1 -0
  209. package/dist/routers/builds.handlers.mjs +46 -55
  210. package/dist/routers/builds.handlers.mjs.map +1 -1
  211. package/dist/routers/builds.mjs +71 -17
  212. package/dist/routers/builds.mjs.map +1 -1
  213. package/dist/routers/capture-config.mjs +35 -0
  214. package/dist/routers/capture-config.mjs.map +1 -0
  215. package/dist/routers/comments.mjs +41 -15
  216. package/dist/routers/comments.mjs.map +1 -1
  217. package/dist/routers/component-catalog.handlers.mjs +71 -0
  218. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  219. package/dist/routers/component.handlers.mjs +121 -0
  220. package/dist/routers/component.handlers.mjs.map +1 -0
  221. package/dist/routers/connections.mjs +118 -0
  222. package/dist/routers/connections.mjs.map +1 -0
  223. package/dist/routers/design-connect.handlers.mjs +44 -0
  224. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  225. package/dist/routers/design.handlers.mjs +114 -0
  226. package/dist/routers/design.handlers.mjs.map +1 -0
  227. package/dist/routers/health-report.mjs +2 -3
  228. package/dist/routers/health-report.mjs.map +1 -1
  229. package/dist/routers/health.mjs +4 -3
  230. package/dist/routers/health.mjs.map +1 -1
  231. package/dist/routers/helpers.mjs +19 -3
  232. package/dist/routers/helpers.mjs.map +1 -1
  233. package/dist/routers/insights-ui.mjs +64 -0
  234. package/dist/routers/insights-ui.mjs.map +1 -0
  235. package/dist/routers/insights.handlers.mjs +45 -0
  236. package/dist/routers/insights.handlers.mjs.map +1 -0
  237. package/dist/routers/insights.mjs +189 -0
  238. package/dist/routers/insights.mjs.map +1 -0
  239. package/dist/routers/links.handlers.mjs +80 -0
  240. package/dist/routers/links.handlers.mjs.map +1 -0
  241. package/dist/routers/links.mjs +139 -0
  242. package/dist/routers/links.mjs.map +1 -0
  243. package/dist/routers/media.mjs +18 -4
  244. package/dist/routers/media.mjs.map +1 -1
  245. package/dist/routers/members.mjs +2 -1
  246. package/dist/routers/members.mjs.map +1 -1
  247. package/dist/routers/notification-channels.mjs +17 -23
  248. package/dist/routers/notification-channels.mjs.map +1 -1
  249. package/dist/routers/oauth-state.mjs +45 -0
  250. package/dist/routers/oauth-state.mjs.map +1 -0
  251. package/dist/routers/preview-base.mjs +38 -0
  252. package/dist/routers/preview-base.mjs.map +1 -0
  253. package/dist/routers/preview-token.mjs +51 -0
  254. package/dist/routers/preview-token.mjs.map +1 -0
  255. package/dist/routers/profile.mjs +3 -1
  256. package/dist/routers/profile.mjs.map +1 -1
  257. package/dist/routers/projects.mjs +17 -1
  258. package/dist/routers/projects.mjs.map +1 -1
  259. package/dist/routers/publish-upload.mjs +78 -0
  260. package/dist/routers/publish-upload.mjs.map +1 -0
  261. package/dist/routers/published-access.mjs +52 -0
  262. package/dist/routers/published-access.mjs.map +1 -0
  263. package/dist/routers/published-files.mjs +52 -0
  264. package/dist/routers/published-files.mjs.map +1 -0
  265. package/dist/routers/review-forms.mjs +72 -0
  266. package/dist/routers/review-forms.mjs.map +1 -0
  267. package/dist/routers/review-section.handlers.mjs +85 -0
  268. package/dist/routers/review-section.handlers.mjs.map +1 -0
  269. package/dist/routers/review-switch-off.mjs +29 -0
  270. package/dist/routers/review-switch-off.mjs.map +1 -0
  271. package/dist/routers/schemas.mjs +140 -22
  272. package/dist/routers/schemas.mjs.map +1 -1
  273. package/dist/routers/scoped-models.mjs +22 -0
  274. package/dist/routers/scoped-models.mjs.map +1 -0
  275. package/dist/routers/settings-ai.mjs +35 -0
  276. package/dist/routers/settings-ai.mjs.map +1 -0
  277. package/dist/routers/settings-capture.mjs +77 -0
  278. package/dist/routers/settings-capture.mjs.map +1 -0
  279. package/dist/routers/settings-design.mjs +103 -0
  280. package/dist/routers/settings-design.mjs.map +1 -0
  281. package/dist/routers/settings-general.mjs +55 -10
  282. package/dist/routers/settings-general.mjs.map +1 -1
  283. package/dist/routers/settings-git-channel.mjs +77 -0
  284. package/dist/routers/settings-git-channel.mjs.map +1 -0
  285. package/dist/routers/settings-labels.mjs +27 -1
  286. package/dist/routers/settings-labels.mjs.map +1 -1
  287. package/dist/routers/settings-links.mjs +63 -0
  288. package/dist/routers/settings-links.mjs.map +1 -0
  289. package/dist/routers/settings-members.mjs +4 -7
  290. package/dist/routers/settings-members.mjs.map +1 -1
  291. package/dist/routers/settings-notifications.mjs +15 -10
  292. package/dist/routers/settings-notifications.mjs.map +1 -1
  293. package/dist/routers/settings-tokens.mjs +2 -5
  294. package/dist/routers/settings-tokens.mjs.map +1 -1
  295. package/dist/routers/settings-webhooks.mjs +7 -24
  296. package/dist/routers/settings-webhooks.mjs.map +1 -1
  297. package/dist/routers/settings.handlers.mjs +107 -26
  298. package/dist/routers/settings.handlers.mjs.map +1 -1
  299. package/dist/routers/settings.mjs +16 -4
  300. package/dist/routers/settings.mjs.map +1 -1
  301. package/dist/routers/signed-storybook.mjs +53 -0
  302. package/dist/routers/signed-storybook.mjs.map +1 -0
  303. package/dist/routers/snapshot-groups.mjs +63 -0
  304. package/dist/routers/snapshot-groups.mjs.map +1 -0
  305. package/dist/routers/snapshots.mjs +103 -5
  306. package/dist/routers/snapshots.mjs.map +1 -1
  307. package/dist/routers/storybook.mjs +37 -88
  308. package/dist/routers/storybook.mjs.map +1 -1
  309. package/dist/routers/tokens.mjs +2 -5
  310. package/dist/routers/tokens.mjs.map +1 -1
  311. package/dist/routers/ui.mjs +34 -5
  312. package/dist/routers/ui.mjs.map +1 -1
  313. package/dist/routers/webhooks.mjs +5 -11
  314. package/dist/routers/webhooks.mjs.map +1 -1
  315. package/dist/routers/well-known.mjs +11 -4
  316. package/dist/routers/well-known.mjs.map +1 -1
  317. package/dist/routes/api-routes.mjs +93 -0
  318. package/dist/routes/api-routes.mjs.map +1 -0
  319. package/dist/routes/classification.mjs +38 -0
  320. package/dist/routes/classification.mjs.map +1 -0
  321. package/dist/routes/openapi-classes.mjs +22 -0
  322. package/dist/routes/openapi-classes.mjs.map +1 -0
  323. package/dist/routes/page-routes.mjs +104 -0
  324. package/dist/routes/page-routes.mjs.map +1 -0
  325. package/dist/runtime.mjs +18 -13
  326. package/dist/runtime.mjs.map +1 -1
  327. package/dist/serverless.mjs +5 -0
  328. package/dist/store.mjs +19 -1
  329. package/dist/store.mjs.map +1 -1
  330. package/dist/targets.mjs +46 -0
  331. package/dist/targets.mjs.map +1 -0
  332. package/dist/ui/buttons.mjs +16 -3
  333. package/dist/ui/buttons.mjs.map +1 -1
  334. package/dist/ui/design-reference.mjs +97 -0
  335. package/dist/ui/design-reference.mjs.map +1 -0
  336. package/dist/ui/document.mjs +1 -2
  337. package/dist/ui/document.mjs.map +1 -1
  338. package/dist/ui/forms.mjs +6 -5
  339. package/dist/ui/forms.mjs.map +1 -1
  340. package/dist/ui/icons/paths.mjs +1 -0
  341. package/dist/ui/icons/paths.mjs.map +1 -1
  342. package/dist/ui/preview-embed.mjs +82 -0
  343. package/dist/ui/preview-embed.mjs.map +1 -0
  344. package/dist/ui/shell/client-script.mjs +85 -0
  345. package/dist/ui/shell/client-script.mjs.map +1 -1
  346. package/dist/ui/shell/nav.mjs +12 -8
  347. package/dist/ui/shell/nav.mjs.map +1 -1
  348. package/dist/ui/shell/review-script.mjs +8 -0
  349. package/dist/ui/shell/review-script.mjs.map +1 -1
  350. package/dist/ui/shell/sidebar.mjs +2 -2
  351. package/dist/ui/shell/sidebar.mjs.map +1 -1
  352. package/package.json +11 -9
  353. package/dist/asset-paths.mjs +0 -23
  354. package/dist/asset-paths.mjs.map +0 -1
  355. package/dist/assets/mark.svg +0 -1
  356. package/dist/pages/settings-status.mjs +0 -137
  357. package/dist/pages/settings-status.mjs.map +0 -1
  358. package/dist/routers/settings-status.mjs +0 -77
  359. package/dist/routers/settings-status.mjs.map +0 -1
  360. package/dist/routers/status-configs.mjs +0 -108
  361. package/dist/routers/status-configs.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"profile.mjs","names":[],"sources":["../../src/pages/profile.tsx"],"sourcesContent":["import type { AuthUser } from \"@storyshelf/core/types\";\nimport { safeImageUrl } from \"@storyshelf/core/urls\";\nimport type { FC } from \"hono/jsx\";\nimport {\n Alert,\n Badge,\n Button,\n Card,\n Field,\n HStack,\n PageHeader,\n Table,\n VStack,\n} from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\nimport { css } from \"../ui/css.ts\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\nimport { passkeyRegisterScript } from \"./passkey-ceremony.ts\";\n\nexport interface ProfilePageState {\n user: AuthUser;\n dbUser?: {\n email: string;\n name: string;\n displayNameOverride: string | null;\n authProvider: string;\n lastLoginAt: string | null;\n createdAt: string;\n };\n memberships: Array<{ projectName: string; projectSlug: string; role: string; source: string }>;\n subscribedSlugs: string[];\n error?: string;\n isLocal?: boolean;\n security?: {\n sessions: Array<{\n id: string;\n ipAddress: string | null;\n userAgent: string | null;\n createdAt: string;\n expiresAt: string;\n current: boolean;\n }>;\n passkeys: Array<{\n id: string;\n name: string | null;\n deviceType: string;\n backedUp: boolean;\n transports: string | null;\n aaguid: string | null;\n createdAt: string;\n }>;\n passkeysEnabled: boolean;\n hasPassword: boolean;\n };\n}\n\nconst profileAvatar = css`\n /* profile-avatar */\n width: 64px;\n height: 64px;\n border-radius: 999px;\n object-fit: cover;\n`;\n\nconst profileAvatarFallback = css`\n /* profile-avatar-fallback */\n width: 64px;\n height: 64px;\n display: grid;\n place-items: center;\n background: var(--accent);\n color: #fff;\n border-radius: 999px;\n font-size: 1.5rem;\n font-weight: 700;\n`;\n\nconst profileName = css`\n /* profile-name */\n font-weight: 700;\n font-size: 1.2rem;\n`;\n\nconst profileMeta = css`\n /* profile-meta */\n color: var(--text-secondary);\n font-size: 0.85rem;\n`;\n\n/* eslint-disable promise-function-async -- JSX components return HtmlEscapedString */\n\ntype SecurityState = NonNullable<ProfilePageState[\"security\"]>;\ntype PasskeyState = SecurityState[\"passkeys\"][number];\n\n/** Registered passkey rows with per-key remove actions. */\nconst PasskeyTable: FC<{ passkeys: PasskeyState[] }> = ({ passkeys }) => {\n return (\n <Table dense>\n <table>\n <thead>\n <tr>\n <th>Key</th>\n <th>Added</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n {passkeys.map(\n // oxlint-disable-next-line typescript/promise-function-async -- JSX map is sync\n (key) => (\n <tr key={key.id}>\n <td>\n <div>{key.name ?? \"Unnamed key\"}</div>\n <div class={profileMeta}>{key.deviceType}</div>\n </td>\n <td>{new Date(key.createdAt).toLocaleDateString()}</td>\n <td>\n <HStack>\n {key.backedUp ? <Badge>Synced</Badge> : null}\n <form\n method=\"post\"\n action={`/profile/passkeys/${key.id}/delete`}\n data-confirm=\"Remove this passkey?\"\n >\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n Remove\n </Button>\n </form>\n </HStack>\n </td>\n </tr>\n ),\n )}\n </tbody>\n </table>\n </Table>\n );\n};\n\ntype SessionInfo = SecurityState[\"sessions\"][number];\n\n/** One signed-in device with its revoke action. */\nconst SessionRow: FC<{ session: SessionInfo }> = ({ session }) => {\n return (\n <tr>\n <td>\n <div class=\"truncate\">{session.userAgent ?? \"Unknown device\"}</div>\n <div class={profileMeta}>\n <span class=\"mono\">{session.ipAddress ?? \"—\"}</span>\n </div>\n </td>\n <td>{new Date(session.createdAt).toLocaleString()}</td>\n <td>\n {session.current ? (\n <Badge>This device</Badge>\n ) : (\n <form\n method=\"post\"\n action=\"/profile/sessions/revoke\"\n data-confirm=\"Sign this device out?\"\n >\n <input type=\"hidden\" name=\"sessionId\" value={session.id} />\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n Revoke\n </Button>\n </form>\n )}\n </td>\n </tr>\n );\n};\n\n/** Active device sessions with per-device revoke and sign-out-others. */\nconst SessionsSection: FC<{ security: SecurityState }> = ({ security }) => {\n return (\n <Card>\n <PageHeader title=\"Devices\" description=\"Where you are signed in\" />\n <Table dense>\n <table>\n <thead>\n <tr>\n <th>Device</th>\n <th>Signed in</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n {security.sessions.map((session) => (\n <SessionRow key={session.id} session={session} />\n ))}\n </tbody>\n </table>\n </Table>\n {security.sessions.length > 1 ? (\n <form\n method=\"post\"\n action=\"/profile/sessions/revoke-others\"\n data-confirm=\"Sign out all other devices?\"\n >\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n Sign out other devices\n </Button>\n </form>\n ) : null}\n </Card>\n );\n};\n\n/** Registered passkeys with delete, register button, and the backup nudge. */\nconst PasskeysSection: FC<{ security: SecurityState }> = ({ security }) => {\n return (\n <Card>\n <PageHeader title=\"Passkeys\" description=\"Sign in without a password using your device\" />\n {security.passkeys.length < 2 ? (\n <Alert title=\"Add a backup key\">\n Register a 2nd key so a lost device doesn&apos;t lock you out.\n </Alert>\n ) : null}\n {security.passkeys.length === 0 ? (\n <p class={profileMeta}>No passkeys yet.</p>\n ) : (\n <PasskeyTable passkeys={security.passkeys} />\n )}\n <HStack>\n <Field label=\"Key name\" name=\"keyName\" type=\"text\" placeholder=\"e.g. Security key\" />\n <Button variant=\"primary\" size=\"sm\" type=\"button\" data-passkey-register=\"true\">\n Register a passkey\n </Button>\n </HStack>\n <p class={profileMeta} data-passkey-status=\"true\" aria-live=\"polite\">\n {\"\"}\n </p>\n <script dangerouslySetInnerHTML={{ __html: passkeyRegisterScript() }} />\n </Card>\n );\n};\n\n/** Personal profile page (editable display name, password for local accounts). */\n// oxlint-disable-next-line eslint/max-lines-per-function -- profile page composes several sections, cohesive\nexport function renderProfilePage(state: ProfilePageState): RenderedContent {\n const { user, dbUser, memberships, subscribedSlugs, error, isLocal, security } = state;\n const avatar = safeImageUrl(user.avatarUrl);\n const displayName = dbUser?.displayNameOverride ?? user.name;\n const provider = dbUser?.authProvider ?? user.providerId ?? \"oidc\";\n return (\n <DocumentLayout title=\"Profile\" nav={{ active: \"profile\" }}>\n <PageHeader title=\"Profile\" description=\"Your account and project memberships\" />\n {error ? (\n <Alert tone=\"danger\" title=\"Could not save\">\n {error}\n </Alert>\n ) : null}\n\n <Card>\n <VStack>\n <HStack>\n {avatar ? (\n <img class={profileAvatar} src={avatar} alt=\"\" width=\"64\" height=\"64\" />\n ) : (\n <span class={profileAvatarFallback} aria-hidden=\"true\">\n {user.name.slice(0, 1).toUpperCase()}\n </span>\n )}\n <VStack>\n <div class={profileName}>{displayName}</div>\n <div class={profileMeta}>{user.email}</div>\n <HStack>\n <Badge>{user.role}</Badge>\n <Badge>via {provider}</Badge>\n </HStack>\n </VStack>\n </HStack>\n <div class={profileMeta}>\n Member since {dbUser?.createdAt ? new Date(dbUser.createdAt).toLocaleDateString() : \"—\"}{\" \"}\n · Last login {dbUser?.lastLoginAt ? new Date(dbUser.lastLoginAt).toLocaleString() : \"—\"}\n </div>\n </VStack>\n </Card>\n\n <Card>\n <PageHeader\n title=\"Display name\"\n description=\"Shown in comments and the header. Survives SSO refresh.\"\n />\n <form method=\"post\" action=\"/profile\" novalidate>\n <Field label=\"Display name\" name=\"displayName\" type=\"text\" required value={displayName} />\n <Button variant=\"primary\" type=\"submit\">\n Save name\n </Button>\n </form>\n </Card>\n\n {isLocal ? (\n <Card>\n <PageHeader title=\"Change password\" description=\"For local accounts only\" />\n <form method=\"post\" action=\"/profile/password\" novalidate>\n <Field\n label=\"Current password\"\n name=\"currentPassword\"\n type=\"password\"\n required\n autocomplete=\"current-password\"\n />\n <Field\n label=\"New password\"\n name=\"newPassword\"\n type=\"password\"\n required\n autocomplete=\"new-password\"\n />\n <Field\n label=\"Confirm new password\"\n name=\"confirmPassword\"\n type=\"password\"\n required\n autocomplete=\"new-password\"\n />\n <Button variant=\"primary\" type=\"submit\">\n Change password\n </Button>\n </form>\n </Card>\n ) : null}\n\n {security ? <SessionsSection security={security} /> : null}\n\n {security?.passkeysEnabled ? <PasskeysSection security={security} /> : null}\n\n <Card>\n <PageHeader title=\"Project memberships\" description=\"Your role on each project\" />{\" \"}\n {memberships.length === 0 ? (\n <p class={profileMeta}>No project memberships.</p>\n ) : (\n <Table dense>\n <table>\n <thead>\n <tr>\n <th>Project</th>\n <th>Role</th>\n <th>Source</th>\n </tr>\n </thead>\n <tbody>\n {memberships.map(\n // oxlint-disable-next-line typescript/promise-function-async -- JSX map is sync\n (m) => (\n <tr key={m.projectSlug}>\n <td>\n <a href={`/projects/${m.projectSlug}`}>{m.projectName}</a>\n </td>\n <td>\n <Badge>{m.role}</Badge>\n </td>\n <td>{m.source}</td>\n </tr>\n ),\n )}\n </tbody>\n </table>\n </Table>\n )}\n </Card>\n\n <Card>\n <PageHeader\n title=\"Notifications\"\n description=\"Email alerts per project. Fine-tune topics in each project's settings.\"\n />\n {memberships.length === 0 ? (\n <p class={profileMeta}>No project memberships.</p>\n ) : (\n <Table dense>\n <table>\n <thead>\n <tr>\n <th>Project</th>\n <th>Status</th>\n <th>\n <span class=\"visually-hidden\">Actions</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {memberships.map(\n // oxlint-disable-next-line typescript/promise-function-async -- JSX map is sync\n (m) => {\n const subscribed = subscribedSlugs.includes(m.projectSlug);\n return (\n <tr key={m.projectSlug}>\n <td>\n <a href={`/projects/${m.projectSlug}`}>{m.projectName}</a>\n </td>\n <td>{subscribed ? <Badge>Email on</Badge> : <Badge>Off</Badge>}</td>\n <td>\n <form method=\"post\" action=\"/profile/notifications\">\n {csrfField()}\n <input type=\"hidden\" name=\"slug\" value={m.projectSlug} />\n <input type=\"hidden\" name=\"enabled\" value={subscribed ? \"\" : \"1\"} />\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n {subscribed ? \"Disable\" : \"Enable\"}\n </Button>\n </form>\n </td>\n </tr>\n );\n },\n )}\n </tbody>\n </table>\n </Table>\n )}\n </Card>\n\n <Card>\n <form method=\"post\" action=\"/auth/logout\">\n {csrfField()}\n <Button variant=\"secondary\" type=\"submit\">\n Sign out\n </Button>\n </form>\n </Card>\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"profile.mjs","names":[],"sources":["../../src/pages/profile.tsx"],"sourcesContent":["import type { AuthUser } from \"@storyshelf/core/types\";\nimport { safeImageUrl } from \"@storyshelf/core/urls\";\nimport type { FC } from \"hono/jsx\";\nimport {\n Alert,\n Badge,\n Button,\n Card,\n Field,\n HStack,\n PageHeader,\n Table,\n VStack,\n} from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\nimport { css } from \"../ui/css.ts\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\nimport { NotificationsPausedNotice } from \"./notifications-paused.tsx\";\nimport { passkeyRegisterScript } from \"./passkey-ceremony.ts\";\n\nexport interface ProfilePageState {\n user: AuthUser;\n dbUser?: {\n email: string;\n name: string;\n displayNameOverride: string | null;\n authProvider: string;\n lastLoginAt: string | null;\n createdAt: string;\n };\n memberships: Array<{ projectName: string; projectSlug: string; role: string; source: string }>;\n subscribedSlugs: string[];\n error?: string;\n /** Notifiers are wired but no public origin resolves (ADR 0014, decision 11). */\n notificationsPaused?: boolean | undefined;\n isLocal?: boolean;\n security?: {\n sessions: Array<{\n id: string;\n ipAddress: string | null;\n userAgent: string | null;\n createdAt: string;\n expiresAt: string;\n current: boolean;\n }>;\n passkeys: Array<{\n id: string;\n name: string | null;\n deviceType: string;\n backedUp: boolean;\n transports: string | null;\n aaguid: string | null;\n createdAt: string;\n }>;\n passkeysEnabled: boolean;\n hasPassword: boolean;\n };\n}\n\nconst profileAvatar = css`\n /* profile-avatar */\n width: 64px;\n height: 64px;\n border-radius: 999px;\n object-fit: cover;\n`;\n\nconst profileAvatarFallback = css`\n /* profile-avatar-fallback */\n width: 64px;\n height: 64px;\n display: grid;\n place-items: center;\n background: var(--accent);\n color: #fff;\n border-radius: 999px;\n font-size: 1.5rem;\n font-weight: 700;\n`;\n\nconst profileName = css`\n /* profile-name */\n font-weight: 700;\n font-size: 1.2rem;\n`;\n\nconst profileMeta = css`\n /* profile-meta */\n color: var(--text-secondary);\n font-size: 0.85rem;\n`;\n\n/* eslint-disable promise-function-async -- JSX components return HtmlEscapedString */\n\ntype SecurityState = NonNullable<ProfilePageState[\"security\"]>;\ntype PasskeyState = SecurityState[\"passkeys\"][number];\n\n/** Registered passkey rows with per-key remove actions. */\nconst PasskeyTable: FC<{ passkeys: PasskeyState[] }> = ({ passkeys }) => {\n return (\n <Table dense>\n <table>\n <thead>\n <tr>\n <th>Key</th>\n <th>Added</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n {passkeys.map(\n // oxlint-disable-next-line typescript/promise-function-async -- JSX map is sync\n (key) => (\n <tr key={key.id}>\n <td>\n <div>{key.name ?? \"Unnamed key\"}</div>\n <div class={profileMeta}>{key.deviceType}</div>\n </td>\n <td>{new Date(key.createdAt).toLocaleDateString()}</td>\n <td>\n <HStack>\n {key.backedUp ? <Badge>Synced</Badge> : null}\n <form\n method=\"post\"\n action={`/profile/passkeys/${key.id}/delete`}\n data-confirm=\"Remove this passkey?\"\n >\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n Remove\n </Button>\n </form>\n </HStack>\n </td>\n </tr>\n ),\n )}\n </tbody>\n </table>\n </Table>\n );\n};\n\ntype SessionInfo = SecurityState[\"sessions\"][number];\n\n/** One signed-in device with its revoke action. */\nconst SessionRow: FC<{ session: SessionInfo }> = ({ session }) => {\n return (\n <tr>\n <td>\n <div class=\"truncate\">{session.userAgent ?? \"Unknown device\"}</div>\n <div class={profileMeta}>\n <span class=\"mono\">{session.ipAddress ?? \"—\"}</span>\n </div>\n </td>\n <td>{new Date(session.createdAt).toLocaleString()}</td>\n <td>\n {session.current ? (\n <Badge>This device</Badge>\n ) : (\n <form\n method=\"post\"\n action=\"/profile/sessions/revoke\"\n data-confirm=\"Sign this device out?\"\n >\n <input type=\"hidden\" name=\"sessionId\" value={session.id} />\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n Revoke\n </Button>\n </form>\n )}\n </td>\n </tr>\n );\n};\n\n/** Active device sessions with per-device revoke and sign-out-others. */\nconst SessionsSection: FC<{ security: SecurityState }> = ({ security }) => {\n return (\n <Card>\n <PageHeader title=\"Devices\" description=\"Where you are signed in\" />\n <Table dense>\n <table>\n <thead>\n <tr>\n <th>Device</th>\n <th>Signed in</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n {security.sessions.map((session) => (\n <SessionRow key={session.id} session={session} />\n ))}\n </tbody>\n </table>\n </Table>\n {security.sessions.length > 1 ? (\n <form\n method=\"post\"\n action=\"/profile/sessions/revoke-others\"\n data-confirm=\"Sign out all other devices?\"\n >\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n Sign out other devices\n </Button>\n </form>\n ) : null}\n </Card>\n );\n};\n\n/** Registered passkeys with delete, register button, and the backup nudge. */\nconst PasskeysSection: FC<{ security: SecurityState }> = ({ security }) => {\n return (\n <Card>\n <PageHeader title=\"Passkeys\" description=\"Sign in without a password using your device\" />\n {security.passkeys.length < 2 ? (\n <Alert title=\"Add a backup key\">\n Register a 2nd key so a lost device doesn&apos;t lock you out.\n </Alert>\n ) : null}\n {security.passkeys.length === 0 ? (\n <p class={profileMeta}>No passkeys yet.</p>\n ) : (\n <PasskeyTable passkeys={security.passkeys} />\n )}\n <HStack>\n <Field label=\"Key name\" name=\"keyName\" type=\"text\" placeholder=\"e.g. Security key\" />\n <Button variant=\"primary\" size=\"sm\" type=\"button\" data-passkey-register=\"true\">\n Register a passkey\n </Button>\n </HStack>\n <p class={profileMeta} data-passkey-status=\"true\" aria-live=\"polite\">\n {\"\"}\n </p>\n <script dangerouslySetInnerHTML={{ __html: passkeyRegisterScript() }} />\n </Card>\n );\n};\n\n/** Personal profile page (editable display name, password for local accounts). */\n// oxlint-disable-next-line eslint/max-lines-per-function -- profile page composes several sections, cohesive\nexport function renderProfilePage(state: ProfilePageState): RenderedContent {\n const { user, dbUser, memberships, subscribedSlugs, error, isLocal, security } = state;\n const avatar = safeImageUrl(user.avatarUrl);\n const displayName = dbUser?.displayNameOverride ?? user.name;\n const provider = dbUser?.authProvider ?? user.providerId ?? \"oidc\";\n return (\n <DocumentLayout title=\"Profile\" nav={{ active: \"profile\" }}>\n <PageHeader title=\"Profile\" description=\"Your account and project memberships\" />\n {error ? (\n <Alert tone=\"danger\" title=\"Could not save\">\n {error}\n </Alert>\n ) : null}\n\n <Card>\n <VStack>\n <HStack>\n {avatar ? (\n <img class={profileAvatar} src={avatar} alt=\"\" width=\"64\" height=\"64\" />\n ) : (\n <span class={profileAvatarFallback} aria-hidden=\"true\">\n {user.name.slice(0, 1).toUpperCase()}\n </span>\n )}\n <VStack>\n <div class={profileName}>{displayName}</div>\n <div class={profileMeta}>{user.email}</div>\n <HStack>\n <Badge>{user.role}</Badge>\n <Badge>via {provider}</Badge>\n </HStack>\n </VStack>\n </HStack>\n <div class={profileMeta}>\n Member since {dbUser?.createdAt ? new Date(dbUser.createdAt).toLocaleDateString() : \"—\"}{\" \"}\n · Last login {dbUser?.lastLoginAt ? new Date(dbUser.lastLoginAt).toLocaleString() : \"—\"}\n </div>\n </VStack>\n </Card>\n\n <Card>\n <PageHeader\n title=\"Display name\"\n description=\"Shown in comments and the header. Survives SSO refresh.\"\n />\n <form method=\"post\" action=\"/profile\" novalidate>\n <Field label=\"Display name\" name=\"displayName\" type=\"text\" required value={displayName} />\n <Button variant=\"primary\" type=\"submit\">\n Save name\n </Button>\n </form>\n </Card>\n\n {isLocal ? (\n <Card>\n <PageHeader title=\"Change password\" description=\"For local accounts only\" />\n <form method=\"post\" action=\"/profile/password\" novalidate>\n <Field\n label=\"Current password\"\n name=\"currentPassword\"\n type=\"password\"\n required\n autocomplete=\"current-password\"\n />\n <Field\n label=\"New password\"\n name=\"newPassword\"\n type=\"password\"\n required\n autocomplete=\"new-password\"\n />\n <Field\n label=\"Confirm new password\"\n name=\"confirmPassword\"\n type=\"password\"\n required\n autocomplete=\"new-password\"\n />\n <Button variant=\"primary\" type=\"submit\">\n Change password\n </Button>\n </form>\n </Card>\n ) : null}\n\n {security ? <SessionsSection security={security} /> : null}\n\n {security?.passkeysEnabled ? <PasskeysSection security={security} /> : null}\n\n <Card>\n <PageHeader title=\"Project memberships\" description=\"Your role on each project\" />{\" \"}\n {memberships.length === 0 ? (\n <p class={profileMeta}>No project memberships.</p>\n ) : (\n <Table dense>\n <table>\n <thead>\n <tr>\n <th>Project</th>\n <th>Role</th>\n <th>Source</th>\n </tr>\n </thead>\n <tbody>\n {memberships.map(\n // oxlint-disable-next-line typescript/promise-function-async -- JSX map is sync\n (m) => (\n <tr key={m.projectSlug}>\n <td>\n <a href={`/projects/${m.projectSlug}`}>{m.projectName}</a>\n </td>\n <td>\n <Badge>{m.role}</Badge>\n </td>\n <td>{m.source}</td>\n </tr>\n ),\n )}\n </tbody>\n </table>\n </Table>\n )}\n </Card>\n\n <Card>\n <PageHeader\n title=\"Notifications\"\n description=\"Email alerts per project. Fine-tune topics in each project's settings.\"\n />\n <NotificationsPausedNotice paused={state.notificationsPaused} />\n {memberships.length === 0 ? (\n <p class={profileMeta}>No project memberships.</p>\n ) : (\n <Table dense>\n <table>\n <thead>\n <tr>\n <th>Project</th>\n <th>Status</th>\n <th>\n <span class=\"visually-hidden\">Actions</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {memberships.map(\n // oxlint-disable-next-line typescript/promise-function-async -- JSX map is sync\n (m) => {\n const subscribed = subscribedSlugs.includes(m.projectSlug);\n return (\n <tr key={m.projectSlug}>\n <td>\n <a href={`/projects/${m.projectSlug}`}>{m.projectName}</a>\n </td>\n <td>{subscribed ? <Badge>Email on</Badge> : <Badge>Off</Badge>}</td>\n <td>\n <form method=\"post\" action=\"/profile/notifications\">\n {csrfField()}\n <input type=\"hidden\" name=\"slug\" value={m.projectSlug} />\n <input type=\"hidden\" name=\"enabled\" value={subscribed ? \"\" : \"1\"} />\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n {subscribed ? \"Disable\" : \"Enable\"}\n </Button>\n </form>\n </td>\n </tr>\n );\n },\n )}\n </tbody>\n </table>\n </Table>\n )}\n </Card>\n\n <Card>\n <form method=\"post\" action=\"/auth/logout\">\n {csrfField()}\n <Button variant=\"secondary\" type=\"submit\">\n Sign out\n </Button>\n </form>\n </Card>\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { getStore } from "../store.mjs";
4
+ import { getStore, testingEnabled } from "../store.mjs";
5
5
  import { css } from "../ui/css.mjs";
6
6
  import { Button } from "../ui/buttons.mjs";
7
7
  import { Badge, Meta, statusTone } from "../ui/feedback.mjs";
@@ -71,7 +71,7 @@ function BuildSummaryCell({ project, build }) {
71
71
  }
72
72
  function BuildActions({ project, build }) {
73
73
  const urls = createUrlBuilder("/", getStore().config.publishedBaseDomain);
74
- const needsReview = build.status === "reviewing";
74
+ const needsReview = build.status === "reviewing" && testingEnabled();
75
75
  return /* @__PURE__ */ jsxs(HStack, {
76
76
  wrap: false,
77
77
  justify: "end",
@@ -1 +1 @@
1
- {"version":3,"file":"project-builds-table.mjs","names":[],"sources":["../../src/pages/project-builds-table.tsx"],"sourcesContent":["import type { Build, Project } from \"@storyshelf/core/schema\";\nimport { createUrlBuilder } from \"@storyshelf/core/urls\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { getStore } from \"../store.ts\";\nimport {\n Avatar,\n Badge,\n Button,\n HStack,\n Meta,\n RelativeTime,\n Table,\n statusTone,\n} from \"../ui/components.tsx\";\nimport { css } from \"../ui/css.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\nconst buildCell = css`\n /* build-cell */\n display: grid;\n gap: 2px;\n min-width: 0;\n max-width: 38ch;\n & strong a {\n color: var(--text-primary);\n }\n & strong a:hover {\n color: var(--accent-fg);\n }\n`;\n\nconst messageLine = css`\n /* build-message */\n overflow: hidden;\n color: var(--text-secondary);\n font-size: var(--text-sm);\n text-overflow: ellipsis;\n white-space: nowrap;\n`;\n\nconst authorCell = css`\n /* build-author */\n display: flex;\n gap: var(--space-2);\n align-items: center;\n white-space: nowrap;\n`;\n\n/** \"4 to review · 1 approved · 12 snapshots\", leaving out zero counts. */\nfunction changeSummary(build: Build): string {\n const parts: string[] = [];\n if (build.changedCount > 0) {\n parts.push(`${build.changedCount} to review`);\n }\n if (build.approvedCount > 0) {\n parts.push(`${build.approvedCount} approved`);\n }\n parts.push(`${build.snapshotCount} ${build.snapshotCount === 1 ? \"snapshot\" : \"snapshots\"}`);\n return parts.join(\" · \");\n}\n\nfunction BuildSummaryCell({\n project,\n build,\n}: {\n project: Project;\n build: Build;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const urls = createUrlBuilder(\"/\", getStore().config.publishedBaseDomain);\n return (\n <div class={buildCell}>\n <strong>\n <a href={urls.build(project.slug, build.id)}>{build.gitBranch}</a>{\" \"}\n <Meta as=\"span\" mono>\n {build.gitSha.slice(0, 7)}\n </Meta>\n </strong>\n <span class={messageLine}>{build.message ?? \"No message\"}</span>\n </div>\n );\n}\n\nfunction BuildActions({\n project,\n build,\n}: {\n project: Project;\n build: Build;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const urls = createUrlBuilder(\"/\", getStore().config.publishedBaseDomain);\n const needsReview = build.status === \"reviewing\";\n return (\n <HStack wrap={false} justify=\"end\">\n <Button\n variant={needsReview ? \"primary\" : \"secondary\"}\n size=\"sm\"\n href={\n needsReview ? urls.buildDiff(project.slug, build.id) : urls.build(project.slug, build.id)\n }\n >\n {needsReview ? \"Review\" : \"View\"}\n </Button>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n icon=\"external-link\"\n href={urls.short(build.id)}\n aria-label=\"View Storybook\"\n title=\"View Storybook\"\n />\n </HStack>\n );\n}\n\nfunction BuildRow({\n project,\n build,\n}: {\n project: Project;\n build: Build;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n return (\n <tr>\n <td>\n <BuildSummaryCell project={project} build={build} />\n </td>\n <td>\n <Badge tone={statusTone(build.status)} icon>\n {build.status}\n </Badge>\n </td>\n <td>\n <Meta as=\"span\">{changeSummary(build)}</Meta>\n </td>\n <td>\n <div class={authorCell}>\n {build.authorName ? <Avatar name={build.authorName} size=\"sm\" /> : null}\n <span>{build.authorName ?? \"—\"}</span>\n </div>\n </td>\n <td class=\"nowrap\">\n <Meta as=\"span\">\n <RelativeTime value={build.createdAt} />\n </Meta>\n </td>\n <td class=\"nowrap\">\n <BuildActions project={project} build={build} />\n </td>\n </tr>\n );\n}\n\n/** Builds as a table: branch/sha, status, change summary, author, age, actions. */\nexport function BuildsTable({\n project,\n builds,\n}: {\n project: Project;\n builds: Build[];\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n return (\n <Table>\n <table>\n <thead>\n <tr>\n <th>Build</th>\n <th>Status</th>\n <th>Changes</th>\n <th>Author</th>\n <th>Created</th>\n <th>\n <span class=\"visually-hidden\">Actions</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {builds.map((build) => (\n <BuildRow key={build.id} project={project} build={build} />\n ))}\n </tbody>\n </table>\n </Table>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"project-builds-table.mjs","names":[],"sources":["../../src/pages/project-builds-table.tsx"],"sourcesContent":["import type { Build, Project } from \"@storyshelf/core/schema\";\nimport { createUrlBuilder } from \"@storyshelf/core/urls\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { getStore, testingEnabled } from \"../store.ts\";\nimport {\n Avatar,\n Badge,\n Button,\n HStack,\n Meta,\n RelativeTime,\n Table,\n statusTone,\n} from \"../ui/components.tsx\";\nimport { css } from \"../ui/css.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\nconst buildCell = css`\n /* build-cell */\n display: grid;\n gap: 2px;\n min-width: 0;\n max-width: 38ch;\n & strong a {\n color: var(--text-primary);\n }\n & strong a:hover {\n color: var(--accent-fg);\n }\n`;\n\nconst messageLine = css`\n /* build-message */\n overflow: hidden;\n color: var(--text-secondary);\n font-size: var(--text-sm);\n text-overflow: ellipsis;\n white-space: nowrap;\n`;\n\nconst authorCell = css`\n /* build-author */\n display: flex;\n gap: var(--space-2);\n align-items: center;\n white-space: nowrap;\n`;\n\n/** \"4 to review · 1 approved · 12 snapshots\", leaving out zero counts. */\nfunction changeSummary(build: Build): string {\n const parts: string[] = [];\n if (build.changedCount > 0) {\n parts.push(`${build.changedCount} to review`);\n }\n if (build.approvedCount > 0) {\n parts.push(`${build.approvedCount} approved`);\n }\n parts.push(`${build.snapshotCount} ${build.snapshotCount === 1 ? \"snapshot\" : \"snapshots\"}`);\n return parts.join(\" · \");\n}\n\nfunction BuildSummaryCell({\n project,\n build,\n}: {\n project: Project;\n build: Build;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const urls = createUrlBuilder(\"/\", getStore().config.publishedBaseDomain);\n return (\n <div class={buildCell}>\n <strong>\n <a href={urls.build(project.slug, build.id)}>{build.gitBranch}</a>{\" \"}\n <Meta as=\"span\" mono>\n {build.gitSha.slice(0, 7)}\n </Meta>\n </strong>\n <span class={messageLine}>{build.message ?? \"No message\"}</span>\n </div>\n );\n}\n\nfunction BuildActions({\n project,\n build,\n}: {\n project: Project;\n build: Build;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const urls = createUrlBuilder(\"/\", getStore().config.publishedBaseDomain);\n // The diff page is a testing route; hosting-only instances link to the build page.\n const needsReview = build.status === \"reviewing\" && testingEnabled();\n return (\n <HStack wrap={false} justify=\"end\">\n <Button\n variant={needsReview ? \"primary\" : \"secondary\"}\n size=\"sm\"\n href={\n needsReview ? urls.buildDiff(project.slug, build.id) : urls.build(project.slug, build.id)\n }\n >\n {needsReview ? \"Review\" : \"View\"}\n </Button>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n icon=\"external-link\"\n href={urls.short(build.id)}\n aria-label=\"View Storybook\"\n title=\"View Storybook\"\n />\n </HStack>\n );\n}\n\nfunction BuildRow({\n project,\n build,\n}: {\n project: Project;\n build: Build;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n return (\n <tr>\n <td>\n <BuildSummaryCell project={project} build={build} />\n </td>\n <td>\n <Badge tone={statusTone(build.status)} icon>\n {build.status}\n </Badge>\n </td>\n <td>\n <Meta as=\"span\">{changeSummary(build)}</Meta>\n </td>\n <td>\n <div class={authorCell}>\n {build.authorName ? <Avatar name={build.authorName} size=\"sm\" /> : null}\n <span>{build.authorName ?? \"—\"}</span>\n </div>\n </td>\n <td class=\"nowrap\">\n <Meta as=\"span\">\n <RelativeTime value={build.createdAt} />\n </Meta>\n </td>\n <td class=\"nowrap\">\n <BuildActions project={project} build={build} />\n </td>\n </tr>\n );\n}\n\n/** Builds as a table: branch/sha, status, change summary, author, age, actions. */\nexport function BuildsTable({\n project,\n builds,\n}: {\n project: Project;\n builds: Build[];\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n return (\n <Table>\n <table>\n <thead>\n <tr>\n <th>Build</th>\n <th>Status</th>\n <th>Changes</th>\n <th>Author</th>\n <th>Created</th>\n <th>\n <span class=\"visually-hidden\">Actions</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {builds.map((build) => (\n <BuildRow key={build.id} project={project} build={build} />\n ))}\n </tbody>\n </table>\n </Table>\n );\n}\n"],"mappings":"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"}
@@ -1,16 +1,18 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { getStore } from "../store.mjs";
4
+ import { getStore, testingEnabled } from "../store.mjs";
5
5
  import { Button } from "../ui/buttons.mjs";
6
- import { Badge, EmptyState } from "../ui/feedback.mjs";
6
+ import { Badge, EmptyState, Meta, Stat } from "../ui/feedback.mjs";
7
7
  import { SelectField } from "../ui/forms.mjs";
8
- import { PageHeader } from "../ui/layout.mjs";
8
+ import { Card, PageHeader } from "../ui/layout.mjs";
9
9
  import { HStack, VStack } from "../ui/stacks.mjs";
10
10
  import { Segmented } from "../ui/segmented.mjs";
11
11
  import { DocumentLayout } from "../ui/document.mjs";
12
+ import { loadHealthPanel } from "../insights/health-panel.mjs";
13
+ import { HealthSlot } from "./health-panel.mjs";
12
14
  import { BuildsTable } from "./project-builds-table.mjs";
13
- import { BuildModel, ProjectModel } from "@storyshelf/core/models";
15
+ import { BuildModel, ProjectModel, averageInheritedRatio } from "@storyshelf/core/models";
14
16
  import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
15
17
  import { createUrlBuilder } from "@storyshelf/core/urls";
16
18
  //#region src/pages/project-builds.tsx
@@ -19,6 +21,7 @@ const STATUSES = [
19
21
  "approved",
20
22
  "rejected",
21
23
  "failed",
24
+ "cancelled",
22
25
  "pending"
23
26
  ];
24
27
  function listHref(base, filters) {
@@ -86,6 +89,24 @@ function BuildsToolbar(props) {
86
89
  })]
87
90
  });
88
91
  }
92
+ /**
93
+ * Affected-capture efficiency: the average share of a snapshot set that recent builds inherited
94
+ * from the baseline instead of rendering, from the counts stored on the builds (no snapshot reads).
95
+ */
96
+ function InheritedRatioCard(props) {
97
+ const average = averageInheritedRatio(props.builds);
98
+ if (average === null) return null;
99
+ return /* @__PURE__ */ jsxs(Card, { children: [/* @__PURE__ */ jsx(Stat, {
100
+ label: "Inherited from baseline",
101
+ value: `${Math.round(average.ratio * 100)}%`
102
+ }), /* @__PURE__ */ jsxs(Meta, { children: [
103
+ "Average over the last ",
104
+ average.builds,
105
+ " ",
106
+ average.builds === 1 ? "build" : "builds",
107
+ ": the share of snapshots reused without rendering."
108
+ ] })] });
109
+ }
89
110
  /** Project builds page: filterable build history for one project. */
90
111
  async function renderProjectBuildsPage(slug, query = {}) {
91
112
  const project = (await new ProjectModel(getStore().db).list()).find((item) => item.slug === slug);
@@ -94,6 +115,7 @@ async function renderProjectBuildsPage(slug, query = {}) {
94
115
  const builds = all.filter((build) => matches(build, query));
95
116
  const urls = createUrlBuilder("/", getStore().config.publishedBaseDomain);
96
117
  const filtered = Boolean(query.status ?? query.branch);
118
+ const health = await loadHealthPanel(project);
97
119
  return /* @__PURE__ */ jsxs(DocumentLayout, {
98
120
  title: project.name,
99
121
  nav: {
@@ -118,23 +140,31 @@ async function renderProjectBuildsPage(slug, query = {}) {
118
140
  href: urls.settings(project.slug),
119
141
  children: "Settings"
120
142
  })
121
- }), /* @__PURE__ */ jsxs(VStack, { children: [/* @__PURE__ */ jsx(BuildsToolbar, {
122
- project,
123
- all,
124
- filters: query,
125
- base: urls.buildsList(project.slug)
126
- }), builds.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
127
- title: "No builds",
128
- description: filtered ? "No builds match the current filter." : "Upload your first build with the CLI. Builds appear here once uploaded.",
129
- action: filtered ? /* @__PURE__ */ jsx(Button, {
130
- variant: "secondary",
131
- href: urls.buildsList(project.slug),
132
- children: "Clear filters"
133
- }) : null
134
- }) : /* @__PURE__ */ jsx(BuildsTable, {
135
- project,
136
- builds
137
- })] })]
143
+ }), /* @__PURE__ */ jsxs(VStack, { children: [
144
+ /* @__PURE__ */ jsx(HealthSlot, {
145
+ project,
146
+ data: health
147
+ }),
148
+ testingEnabled() ? /* @__PURE__ */ jsx(InheritedRatioCard, { builds: all }) : null,
149
+ /* @__PURE__ */ jsx(BuildsToolbar, {
150
+ project,
151
+ all,
152
+ filters: query,
153
+ base: urls.buildsList(project.slug)
154
+ }),
155
+ builds.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
156
+ title: "No builds",
157
+ description: filtered ? "No builds match the current filter." : "Upload your first build with the CLI. Builds appear here once uploaded.",
158
+ action: filtered ? /* @__PURE__ */ jsx(Button, {
159
+ variant: "secondary",
160
+ href: urls.buildsList(project.slug),
161
+ children: "Clear filters"
162
+ }) : null
163
+ }) : /* @__PURE__ */ jsx(BuildsTable, {
164
+ project,
165
+ builds
166
+ })
167
+ ] })]
138
168
  });
139
169
  }
140
170
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"project-builds.mjs","names":[],"sources":["../../src/pages/project-builds.tsx"],"sourcesContent":["import { BuildModel, ProjectModel } from \"@storyshelf/core/models\";\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport { createUrlBuilder } from \"@storyshelf/core/urls\";\nimport { getStore } from \"../store.ts\";\nimport {\n Badge,\n Button,\n EmptyState,\n HStack,\n PageHeader,\n Segmented,\n SelectField,\n VStack,\n} from \"../ui/components.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\nimport { BuildsTable } from \"./project-builds-table.tsx\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\nconst STATUSES = [\"reviewing\", \"approved\", \"rejected\", \"failed\", \"pending\"] as const;\n\n/** Current list filters (from the query string). */\ninterface BuildFilters {\n status?: string;\n branch?: string;\n}\n\nfunction listHref(base: string, filters: BuildFilters): string {\n const params = new URLSearchParams();\n if (filters.status) {\n params.set(\"status\", filters.status);\n }\n if (filters.branch) {\n params.set(\"branch\", filters.branch);\n }\n const query = params.toString();\n return query ? `${base}?${query}` : base;\n}\n\nfunction matches(build: Build, filters: BuildFilters): boolean {\n return (\n (!filters.status || build.status === filters.status) &&\n (!filters.branch || build.gitBranch === filters.branch)\n );\n}\n\n/** Status chips (with counts) plus a branch select that applies instantly. */\nfunction BuildsToolbar(props: {\n project: Project;\n all: Build[];\n filters: BuildFilters;\n base: string;\n}): ReturnType<typeof HStack> {\n const { all, filters, base } = props;\n const branches = [...new Set(all.map((build) => build.gitBranch))].toSorted();\n const inBranch = all.filter((build) => !filters.branch || build.gitBranch === filters.branch);\n const count = (status: string): number =>\n inBranch.filter((build) => build.status === status).length;\n const items = [\n { label: `All ${inBranch.length}`, value: \"\", status: undefined },\n ...STATUSES.filter((status) => count(status) > 0 || filters.status === status).map(\n (status) => ({ label: `${status} ${count(status)}`, value: status, status }),\n ),\n ];\n return (\n <HStack justify=\"between\">\n <Segmented\n label=\"Filter by status\"\n items={items.map((item) => ({\n label: item.label,\n value: item.value,\n href: listHref(base, { ...filters, status: item.status }),\n active: (filters.status ?? \"\") === item.value,\n }))}\n />\n <form\n method=\"get\"\n action={base}\n hx-get={base}\n hx-trigger=\"change\"\n hx-target=\"body\"\n hx-push-url=\"true\"\n >\n {filters.status ? <input type=\"hidden\" name=\"status\" value={filters.status} /> : null}\n <SelectField\n label=\"Branch\"\n name=\"branch\"\n layout=\"inline\"\n value={filters.branch ?? \"\"}\n options={[\n { value: \"\", label: \"All branches\" },\n ...branches.map((branch) => ({ value: branch, label: branch })),\n ]}\n />\n </form>\n </HStack>\n );\n}\n\n/** Project builds page: filterable build history for one project. */\nexport async function renderProjectBuildsPage(\n slug: string,\n query: BuildFilters = {},\n): Promise<RenderedContent | null> {\n const projects = await new ProjectModel(getStore().db).list();\n const project = projects.find((item) => item.slug === slug);\n if (!project) {\n return null;\n }\n const all = await new BuildModel(getStore().db).list(project.id);\n const builds = all.filter((build) => matches(build, query));\n const urls = createUrlBuilder(\"/\", getStore().config.publishedBaseDomain);\n const filtered = Boolean(query.status ?? query.branch);\n\n return (\n <DocumentLayout\n title={project.name}\n nav={{ active: \"builds\", projectSlug: project.slug, projectName: project.name }}\n >\n <PageHeader\n title=\"Builds\"\n description={\n <>\n {project.gitRepository ?? project.slug} · default branch{\" \"}\n <Badge tone=\"neutral\">{project.gitDefaultBranch}</Badge>\n </>\n }\n actions={\n <Button variant=\"secondary\" icon=\"settings\" href={urls.settings(project.slug)}>\n Settings\n </Button>\n }\n />\n <VStack>\n <BuildsToolbar\n project={project}\n all={all}\n filters={query}\n base={urls.buildsList(project.slug)}\n />\n {builds.length === 0 ? (\n <EmptyState\n title=\"No builds\"\n description={\n filtered\n ? \"No builds match the current filter.\"\n : \"Upload your first build with the CLI. Builds appear here once uploaded.\"\n }\n action={\n filtered ? (\n <Button variant=\"secondary\" href={urls.buildsList(project.slug)}>\n Clear filters\n </Button>\n ) : null\n }\n />\n ) : (\n <BuildsTable project={project} builds={builds} />\n )}\n </VStack>\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"project-builds.mjs","names":[],"sources":["../../src/pages/project-builds.tsx"],"sourcesContent":["import { BuildModel, ProjectModel, averageInheritedRatio } from \"@storyshelf/core/models\";\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport { createUrlBuilder } from \"@storyshelf/core/urls\";\nimport { loadHealthPanel } from \"../insights/health-panel.ts\";\nimport { getStore, testingEnabled } from \"../store.ts\";\nimport {\n Badge,\n Button,\n Card,\n EmptyState,\n HStack,\n Meta,\n PageHeader,\n Segmented,\n SelectField,\n Stat,\n VStack,\n} from \"../ui/components.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\nimport { HealthSlot } from \"./health-panel.tsx\";\nimport { BuildsTable } from \"./project-builds-table.tsx\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\nconst STATUSES = [\"reviewing\", \"approved\", \"rejected\", \"failed\", \"cancelled\", \"pending\"] as const;\n\n/** Current list filters (from the query string). */\ninterface BuildFilters {\n status?: string;\n branch?: string;\n}\n\nfunction listHref(base: string, filters: BuildFilters): string {\n const params = new URLSearchParams();\n if (filters.status) {\n params.set(\"status\", filters.status);\n }\n if (filters.branch) {\n params.set(\"branch\", filters.branch);\n }\n const query = params.toString();\n return query ? `${base}?${query}` : base;\n}\n\nfunction matches(build: Build, filters: BuildFilters): boolean {\n return (\n (!filters.status || build.status === filters.status) &&\n (!filters.branch || build.gitBranch === filters.branch)\n );\n}\n\n/** Status chips (with counts) plus a branch select that applies instantly. */\nfunction BuildsToolbar(props: {\n project: Project;\n all: Build[];\n filters: BuildFilters;\n base: string;\n}): ReturnType<typeof HStack> {\n const { all, filters, base } = props;\n const branches = [...new Set(all.map((build) => build.gitBranch))].toSorted();\n const inBranch = all.filter((build) => !filters.branch || build.gitBranch === filters.branch);\n const count = (status: string): number =>\n inBranch.filter((build) => build.status === status).length;\n const items = [\n { label: `All ${inBranch.length}`, value: \"\", status: undefined },\n ...STATUSES.filter((status) => count(status) > 0 || filters.status === status).map(\n (status) => ({ label: `${status} ${count(status)}`, value: status, status }),\n ),\n ];\n return (\n <HStack justify=\"between\">\n <Segmented\n label=\"Filter by status\"\n items={items.map((item) => ({\n label: item.label,\n value: item.value,\n href: listHref(base, { ...filters, status: item.status }),\n active: (filters.status ?? \"\") === item.value,\n }))}\n />\n <form\n method=\"get\"\n action={base}\n hx-get={base}\n hx-trigger=\"change\"\n hx-target=\"body\"\n hx-push-url=\"true\"\n >\n {filters.status ? <input type=\"hidden\" name=\"status\" value={filters.status} /> : null}\n <SelectField\n label=\"Branch\"\n name=\"branch\"\n layout=\"inline\"\n value={filters.branch ?? \"\"}\n options={[\n { value: \"\", label: \"All branches\" },\n ...branches.map((branch) => ({ value: branch, label: branch })),\n ]}\n />\n </form>\n </HStack>\n );\n}\n\n/**\n * Affected-capture efficiency: the average share of a snapshot set that recent builds inherited\n * from the baseline instead of rendering, from the counts stored on the builds (no snapshot reads).\n */\nfunction InheritedRatioCard(props: { builds: Build[] }): ReturnType<typeof Card> | null {\n const average = averageInheritedRatio(props.builds);\n if (average === null) {\n return null;\n }\n return (\n <Card>\n <Stat label=\"Inherited from baseline\" value={`${Math.round(average.ratio * 100)}%`} />\n <Meta>\n Average over the last {average.builds} {average.builds === 1 ? \"build\" : \"builds\"}: the\n share of snapshots reused without rendering.\n </Meta>\n </Card>\n );\n}\n\n/** Project builds page: filterable build history for one project. */\nexport async function renderProjectBuildsPage(\n slug: string,\n query: BuildFilters = {},\n): Promise<RenderedContent | null> {\n const projects = await new ProjectModel(getStore().db).list();\n const project = projects.find((item) => item.slug === slug);\n if (!project) {\n return null;\n }\n const all = await new BuildModel(getStore().db).list(project.id);\n const builds = all.filter((build) => matches(build, query));\n const urls = createUrlBuilder(\"/\", getStore().config.publishedBaseDomain);\n const filtered = Boolean(query.status ?? query.branch);\n const health = await loadHealthPanel(project);\n\n return (\n <DocumentLayout\n title={project.name}\n nav={{ active: \"builds\", projectSlug: project.slug, projectName: project.name }}\n >\n <PageHeader\n title=\"Builds\"\n description={\n <>\n {project.gitRepository ?? project.slug} · default branch{\" \"}\n <Badge tone=\"neutral\">{project.gitDefaultBranch}</Badge>\n </>\n }\n actions={\n <Button variant=\"secondary\" icon=\"settings\" href={urls.settings(project.slug)}>\n Settings\n </Button>\n }\n />\n <VStack>\n <HealthSlot project={project} data={health} />\n {testingEnabled() ? <InheritedRatioCard builds={all} /> : null}\n <BuildsToolbar\n project={project}\n all={all}\n filters={query}\n base={urls.buildsList(project.slug)}\n />\n {builds.length === 0 ? (\n <EmptyState\n title=\"No builds\"\n description={\n filtered\n ? \"No builds match the current filter.\"\n : \"Upload your first build with the CLI. Builds appear here once uploaded.\"\n }\n action={\n filtered ? (\n <Button variant=\"secondary\" href={urls.buildsList(project.slug)}>\n Clear filters\n </Button>\n ) : null\n }\n />\n ) : (\n <BuildsTable project={project} builds={builds} />\n )}\n </VStack>\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
@@ -1,14 +1,18 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { testingEnabled } from "../store.mjs";
4
5
  import { PageHeader } from "../ui/layout.mjs";
5
6
  import { SubNavLayout } from "../ui/subnav.mjs";
6
7
  import { DocumentLayout } from "../ui/document.mjs";
8
+ import { renderSettingsAi } from "./settings-ai.mjs";
9
+ import { renderSettingsCapture } from "./settings-capture.mjs";
10
+ import { renderSettingsDesign } from "./settings-design.mjs";
7
11
  import { renderSettingsGeneral } from "./settings-general.mjs";
8
12
  import { renderSettingsLabels } from "./settings-labels.mjs";
13
+ import { renderSettingsLinks } from "./settings-links.mjs";
9
14
  import { renderSettingsMembers } from "./settings-members.mjs";
10
15
  import { renderSettingsNotifications } from "./settings-notifications.mjs";
11
- import { renderSettingsStatus } from "./settings-status.mjs";
12
16
  import { renderSettingsTests } from "./settings-tests.mjs";
13
17
  import { renderSettingsTokens } from "./settings-tokens.mjs";
14
18
  import { renderSettingsWebhooks } from "./settings-webhooks.mjs";
@@ -19,44 +23,79 @@ function tabHref(project, tab) {
19
23
  }
20
24
  const TAB_LABELS = {
21
25
  general: "General",
26
+ capture: "Capture",
22
27
  tests: "Tests",
23
28
  labels: "Labels",
29
+ links: "Links",
30
+ design: "Design",
24
31
  tokens: "Tokens",
25
32
  webhooks: "Webhooks",
26
33
  notifications: "Notifications",
27
34
  members: "Members",
28
- status: "Git status"
35
+ ai: "AI"
29
36
  };
30
37
  const TAB_ICONS = {
31
38
  general: "settings",
39
+ capture: "image",
32
40
  tests: "activity",
33
41
  labels: "tag",
42
+ links: "external-link",
43
+ design: "image",
34
44
  tokens: "key",
35
45
  webhooks: "webhook",
36
46
  notifications: "bell",
37
47
  members: "users",
38
- status: "git-branch"
48
+ ai: "sparkles"
39
49
  };
50
+ /** Tabs whose pages are `testing` routes: hidden on a hosting-only instance. */
51
+ const TESTING_TABS = /* @__PURE__ */ new Set([
52
+ "capture",
53
+ "tests",
54
+ "ai"
55
+ ]);
40
56
  const SETTINGS_TABS = [
41
57
  "general",
58
+ "capture",
42
59
  "tests",
43
60
  "labels",
61
+ "links",
62
+ "design",
44
63
  "tokens",
45
64
  "webhooks",
46
65
  "notifications",
47
66
  "members",
48
- "status"
67
+ "ai"
49
68
  ];
69
+ /** A tab shows when its page exists: `testing` tabs need testing, and the AI tab needs AI. */
70
+ function settingsTabShown(tab, data) {
71
+ if (TESTING_TABS.has(tab) && !testingEnabled()) return false;
72
+ return tab !== "ai" || data.ai?.available === true;
73
+ }
74
+ /** Project-behaviour tabs (general, tests, AI); undefined for any other tab. */
75
+ function renderBehaviourTab(data, formState) {
76
+ const { project, activeTab } = data;
77
+ if (activeTab === "general") return renderSettingsGeneral(project, formState, data.isAdmin, data.targets);
78
+ if (activeTab === "capture") return renderSettingsCapture(project, data.isAdmin, formState, data.targets);
79
+ if (activeTab === "ai") return renderSettingsAi(project, data.ai, formState);
80
+ return activeTab === "tests" ? renderSettingsTests(project, data.isAdmin, formState) : void 0;
81
+ }
82
+ /** Labels, links, tokens, and webhooks tabs; undefined for any other tab. */
83
+ function renderCatalogTab(data, formState) {
84
+ const { project, activeTab, isAdmin } = data;
85
+ if (activeTab === "labels") return renderSettingsLabels(project, data.labelTypes, isAdmin, formState);
86
+ if (activeTab === "links") return renderSettingsLinks(project, data.links, data.linkCandidates, isAdmin, formState);
87
+ if (activeTab === "design") return renderSettingsDesign(project, data.design, isAdmin, formState);
88
+ if (activeTab === "tokens") return renderSettingsTokens(project, data.tokens, isAdmin, formState);
89
+ return activeTab === "webhooks" ? renderSettingsWebhooks(project, data.webhooks, isAdmin, formState) : void 0;
90
+ }
50
91
  function renderActiveTab(data, formState) {
51
92
  const { project, activeTab } = data;
52
- if (activeTab === "general") return renderSettingsGeneral(project, formState, data.isAdmin);
53
- if (activeTab === "tests") return renderSettingsTests(project, data.isAdmin, formState);
54
- if (activeTab === "labels") return renderSettingsLabels(project, data.labelTypes, data.isAdmin, formState);
55
- if (activeTab === "tokens") return renderSettingsTokens(project, data.tokens, data.isAdmin, formState);
56
- if (activeTab === "webhooks") return renderSettingsWebhooks(project, data.webhooks, data.isAdmin, formState);
57
- if (activeTab === "notifications") return renderSettingsNotifications(project, data.notificationChannels, data.notifyProviders, data.mySubscription, data.isAdmin, formState);
93
+ const behaviour = renderBehaviourTab(data, formState);
94
+ if (behaviour !== void 0) return behaviour;
95
+ const catalog = renderCatalogTab(data, formState);
96
+ if (catalog !== void 0) return catalog;
97
+ if (activeTab === "notifications") return renderSettingsNotifications(project, data.notificationChannels, data.notifyProviders, data.mySubscription, data.isAdmin, formState, data.notificationsPaused);
58
98
  if (activeTab === "members") return renderSettingsMembers(project, data.members, data.groupMappings, data.isAdmin, formState);
59
- if (activeTab === "status") return renderSettingsStatus(project, data.statusConfigs, data.gitHosts, data.isAdmin, formState);
60
99
  return null;
61
100
  }
62
101
  /** Project settings shell: tab navigation plus the active settings tab. */
@@ -79,7 +118,7 @@ function renderProjectSettingsPage(data, formState) {
79
118
  ] })
80
119
  }), /* @__PURE__ */ jsx(SubNavLayout, {
81
120
  label: "Settings sections",
82
- items: SETTINGS_TABS.map((tab) => ({
121
+ items: SETTINGS_TABS.filter((tab) => settingsTabShown(tab, data)).map((tab) => ({
83
122
  label: TAB_LABELS[tab],
84
123
  href: tabHref(project, tab),
85
124
  icon: TAB_ICONS[tab],
@@ -1 +1 @@
1
- {"version":3,"file":"project-settings.mjs","names":[],"sources":["../../src/pages/project-settings.tsx"],"sourcesContent":["import type { GitHostProvider } from \"@storyshelf/core/adapter/git-host\";\nimport type { LabelType } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { ProjectGroupMapping } from \"@storyshelf/core/schema\";\nimport type { Token } from \"@storyshelf/core/schema\";\nimport { PageHeader, SubNavLayout } from \"../ui/components.tsx\";\nimport type { IconName } from \"../ui/components.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\nimport { renderSettingsGeneral } from \"./settings-general.tsx\";\nimport { renderSettingsLabels } from \"./settings-labels.tsx\";\nimport { renderSettingsMembers, type SettingsMember } from \"./settings-members.tsx\";\nimport {\n renderSettingsNotifications,\n type SettingsMySubscription,\n type SettingsNotificationChannel,\n} from \"./settings-notifications.tsx\";\nimport { renderSettingsStatus, type SettingsStatusConfig } from \"./settings-status.tsx\";\nimport { renderSettingsTests } from \"./settings-tests.tsx\";\nimport { renderSettingsTokens } from \"./settings-tokens.tsx\";\nimport { renderSettingsWebhooks, type SettingsWebhook } from \"./settings-webhooks.tsx\";\n\n/** Tabs available in the project settings section. */\nexport type SettingsTab =\n | \"general\"\n | \"tests\"\n | \"labels\"\n | \"tokens\"\n | \"webhooks\"\n | \"notifications\"\n | \"members\"\n | \"status\";\n\n/** Data required to render the project settings page with its active tab. */\nexport interface ProjectSettingsData {\n project: Project;\n activeTab: SettingsTab;\n labelTypes: LabelType[];\n tokens: Omit<Token, \"hash\">[];\n members: SettingsMember[];\n groupMappings: ProjectGroupMapping[];\n webhooks: SettingsWebhook[];\n notificationChannels: SettingsNotificationChannel[];\n notifyProviders: string[];\n mySubscription: SettingsMySubscription | null;\n statusConfigs: SettingsStatusConfig[];\n gitHosts: GitHostProvider[];\n isAdmin: boolean;\n}\n\n/** Form state shared by the settings tabs (field errors, global error, one-time secret). */\nexport interface SettingsFormState {\n errors?: Record<string, string>;\n globalError?: string;\n secret?: string;\n}\n\nfunction tabHref(project: Project, tab: SettingsTab): string {\n return tab === \"general\"\n ? `/projects/${project.slug}/settings`\n : `/projects/${project.slug}/settings/${tab}`;\n}\n\nconst TAB_LABELS: Record<SettingsTab, string> = {\n general: \"General\",\n tests: \"Tests\",\n labels: \"Labels\",\n tokens: \"Tokens\",\n webhooks: \"Webhooks\",\n notifications: \"Notifications\",\n members: \"Members\",\n status: \"Git status\",\n};\n\nconst TAB_ICONS: Record<SettingsTab, IconName> = {\n general: \"settings\",\n tests: \"activity\",\n labels: \"tag\",\n tokens: \"key\",\n webhooks: \"webhook\",\n notifications: \"bell\",\n members: \"users\",\n status: \"git-branch\",\n};\n\nconst SETTINGS_TABS: SettingsTab[] = [\n \"general\",\n \"tests\",\n \"labels\",\n \"tokens\",\n \"webhooks\",\n \"notifications\",\n \"members\",\n \"status\",\n];\n\nfunction renderActiveTab(data: ProjectSettingsData, formState?: SettingsFormState): unknown {\n const { project, activeTab } = data;\n if (activeTab === \"general\") return renderSettingsGeneral(project, formState, data.isAdmin);\n if (activeTab === \"tests\") return renderSettingsTests(project, data.isAdmin, formState);\n if (activeTab === \"labels\")\n return renderSettingsLabels(project, data.labelTypes, data.isAdmin, formState);\n if (activeTab === \"tokens\")\n return renderSettingsTokens(project, data.tokens, data.isAdmin, formState);\n if (activeTab === \"webhooks\")\n return renderSettingsWebhooks(project, data.webhooks, data.isAdmin, formState);\n if (activeTab === \"notifications\")\n return renderSettingsNotifications(\n project,\n data.notificationChannels,\n data.notifyProviders,\n data.mySubscription,\n data.isAdmin,\n formState,\n );\n if (activeTab === \"members\")\n return renderSettingsMembers(\n project,\n data.members,\n data.groupMappings,\n data.isAdmin,\n formState,\n );\n if (activeTab === \"status\")\n return renderSettingsStatus(\n project,\n data.statusConfigs,\n data.gitHosts,\n data.isAdmin,\n formState,\n );\n return null;\n}\n\n/** Project settings shell: tab navigation plus the active settings tab. */\nexport function renderProjectSettingsPage(\n data: ProjectSettingsData,\n formState?: SettingsFormState,\n): RenderedContent {\n const { project, activeTab } = data;\n return (\n <DocumentLayout\n title={`${project.name} · Settings`}\n nav={{\n active: \"settings\",\n projectSlug: project.slug,\n projectName: project.name,\n trail: activeTab === \"general\" ? [] : [{ label: TAB_LABELS[activeTab] }],\n }}\n >\n <PageHeader\n title=\"Project settings\"\n description={\n <>Manage general settings, labels, tokens, webhooks and members for {project.name}.</>\n }\n />\n\n <SubNavLayout\n label=\"Settings sections\"\n items={SETTINGS_TABS.map((tab) => ({\n label: TAB_LABELS[tab],\n href: tabHref(project, tab),\n icon: TAB_ICONS[tab],\n active: activeTab === tab,\n }))}\n >\n {renderActiveTab(data, formState)}\n </SubNavLayout>\n </DocumentLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAwDA,SAAS,QAAQ,SAAkB,KAA0B;CAC3D,OAAO,QAAQ,YACX,aAAa,QAAQ,KAAK,aAC1B,aAAa,QAAQ,KAAK,YAAY;AAC5C;AAEA,MAAM,aAA0C;CAC9C,SAAS;CACT,OAAO;CACP,QAAQ;CACR,QAAQ;CACR,UAAU;CACV,eAAe;CACf,SAAS;CACT,QAAQ;AACV;AAEA,MAAM,YAA2C;CAC/C,SAAS;CACT,OAAO;CACP,QAAQ;CACR,QAAQ;CACR,UAAU;CACV,eAAe;CACf,SAAS;CACT,QAAQ;AACV;AAEA,MAAM,gBAA+B;CACnC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,SAAS,gBAAgB,MAA2B,WAAwC;CAC1F,MAAM,EAAE,SAAS,cAAc;CAC/B,IAAI,cAAc,WAAW,OAAO,sBAAsB,SAAS,WAAW,KAAK,OAAO;CAC1F,IAAI,cAAc,SAAS,OAAO,oBAAoB,SAAS,KAAK,SAAS,SAAS;CACtF,IAAI,cAAc,UAChB,OAAO,qBAAqB,SAAS,KAAK,YAAY,KAAK,SAAS,SAAS;CAC/E,IAAI,cAAc,UAChB,OAAO,qBAAqB,SAAS,KAAK,QAAQ,KAAK,SAAS,SAAS;CAC3E,IAAI,cAAc,YAChB,OAAO,uBAAuB,SAAS,KAAK,UAAU,KAAK,SAAS,SAAS;CAC/E,IAAI,cAAc,iBAChB,OAAO,4BACL,SACA,KAAK,sBACL,KAAK,iBACL,KAAK,gBACL,KAAK,SACL,SACF;CACF,IAAI,cAAc,WAChB,OAAO,sBACL,SACA,KAAK,SACL,KAAK,eACL,KAAK,SACL,SACF;CACF,IAAI,cAAc,UAChB,OAAO,qBACL,SACA,KAAK,eACL,KAAK,UACL,KAAK,SACL,SACF;CACF,OAAO;AACT;;AAGA,SAAgB,0BACd,MACA,WACiB;CACjB,MAAM,EAAE,SAAS,cAAc;CAC/B,OACE,qBAAC,gBAAD;EACE,OAAO,GAAG,QAAQ,KAAK;EACvB,KAAK;GACH,QAAQ;GACR,aAAa,QAAQ;GACrB,aAAa,QAAQ;GACrB,OAAO,cAAc,YAAY,CAAC,IAAI,CAAC,EAAE,OAAO,WAAW,WAAW,CAAC;EACzE;EAPF,UAAA,CASE,oBAAC,YAAD;GACE,OAAM;GACN,aACE,qBAAA,UAAA,EAAA,UAAA;IAAE;IAAmE,QAAQ;IAAK;GAAG,EAAA,CAAA;EAExF,CAAA,GAED,oBAAC,cAAD;GACE,OAAM;GACN,OAAO,cAAc,KAAK,SAAS;IACjC,OAAO,WAAW;IAClB,MAAM,QAAQ,SAAS,GAAG;IAC1B,MAAM,UAAU;IAChB,QAAQ,cAAc;GACxB,EAAE;GAED,UAAA,gBAAgB,MAAM,SAAS;EACpB,CAAA,CACA;;AAEpB"}
1
+ {"version":3,"file":"project-settings.mjs","names":[],"sources":["../../src/pages/project-settings.tsx"],"sourcesContent":["import type { RunnerTarget } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { LabelType } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { ProjectGroupMapping } from \"@storyshelf/core/schema\";\nimport type { Token } from \"@storyshelf/core/schema\";\nimport { testingEnabled } from \"../store.ts\";\nimport { PageHeader, SubNavLayout } from \"../ui/components.tsx\";\nimport type { IconName } from \"../ui/components.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\nimport { renderSettingsAi, type SettingsAiData } from \"./settings-ai.tsx\";\nimport { renderSettingsCapture } from \"./settings-capture.tsx\";\nimport { renderSettingsDesign, type SettingsDesignData } from \"./settings-design.tsx\";\nimport { renderSettingsGeneral } from \"./settings-general.tsx\";\nimport { renderSettingsLabels } from \"./settings-labels.tsx\";\nimport {\n renderSettingsLinks,\n type SettingsLink,\n type SettingsLinkCandidate,\n} from \"./settings-links.tsx\";\nimport { renderSettingsMembers, type SettingsMember } from \"./settings-members.tsx\";\nimport {\n renderSettingsNotifications,\n type SettingsMySubscription,\n type SettingsNotificationChannel,\n type SettingsNotifyProvider,\n} from \"./settings-notifications.tsx\";\nimport { renderSettingsTests } from \"./settings-tests.tsx\";\nimport { renderSettingsTokens } from \"./settings-tokens.tsx\";\nimport { renderSettingsWebhooks, type SettingsWebhook } from \"./settings-webhooks.tsx\";\n\n/** Tabs available in the project settings section. */\nexport type SettingsTab =\n | \"general\"\n | \"capture\"\n | \"tests\"\n | \"labels\"\n | \"links\"\n | \"design\"\n | \"tokens\"\n | \"webhooks\"\n | \"notifications\"\n | \"members\"\n | \"ai\";\n\n/** Data required to render the project settings page with its active tab. */\nexport interface ProjectSettingsData {\n project: Project;\n activeTab: SettingsTab;\n labelTypes: LabelType[];\n links: SettingsLink[];\n linkCandidates: SettingsLinkCandidate[];\n design: SettingsDesignData;\n tokens: Omit<Token, \"hash\">[];\n members: SettingsMember[];\n groupMappings: ProjectGroupMapping[];\n webhooks: SettingsWebhook[];\n notificationChannels: SettingsNotificationChannel[];\n notifyProviders: SettingsNotifyProvider[];\n mySubscription: SettingsMySubscription | null;\n isAdmin: boolean;\n /** Notifiers are wired but no public origin resolves (ADR 0014, decision 11). */\n notificationsPaused?: boolean | undefined;\n /** AI availability and the profile choices (omitted ⇒ AI off). */\n ai?: SettingsAiData;\n /** What the capture runner offers (ADR 0007); omitted when it publishes no list. */\n targets?: readonly RunnerTarget[] | undefined;\n}\n\n/** Form state shared by the settings tabs (field errors, global error, one-time secret). */\nexport interface SettingsFormState {\n errors?: Record<string, string>;\n globalError?: string;\n secret?: string;\n /** Label type key whose edit form is open (labels tab). */\n editKey?: string;\n}\n\nfunction tabHref(project: Project, tab: SettingsTab): string {\n return tab === \"general\"\n ? `/projects/${project.slug}/settings`\n : `/projects/${project.slug}/settings/${tab}`;\n}\n\nconst TAB_LABELS: Record<SettingsTab, string> = {\n general: \"General\",\n capture: \"Capture\",\n tests: \"Tests\",\n labels: \"Labels\",\n links: \"Links\",\n design: \"Design\",\n tokens: \"Tokens\",\n webhooks: \"Webhooks\",\n notifications: \"Notifications\",\n members: \"Members\",\n ai: \"AI\",\n};\n\nconst TAB_ICONS: Record<SettingsTab, IconName> = {\n general: \"settings\",\n capture: \"image\",\n tests: \"activity\",\n labels: \"tag\",\n links: \"external-link\",\n design: \"image\",\n tokens: \"key\",\n webhooks: \"webhook\",\n notifications: \"bell\",\n members: \"users\",\n ai: \"sparkles\",\n};\n\n/** Tabs whose pages are `testing` routes: hidden on a hosting-only instance. */\nconst TESTING_TABS: ReadonlySet<SettingsTab> = new Set([\"capture\", \"tests\", \"ai\"]);\n\nconst SETTINGS_TABS: SettingsTab[] = [\n \"general\",\n \"capture\",\n \"tests\",\n \"labels\",\n \"links\",\n \"design\",\n \"tokens\",\n \"webhooks\",\n \"notifications\",\n \"members\",\n \"ai\",\n];\n\n/** A tab shows when its page exists: `testing` tabs need testing, and the AI tab needs AI. */\nfunction settingsTabShown(tab: SettingsTab, data: ProjectSettingsData): boolean {\n if (TESTING_TABS.has(tab) && !testingEnabled()) {\n return false;\n }\n return tab !== \"ai\" || data.ai?.available === true;\n}\n\n/** Project-behaviour tabs (general, tests, AI); undefined for any other tab. */\nfunction renderBehaviourTab(data: ProjectSettingsData, formState?: SettingsFormState): unknown {\n const { project, activeTab } = data;\n if (activeTab === \"general\")\n return renderSettingsGeneral(project, formState, data.isAdmin, data.targets);\n if (activeTab === \"capture\")\n return renderSettingsCapture(project, data.isAdmin, formState, data.targets);\n if (activeTab === \"ai\") return renderSettingsAi(project, data.ai, formState);\n return activeTab === \"tests\" ? renderSettingsTests(project, data.isAdmin, formState) : undefined;\n}\n\n/** Labels, links, tokens, and webhooks tabs; undefined for any other tab. */\nfunction renderCatalogTab(data: ProjectSettingsData, formState?: SettingsFormState): unknown {\n const { project, activeTab, isAdmin } = data;\n if (activeTab === \"labels\")\n return renderSettingsLabels(project, data.labelTypes, isAdmin, formState);\n if (activeTab === \"links\")\n return renderSettingsLinks(project, data.links, data.linkCandidates, isAdmin, formState);\n if (activeTab === \"design\") return renderSettingsDesign(project, data.design, isAdmin, formState);\n if (activeTab === \"tokens\") return renderSettingsTokens(project, data.tokens, isAdmin, formState);\n return activeTab === \"webhooks\"\n ? renderSettingsWebhooks(project, data.webhooks, isAdmin, formState)\n : undefined;\n}\n\nfunction renderActiveTab(data: ProjectSettingsData, formState?: SettingsFormState): unknown {\n const { project, activeTab } = data;\n const behaviour = renderBehaviourTab(data, formState);\n if (behaviour !== undefined) return behaviour;\n const catalog = renderCatalogTab(data, formState);\n if (catalog !== undefined) return catalog;\n if (activeTab === \"notifications\")\n return renderSettingsNotifications(\n project,\n data.notificationChannels,\n data.notifyProviders,\n data.mySubscription,\n data.isAdmin,\n formState,\n data.notificationsPaused,\n );\n if (activeTab === \"members\")\n return renderSettingsMembers(\n project,\n data.members,\n data.groupMappings,\n data.isAdmin,\n formState,\n );\n return null;\n}\n\n/** Project settings shell: tab navigation plus the active settings tab. */\nexport function renderProjectSettingsPage(\n data: ProjectSettingsData,\n formState?: SettingsFormState,\n): RenderedContent {\n const { project, activeTab } = data;\n return (\n <DocumentLayout\n title={`${project.name} · Settings`}\n nav={{\n active: \"settings\",\n projectSlug: project.slug,\n projectName: project.name,\n trail: activeTab === \"general\" ? [] : [{ label: TAB_LABELS[activeTab] }],\n }}\n >\n <PageHeader\n title=\"Project settings\"\n description={\n <>Manage general settings, labels, tokens, webhooks and members for {project.name}.</>\n }\n />\n\n <SubNavLayout\n label=\"Settings sections\"\n items={SETTINGS_TABS.filter((tab) => settingsTabShown(tab, data)).map((tab) => ({\n label: TAB_LABELS[tab],\n href: tabHref(project, tab),\n icon: TAB_ICONS[tab],\n active: activeTab === tab,\n }))}\n >\n {renderActiveTab(data, formState)}\n </SubNavLayout>\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
@@ -7,6 +7,7 @@ import { Badge, Meta, statusTone } from "../ui/feedback.mjs";
7
7
  import { Card } from "../ui/layout.mjs";
8
8
  import { Thumbnail } from "../ui/thumbnail.mjs";
9
9
  import { RelativeTime } from "../ui/time.mjs";
10
+ import { healthTone } from "./health-panel.mjs";
10
11
  import { jsx, jsxs } from "hono/jsx/jsx-runtime";
11
12
  //#region src/pages/projects-card.tsx
12
13
  const thumbStrip = css`
@@ -156,7 +157,19 @@ function ProjectCard(props) {
156
157
  children: project.gitDefaultBranch
157
158
  })
158
159
  ] }),
159
- /* @__PURE__ */ jsx(LatestBuild, { latest })
160
+ /* @__PURE__ */ jsx(LatestBuild, { latest }),
161
+ summary.health ? /* @__PURE__ */ jsx("a", {
162
+ href: urls.library,
163
+ children: /* @__PURE__ */ jsxs(Badge, {
164
+ tone: healthTone(summary.health.verdict),
165
+ icon: true,
166
+ children: [
167
+ "health: ",
168
+ summary.health.verdict,
169
+ summary.health.score === null ? "" : ` · ${Math.round(summary.health.score)}`
170
+ ]
171
+ })
172
+ }) : null
160
173
  ]
161
174
  }),
162
175
  /* @__PURE__ */ jsxs("div", {
@@ -1 +1 @@
1
- {"version":3,"file":"projects-card.mjs","names":[],"sources":["../../src/pages/projects-card.tsx"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport {\n Badge,\n Button,\n Card,\n Meta,\n RelativeTime,\n Thumbnail,\n statusTone,\n} from \"../ui/components.tsx\";\nimport { css } from \"../ui/css.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\nconst thumbStrip = css`\n /* project-thumbs */\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: var(--space-2);\n padding: var(--space-3);\n background: var(--accent-wash);\n border-bottom: 1px solid var(--border-subtle);\n`;\n\nconst cardBody = css`\n /* project-body */\n flex: 1;\n display: grid;\n align-content: start;\n gap: var(--space-2);\n padding: var(--space-4);\n`;\n\nconst cardTitleRow = css`\n /* project-title-row */\n display: flex;\n gap: var(--space-2);\n align-items: flex-start;\n justify-content: space-between;\n`;\n\nconst cardTitle = css`\n /* project-title */\n margin: 0;\n font-size: var(--text-lg);\n font-weight: 650;\n letter-spacing: -0.01em;\n & a {\n color: var(--text-primary);\n }\n & a:hover {\n color: var(--accent-fg);\n }\n`;\n\nconst latestRow = css`\n /* project-latest */\n display: flex;\n flex-wrap: wrap;\n gap: var(--space-2);\n align-items: center;\n color: var(--text-secondary);\n font-size: var(--text-sm);\n`;\n\nconst cardFooter = css`\n /* project-footer */\n display: flex;\n flex-wrap: wrap;\n gap: var(--space-2);\n padding: var(--space-3) var(--space-4);\n border-top: 1px solid var(--border-subtle);\n`;\n\n/** What the overview shows about a project's most recent build. */\nexport interface ProjectSummary {\n project: Project;\n latest: Build | null;\n /** Up to three screenshots of the latest build. */\n previews: Snapshot[];\n /** Snapshots in the latest build still waiting for a decision. */\n pending: number;\n}\n\nfunction PendingBadge({\n pending,\n href,\n}: {\n pending: number;\n href: string;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n if (pending === 0) {\n return (\n <Badge tone=\"success\" icon>\n All clear\n </Badge>\n );\n }\n return (\n <a href={href}>\n <Badge tone=\"warning\" icon>\n {pending} to review\n </Badge>\n </a>\n );\n}\n\nfunction LatestBuild({\n latest,\n}: {\n latest: Build | null;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n if (!latest) {\n return <Meta>No builds yet — upload one from CI.</Meta>;\n }\n return (\n <div class={latestRow}>\n <Meta as=\"span\" mono>\n {latest.gitBranch} · {latest.gitSha.slice(0, 7)}\n </Meta>\n <Badge tone={statusTone(latest.status)}>{latest.status}</Badge>\n <RelativeTime value={latest.createdAt} />\n </div>\n );\n}\n\nfunction Previews({\n project,\n latest,\n previews,\n}: Pick<ProjectSummary, \"project\" | \"latest\" | \"previews\">):\n | HtmlEscapedString\n | Promise<HtmlEscapedString> {\n return (\n <div class={thumbStrip}>\n {[0, 1, 2].map((slot): HtmlEscapedString | Promise<HtmlEscapedString> => {\n const snap = previews[slot];\n return (\n <Thumbnail\n key={String(slot)}\n src={\n snap && latest\n ? `/api/v1/projects/${project.slug}/builds/${latest.id}/snapshots/${snap.id}/image`\n : null\n }\n alt={snap ? `${snap.storyTitle} / ${snap.storyName}` : \"\"}\n placeholder=\"\"\n />\n );\n })}\n </div>\n );\n}\n\n/** One project in the overview grid: previews, latest build, review state, actions. */\nexport function ProjectCard(props: {\n summary: ProjectSummary;\n urls: { library: string; settings: string; storybook: string; review: string };\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { summary, urls } = props;\n const { project, latest, previews, pending } = summary;\n return (\n <Card\n fill\n data-filter-item\n data-filter-text={`${project.name} ${project.slug} ${project.gitRepository ?? \"\"}`}\n >\n {previews.length > 0 ? (\n <Previews project={project} latest={latest} previews={previews} />\n ) : null}\n <div class={cardBody}>\n <div class={cardTitleRow}>\n <h2 class={cardTitle}>\n <a href={urls.library}>{project.name}</a>\n </h2>\n {latest ? <PendingBadge pending={pending} href={urls.review} /> : null}\n </div>\n <Meta>\n {project.gitRepository ?? project.slug} · default{\" \"}\n <Badge tone=\"neutral\">{project.gitDefaultBranch}</Badge>\n </Meta>\n <LatestBuild latest={latest} />\n </div>\n <div class={cardFooter}>\n <Button variant=\"secondary\" size=\"sm\" icon=\"image\" href={urls.library}>\n Library\n </Button>\n <Button variant=\"ghost\" size=\"sm\" icon=\"external-link\" href={urls.storybook}>\n Storybook\n </Button>\n <Button variant=\"ghost\" size=\"sm\" icon=\"settings\" href={urls.settings}>\n Settings\n </Button>\n </div>\n </Card>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"projects-card.mjs","names":[],"sources":["../../src/pages/projects-card.tsx"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport type { HealthBadge } from \"../insights/health-panel.ts\";\nimport {\n Badge,\n Button,\n Card,\n Meta,\n RelativeTime,\n Thumbnail,\n statusTone,\n} from \"../ui/components.tsx\";\nimport { css } from \"../ui/css.ts\";\nimport { healthTone } from \"./health-panel.tsx\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\nconst thumbStrip = css`\n /* project-thumbs */\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: var(--space-2);\n padding: var(--space-3);\n background: var(--accent-wash);\n border-bottom: 1px solid var(--border-subtle);\n`;\n\nconst cardBody = css`\n /* project-body */\n flex: 1;\n display: grid;\n align-content: start;\n gap: var(--space-2);\n padding: var(--space-4);\n`;\n\nconst cardTitleRow = css`\n /* project-title-row */\n display: flex;\n gap: var(--space-2);\n align-items: flex-start;\n justify-content: space-between;\n`;\n\nconst cardTitle = css`\n /* project-title */\n margin: 0;\n font-size: var(--text-lg);\n font-weight: 650;\n letter-spacing: -0.01em;\n & a {\n color: var(--text-primary);\n }\n & a:hover {\n color: var(--accent-fg);\n }\n`;\n\nconst latestRow = css`\n /* project-latest */\n display: flex;\n flex-wrap: wrap;\n gap: var(--space-2);\n align-items: center;\n color: var(--text-secondary);\n font-size: var(--text-sm);\n`;\n\nconst cardFooter = css`\n /* project-footer */\n display: flex;\n flex-wrap: wrap;\n gap: var(--space-2);\n padding: var(--space-3) var(--space-4);\n border-top: 1px solid var(--border-subtle);\n`;\n\n/** What the overview shows about a project's most recent build. */\nexport interface ProjectSummary {\n project: Project;\n latest: Build | null;\n /** Up to three screenshots of the latest build. */\n previews: Snapshot[];\n /** Snapshots in the latest build still waiting for a decision. */\n pending: number;\n /** AI health digest (last 30 days) when one has finished. */\n health?: HealthBadge | null;\n}\n\nfunction PendingBadge({\n pending,\n href,\n}: {\n pending: number;\n href: string;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n if (pending === 0) {\n return (\n <Badge tone=\"success\" icon>\n All clear\n </Badge>\n );\n }\n return (\n <a href={href}>\n <Badge tone=\"warning\" icon>\n {pending} to review\n </Badge>\n </a>\n );\n}\n\nfunction LatestBuild({\n latest,\n}: {\n latest: Build | null;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n if (!latest) {\n return <Meta>No builds yet — upload one from CI.</Meta>;\n }\n return (\n <div class={latestRow}>\n <Meta as=\"span\" mono>\n {latest.gitBranch} · {latest.gitSha.slice(0, 7)}\n </Meta>\n <Badge tone={statusTone(latest.status)}>{latest.status}</Badge>\n <RelativeTime value={latest.createdAt} />\n </div>\n );\n}\n\nfunction Previews({\n project,\n latest,\n previews,\n}: Pick<ProjectSummary, \"project\" | \"latest\" | \"previews\">):\n | HtmlEscapedString\n | Promise<HtmlEscapedString> {\n return (\n <div class={thumbStrip}>\n {[0, 1, 2].map((slot): HtmlEscapedString | Promise<HtmlEscapedString> => {\n const snap = previews[slot];\n return (\n <Thumbnail\n key={String(slot)}\n src={\n snap && latest\n ? `/api/v1/projects/${project.slug}/builds/${latest.id}/snapshots/${snap.id}/image`\n : null\n }\n alt={snap ? `${snap.storyTitle} / ${snap.storyName}` : \"\"}\n placeholder=\"\"\n />\n );\n })}\n </div>\n );\n}\n\n/** One project in the overview grid: previews, latest build, review state, actions. */\nexport function ProjectCard(props: {\n summary: ProjectSummary;\n urls: { library: string; settings: string; storybook: string; review: string };\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { summary, urls } = props;\n const { project, latest, previews, pending } = summary;\n return (\n <Card\n fill\n data-filter-item\n data-filter-text={`${project.name} ${project.slug} ${project.gitRepository ?? \"\"}`}\n >\n {previews.length > 0 ? (\n <Previews project={project} latest={latest} previews={previews} />\n ) : null}\n <div class={cardBody}>\n <div class={cardTitleRow}>\n <h2 class={cardTitle}>\n <a href={urls.library}>{project.name}</a>\n </h2>\n {latest ? <PendingBadge pending={pending} href={urls.review} /> : null}\n </div>\n <Meta>\n {project.gitRepository ?? project.slug} · default{\" \"}\n <Badge tone=\"neutral\">{project.gitDefaultBranch}</Badge>\n </Meta>\n <LatestBuild latest={latest} />\n {summary.health ? (\n <a href={urls.library}>\n <Badge tone={healthTone(summary.health.verdict)} icon>\n health: {summary.health.verdict}\n {summary.health.score === null ? \"\" : ` · ${Math.round(summary.health.score)}`}\n </Badge>\n </a>\n ) : null}\n </div>\n <div class={cardFooter}>\n <Button variant=\"secondary\" size=\"sm\" icon=\"image\" href={urls.library}>\n Library\n </Button>\n <Button variant=\"ghost\" size=\"sm\" icon=\"external-link\" href={urls.storybook}>\n Storybook\n </Button>\n <Button variant=\"ghost\" size=\"sm\" icon=\"settings\" href={urls.settings}>\n Settings\n </Button>\n </div>\n </Card>\n );\n}\n"],"mappings":";;;;;;;;;;;;AAiBA,MAAM,aAAa,GAAG;;;;;;;;;AAUtB,MAAM,WAAW,GAAG;;;;;;;;AASpB,MAAM,eAAe,GAAG;;;;;;;AAQxB,MAAM,YAAY,GAAG;;;;;;;;;;;;;AAcrB,MAAM,YAAY,GAAG;;;;;;;;;AAUrB,MAAM,aAAa,GAAG;;;;;;;;AAqBtB,SAAS,aAAa,EACpB,SACA,QAIiD;CACjD,IAAI,YAAY,GACd,OACE,oBAAC,OAAD;EAAO,MAAK;EAAU,MAAA;EAAK,UAAA;CAEpB,CAAA;CAGX,OACE,oBAAC,KAAD;EAAS;EACP,UAAA,qBAAC,OAAD;GAAO,MAAK;GAAU,MAAA;GAAtB,UAAA,CACG,SAAQ,YACJ;;CACN,CAAA;AAEP;AAEA,SAAS,YAAY,EACnB,UAGiD;CACjD,IAAI,CAAC,QACH,OAAO,oBAAC,MAAD,EAAA,UAAM,sCAAyC,CAAA;CAExD,OACE,qBAAC,OAAD;EAAK,OAAO;EAAZ,UAAA;GACE,qBAAC,MAAD;IAAM,IAAG;IAAO,MAAA;IAAhB,UAAA;KACG,OAAO;KAAU;KAAI,OAAO,OAAO,MAAM,GAAG,CAAC;IAC1C;;GACN,oBAAC,OAAD;IAAO,MAAM,WAAW,OAAO,MAAM;IAAI,UAAA,OAAO;GAAc,CAAA;GAC9D,oBAAC,cAAD,EAAc,OAAO,OAAO,UAAY,CAAA;EACrC;;AAET;AAEA,SAAS,SAAS,EAChB,SACA,QACA,YAG6B;CAC7B,OACE,oBAAC,OAAD;EAAK,OAAO;EACT,UAAA;GAAC;GAAG;GAAG;EAAC,CAAC,CAAC,KAAK,SAAyD;GACvE,MAAM,OAAO,SAAS;GACtB,OACE,oBAAC,WAAD;IAEE,KACE,QAAQ,SACJ,oBAAoB,QAAQ,KAAK,UAAU,OAAO,GAAG,aAAa,KAAK,GAAG,UAC1E;IAEN,KAAK,OAAO,GAAG,KAAK,WAAW,KAAK,KAAK,cAAc;IACvD,aAAY;GACb,GARM,OAAO,IAAI,CAQjB;EAEL,CAAC;CACE,CAAA;AAET;;AAGA,SAAgB,YAAY,OAGuB;CACjD,MAAM,EAAE,SAAS,SAAS;CAC1B,MAAM,EAAE,SAAS,QAAQ,UAAU,YAAY;CAC/C,OACE,qBAAC,MAAD;EACE,MAAA;EACA,oBAAA;EACA,oBAAkB,GAAG,QAAQ,KAAK,GAAG,QAAQ,KAAK,GAAG,QAAQ,iBAAiB;EAHhF,UAAA;GAKG,SAAS,SAAS,IACjB,oBAAC,UAAD;IAAmB;IAAiB;IAAkB;GAAW,CAAA,IAC/D;GACJ,qBAAC,OAAD;IAAK,OAAO;IAAZ,UAAA;KACE,qBAAC,OAAD;MAAK,OAAO;MAAZ,UAAA,CACE,oBAAC,MAAD;OAAI,OAAO;OACT,UAAA,oBAAC,KAAD;QAAG,MAAM,KAAK;QAAU,UAAA,QAAQ;OAAQ,CAAA;MACtC,CAAA,GACH,SAAS,oBAAC,cAAD;OAAuB;OAAS,MAAM,KAAK;MAAS,CAAA,IAAI,IAC/D;;KACL,qBAAC,MAAD,EAAA,UAAA;MACG,QAAQ,iBAAiB,QAAQ;MAAK;MAAW;MAClD,oBAAC,OAAD;OAAO,MAAK;OAAW,UAAA,QAAQ;MAAwB,CAAA;KACnD,EAAA,CAAA;KACN,oBAAC,aAAD,EAAqB,OAAS,CAAA;KAC7B,QAAQ,SACP,oBAAC,KAAD;MAAG,MAAM,KAAK;MACZ,UAAA,qBAAC,OAAD;OAAO,MAAM,WAAW,QAAQ,OAAO,OAAO;OAAG,MAAA;OAAjD,UAAA;QAAsD;QAC3C,QAAQ,OAAO;QACvB,QAAQ,OAAO,UAAU,OAAO,KAAK,MAAM,KAAK,MAAM,QAAQ,OAAO,KAAK;OACtE;;KACN,CAAA,IACD;IACD;;GACL,qBAAC,OAAD;IAAK,OAAO;IAAZ,UAAA;KACE,oBAAC,QAAD;MAAQ,SAAQ;MAAY,MAAK;MAAK,MAAK;MAAQ,MAAM,KAAK;MAAS,UAAA;KAE/D,CAAA;KACR,oBAAC,QAAD;MAAQ,SAAQ;MAAQ,MAAK;MAAK,MAAK;MAAgB,MAAM,KAAK;MAAW,UAAA;KAErE,CAAA;KACR,oBAAC,QAAD;MAAQ,SAAQ;MAAQ,MAAK;MAAK,MAAK;MAAW,MAAM,KAAK;MAAU,UAAA;KAE/D,CAAA;IACL;;EACD;;AAEV"}
@@ -10,6 +10,7 @@ import { HStack, VStack } from "../ui/stacks.mjs";
10
10
  import { CodeBlock } from "../ui/code-block.mjs";
11
11
  import { FilterInput } from "../ui/filter-input.mjs";
12
12
  import { DocumentLayout } from "../ui/document.mjs";
13
+ import { loadHealthBadge } from "../insights/health-panel.mjs";
13
14
  import { ProjectCard } from "./projects-card.mjs";
14
15
  import { BuildModel, ProjectModel, SnapshotModel } from "@storyshelf/core/models";
15
16
  import { jsx, jsxs } from "hono/jsx/jsx-runtime";
@@ -32,11 +33,13 @@ const setupSummary = css`
32
33
  async function summarize(project) {
33
34
  const { db } = getStore();
34
35
  const [latest] = await new BuildModel(db).list(project.id);
36
+ const health = await loadHealthBadge(project);
35
37
  if (!latest) return {
36
38
  project,
37
39
  latest: null,
38
40
  previews: [],
39
- pending: 0
41
+ pending: 0,
42
+ health
40
43
  };
41
44
  const snapshots = await new SnapshotModel(db).listByBuild(latest.id);
42
45
  const pending = snapshots.filter((snap) => snap.status === "new" || snap.status === "changed");
@@ -44,7 +47,8 @@ async function summarize(project) {
44
47
  project,
45
48
  latest,
46
49
  previews: (pending.length > 0 ? pending : snapshots).slice(0, 3),
47
- pending: pending.length
50
+ pending: pending.length,
51
+ health
48
52
  };
49
53
  }
50
54
  /** Copy-ready steps to get a first build uploaded. */