@storyshelf/app 0.6.1 → 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 (364) 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/credentials.mjs +3 -2
  26. package/dist/credentials.mjs.map +1 -1
  27. package/dist/git-status.mjs +46 -0
  28. package/dist/git-status.mjs.map +1 -0
  29. package/dist/hidden-data.mjs +24 -0
  30. package/dist/hidden-data.mjs.map +1 -0
  31. package/dist/index.d.mts +3 -3
  32. package/dist/index.mjs +202 -124
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/insights/budget.mjs +49 -0
  35. package/dist/insights/budget.mjs.map +1 -0
  36. package/dist/insights/deps.mjs +21 -0
  37. package/dist/insights/deps.mjs.map +1 -0
  38. package/dist/insights/errors.mjs +22 -0
  39. package/dist/insights/errors.mjs.map +1 -0
  40. package/dist/insights/health-panel.mjs +40 -0
  41. package/dist/insights/health-panel.mjs.map +1 -0
  42. package/dist/insights/job.mjs +103 -0
  43. package/dist/insights/job.mjs.map +1 -0
  44. package/dist/insights/load-health.mjs +59 -0
  45. package/dist/insights/load-health.mjs.map +1 -0
  46. package/dist/insights/load-triage.mjs +98 -0
  47. package/dist/insights/load-triage.mjs.map +1 -0
  48. package/dist/insights/panel.mjs +28 -0
  49. package/dist/insights/panel.mjs.map +1 -0
  50. package/dist/insights/request.mjs +113 -0
  51. package/dist/insights/request.mjs.map +1 -0
  52. package/dist/insights/stale.mjs +21 -0
  53. package/dist/insights/stale.mjs.map +1 -0
  54. package/dist/insights/usage-report.mjs +60 -0
  55. package/dist/insights/usage-report.mjs.map +1 -0
  56. package/dist/insights/view.mjs +48 -0
  57. package/dist/insights/view.mjs.map +1 -0
  58. package/dist/job-canceller.mjs +14 -0
  59. package/dist/job-canceller.mjs.map +1 -0
  60. package/dist/job-queue.mjs +51 -0
  61. package/dist/job-queue.mjs.map +1 -0
  62. package/dist/jobs-setup.mjs +132 -0
  63. package/dist/jobs-setup.mjs.map +1 -0
  64. package/dist/lifecycle.mjs +109 -19
  65. package/dist/lifecycle.mjs.map +1 -1
  66. package/dist/middleware/auth-gate.mjs +4 -2
  67. package/dist/middleware/auth-gate.mjs.map +1 -1
  68. package/dist/middleware/rate-limit.mjs +41 -33
  69. package/dist/middleware/rate-limit.mjs.map +1 -1
  70. package/dist/middleware/request-log.mjs +3 -2
  71. package/dist/middleware/request-log.mjs.map +1 -1
  72. package/dist/middleware/require-review.mjs +51 -0
  73. package/dist/middleware/require-review.mjs.map +1 -0
  74. package/dist/middleware/require-testing.mjs +41 -0
  75. package/dist/middleware/require-testing.mjs.map +1 -0
  76. package/dist/middleware/setup-gate.mjs.map +1 -1
  77. package/dist/middleware/store-scope.mjs +12 -2
  78. package/dist/middleware/store-scope.mjs.map +1 -1
  79. package/dist/notifications-pause.mjs +33 -0
  80. package/dist/notifications-pause.mjs.map +1 -0
  81. package/dist/notify.d.mts +2 -1
  82. package/dist/notify.mjs +78 -39
  83. package/dist/notify.mjs.map +1 -1
  84. package/dist/pages/a11y-violations.mjs +116 -0
  85. package/dist/pages/a11y-violations.mjs.map +1 -0
  86. package/dist/pages/admin-ai.mjs +78 -0
  87. package/dist/pages/admin-ai.mjs.map +1 -0
  88. package/dist/pages/admin-system-origin.mjs +123 -0
  89. package/dist/pages/admin-system-origin.mjs.map +1 -0
  90. package/dist/pages/admin-system-targets.mjs +53 -0
  91. package/dist/pages/admin-system-targets.mjs.map +1 -0
  92. package/dist/pages/admin-system.mjs +63 -13
  93. package/dist/pages/admin-system.mjs.map +1 -1
  94. package/dist/pages/build-detail.mjs +104 -76
  95. package/dist/pages/build-detail.mjs.map +1 -1
  96. package/dist/pages/build-diff-actions.mjs +87 -33
  97. package/dist/pages/build-diff-actions.mjs.map +1 -1
  98. package/dist/pages/build-diff-comments.mjs +16 -14
  99. package/dist/pages/build-diff-comments.mjs.map +1 -1
  100. package/dist/pages/build-diff-environment.mjs +82 -0
  101. package/dist/pages/build-diff-environment.mjs.map +1 -0
  102. package/dist/pages/build-diff-header.mjs +22 -4
  103. package/dist/pages/build-diff-header.mjs.map +1 -1
  104. package/dist/pages/build-diff-insight.mjs +113 -0
  105. package/dist/pages/build-diff-insight.mjs.map +1 -0
  106. package/dist/pages/build-diff-model.mjs +55 -10
  107. package/dist/pages/build-diff-model.mjs.map +1 -1
  108. package/dist/pages/build-diff-nav.mjs +28 -12
  109. package/dist/pages/build-diff-nav.mjs.map +1 -1
  110. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  111. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  112. package/dist/pages/build-diff-usage.mjs +63 -0
  113. package/dist/pages/build-diff-usage.mjs.map +1 -0
  114. package/dist/pages/build-diff-viewer.mjs +62 -5
  115. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  116. package/dist/pages/build-diff.mjs +34 -13
  117. package/dist/pages/build-diff.mjs.map +1 -1
  118. package/dist/pages/build-tests.mjs +74 -0
  119. package/dist/pages/build-tests.mjs.map +1 -0
  120. package/dist/pages/component-detail.mjs +249 -0
  121. package/dist/pages/component-detail.mjs.map +1 -0
  122. package/dist/pages/component-stories.mjs +168 -0
  123. package/dist/pages/component-stories.mjs.map +1 -0
  124. package/dist/pages/compute-jobs.mjs +13 -26
  125. package/dist/pages/compute-jobs.mjs.map +1 -1
  126. package/dist/pages/design-links.mjs +182 -0
  127. package/dist/pages/design-links.mjs.map +1 -0
  128. package/dist/pages/environment-change.mjs +53 -0
  129. package/dist/pages/environment-change.mjs.map +1 -0
  130. package/dist/pages/health-panel.mjs +126 -0
  131. package/dist/pages/health-panel.mjs.map +1 -0
  132. package/dist/pages/library-grid.mjs +105 -0
  133. package/dist/pages/library-grid.mjs.map +1 -0
  134. package/dist/pages/library-model.mjs +47 -0
  135. package/dist/pages/library-model.mjs.map +1 -0
  136. package/dist/pages/library-snapshots.mjs +130 -0
  137. package/dist/pages/library-snapshots.mjs.map +1 -0
  138. package/dist/pages/library.mjs +112 -218
  139. package/dist/pages/library.mjs.map +1 -1
  140. package/dist/pages/notifications-paused.mjs +22 -0
  141. package/dist/pages/notifications-paused.mjs.map +1 -0
  142. package/dist/pages/profile.mjs +53 -48
  143. package/dist/pages/profile.mjs.map +1 -1
  144. package/dist/pages/project-builds-table.mjs +2 -2
  145. package/dist/pages/project-builds-table.mjs.map +1 -1
  146. package/dist/pages/project-builds.mjs +51 -21
  147. package/dist/pages/project-builds.mjs.map +1 -1
  148. package/dist/pages/project-settings.mjs +51 -12
  149. package/dist/pages/project-settings.mjs.map +1 -1
  150. package/dist/pages/projects-card.mjs +14 -1
  151. package/dist/pages/projects-card.mjs.map +1 -1
  152. package/dist/pages/projects.mjs +6 -2
  153. package/dist/pages/projects.mjs.map +1 -1
  154. package/dist/pages/review-changeset.mjs +116 -0
  155. package/dist/pages/review-changeset.mjs.map +1 -0
  156. package/dist/pages/review-comments.mjs +103 -0
  157. package/dist/pages/review-comments.mjs.map +1 -0
  158. package/dist/pages/review-verdict.mjs +102 -0
  159. package/dist/pages/review-verdict.mjs.map +1 -0
  160. package/dist/pages/settings-ai.mjs +69 -0
  161. package/dist/pages/settings-ai.mjs.map +1 -0
  162. package/dist/pages/settings-capture.mjs +124 -0
  163. package/dist/pages/settings-capture.mjs.map +1 -0
  164. package/dist/pages/settings-design-credential.mjs +99 -0
  165. package/dist/pages/settings-design-credential.mjs.map +1 -0
  166. package/dist/pages/settings-design-links.mjs +179 -0
  167. package/dist/pages/settings-design-links.mjs.map +1 -0
  168. package/dist/pages/settings-design.mjs +48 -0
  169. package/dist/pages/settings-design.mjs.map +1 -0
  170. package/dist/pages/settings-general.mjs +46 -21
  171. package/dist/pages/settings-general.mjs.map +1 -1
  172. package/dist/pages/settings-labels.mjs +43 -2
  173. package/dist/pages/settings-labels.mjs.map +1 -1
  174. package/dist/pages/settings-links.mjs +112 -0
  175. package/dist/pages/settings-links.mjs.map +1 -0
  176. package/dist/pages/settings-notifications-git.mjs +128 -0
  177. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  178. package/dist/pages/settings-notifications.mjs +79 -42
  179. package/dist/pages/settings-notifications.mjs.map +1 -1
  180. package/dist/pages/settings-tests.mjs +33 -2
  181. package/dist/pages/settings-tests.mjs.map +1 -1
  182. package/dist/pages/target-choices.mjs +39 -0
  183. package/dist/pages/target-choices.mjs.map +1 -0
  184. package/dist/publish-setup.mjs +89 -0
  185. package/dist/publish-setup.mjs.map +1 -0
  186. package/dist/retention-timer.mjs +2 -1
  187. package/dist/retention-timer.mjs.map +1 -1
  188. package/dist/review-events.mjs +48 -0
  189. package/dist/review-events.mjs.map +1 -0
  190. package/dist/routers/admin-pages.mjs +45 -3
  191. package/dist/routers/admin-pages.mjs.map +1 -1
  192. package/dist/routers/admin-public-origin.mjs +85 -0
  193. package/dist/routers/admin-public-origin.mjs.map +1 -0
  194. package/dist/routers/assets.mjs +9 -2
  195. package/dist/routers/assets.mjs.map +1 -1
  196. package/dist/routers/auth.mjs +2 -2
  197. package/dist/routers/auth.mjs.map +1 -1
  198. package/dist/routers/baseline-guard.mjs +2 -2
  199. package/dist/routers/baseline-guard.mjs.map +1 -1
  200. package/dist/routers/baselines.handlers.mjs +64 -0
  201. package/dist/routers/baselines.handlers.mjs.map +1 -0
  202. package/dist/routers/baselines.mjs +125 -0
  203. package/dist/routers/baselines.mjs.map +1 -0
  204. package/dist/routers/build-cancel.mjs +68 -0
  205. package/dist/routers/build-cancel.mjs.map +1 -0
  206. package/dist/routers/build-changeset.mjs +70 -0
  207. package/dist/routers/build-changeset.mjs.map +1 -0
  208. package/dist/routers/build-reviews.handlers.mjs +86 -0
  209. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  210. package/dist/routers/build-reviews.mjs +74 -0
  211. package/dist/routers/build-reviews.mjs.map +1 -0
  212. package/dist/routers/builds.handlers.mjs +46 -55
  213. package/dist/routers/builds.handlers.mjs.map +1 -1
  214. package/dist/routers/builds.mjs +71 -17
  215. package/dist/routers/builds.mjs.map +1 -1
  216. package/dist/routers/capture-config.mjs +35 -0
  217. package/dist/routers/capture-config.mjs.map +1 -0
  218. package/dist/routers/comments.mjs +41 -15
  219. package/dist/routers/comments.mjs.map +1 -1
  220. package/dist/routers/component-catalog.handlers.mjs +71 -0
  221. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  222. package/dist/routers/component.handlers.mjs +121 -0
  223. package/dist/routers/component.handlers.mjs.map +1 -0
  224. package/dist/routers/connections.mjs +118 -0
  225. package/dist/routers/connections.mjs.map +1 -0
  226. package/dist/routers/design-connect.handlers.mjs +44 -0
  227. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  228. package/dist/routers/design.handlers.mjs +114 -0
  229. package/dist/routers/design.handlers.mjs.map +1 -0
  230. package/dist/routers/health-report.mjs +2 -3
  231. package/dist/routers/health-report.mjs.map +1 -1
  232. package/dist/routers/health.mjs +4 -3
  233. package/dist/routers/health.mjs.map +1 -1
  234. package/dist/routers/helpers.mjs +19 -3
  235. package/dist/routers/helpers.mjs.map +1 -1
  236. package/dist/routers/insights-ui.mjs +64 -0
  237. package/dist/routers/insights-ui.mjs.map +1 -0
  238. package/dist/routers/insights.handlers.mjs +45 -0
  239. package/dist/routers/insights.handlers.mjs.map +1 -0
  240. package/dist/routers/insights.mjs +189 -0
  241. package/dist/routers/insights.mjs.map +1 -0
  242. package/dist/routers/links.handlers.mjs +80 -0
  243. package/dist/routers/links.handlers.mjs.map +1 -0
  244. package/dist/routers/links.mjs +139 -0
  245. package/dist/routers/links.mjs.map +1 -0
  246. package/dist/routers/media.mjs +18 -4
  247. package/dist/routers/media.mjs.map +1 -1
  248. package/dist/routers/members.mjs +2 -1
  249. package/dist/routers/members.mjs.map +1 -1
  250. package/dist/routers/notification-channels.mjs +17 -23
  251. package/dist/routers/notification-channels.mjs.map +1 -1
  252. package/dist/routers/oauth-state.mjs +45 -0
  253. package/dist/routers/oauth-state.mjs.map +1 -0
  254. package/dist/routers/preview-base.mjs +38 -0
  255. package/dist/routers/preview-base.mjs.map +1 -0
  256. package/dist/routers/preview-token.mjs +51 -0
  257. package/dist/routers/preview-token.mjs.map +1 -0
  258. package/dist/routers/profile.mjs +4 -2
  259. package/dist/routers/profile.mjs.map +1 -1
  260. package/dist/routers/projects.mjs +17 -1
  261. package/dist/routers/projects.mjs.map +1 -1
  262. package/dist/routers/publish-upload.mjs +78 -0
  263. package/dist/routers/publish-upload.mjs.map +1 -0
  264. package/dist/routers/published-access.mjs +52 -0
  265. package/dist/routers/published-access.mjs.map +1 -0
  266. package/dist/routers/published-files.mjs +52 -0
  267. package/dist/routers/published-files.mjs.map +1 -0
  268. package/dist/routers/review-forms.mjs +72 -0
  269. package/dist/routers/review-forms.mjs.map +1 -0
  270. package/dist/routers/review-section.handlers.mjs +85 -0
  271. package/dist/routers/review-section.handlers.mjs.map +1 -0
  272. package/dist/routers/review-switch-off.mjs +29 -0
  273. package/dist/routers/review-switch-off.mjs.map +1 -0
  274. package/dist/routers/schemas.mjs +140 -22
  275. package/dist/routers/schemas.mjs.map +1 -1
  276. package/dist/routers/scoped-models.mjs +22 -0
  277. package/dist/routers/scoped-models.mjs.map +1 -0
  278. package/dist/routers/settings-ai.mjs +35 -0
  279. package/dist/routers/settings-ai.mjs.map +1 -0
  280. package/dist/routers/settings-capture.mjs +77 -0
  281. package/dist/routers/settings-capture.mjs.map +1 -0
  282. package/dist/routers/settings-design.mjs +103 -0
  283. package/dist/routers/settings-design.mjs.map +1 -0
  284. package/dist/routers/settings-general.mjs +55 -10
  285. package/dist/routers/settings-general.mjs.map +1 -1
  286. package/dist/routers/settings-git-channel.mjs +77 -0
  287. package/dist/routers/settings-git-channel.mjs.map +1 -0
  288. package/dist/routers/settings-labels.mjs +27 -1
  289. package/dist/routers/settings-labels.mjs.map +1 -1
  290. package/dist/routers/settings-links.mjs +63 -0
  291. package/dist/routers/settings-links.mjs.map +1 -0
  292. package/dist/routers/settings-members.mjs +4 -7
  293. package/dist/routers/settings-members.mjs.map +1 -1
  294. package/dist/routers/settings-notifications.mjs +15 -10
  295. package/dist/routers/settings-notifications.mjs.map +1 -1
  296. package/dist/routers/settings-tokens.mjs +2 -5
  297. package/dist/routers/settings-tokens.mjs.map +1 -1
  298. package/dist/routers/settings-webhooks.mjs +7 -24
  299. package/dist/routers/settings-webhooks.mjs.map +1 -1
  300. package/dist/routers/settings.handlers.mjs +107 -26
  301. package/dist/routers/settings.handlers.mjs.map +1 -1
  302. package/dist/routers/settings.mjs +16 -4
  303. package/dist/routers/settings.mjs.map +1 -1
  304. package/dist/routers/signed-storybook.mjs +53 -0
  305. package/dist/routers/signed-storybook.mjs.map +1 -0
  306. package/dist/routers/snapshot-groups.mjs +63 -0
  307. package/dist/routers/snapshot-groups.mjs.map +1 -0
  308. package/dist/routers/snapshots.mjs +103 -5
  309. package/dist/routers/snapshots.mjs.map +1 -1
  310. package/dist/routers/storybook.mjs +37 -88
  311. package/dist/routers/storybook.mjs.map +1 -1
  312. package/dist/routers/tokens.mjs +2 -5
  313. package/dist/routers/tokens.mjs.map +1 -1
  314. package/dist/routers/ui.mjs +34 -5
  315. package/dist/routers/ui.mjs.map +1 -1
  316. package/dist/routers/webhooks.mjs +5 -11
  317. package/dist/routers/webhooks.mjs.map +1 -1
  318. package/dist/routers/well-known.mjs +11 -4
  319. package/dist/routers/well-known.mjs.map +1 -1
  320. package/dist/routes/api-routes.mjs +93 -0
  321. package/dist/routes/api-routes.mjs.map +1 -0
  322. package/dist/routes/classification.mjs +38 -0
  323. package/dist/routes/classification.mjs.map +1 -0
  324. package/dist/routes/openapi-classes.mjs +22 -0
  325. package/dist/routes/openapi-classes.mjs.map +1 -0
  326. package/dist/routes/page-routes.mjs +104 -0
  327. package/dist/routes/page-routes.mjs.map +1 -0
  328. package/dist/runtime.mjs +18 -13
  329. package/dist/runtime.mjs.map +1 -1
  330. package/dist/serverless.mjs +5 -0
  331. package/dist/store.mjs +19 -1
  332. package/dist/store.mjs.map +1 -1
  333. package/dist/targets.mjs +46 -0
  334. package/dist/targets.mjs.map +1 -0
  335. package/dist/ui/buttons.mjs +16 -3
  336. package/dist/ui/buttons.mjs.map +1 -1
  337. package/dist/ui/design-reference.mjs +97 -0
  338. package/dist/ui/design-reference.mjs.map +1 -0
  339. package/dist/ui/document.mjs +1 -2
  340. package/dist/ui/document.mjs.map +1 -1
  341. package/dist/ui/forms.mjs +6 -5
  342. package/dist/ui/forms.mjs.map +1 -1
  343. package/dist/ui/icons/paths.mjs +1 -0
  344. package/dist/ui/icons/paths.mjs.map +1 -1
  345. package/dist/ui/preview-embed.mjs +82 -0
  346. package/dist/ui/preview-embed.mjs.map +1 -0
  347. package/dist/ui/shell/client-script.mjs +85 -0
  348. package/dist/ui/shell/client-script.mjs.map +1 -1
  349. package/dist/ui/shell/nav.mjs +12 -8
  350. package/dist/ui/shell/nav.mjs.map +1 -1
  351. package/dist/ui/shell/review-script.mjs +8 -0
  352. package/dist/ui/shell/review-script.mjs.map +1 -1
  353. package/dist/ui/shell/sidebar.mjs +2 -2
  354. package/dist/ui/shell/sidebar.mjs.map +1 -1
  355. package/package.json +11 -6
  356. package/dist/asset-paths.mjs +0 -23
  357. package/dist/asset-paths.mjs.map +0 -1
  358. package/dist/assets/mark.svg +0 -1
  359. package/dist/pages/settings-status.mjs +0 -137
  360. package/dist/pages/settings-status.mjs.map +0 -1
  361. package/dist/routers/settings-status.mjs +0 -77
  362. package/dist/routers/settings-status.mjs.map +0 -1
  363. package/dist/routers/status-configs.mjs +0 -108
  364. package/dist/routers/status-configs.mjs.map +0 -1
@@ -0,0 +1,113 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Button } from "../ui/buttons.mjs";
5
+ import { Alert, Badge, Meta } from "../ui/feedback.mjs";
6
+ import { Card, SectionTitle } from "../ui/layout.mjs";
7
+ import { HStack, VStack } from "../ui/stacks.mjs";
8
+ import { csrfField } from "../ui/csrf-field.mjs";
9
+ import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
10
+ //#region src/pages/build-diff-insight.tsx
11
+ function itemsOf(output) {
12
+ const items = output?.items;
13
+ return Array.isArray(items) ? items : [];
14
+ }
15
+ function verdictTone(verdict) {
16
+ if (verdict === "likely-intended") return "success";
17
+ if (verdict === "needs-review") return "warning";
18
+ return verdict === "likely-regression" ? "danger" : "neutral";
19
+ }
20
+ function InsightBody(props) {
21
+ const { latest } = props;
22
+ if (!latest) return /* @__PURE__ */ jsx(Meta, { children: "No AI triage yet for this build." });
23
+ if (latest.status === "failed") return /* @__PURE__ */ jsxs(Alert, {
24
+ tone: "warning",
25
+ title: "Triage failed",
26
+ children: [
27
+ "The model call did not finish (",
28
+ latest.errorCode,
29
+ "). You can try again."
30
+ ]
31
+ });
32
+ if (latest.status !== "done") return /* @__PURE__ */ jsx(Meta, { children: "Working on it…" });
33
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
34
+ /* @__PURE__ */ jsx("span", { children: latest.summary }),
35
+ /* @__PURE__ */ jsx("ul", { children: itemsOf(latest.output).map((item) => /* @__PURE__ */ jsxs("li", { children: [
36
+ /* @__PURE__ */ jsx("strong", { children: item.snapshotKey }),
37
+ " (",
38
+ item.severity,
39
+ "): ",
40
+ item.note
41
+ ] })) }),
42
+ /* @__PURE__ */ jsxs(Meta, { children: [
43
+ latest.profile,
44
+ " · ",
45
+ latest.model,
46
+ " · ",
47
+ latest.createdAt.slice(0, 16).replace("T", " ")
48
+ ] })
49
+ ] });
50
+ }
51
+ /** AI triage panel: verdict, summary, per-snapshot notes (plain text) and regenerate. */
52
+ function InsightPanel(props) {
53
+ const { project, build, data } = props;
54
+ const base = `/projects/${project.slug}/builds/${build.id}/insights`;
55
+ const latest = data.latest;
56
+ const inFlight = latest?.status === "pending" || latest?.status === "running";
57
+ const poll = inFlight ? {
58
+ "hx-get": `${base}/panel`,
59
+ "hx-trigger": "load delay:3s",
60
+ "hx-swap": "outerHTML"
61
+ } : {};
62
+ return /* @__PURE__ */ jsx("div", {
63
+ id: "insight-panel",
64
+ ...poll,
65
+ children: /* @__PURE__ */ jsxs(Card, { children: [/* @__PURE__ */ jsxs(HStack, { children: [
66
+ /* @__PURE__ */ jsx(SectionTitle, {
67
+ level: 3,
68
+ children: "AI triage (advisory)"
69
+ }),
70
+ latest?.status === "done" ? /* @__PURE__ */ jsx(Badge, {
71
+ tone: verdictTone(latest.verdict),
72
+ children: latest.verdict
73
+ }) : null,
74
+ inFlight ? /* @__PURE__ */ jsx(Badge, {
75
+ tone: "info",
76
+ children: "generating…"
77
+ }) : null
78
+ ] }), /* @__PURE__ */ jsxs(VStack, { children: [/* @__PURE__ */ jsx(InsightBody, { latest }), data.canGenerate && !inFlight ? /* @__PURE__ */ jsxs("form", {
79
+ method: "post",
80
+ action: `${base}/generate`,
81
+ "hx-post": `${base}/generate`,
82
+ "hx-target": "#insight-panel",
83
+ "hx-swap": "outerHTML",
84
+ children: [
85
+ csrfField(),
86
+ /* @__PURE__ */ jsx("input", {
87
+ type: "hidden",
88
+ name: "force",
89
+ value: latest ? "true" : "false"
90
+ }),
91
+ /* @__PURE__ */ jsx(Button, {
92
+ variant: "secondary",
93
+ size: "sm",
94
+ type: "submit",
95
+ icon: "sparkles",
96
+ children: latest ? "Regenerate" : "Generate triage"
97
+ })
98
+ ]
99
+ }) : null] })] })
100
+ });
101
+ }
102
+ /** Renders the panel when AI triage is available for the project, nothing otherwise. */
103
+ function InsightSlot(props) {
104
+ return props.data ? /* @__PURE__ */ jsx(InsightPanel, {
105
+ project: props.project,
106
+ build: props.build,
107
+ data: props.data
108
+ }) : null;
109
+ }
110
+ //#endregion
111
+ export { InsightPanel, InsightSlot };
112
+
113
+ //# sourceMappingURL=build-diff-insight.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-diff-insight.mjs","names":[],"sources":["../../src/pages/build-diff-insight.tsx"],"sourcesContent":["import type { Build, Project } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport type { InsightPanelData } from \"../insights/panel.ts\";\nimport {\n Alert,\n Badge,\n Button,\n Card,\n HStack,\n Meta,\n SectionTitle,\n VStack,\n} from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\ninterface TriageItem {\n snapshotKey: string;\n note: string;\n severity: string;\n}\n\nfunction itemsOf(output: unknown): TriageItem[] {\n const items = (output as { items?: unknown } | null)?.items;\n return Array.isArray(items) ? (items as TriageItem[]) : [];\n}\n\nfunction verdictTone(verdict: string | null): \"success\" | \"warning\" | \"danger\" | \"neutral\" {\n if (verdict === \"likely-intended\") {\n return \"success\";\n }\n if (verdict === \"needs-review\") {\n return \"warning\";\n }\n return verdict === \"likely-regression\" ? \"danger\" : \"neutral\";\n}\n\nfunction InsightBody(props: {\n latest: InsightPanelData[\"latest\"];\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { latest } = props;\n if (!latest) {\n return <Meta>No AI triage yet for this build.</Meta>;\n }\n if (latest.status === \"failed\") {\n return (\n <Alert tone=\"warning\" title=\"Triage failed\">\n The model call did not finish ({latest.errorCode}). You can try again.\n </Alert>\n );\n }\n if (latest.status !== \"done\") {\n return <Meta>Working on it…</Meta>;\n }\n return (\n <>\n <span>{latest.summary}</span>\n <ul>\n {itemsOf(latest.output).map((item) => (\n <li>\n <strong>{item.snapshotKey}</strong> ({item.severity}): {item.note}\n </li>\n ))}\n </ul>\n <Meta>\n {latest.profile} · {latest.model} · {latest.createdAt.slice(0, 16).replace(\"T\", \" \")}\n </Meta>\n </>\n );\n}\n\n/** Props for the AI triage panel on the build review page. */\nexport interface InsightPanelProps {\n project: Project;\n build: Build;\n data: InsightPanelData;\n}\n\n/** AI triage panel: verdict, summary, per-snapshot notes (plain text) and regenerate. */\nexport function InsightPanel(\n props: InsightPanelProps,\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, data } = props;\n const base = `/projects/${project.slug}/builds/${build.id}/insights`;\n const latest = data.latest;\n const inFlight = latest?.status === \"pending\" || latest?.status === \"running\";\n const poll = inFlight\n ? { \"hx-get\": `${base}/panel`, \"hx-trigger\": \"load delay:3s\", \"hx-swap\": \"outerHTML\" }\n : {};\n return (\n <div id=\"insight-panel\" {...poll}>\n <Card>\n <HStack>\n <SectionTitle level={3}>AI triage (advisory)</SectionTitle>\n {latest?.status === \"done\" ? (\n <Badge tone={verdictTone(latest.verdict)}>{latest.verdict}</Badge>\n ) : null}\n {inFlight ? <Badge tone=\"info\">generating…</Badge> : null}\n </HStack>\n <VStack>\n <InsightBody latest={latest} />\n {data.canGenerate && !inFlight ? (\n <form\n method=\"post\"\n action={`${base}/generate`}\n hx-post={`${base}/generate`}\n hx-target=\"#insight-panel\"\n hx-swap=\"outerHTML\"\n >\n {csrfField()}\n <input type=\"hidden\" name=\"force\" value={latest ? \"true\" : \"false\"} />\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\" icon=\"sparkles\">\n {latest ? \"Regenerate\" : \"Generate triage\"}\n </Button>\n </form>\n ) : null}\n </VStack>\n </Card>\n </div>\n );\n}\n\n/** Renders the panel when AI triage is available for the project, nothing otherwise. */\nexport function InsightSlot(props: {\n project: Project;\n build: Build;\n data: InsightPanelData | null | undefined;\n}): HtmlEscapedString | Promise<HtmlEscapedString> | null {\n return props.data ? (\n <InsightPanel project={props.project} build={props.build} data={props.data} />\n ) : null;\n}\n"],"mappings":";;;;;;;;;;AAuBA,SAAS,QAAQ,QAA+B;CAC9C,MAAM,QAAS,QAAuC;CACtD,OAAO,MAAM,QAAQ,KAAK,IAAK,QAAyB,CAAC;AAC3D;AAEA,SAAS,YAAY,SAAsE;CACzF,IAAI,YAAY,mBACd,OAAO;CAET,IAAI,YAAY,gBACd,OAAO;CAET,OAAO,YAAY,sBAAsB,WAAW;AACtD;AAEA,SAAS,YAAY,OAE8B;CACjD,MAAM,EAAE,WAAW;CACnB,IAAI,CAAC,QACH,OAAO,oBAAC,MAAD,EAAA,UAAM,mCAAsC,CAAA;CAErD,IAAI,OAAO,WAAW,UACpB,OACE,qBAAC,OAAD;EAAO,MAAK;EAAU,OAAM;EAA5B,UAAA;GAA4C;GACV,OAAO;GAAU;EAC5C;;CAGX,IAAI,OAAO,WAAW,QACpB,OAAO,oBAAC,MAAD,EAAA,UAAM,iBAAoB,CAAA;CAEnC,OACE,qBAAA,UAAA,EAAA,UAAA;EACE,oBAAC,QAAD,EAAA,UAAO,OAAO,QAAc,CAAA;EAC5B,oBAAC,MAAD,EAAA,UACG,QAAQ,OAAO,MAAM,CAAC,CAAC,KAAK,SAC3B,qBAAC,MAAD,EAAA,UAAA;GACE,oBAAC,UAAD,EAAA,UAAS,KAAK,YAAoB,CAAA;GAAC;GAAG,KAAK;GAAS;GAAI,KAAK;EAC3D,EAAA,CAAA,CACL,EACC,CAAA;EACJ,qBAAC,MAAD,EAAA,UAAA;GACG,OAAO;GAAQ;GAAI,OAAO;GAAM;GAAI,OAAO,UAAU,MAAM,GAAG,EAAE,CAAC,CAAC,QAAQ,KAAK,GAAG;EAC/E,EAAA,CAAA;CACN,EAAA,CAAA;AAEN;;AAUA,SAAgB,aACd,OACgD;CAChD,MAAM,EAAE,SAAS,OAAO,SAAS;CACjC,MAAM,OAAO,aAAa,QAAQ,KAAK,UAAU,MAAM,GAAG;CAC1D,MAAM,SAAS,KAAK;CACpB,MAAM,WAAW,QAAQ,WAAW,aAAa,QAAQ,WAAW;CACpE,MAAM,OAAO,WACT;EAAE,UAAU,GAAG,KAAK;EAAS,cAAc;EAAiB,WAAW;CAAY,IACnF,CAAC;CACL,OACE,oBAAC,OAAD;EAAK,IAAG;EAAgB,GAAI;EAC1B,UAAA,qBAAC,MAAD,EAAA,UAAA,CACE,qBAAC,QAAD,EAAA,UAAA;GACE,oBAAC,cAAD;IAAc,OAAO;IAAG,UAAA;GAAkC,CAAA;GACzD,QAAQ,WAAW,SAClB,oBAAC,OAAD;IAAO,MAAM,YAAY,OAAO,OAAO;IAAI,UAAA,OAAO;GAAe,CAAA,IAC/D;GACH,WAAW,oBAAC,OAAD;IAAO,MAAK;IAAO,UAAA;GAAkB,CAAA,IAAI;EAC/C,EAAA,CAAA,GACR,qBAAC,QAAD,EAAA,UAAA,CACE,oBAAC,aAAD,EAAqB,OAAS,CAAA,GAC7B,KAAK,eAAe,CAAC,WACpB,qBAAC,QAAD;GACE,QAAO;GACP,QAAQ,GAAG,KAAK;GAChB,WAAS,GAAG,KAAK;GACjB,aAAU;GACV,WAAQ;GALV,UAAA;IAOG,UAAU;IACX,oBAAC,SAAD;KAAO,MAAK;KAAS,MAAK;KAAQ,OAAO,SAAS,SAAS;IAAU,CAAA;IACrE,oBAAC,QAAD;KAAQ,SAAQ;KAAY,MAAK;KAAK,MAAK;KAAS,MAAK;KACtD,UAAA,SAAS,eAAe;IACnB,CAAA;GACJ;EACJ,CAAA,IAAA,IACE,EAAA,CAAA,CACJ,EAAA,CAAA;CACH,CAAA;AAET;;AAGA,SAAgB,YAAY,OAI8B;CACxD,OAAO,MAAM,OACX,oBAAC,cAAD;EAAc,SAAS,MAAM;EAAS,OAAO,MAAM;EAAO,MAAM,MAAM;CAAO,CAAA,IAC3E;AACN"}
@@ -4,32 +4,72 @@ import.meta.url;
4
4
  //#region src/pages/build-diff-model.ts
5
5
  const OPEN = /* @__PURE__ */ new Set(["new", "changed"]);
6
6
  const DECIDED = /* @__PURE__ */ new Set(["approved", "rejected"]);
7
- /** Count review progress; unchanged snapshots need no decision and are excluded. */
7
+ const browserOf = (snap) => snap.browser ?? "chromium";
8
+ function memberOrder(a, b) {
9
+ const left = browserOf(a);
10
+ const right = browserOf(b);
11
+ if (left === right) return 0;
12
+ if (left === "chromium") return -1;
13
+ if (right === "chromium") return 1;
14
+ return left < right ? -1 : 1;
15
+ }
16
+ /** Group snapshots by story and mode, keeping first-appearance order. */
17
+ function groupSnapshots(snapshots) {
18
+ const groups = /* @__PURE__ */ new Map();
19
+ for (const snap of snapshots) {
20
+ const key = `${snap.storyId}\u0000${snap.viewportName}`;
21
+ groups.set(key, [...groups.get(key) ?? [], snap]);
22
+ }
23
+ return [...groups.entries()].flatMap(([key, list]) => {
24
+ const members = list.toSorted(memberOrder);
25
+ const [primary] = members;
26
+ if (!primary) return [];
27
+ return [{
28
+ key,
29
+ storyId: primary.storyId,
30
+ modeKey: primary.viewportName,
31
+ members,
32
+ primary,
33
+ representative: members.find((snap) => OPEN.has(snap.status)) ?? primary
34
+ }];
35
+ });
36
+ }
37
+ /** Whether a group still has a snapshot waiting for a decision. */
38
+ function isOpenGroup(group) {
39
+ return group.members.some((snap) => OPEN.has(snap.status));
40
+ }
41
+ /** Count review progress per group; groups of unchanged snapshots need no decision. */
8
42
  function reviewProgress(snapshots) {
9
- const pending = snapshots.filter((snap) => OPEN.has(snap.status)).length;
10
- const done = snapshots.filter((snap) => DECIDED.has(snap.status)).length;
43
+ const groups = groupSnapshots(snapshots);
44
+ const pending = groups.filter((group) => isOpenGroup(group)).length;
45
+ const done = groups.filter((group) => !isOpenGroup(group) && group.members.some((snap) => DECIDED.has(snap.status))).length;
11
46
  return {
12
47
  reviewable: pending + done,
13
48
  done,
14
49
  pending
15
50
  };
16
51
  }
17
- /** Previous/next snapshot around the selected one. */
52
+ /** Previous/next group (as its representative snapshot) around the selected snapshot. */
18
53
  function neighbours(snapshots, selectedId) {
19
- const index = snapshots.findIndex((snap) => snap.id === selectedId);
54
+ const groups = groupSnapshots(snapshots);
55
+ const index = groups.findIndex((group) => group.members.some((snap) => snap.id === selectedId));
20
56
  if (index === -1) return {
21
57
  position: 0,
22
- total: snapshots.length,
58
+ total: groups.length,
23
59
  prev: null,
24
60
  next: null
25
61
  };
26
62
  return {
27
63
  position: index + 1,
28
- total: snapshots.length,
29
- prev: snapshots[index - 1] ?? null,
30
- next: snapshots[index + 1] ?? null
64
+ total: groups.length,
65
+ prev: groups[index - 1]?.representative ?? null,
66
+ next: groups[index + 1]?.representative ?? null
31
67
  };
32
68
  }
69
+ /** The group a snapshot belongs to. */
70
+ function groupOf(snapshots, snapshotId) {
71
+ return groupSnapshots(snapshots).find((group) => group.members.some((snap) => snap.id === snapshotId));
72
+ }
33
73
  /** Diff size as a percentage string ("3.1%"), or null when not computed. */
34
74
  function diffPercent(snapshot) {
35
75
  return snapshot.diffRatio === null || snapshot.diffRatio === void 0 ? null : `${(snapshot.diffRatio * 100).toFixed(1)}%`;
@@ -42,7 +82,12 @@ function snapshotPageUrl(project, build, snapshotId) {
42
82
  function snapshotImageUrl(project, build, snapshot, kind) {
43
83
  return `/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snapshot.id}/${kind}`;
44
84
  }
85
+ /** Mode and engine of a snapshot for labels: `dark`, `dark · firefox` (chromium is implicit). */
86
+ function snapshotVariant(snap) {
87
+ const browser = snap.browser ?? "chromium";
88
+ return browser === "chromium" ? snap.viewportName : `${snap.viewportName} · ${browser}`;
89
+ }
45
90
  //#endregion
46
- export { diffPercent, neighbours, reviewProgress, snapshotImageUrl, snapshotPageUrl };
91
+ export { diffPercent, groupOf, groupSnapshots, isOpenGroup, neighbours, reviewProgress, snapshotImageUrl, snapshotPageUrl, snapshotVariant };
47
92
 
48
93
  //# sourceMappingURL=build-diff-model.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"build-diff-model.mjs","names":[],"sources":["../../src/pages/build-diff-model.ts"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\n\n/** How far a build's review has progressed. */\nexport interface ReviewProgress {\n /** Snapshots a human has to decide on (changed/new, or already decided). */\n reviewable: number;\n /** Reviewable snapshots that are approved or rejected. */\n done: number;\n /** Snapshots still waiting for a decision. */\n pending: number;\n}\n\nconst OPEN = new Set([\"new\", \"changed\"]);\nconst DECIDED = new Set([\"approved\", \"rejected\"]);\n\n/** Count review progress; unchanged snapshots need no decision and are excluded. */\nexport function reviewProgress(snapshots: Snapshot[]): ReviewProgress {\n const pending = snapshots.filter((snap) => OPEN.has(snap.status)).length;\n const done = snapshots.filter((snap) => DECIDED.has(snap.status)).length;\n return { reviewable: pending + done, done, pending };\n}\n\n/** Position of the selected snapshot plus its neighbours in list order. */\nexport interface Neighbours {\n /** 1-based position, or 0 when nothing is selected. */\n position: number;\n total: number;\n prev: Snapshot | null;\n next: Snapshot | null;\n}\n\n/** Previous/next snapshot around the selected one. */\nexport function neighbours(snapshots: Snapshot[], selectedId?: string): Neighbours {\n const index = snapshots.findIndex((snap) => snap.id === selectedId);\n if (index === -1) {\n return { position: 0, total: snapshots.length, prev: null, next: null };\n }\n return {\n position: index + 1,\n total: snapshots.length,\n prev: snapshots[index - 1] ?? null,\n next: snapshots[index + 1] ?? null,\n };\n}\n\n/** Diff size as a percentage string (\"3.1%\"), or null when not computed. */\nexport function diffPercent(snapshot: Pick<Snapshot, \"diffRatio\">): string | null {\n return snapshot.diffRatio === null || snapshot.diffRatio === undefined\n ? null\n : `${(snapshot.diffRatio * 100).toFixed(1)}%`;\n}\n\n/** Review-page URL for one snapshot. */\nexport function snapshotPageUrl(project: Project, build: Build, snapshotId: string): string {\n return `/projects/${project.slug}/builds/${build.id}/diff?snapshot=${snapshotId}`;\n}\n\n/** API URL of a snapshot image (`image`), its diff overlay, or its baseline. */\nexport function snapshotImageUrl(\n project: Project,\n build: Build,\n snapshot: Snapshot,\n kind: \"image\" | \"diff\" | \"baseline\",\n): string {\n return `/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snapshot.id}/${kind}`;\n}\n"],"mappings":";;;;AAYA,MAAM,uBAAO,IAAI,IAAI,CAAC,OAAO,SAAS,CAAC;AACvC,MAAM,0BAAU,IAAI,IAAI,CAAC,YAAY,UAAU,CAAC;;AAGhD,SAAgB,eAAe,WAAuC;CACpE,MAAM,UAAU,UAAU,QAAQ,SAAS,KAAK,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC;CAClE,MAAM,OAAO,UAAU,QAAQ,SAAS,QAAQ,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC;CAClE,OAAO;EAAE,YAAY,UAAU;EAAM;EAAM;CAAQ;AACrD;;AAYA,SAAgB,WAAW,WAAuB,YAAiC;CACjF,MAAM,QAAQ,UAAU,WAAW,SAAS,KAAK,OAAO,UAAU;CAClE,IAAI,UAAU,IACZ,OAAO;EAAE,UAAU;EAAG,OAAO,UAAU;EAAQ,MAAM;EAAM,MAAM;CAAK;CAExE,OAAO;EACL,UAAU,QAAQ;EAClB,OAAO,UAAU;EACjB,MAAM,UAAU,QAAQ,MAAM;EAC9B,MAAM,UAAU,QAAQ,MAAM;CAChC;AACF;;AAGA,SAAgB,YAAY,UAAsD;CAChF,OAAO,SAAS,cAAc,QAAQ,SAAS,cAAc,KAAA,IACzD,OACA,IAAI,SAAS,YAAY,IAAA,CAAK,QAAQ,CAAC,EAAE;AAC/C;;AAGA,SAAgB,gBAAgB,SAAkB,OAAc,YAA4B;CAC1F,OAAO,aAAa,QAAQ,KAAK,UAAU,MAAM,GAAG,iBAAiB;AACvE;;AAGA,SAAgB,iBACd,SACA,OACA,UACA,MACQ;CACR,OAAO,oBAAoB,QAAQ,KAAK,UAAU,MAAM,GAAG,aAAa,SAAS,GAAG,GAAG;AACzF"}
1
+ {"version":3,"file":"build-diff-model.mjs","names":[],"sources":["../../src/pages/build-diff-model.ts"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\n\n/** How far a build's review has progressed, counted per story/mode (not per browser). */\nexport interface ReviewProgress {\n /** Groups a human has to decide on (any open or decided member). */\n reviewable: number;\n /** Reviewable groups with no open member left. */\n done: number;\n /** Groups still waiting for a decision. */\n pending: number;\n}\n\nconst OPEN = new Set([\"new\", \"changed\"]);\nconst DECIDED = new Set([\"approved\", \"rejected\"]);\n\n/**\n * One story in one mode, with its snapshot on every engine. Approval is shared:\n * a single decision covers all members.\n */\nexport interface SnapshotGroup {\n /** Stable identity (story + mode). */\n key: string;\n storyId: string;\n /** The mode key (`viewportName`). */\n modeKey: string;\n /** Member snapshots, chromium first, then by engine name. */\n members: Snapshot[];\n /** The primary engine's snapshot (chromium when present). */\n primary: Snapshot;\n /** Snapshot the filmstrip links to: first open member, else the first member. */\n representative: Snapshot;\n}\n\nconst browserOf = (snap: Pick<Snapshot, \"browser\">): string => snap.browser ?? \"chromium\";\n\nfunction memberOrder(a: Snapshot, b: Snapshot): number {\n const left = browserOf(a);\n const right = browserOf(b);\n if (left === right) return 0;\n if (left === \"chromium\") return -1;\n if (right === \"chromium\") return 1;\n return left < right ? -1 : 1;\n}\n\n/** Group snapshots by story and mode, keeping first-appearance order. */\nexport function groupSnapshots(snapshots: Snapshot[]): SnapshotGroup[] {\n const groups = new Map<string, Snapshot[]>();\n for (const snap of snapshots) {\n const key = `${snap.storyId}\\u0000${snap.viewportName}`;\n groups.set(key, [...(groups.get(key) ?? []), snap]);\n }\n return [...groups.entries()].flatMap(([key, list]): SnapshotGroup[] => {\n const members = list.toSorted(memberOrder);\n const [primary] = members;\n if (!primary) return [];\n return [\n {\n key,\n storyId: primary.storyId,\n modeKey: primary.viewportName,\n members,\n primary,\n representative: members.find((snap) => OPEN.has(snap.status)) ?? primary,\n },\n ];\n });\n}\n\n/** Whether a group still has a snapshot waiting for a decision. */\nexport function isOpenGroup(group: SnapshotGroup): boolean {\n return group.members.some((snap) => OPEN.has(snap.status));\n}\n\n/** Count review progress per group; groups of unchanged snapshots need no decision. */\nexport function reviewProgress(snapshots: Snapshot[]): ReviewProgress {\n const groups = groupSnapshots(snapshots);\n const pending = groups.filter((group) => isOpenGroup(group)).length;\n const done = groups.filter(\n (group) => !isOpenGroup(group) && group.members.some((snap) => DECIDED.has(snap.status)),\n ).length;\n return { reviewable: pending + done, done, pending };\n}\n\n/** Position of the selected group plus its neighbours in list order. */\nexport interface Neighbours {\n /** 1-based group position, or 0 when nothing is selected. */\n position: number;\n /** Number of groups. */\n total: number;\n prev: Snapshot | null;\n next: Snapshot | null;\n}\n\n/** Previous/next group (as its representative snapshot) around the selected snapshot. */\nexport function neighbours(snapshots: Snapshot[], selectedId?: string): Neighbours {\n const groups = groupSnapshots(snapshots);\n const index = groups.findIndex((group) => group.members.some((snap) => snap.id === selectedId));\n if (index === -1) {\n return { position: 0, total: groups.length, prev: null, next: null };\n }\n return {\n position: index + 1,\n total: groups.length,\n prev: groups[index - 1]?.representative ?? null,\n next: groups[index + 1]?.representative ?? null,\n };\n}\n\n/** The group a snapshot belongs to. */\nexport function groupOf(snapshots: Snapshot[], snapshotId: string): SnapshotGroup | undefined {\n return groupSnapshots(snapshots).find((group) =>\n group.members.some((snap) => snap.id === snapshotId),\n );\n}\n\n/** Diff size as a percentage string (\"3.1%\"), or null when not computed. */\nexport function diffPercent(snapshot: Pick<Snapshot, \"diffRatio\">): string | null {\n return snapshot.diffRatio === null || snapshot.diffRatio === undefined\n ? null\n : `${(snapshot.diffRatio * 100).toFixed(1)}%`;\n}\n\n/** Review-page URL for one snapshot. */\nexport function snapshotPageUrl(project: Project, build: Build, snapshotId: string): string {\n return `/projects/${project.slug}/builds/${build.id}/diff?snapshot=${snapshotId}`;\n}\n\n/** API URL of a snapshot image (`image`), its diff overlay, or its baseline. */\nexport function snapshotImageUrl(\n project: Project,\n build: Build,\n snapshot: Snapshot,\n kind: \"image\" | \"diff\" | \"baseline\",\n): string {\n return `/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snapshot.id}/${kind}`;\n}\n\n/** Mode and engine of a snapshot for labels: `dark`, `dark · firefox` (chromium is implicit). */\nexport function snapshotVariant(snap: Pick<Snapshot, \"viewportName\" | \"browser\">): string {\n const browser = snap.browser ?? \"chromium\";\n return browser === \"chromium\" ? snap.viewportName : `${snap.viewportName} · ${browser}`;\n}\n"],"mappings":";;;;AAYA,MAAM,uBAAO,IAAI,IAAI,CAAC,OAAO,SAAS,CAAC;AACvC,MAAM,0BAAU,IAAI,IAAI,CAAC,YAAY,UAAU,CAAC;AAoBhD,MAAM,aAAa,SAA4C,KAAK,WAAW;AAE/E,SAAS,YAAY,GAAa,GAAqB;CACrD,MAAM,OAAO,UAAU,CAAC;CACxB,MAAM,QAAQ,UAAU,CAAC;CACzB,IAAI,SAAS,OAAO,OAAO;CAC3B,IAAI,SAAS,YAAY,OAAO;CAChC,IAAI,UAAU,YAAY,OAAO;CACjC,OAAO,OAAO,QAAQ,KAAK;AAC7B;;AAGA,SAAgB,eAAe,WAAwC;CACrE,MAAM,yBAAS,IAAI,IAAwB;CAC3C,KAAK,MAAM,QAAQ,WAAW;EAC5B,MAAM,MAAM,GAAG,KAAK,QAAQ,QAAQ,KAAK;EACzC,OAAO,IAAI,KAAK,CAAC,GAAI,OAAO,IAAI,GAAG,KAAK,CAAC,GAAI,IAAI,CAAC;CACpD;CACA,OAAO,CAAC,GAAG,OAAO,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,UAA2B;EACrE,MAAM,UAAU,KAAK,SAAS,WAAW;EACzC,MAAM,CAAC,WAAW;EAClB,IAAI,CAAC,SAAS,OAAO,CAAC;EACtB,OAAO,CACL;GACE;GACA,SAAS,QAAQ;GACjB,SAAS,QAAQ;GACjB;GACA;GACA,gBAAgB,QAAQ,MAAM,SAAS,KAAK,IAAI,KAAK,MAAM,CAAC,KAAK;EACnE,CACF;CACF,CAAC;AACH;;AAGA,SAAgB,YAAY,OAA+B;CACzD,OAAO,MAAM,QAAQ,MAAM,SAAS,KAAK,IAAI,KAAK,MAAM,CAAC;AAC3D;;AAGA,SAAgB,eAAe,WAAuC;CACpE,MAAM,SAAS,eAAe,SAAS;CACvC,MAAM,UAAU,OAAO,QAAQ,UAAU,YAAY,KAAK,CAAC,CAAC,CAAC;CAC7D,MAAM,OAAO,OAAO,QACjB,UAAU,CAAC,YAAY,KAAK,KAAK,MAAM,QAAQ,MAAM,SAAS,QAAQ,IAAI,KAAK,MAAM,CAAC,CACzF,CAAC,CAAC;CACF,OAAO;EAAE,YAAY,UAAU;EAAM;EAAM;CAAQ;AACrD;;AAaA,SAAgB,WAAW,WAAuB,YAAiC;CACjF,MAAM,SAAS,eAAe,SAAS;CACvC,MAAM,QAAQ,OAAO,WAAW,UAAU,MAAM,QAAQ,MAAM,SAAS,KAAK,OAAO,UAAU,CAAC;CAC9F,IAAI,UAAU,IACZ,OAAO;EAAE,UAAU;EAAG,OAAO,OAAO;EAAQ,MAAM;EAAM,MAAM;CAAK;CAErE,OAAO;EACL,UAAU,QAAQ;EAClB,OAAO,OAAO;EACd,MAAM,OAAO,QAAQ,EAAE,EAAE,kBAAkB;EAC3C,MAAM,OAAO,QAAQ,EAAE,EAAE,kBAAkB;CAC7C;AACF;;AAGA,SAAgB,QAAQ,WAAuB,YAA+C;CAC5F,OAAO,eAAe,SAAS,CAAC,CAAC,MAAM,UACrC,MAAM,QAAQ,MAAM,SAAS,KAAK,OAAO,UAAU,CACrD;AACF;;AAGA,SAAgB,YAAY,UAAsD;CAChF,OAAO,SAAS,cAAc,QAAQ,SAAS,cAAc,KAAA,IACzD,OACA,IAAI,SAAS,YAAY,IAAA,CAAK,QAAQ,CAAC,EAAE;AAC/C;;AAGA,SAAgB,gBAAgB,SAAkB,OAAc,YAA4B;CAC1F,OAAO,aAAa,QAAQ,KAAK,UAAU,MAAM,GAAG,iBAAiB;AACvE;;AAGA,SAAgB,iBACd,SACA,OACA,UACA,MACQ;CACR,OAAO,oBAAoB,QAAQ,KAAK,UAAU,MAAM,GAAG,aAAa,SAAS,GAAG,GAAG;AACzF;;AAGA,SAAgB,gBAAgB,MAA0D;CACxF,MAAM,UAAU,KAAK,WAAW;CAChC,OAAO,YAAY,aAAa,KAAK,eAAe,GAAG,KAAK,aAAa,KAAK;AAChF"}
@@ -5,8 +5,9 @@ import { css } from "../ui/css.mjs";
5
5
  import { statusTone } from "../ui/feedback.mjs";
6
6
  import { Segmented } from "../ui/segmented.mjs";
7
7
  import { Thumbnail } from "../ui/thumbnail.mjs";
8
+ import { a11yCarrierOf, a11yFindingsOf } from "./a11y-violations.mjs";
9
+ import { diffPercent, groupSnapshots, isOpenGroup, reviewProgress, snapshotImageUrl, snapshotPageUrl } from "./build-diff-model.mjs";
8
10
  import { filmBody, filmItem, filmItemActive, filmStatus, filmSub, filmTitle, filmstrip, filmstripHead, filmstripList, statusDots } from "../ui/styles/review-layout.mjs";
9
- import { diffPercent, reviewProgress, snapshotImageUrl, snapshotPageUrl } from "./build-diff-model.mjs";
10
11
  import { jsx, jsxs } from "hono/jsx/jsx-runtime";
11
12
  //#region src/pages/build-diff-nav.tsx
12
13
  /**
@@ -20,6 +21,9 @@ const filteredList = css`
20
21
  [data-status]:not([data-status="new"]):not([data-status="changed"]):not([aria-current="true"]) {
21
22
  display: none;
22
23
  }
24
+ &[data-filter="a11y"] [data-snapshot-link]:not([data-a11y]):not([aria-current="true"]) {
25
+ display: none;
26
+ }
23
27
  &[data-filter="done"]
24
28
  [data-status]:not([data-status="approved"]):not([data-status="rejected"]):not(
25
29
  [aria-current="true"]
@@ -28,15 +32,20 @@ const filteredList = css`
28
32
  }
29
33
  `;
30
34
  function FilmItem(props) {
31
- const { project, build, snap, active } = props;
35
+ const { project, build, group, active } = props;
36
+ const snap = group.representative;
32
37
  const url = snapshotPageUrl(project, build, snap.id);
33
38
  const percent = diffPercent(snap);
39
+ const carrier = a11yCarrierOf(snap, group.members);
40
+ const a11yCount = a11yFindingsOf(carrier).length;
41
+ const tags = [group.modeKey === "desktop" ? "" : group.modeKey, group.members.length > 1 ? `${group.members.length} browsers` : ""].filter((tag) => tag !== "");
34
42
  return /* @__PURE__ */ jsxs("a", {
35
43
  href: url,
36
44
  class: active ? filmItemActive : filmItem,
37
45
  "data-snapshot-link": true,
38
46
  "data-snapshot-id": snap.id,
39
- "data-status": snap.status,
47
+ "data-status": isOpenGroup(group) ? "changed" : snap.status,
48
+ "data-a11y": a11yCount > 0 ? "" : void 0,
40
49
  "aria-current": active ? "true" : void 0,
41
50
  "hx-get": url,
42
51
  "hx-target": "body",
@@ -55,7 +64,7 @@ function FilmItem(props) {
55
64
  }),
56
65
  /* @__PURE__ */ jsx("span", {
57
66
  class: filmSub,
58
- children: snap.storyTitle
67
+ children: [snap.storyTitle, ...tags].join(" · ")
59
68
  }),
60
69
  /* @__PURE__ */ jsxs("span", {
61
70
  class: filmStatus,
@@ -65,18 +74,21 @@ function FilmItem(props) {
65
74
  "aria-hidden": "true"
66
75
  }),
67
76
  snap.status,
68
- percent ? ` · ${percent}` : ""
77
+ percent ? ` · ${percent}` : "",
78
+ a11yCount > 0 ? ` · a11y ${a11yCount}${carrier.browser ? ` (${carrier.browser})` : ""}` : ""
69
79
  ]
70
80
  })
71
81
  ]
72
82
  })]
73
83
  });
74
84
  }
75
- /** Left filmstrip: filter chips plus a thumbnail per snapshot. */
85
+ /** Left filmstrip: filter chips plus a thumbnail per story/mode (browsers share one entry). */
76
86
  function DiffNav(props) {
77
87
  const { project, build, snapshots, selectedId } = props;
88
+ const groups = groupSnapshots(snapshots);
78
89
  const progress = reviewProgress(snapshots);
79
90
  const filter = progress.pending > 0 ? "review" : "all";
91
+ const withA11y = groups.filter((group) => a11yFindingsOf(a11yCarrierOf(group.representative, group.members)).length > 0).length;
80
92
  return /* @__PURE__ */ jsxs("aside", {
81
93
  class: filmstrip,
82
94
  "aria-label": "Snapshots",
@@ -92,14 +104,18 @@ function DiffNav(props) {
92
104
  active: filter === "review"
93
105
  },
94
106
  {
95
- label: `All ${snapshots.length}`,
107
+ label: `All ${groups.length}`,
96
108
  value: "all",
97
109
  active: filter === "all"
98
110
  },
99
111
  {
100
112
  label: `Done ${progress.done}`,
101
113
  value: "done"
102
- }
114
+ },
115
+ ...withA11y > 0 ? [{
116
+ label: `A11y ${withA11y}`,
117
+ value: "a11y"
118
+ }] : []
103
119
  ]
104
120
  })
105
121
  }), /* @__PURE__ */ jsx("div", {
@@ -107,12 +123,12 @@ function DiffNav(props) {
107
123
  "data-current": selectedId,
108
124
  "data-filter": filter,
109
125
  class: filteredList,
110
- children: snapshots.map((snap) => /* @__PURE__ */ jsx(FilmItem, {
126
+ children: groups.map((group) => /* @__PURE__ */ jsx(FilmItem, {
111
127
  project,
112
128
  build,
113
- snap,
114
- active: selectedId === snap.id
115
- }, snap.id))
129
+ group,
130
+ active: group.members.some((snap) => snap.id === selectedId)
131
+ }, group.key))
116
132
  })]
117
133
  });
118
134
  }
@@ -1 +1 @@
1
- {"version":3,"file":"build-diff-nav.mjs","names":[],"sources":["../../src/pages/build-diff-nav.tsx"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Segmented, Thumbnail, statusTone } from \"../ui/components.tsx\";\nimport { css } from \"../ui/css.ts\";\nimport {\n filmBody,\n filmItem,\n filmItemActive,\n filmStatus,\n filmSub,\n filmTitle,\n filmstrip,\n filmstripHead,\n filmstripList,\n statusDots,\n} from \"../ui/styles/review-layout.ts\";\nimport {\n diffPercent,\n reviewProgress,\n snapshotImageUrl,\n snapshotPageUrl,\n} from \"./build-diff-model.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/**\n * The list filters purely in CSS off `data-filter`, so it works before the\n * review script runs. The selected snapshot always stays visible.\n */\nconst filteredList = css`\n /* filmstrip-filtered */\n ${filmstripList}\n &[data-filter=\"review\"]\n [data-status]:not([data-status=\"new\"]):not([data-status=\"changed\"]):not([aria-current=\"true\"]) {\n display: none;\n }\n &[data-filter=\"done\"]\n [data-status]:not([data-status=\"approved\"]):not([data-status=\"rejected\"]):not(\n [aria-current=\"true\"]\n ) {\n display: none;\n }\n`;\n\n/** Snapshot navigator props for the diff review page. */\nexport interface DiffNavProps {\n project: Project;\n build: Build;\n snapshots: Snapshot[];\n selectedId?: string;\n}\n\nfunction FilmItem(props: {\n project: Project;\n build: Build;\n snap: Snapshot;\n active: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, snap, active } = props;\n const url = snapshotPageUrl(project, build, snap.id);\n const percent = diffPercent(snap);\n return (\n <a\n href={url}\n class={active ? filmItemActive : filmItem}\n data-snapshot-link\n data-snapshot-id={snap.id}\n data-status={snap.status}\n aria-current={active ? \"true\" : undefined}\n hx-get={url}\n hx-target=\"body\"\n hx-push-url=\"true\"\n >\n <Thumbnail src={snapshotImageUrl(project, build, snap, \"image\")} alt=\"\" placeholder=\"…\" />\n <span class={filmBody}>\n <span class={filmTitle} title={`${snap.storyTitle} / ${snap.storyName}`}>\n {snap.storyName}\n </span>\n <span class={filmSub}>{snap.storyTitle}</span>\n <span class={filmStatus}>\n <span class={statusDots[statusTone(snap.status)]} aria-hidden=\"true\" />\n {snap.status}\n {percent ? ` · ${percent}` : \"\"}\n </span>\n </span>\n </a>\n );\n}\n\n/** Left filmstrip: filter chips plus a thumbnail per snapshot. */\nexport function DiffNav(props: DiffNavProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, snapshots, selectedId } = props;\n const progress = reviewProgress(snapshots);\n const filter = progress.pending > 0 ? \"review\" : \"all\";\n return (\n <aside class={filmstrip} aria-label=\"Snapshots\">\n <div class={filmstripHead}>\n <Segmented\n label=\"Filter snapshots\"\n data-filter-switch\n items={[\n {\n label: `Needs review ${progress.pending}`,\n value: \"review\",\n active: filter === \"review\",\n },\n { label: `All ${snapshots.length}`, value: \"all\", active: filter === \"all\" },\n { label: `Done ${progress.done}`, value: \"done\" },\n ]}\n />\n </div>\n <div data-diff-nav data-current={selectedId} data-filter={filter} class={filteredList}>\n {snapshots.map((snap): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <FilmItem\n key={snap.id}\n project={project}\n build={build}\n snap={snap}\n active={selectedId === snap.id}\n />\n ))}\n </div>\n </aside>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6BA,MAAM,eAAe,GAAG;;IAEpB,cAAc;;;;;;;;;;;;AAqBlB,SAAS,SAAS,OAKiC;CACjD,MAAM,EAAE,SAAS,OAAO,MAAM,WAAW;CACzC,MAAM,MAAM,gBAAgB,SAAS,OAAO,KAAK,EAAE;CACnD,MAAM,UAAU,YAAY,IAAI;CAChC,OACE,qBAAC,KAAD;EACE,MAAM;EACN,OAAO,SAAS,iBAAiB;EACjC,sBAAA;EACA,oBAAkB,KAAK;EACvB,eAAa,KAAK;EAClB,gBAAc,SAAS,SAAS,KAAA;EAChC,UAAQ;EACR,aAAU;EACV,eAAY;EATd,UAAA,CAWE,oBAAC,WAAD;GAAW,KAAK,iBAAiB,SAAS,OAAO,MAAM,OAAO;GAAG,KAAI;GAAG,aAAY;EAAK,CAAA,GACzF,qBAAC,QAAD;GAAM,OAAO;GAAb,UAAA;IACE,oBAAC,QAAD;KAAM,OAAO;KAAW,OAAO,GAAG,KAAK,WAAW,KAAK,KAAK;KACzD,UAAA,KAAK;IACF,CAAA;IACN,oBAAC,QAAD;KAAM,OAAO;KAAU,UAAA,KAAK;IAAiB,CAAA;IAC7C,qBAAC,QAAD;KAAM,OAAO;KAAb,UAAA;MACE,oBAAC,QAAD;OAAM,OAAO,WAAW,WAAW,KAAK,MAAM;OAAI,eAAY;MAAQ,CAAA;MACrE,KAAK;MACL,UAAU,MAAM,YAAY;KACzB;;GACF;EACL,CAAA,CAAA;;AAEP;;AAGA,SAAgB,QAAQ,OAAqE;CAC3F,MAAM,EAAE,SAAS,OAAO,WAAW,eAAe;CAClD,MAAM,WAAW,eAAe,SAAS;CACzC,MAAM,SAAS,SAAS,UAAU,IAAI,WAAW;CACjD,OACE,qBAAC,SAAD;EAAO,OAAO;EAAW,cAAW;EAApC,UAAA,CACE,oBAAC,OAAD;GAAK,OAAO;GACV,UAAA,oBAAC,WAAD;IACE,OAAM;IACN,sBAAA;IACA,OAAO;KACL;MACE,OAAO,gBAAgB,SAAS;MAChC,OAAO;MACP,QAAQ,WAAW;KACrB;KACA;MAAE,OAAO,OAAO,UAAU;MAAU,OAAO;MAAO,QAAQ,WAAW;KAAM;KAC3E;MAAE,OAAO,QAAQ,SAAS;MAAQ,OAAO;KAAO;IAClD;GACD,CAAA;EACE,CAAA,GACL,oBAAC,OAAD;GAAK,iBAAA;GAAc,gBAAc;GAAY,eAAa;GAAQ,OAAO;GACtE,UAAA,UAAU,KAAK,SACd,oBAAC,UAAD;IAEW;IACF;IACD;IACN,QAAQ,eAAe,KAAK;GAC7B,GALM,KAAK,EAKX,CACF;EACE,CAAA,CACA;;AAEX"}
1
+ {"version":3,"file":"build-diff-nav.mjs","names":[],"sources":["../../src/pages/build-diff-nav.tsx"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Segmented, Thumbnail, statusTone } from \"../ui/components.tsx\";\nimport { css } from \"../ui/css.ts\";\nimport {\n filmBody,\n filmItem,\n filmItemActive,\n filmStatus,\n filmSub,\n filmTitle,\n filmstrip,\n filmstripHead,\n filmstripList,\n statusDots,\n} from \"../ui/styles/review-layout.ts\";\nimport { a11yCarrierOf, a11yFindingsOf } from \"./a11y-violations.tsx\";\nimport {\n diffPercent,\n groupSnapshots,\n isOpenGroup,\n reviewProgress,\n snapshotImageUrl,\n snapshotPageUrl,\n type SnapshotGroup,\n} from \"./build-diff-model.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/**\n * The list filters purely in CSS off `data-filter`, so it works before the\n * review script runs. The selected snapshot always stays visible.\n */\nconst filteredList = css`\n /* filmstrip-filtered */\n ${filmstripList}\n &[data-filter=\"review\"]\n [data-status]:not([data-status=\"new\"]):not([data-status=\"changed\"]):not([aria-current=\"true\"]) {\n display: none;\n }\n &[data-filter=\"a11y\"] [data-snapshot-link]:not([data-a11y]):not([aria-current=\"true\"]) {\n display: none;\n }\n &[data-filter=\"done\"]\n [data-status]:not([data-status=\"approved\"]):not([data-status=\"rejected\"]):not(\n [aria-current=\"true\"]\n ) {\n display: none;\n }\n`;\n\n/** Snapshot navigator props for the diff review page. */\nexport interface DiffNavProps {\n project: Project;\n build: Build;\n snapshots: Snapshot[];\n selectedId?: string;\n}\n\nfunction FilmItem(props: {\n project: Project;\n build: Build;\n group: SnapshotGroup;\n active: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, group, active } = props;\n const snap = group.representative;\n const url = snapshotPageUrl(project, build, snap.id);\n const percent = diffPercent(snap);\n const carrier = a11yCarrierOf(snap, group.members);\n const a11yCount = a11yFindingsOf(carrier).length;\n const tags = [\n group.modeKey === \"desktop\" ? \"\" : group.modeKey,\n group.members.length > 1 ? `${group.members.length} browsers` : \"\",\n ].filter((tag) => tag !== \"\");\n return (\n <a\n href={url}\n class={active ? filmItemActive : filmItem}\n data-snapshot-link\n data-snapshot-id={snap.id}\n data-status={isOpenGroup(group) ? \"changed\" : snap.status}\n data-a11y={a11yCount > 0 ? \"\" : undefined}\n aria-current={active ? \"true\" : undefined}\n hx-get={url}\n hx-target=\"body\"\n hx-push-url=\"true\"\n >\n <Thumbnail src={snapshotImageUrl(project, build, snap, \"image\")} alt=\"\" placeholder=\"…\" />\n <span class={filmBody}>\n <span class={filmTitle} title={`${snap.storyTitle} / ${snap.storyName}`}>\n {snap.storyName}\n </span>\n <span class={filmSub}>{[snap.storyTitle, ...tags].join(\" · \")}</span>\n <span class={filmStatus}>\n <span class={statusDots[statusTone(snap.status)]} aria-hidden=\"true\" />\n {snap.status}\n {percent ? ` · ${percent}` : \"\"}\n {a11yCount > 0\n ? ` · a11y ${a11yCount}${carrier.browser ? ` (${carrier.browser})` : \"\"}`\n : \"\"}\n </span>\n </span>\n </a>\n );\n}\n\n/** Left filmstrip: filter chips plus a thumbnail per story/mode (browsers share one entry). */\nexport function DiffNav(props: DiffNavProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, snapshots, selectedId } = props;\n const groups = groupSnapshots(snapshots);\n const progress = reviewProgress(snapshots);\n const filter = progress.pending > 0 ? \"review\" : \"all\";\n const withA11y = groups.filter(\n (group) => a11yFindingsOf(a11yCarrierOf(group.representative, group.members)).length > 0,\n ).length;\n return (\n <aside class={filmstrip} aria-label=\"Snapshots\">\n <div class={filmstripHead}>\n <Segmented\n label=\"Filter snapshots\"\n data-filter-switch\n items={[\n {\n label: `Needs review ${progress.pending}`,\n value: \"review\",\n active: filter === \"review\",\n },\n { label: `All ${groups.length}`, value: \"all\", active: filter === \"all\" },\n { label: `Done ${progress.done}`, value: \"done\" },\n ...(withA11y > 0 ? [{ label: `A11y ${withA11y}`, value: \"a11y\" }] : []),\n ]}\n />\n </div>\n <div data-diff-nav data-current={selectedId} data-filter={filter} class={filteredList}>\n {groups.map((group): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <FilmItem\n key={group.key}\n project={project}\n build={build}\n group={group}\n active={group.members.some((snap) => snap.id === selectedId)}\n />\n ))}\n </div>\n </aside>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiCA,MAAM,eAAe,GAAG;;IAEpB,cAAc;;;;;;;;;;;;;;;AAwBlB,SAAS,SAAS,OAKiC;CACjD,MAAM,EAAE,SAAS,OAAO,OAAO,WAAW;CAC1C,MAAM,OAAO,MAAM;CACnB,MAAM,MAAM,gBAAgB,SAAS,OAAO,KAAK,EAAE;CACnD,MAAM,UAAU,YAAY,IAAI;CAChC,MAAM,UAAU,cAAc,MAAM,MAAM,OAAO;CACjD,MAAM,YAAY,eAAe,OAAO,CAAC,CAAC;CAC1C,MAAM,OAAO,CACX,MAAM,YAAY,YAAY,KAAK,MAAM,SACzC,MAAM,QAAQ,SAAS,IAAI,GAAG,MAAM,QAAQ,OAAO,aAAa,EAClE,CAAC,CAAC,QAAQ,QAAQ,QAAQ,EAAE;CAC5B,OACE,qBAAC,KAAD;EACE,MAAM;EACN,OAAO,SAAS,iBAAiB;EACjC,sBAAA;EACA,oBAAkB,KAAK;EACvB,eAAa,YAAY,KAAK,IAAI,YAAY,KAAK;EACnD,aAAW,YAAY,IAAI,KAAK,KAAA;EAChC,gBAAc,SAAS,SAAS,KAAA;EAChC,UAAQ;EACR,aAAU;EACV,eAAY;EAVd,UAAA,CAYE,oBAAC,WAAD;GAAW,KAAK,iBAAiB,SAAS,OAAO,MAAM,OAAO;GAAG,KAAI;GAAG,aAAY;EAAK,CAAA,GACzF,qBAAC,QAAD;GAAM,OAAO;GAAb,UAAA;IACE,oBAAC,QAAD;KAAM,OAAO;KAAW,OAAO,GAAG,KAAK,WAAW,KAAK,KAAK;KACzD,UAAA,KAAK;IACF,CAAA;IACN,oBAAC,QAAD;KAAM,OAAO;KAAU,UAAA,CAAC,KAAK,YAAY,GAAG,IAAI,CAAC,CAAC,KAAK,KAAK;IAAQ,CAAA;IACpE,qBAAC,QAAD;KAAM,OAAO;KAAb,UAAA;MACE,oBAAC,QAAD;OAAM,OAAO,WAAW,WAAW,KAAK,MAAM;OAAI,eAAY;MAAQ,CAAA;MACrE,KAAK;MACL,UAAU,MAAM,YAAY;MAC5B,YAAY,IACT,WAAW,YAAY,QAAQ,UAAU,KAAK,QAAQ,QAAQ,KAAK,OACnE;KACA;;GACF;EACL,CAAA,CAAA;;AAEP;;AAGA,SAAgB,QAAQ,OAAqE;CAC3F,MAAM,EAAE,SAAS,OAAO,WAAW,eAAe;CAClD,MAAM,SAAS,eAAe,SAAS;CACvC,MAAM,WAAW,eAAe,SAAS;CACzC,MAAM,SAAS,SAAS,UAAU,IAAI,WAAW;CACjD,MAAM,WAAW,OAAO,QACrB,UAAU,eAAe,cAAc,MAAM,gBAAgB,MAAM,OAAO,CAAC,CAAC,CAAC,SAAS,CACzF,CAAC,CAAC;CACF,OACE,qBAAC,SAAD;EAAO,OAAO;EAAW,cAAW;EAApC,UAAA,CACE,oBAAC,OAAD;GAAK,OAAO;GACV,UAAA,oBAAC,WAAD;IACE,OAAM;IACN,sBAAA;IACA,OAAO;KACL;MACE,OAAO,gBAAgB,SAAS;MAChC,OAAO;MACP,QAAQ,WAAW;KACrB;KACA;MAAE,OAAO,OAAO,OAAO;MAAU,OAAO;MAAO,QAAQ,WAAW;KAAM;KACxE;MAAE,OAAO,QAAQ,SAAS;MAAQ,OAAO;KAAO;KAChD,GAAI,WAAW,IAAI,CAAC;MAAE,OAAO,QAAQ;MAAY,OAAO;KAAO,CAAC,IAAI,CAAC;IACvE;GACD,CAAA;EACE,CAAA,GACL,oBAAC,OAAD;GAAK,iBAAA;GAAc,gBAAc;GAAY,eAAa;GAAQ,OAAO;GACtE,UAAA,OAAO,KAAK,UACX,oBAAC,UAAD;IAEW;IACF;IACA;IACP,QAAQ,MAAM,QAAQ,MAAM,SAAS,KAAK,OAAO,UAAU;GAC5D,GALM,MAAM,GAKZ,CACF;EACE,CAAA,CACA;;AAEX"}
@@ -15,9 +15,13 @@ const SHORTCUTS = [
15
15
  keys: ["k", "←"],
16
16
  action: "Previous snapshot"
17
17
  },
18
+ {
19
+ keys: ["[", "]"],
20
+ action: "Previous / next browser of this story"
21
+ },
18
22
  {
19
23
  keys: ["a"],
20
- action: "Approve"
24
+ action: "Approve (all browsers of the story and mode)"
21
25
  },
22
26
  {
23
27
  keys: ["r"],
@@ -1 +1 @@
1
- {"version":3,"file":"build-diff-shortcuts.mjs","names":[],"sources":["../../src/pages/build-diff-shortcuts.tsx"],"sourcesContent":["import type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Button, Kbd } from \"../ui/components.tsx\";\nimport { shortcutsDialog } from \"../ui/styles/review-layout.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\nconst SHORTCUTS: { keys: string[]; action: string }[] = [\n { keys: [\"j\", \"→\"], action: \"Next snapshot\" },\n { keys: [\"k\", \"←\"], action: \"Previous snapshot\" },\n { keys: [\"a\"], action: \"Approve\" },\n { keys: [\"r\"], action: \"Reject\" },\n { keys: [\"1\", \"–\", \"5\"], action: \"Side by side, swipe, onion skin, diff, flip\" },\n { keys: [\"f\"], action: \"Cycle zoom (fit, 100%, 200%, 400%)\" },\n { keys: [\"+\", \"–\"], action: \"Zoom in / out\" },\n { keys: [\"t\"], action: \"Flip baseline / current\" },\n { keys: [\"?\"], action: \"Show this help\" },\n];\n\n/** Native dialog listing the review keyboard shortcuts (opened with `?`). */\nexport function ShortcutsDialog(): HtmlEscapedString | Promise<HtmlEscapedString> {\n return (\n <dialog class={shortcutsDialog} data-shortcuts aria-labelledby=\"shortcuts-title\">\n <h2 id=\"shortcuts-title\">Keyboard shortcuts</h2>\n <dl>\n {SHORTCUTS.map((item) => (\n <>\n <dt>\n <Kbd keys={item.keys} />\n </dt>\n <dd>{item.action}</dd>\n </>\n ))}\n </dl>\n <form method=\"dialog\">\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n Close\n </Button>\n </form>\n </dialog>\n );\n}\n"],"mappings":";;;;;;;;AAMA,MAAM,YAAkD;CACtD;EAAE,MAAM,CAAC,KAAK,GAAG;EAAG,QAAQ;CAAgB;CAC5C;EAAE,MAAM,CAAC,KAAK,GAAG;EAAG,QAAQ;CAAoB;CAChD;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAAU;CACjC;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAAS;CAChC;EAAE,MAAM;GAAC;GAAK;GAAK;EAAG;EAAG,QAAQ;CAA8C;CAC/E;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAAqC;CAC5D;EAAE,MAAM,CAAC,KAAK,GAAG;EAAG,QAAQ;CAAgB;CAC5C;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAA0B;CACjD;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAAiB;AAC1C;;AAGA,SAAgB,kBAAkE;CAChF,OACE,qBAAC,UAAD;EAAQ,OAAO;EAAiB,kBAAA;EAAe,mBAAgB;EAA/D,UAAA;GACE,oBAAC,MAAD;IAAI,IAAG;IAAkB,UAAA;GAAsB,CAAA;GAC/C,oBAAC,MAAD,EAAA,UACG,UAAU,KAAK,SACd,qBAAA,UAAA,EAAA,UAAA,CACE,oBAAC,MAAD,EAAA,UACE,oBAAC,KAAD,EAAK,MAAM,KAAK,KAAO,CAAA,EACrB,CAAA,GACJ,oBAAC,MAAD,EAAA,UAAK,KAAK,OAAW,CAAA,CACrB,EAAA,CAAA,CACH,EACC,CAAA;GACJ,oBAAC,QAAD;IAAM,QAAO;IACX,UAAA,oBAAC,QAAD;KAAQ,SAAQ;KAAY,MAAK;KAAK,MAAK;KAAS,UAAA;IAE5C,CAAA;GACJ,CAAA;EACA;;AAEZ"}
1
+ {"version":3,"file":"build-diff-shortcuts.mjs","names":[],"sources":["../../src/pages/build-diff-shortcuts.tsx"],"sourcesContent":["import type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Button, Kbd } from \"../ui/components.tsx\";\nimport { shortcutsDialog } from \"../ui/styles/review-layout.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\nconst SHORTCUTS: { keys: string[]; action: string }[] = [\n { keys: [\"j\", \"→\"], action: \"Next snapshot\" },\n { keys: [\"k\", \"←\"], action: \"Previous snapshot\" },\n { keys: [\"[\", \"]\"], action: \"Previous / next browser of this story\" },\n { keys: [\"a\"], action: \"Approve (all browsers of the story and mode)\" },\n { keys: [\"r\"], action: \"Reject\" },\n { keys: [\"1\", \"–\", \"5\"], action: \"Side by side, swipe, onion skin, diff, flip\" },\n { keys: [\"f\"], action: \"Cycle zoom (fit, 100%, 200%, 400%)\" },\n { keys: [\"+\", \"–\"], action: \"Zoom in / out\" },\n { keys: [\"t\"], action: \"Flip baseline / current\" },\n { keys: [\"?\"], action: \"Show this help\" },\n];\n\n/** Native dialog listing the review keyboard shortcuts (opened with `?`). */\nexport function ShortcutsDialog(): HtmlEscapedString | Promise<HtmlEscapedString> {\n return (\n <dialog class={shortcutsDialog} data-shortcuts aria-labelledby=\"shortcuts-title\">\n <h2 id=\"shortcuts-title\">Keyboard shortcuts</h2>\n <dl>\n {SHORTCUTS.map((item) => (\n <>\n <dt>\n <Kbd keys={item.keys} />\n </dt>\n <dd>{item.action}</dd>\n </>\n ))}\n </dl>\n <form method=\"dialog\">\n <Button variant=\"secondary\" size=\"sm\" type=\"submit\">\n Close\n </Button>\n </form>\n </dialog>\n );\n}\n"],"mappings":";;;;;;;;AAMA,MAAM,YAAkD;CACtD;EAAE,MAAM,CAAC,KAAK,GAAG;EAAG,QAAQ;CAAgB;CAC5C;EAAE,MAAM,CAAC,KAAK,GAAG;EAAG,QAAQ;CAAoB;CAChD;EAAE,MAAM,CAAC,KAAK,GAAG;EAAG,QAAQ;CAAwC;CACpE;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAA+C;CACtE;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAAS;CAChC;EAAE,MAAM;GAAC;GAAK;GAAK;EAAG;EAAG,QAAQ;CAA8C;CAC/E;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAAqC;CAC5D;EAAE,MAAM,CAAC,KAAK,GAAG;EAAG,QAAQ;CAAgB;CAC5C;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAA0B;CACjD;EAAE,MAAM,CAAC,GAAG;EAAG,QAAQ;CAAiB;AAC1C;;AAGA,SAAgB,kBAAkE;CAChF,OACE,qBAAC,UAAD;EAAQ,OAAO;EAAiB,kBAAA;EAAe,mBAAgB;EAA/D,UAAA;GACE,oBAAC,MAAD;IAAI,IAAG;IAAkB,UAAA;GAAsB,CAAA;GAC/C,oBAAC,MAAD,EAAA,UACG,UAAU,KAAK,SACd,qBAAA,UAAA,EAAA,UAAA,CACE,oBAAC,MAAD,EAAA,UACE,oBAAC,KAAD,EAAK,MAAM,KAAK,KAAO,CAAA,EACrB,CAAA,GACJ,oBAAC,MAAD,EAAA,UAAK,KAAK,OAAW,CAAA,CACrB,EAAA,CAAA,CACH,EACC,CAAA;GACJ,oBAAC,QAAD;IAAM,QAAO;IACX,UAAA,oBAAC,QAAD;KAAQ,SAAQ;KAAY,MAAK;KAAK,MAAK;KAAS,UAAA;IAE5C,CAAA;GACJ,CAAA;EACA;;AAEZ"}
@@ -0,0 +1,63 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Button } from "../ui/buttons.mjs";
5
+ import { Badge } from "../ui/feedback.mjs";
6
+ import { HStack } from "../ui/stacks.mjs";
7
+ import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
8
+ import { createUrlBuilder } from "@storyshelf/core/urls";
9
+ //#region src/pages/build-diff-usage.tsx
10
+ /** Deep link to the upstream component's published story (or its Storybook root). */
11
+ function upstreamPreviewUrl(item, publishedBaseDomain) {
12
+ if (!item.upstreamBuildId) return null;
13
+ const base = createUrlBuilder("/", publishedBaseDomain).storybookBuild(item.upstreamSlug, item.upstreamBuildId);
14
+ return item.upstreamStoryId ? `${base}?storyId=${encodeURIComponent(item.upstreamStoryId)}` : base;
15
+ }
16
+ /** "Uses @acme/ds v2.4.0" provenance badge with Preview and History deep links. */
17
+ function LinkedUsage(props) {
18
+ const urls = createUrlBuilder("/", props.publishedBaseDomain);
19
+ return /* @__PURE__ */ jsx(Fragment, { children: props.usage.map((item) => {
20
+ const preview = upstreamPreviewUrl(item, props.publishedBaseDomain);
21
+ return /* @__PURE__ */ jsxs(HStack, {
22
+ wrap: false,
23
+ children: [
24
+ /* @__PURE__ */ jsxs(Badge, {
25
+ tone: "info",
26
+ children: [
27
+ "Uses ",
28
+ item.package,
29
+ item.version ? ` v${item.version}` : ""
30
+ ]
31
+ }),
32
+ preview ? /* @__PURE__ */ jsx(Button, {
33
+ variant: "ghost",
34
+ size: "sm",
35
+ href: preview,
36
+ target: "_blank",
37
+ rel: "noreferrer",
38
+ children: "Preview"
39
+ }) : null,
40
+ item.upstreamImportPath ? /* @__PURE__ */ jsx(Button, {
41
+ variant: "ghost",
42
+ size: "sm",
43
+ href: urls.component(item.upstreamSlug, item.upstreamImportPath),
44
+ target: "_blank",
45
+ rel: "noreferrer",
46
+ children: "Story →"
47
+ }) : null,
48
+ /* @__PURE__ */ jsx(Button, {
49
+ variant: "ghost",
50
+ size: "sm",
51
+ href: urls.library(item.upstreamSlug),
52
+ target: "_blank",
53
+ rel: "noreferrer",
54
+ children: "History"
55
+ })
56
+ ]
57
+ }, item.package);
58
+ }) });
59
+ }
60
+ //#endregion
61
+ export { LinkedUsage, upstreamPreviewUrl };
62
+
63
+ //# sourceMappingURL=build-diff-usage.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-diff-usage.mjs","names":[],"sources":["../../src/pages/build-diff-usage.tsx"],"sourcesContent":["import { createUrlBuilder } from \"@storyshelf/core/urls\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Badge, Button, HStack } from \"../ui/components.tsx\";\n\n/** A linked package a story uses, resolved against its upstream project. */\nexport interface LinkedUsageItem {\n package: string;\n version: string | null;\n upstreamSlug: string;\n /** Latest approved default build of the upstream project, when it has one. */\n upstreamBuildId: string | null;\n /** Upstream story the package module maps to; null when nothing matched. */\n upstreamStoryId: string | null;\n /** Story file of the matched upstream story, for its component page. */\n upstreamImportPath?: string | null;\n}\n\n/** Deep link to the upstream component's published story (or its Storybook root). */\nexport function upstreamPreviewUrl(\n item: LinkedUsageItem,\n publishedBaseDomain?: string,\n): string | null {\n if (!item.upstreamBuildId) {\n return null;\n }\n const base = createUrlBuilder(\"/\", publishedBaseDomain).storybookBuild(\n item.upstreamSlug,\n item.upstreamBuildId,\n );\n return item.upstreamStoryId\n ? `${base}?storyId=${encodeURIComponent(item.upstreamStoryId)}`\n : base;\n}\n\n/** \"Uses @acme/ds v2.4.0\" provenance badge with Preview and History deep links. */\nexport function LinkedUsage(props: {\n usage: LinkedUsageItem[];\n publishedBaseDomain?: string;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const urls = createUrlBuilder(\"/\", props.publishedBaseDomain);\n return (\n <>\n {props.usage.map((item): HtmlEscapedString | Promise<HtmlEscapedString> => {\n const preview = upstreamPreviewUrl(item, props.publishedBaseDomain);\n return (\n <HStack key={item.package} wrap={false}>\n <Badge tone=\"info\">\n Uses {item.package}\n {item.version ? ` v${item.version}` : \"\"}\n </Badge>\n {preview ? (\n <Button variant=\"ghost\" size=\"sm\" href={preview} target=\"_blank\" rel=\"noreferrer\">\n Preview\n </Button>\n ) : null}\n {item.upstreamImportPath ? (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n href={urls.component(item.upstreamSlug, item.upstreamImportPath)}\n target=\"_blank\"\n rel=\"noreferrer\"\n >\n Story →\n </Button>\n ) : null}\n <Button\n variant=\"ghost\"\n size=\"sm\"\n href={urls.library(item.upstreamSlug)}\n target=\"_blank\"\n rel=\"noreferrer\"\n >\n History\n </Button>\n </HStack>\n );\n })}\n </>\n );\n}\n"],"mappings":";;;;;;;;;;AAkBA,SAAgB,mBACd,MACA,qBACe;CACf,IAAI,CAAC,KAAK,iBACR,OAAO;CAET,MAAM,OAAO,iBAAiB,KAAK,mBAAmB,CAAC,CAAC,eACtD,KAAK,cACL,KAAK,eACP;CACA,OAAO,KAAK,kBACR,GAAG,KAAK,WAAW,mBAAmB,KAAK,eAAe,MAC1D;AACN;;AAGA,SAAgB,YAAY,OAGuB;CACjD,MAAM,OAAO,iBAAiB,KAAK,MAAM,mBAAmB;CAC5D,OACE,oBAAA,UAAA,EAAA,UACG,MAAM,MAAM,KAAK,SAAyD;EACzE,MAAM,UAAU,mBAAmB,MAAM,MAAM,mBAAmB;EAClE,OACE,qBAAC,QAAD;GAA2B,MAAM;GAAjC,UAAA;IACE,qBAAC,OAAD;KAAO,MAAK;KAAZ,UAAA;MAAmB;MACX,KAAK;MACV,KAAK,UAAU,KAAK,KAAK,YAAY;KACjC;;IACN,UACC,oBAAC,QAAD;KAAQ,SAAQ;KAAQ,MAAK;KAAK,MAAM;KAAS,QAAO;KAAS,KAAI;KAAa,UAAA;IAE1E,CAAA,IACN;IACH,KAAK,qBACJ,oBAAC,QAAD;KACE,SAAQ;KACR,MAAK;KACL,MAAM,KAAK,UAAU,KAAK,cAAc,KAAK,kBAAkB;KAC/D,QAAO;KACP,KAAI;KACL,UAAA;IAEO,CAAA,IACN;IACJ,oBAAC,QAAD;KACE,SAAQ;KACR,MAAK;KACL,MAAM,KAAK,QAAQ,KAAK,YAAY;KACpC,QAAO;KACP,KAAI;KACL,UAAA;IAEO,CAAA;GACF;EA9BK,GAAA,KAAK,OA8BV;CAEZ,CAAC,EACD,CAAA;AAEN"}
@@ -1,21 +1,56 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { Button } from "../ui/buttons.mjs";
4
5
  import { Badge, statusTone } from "../ui/feedback.mjs";
5
6
  import { VStack } from "../ui/stacks.mjs";
7
+ import { Segmented } from "../ui/segmented.mjs";
6
8
  import { CompareStage } from "../ui/compare-stage.mjs";
9
+ import { A11yViolationsNotice, a11yCarrierOf } from "./a11y-violations.mjs";
10
+ import { diffPercent, snapshotImageUrl, snapshotPageUrl, snapshotVariant } from "./build-diff-model.mjs";
7
11
  import { StaleSnapshotNotice } from "./build-diff-stale.mjs";
12
+ import { LinkedUsage } from "./build-diff-usage.mjs";
13
+ import { DesignReferences, DesignStrip } from "./design-links.mjs";
8
14
  import { viewerHead, viewerMeta, viewerTitle } from "../ui/styles/review-layout.mjs";
9
- import { diffPercent, snapshotImageUrl } from "./build-diff-model.mjs";
15
+ import { EnvironmentChangeSnapshotNotice } from "./build-diff-environment.mjs";
10
16
  import { jsx, jsxs } from "hono/jsx/jsx-runtime";
17
+ import { createUrlBuilder } from "@storyshelf/core/urls";
11
18
  //#region src/pages/build-diff-viewer.tsx
12
19
  /** Why a snapshot has no diff image, phrased for the empty pane. */
13
20
  function diffEmptyText(status) {
14
21
  return status === "unchanged" || status === "approved" ? "No diff — within threshold" : "No diff yet";
15
22
  }
23
+ /** Browser tabs (one link per engine) when the story/mode was captured on several. */
24
+ function BrowserTabs(props) {
25
+ const { project, build, selected, siblings } = props;
26
+ if (siblings.length < 2) return null;
27
+ return /* @__PURE__ */ jsx(Segmented, {
28
+ label: "Browser",
29
+ "data-browser-tabs": true,
30
+ items: siblings.map((snap) => ({
31
+ label: `${snap.browser ?? "chromium"} · ${snap.status}`,
32
+ value: snap.id,
33
+ href: snapshotPageUrl(project, build, snap.id),
34
+ active: snap.id === selected.id
35
+ }))
36
+ });
37
+ }
38
+ /** Link to the story file's component page, anchored at the story. */
39
+ function ViewComponentButton(props) {
40
+ const { project, selected, base } = props;
41
+ if (!selected.storyImportPath) return null;
42
+ const urls = createUrlBuilder("/", base);
43
+ return /* @__PURE__ */ jsx(Button, {
44
+ variant: "ghost",
45
+ size: "sm",
46
+ href: `${urls.component(project.slug, selected.storyImportPath)}#${selected.storyId}`,
47
+ children: "View component"
48
+ });
49
+ }
16
50
  /** Story heading plus the comparison stage for the selected snapshot. */
17
51
  function DiffViewer(props) {
18
- const { project, build, selected, hasBaseline, drifted } = props;
52
+ const { project, build, selected, hasBaseline, drifted, usage, design, publishedBaseDomain } = props;
53
+ const siblings = props.siblings ?? [selected];
19
54
  const percent = diffPercent(selected);
20
55
  return /* @__PURE__ */ jsxs(VStack, { children: [
21
56
  /* @__PURE__ */ jsxs("div", {
@@ -36,7 +71,7 @@ function DiffViewer(props) {
36
71
  children: selected.status
37
72
  }),
38
73
  /* @__PURE__ */ jsxs("span", { children: [
39
- selected.viewportName,
74
+ snapshotVariant(selected),
40
75
  " · ",
41
76
  selected.viewportWidth,
42
77
  "×",
@@ -49,11 +84,32 @@ function DiffViewer(props) {
49
84
  percent ? /* @__PURE__ */ jsx("span", {
50
85
  class: "mono",
51
86
  children: percent
52
- }) : null
87
+ }) : null,
88
+ /* @__PURE__ */ jsx(ViewComponentButton, {
89
+ project,
90
+ selected,
91
+ base: publishedBaseDomain
92
+ })
53
93
  ]
54
94
  })]
55
95
  }),
96
+ /* @__PURE__ */ jsx(BrowserTabs, {
97
+ project,
98
+ build,
99
+ selected,
100
+ siblings
101
+ }),
56
102
  drifted ? /* @__PURE__ */ jsx(StaleSnapshotNotice, { status: drifted }) : null,
103
+ /* @__PURE__ */ jsx(EnvironmentChangeSnapshotNotice, {
104
+ snapshot: selected,
105
+ migrated: build.isDefault
106
+ }),
107
+ /* @__PURE__ */ jsx(A11yViolationsNotice, { snapshot: a11yCarrierOf(selected, siblings) }),
108
+ usage && usage.length > 0 ? /* @__PURE__ */ jsx(LinkedUsage, {
109
+ usage,
110
+ publishedBaseDomain
111
+ }) : null,
112
+ design && design.length > 0 ? /* @__PURE__ */ jsx(DesignStrip, { items: design }) : null,
57
113
  /* @__PURE__ */ jsx(CompareStage, {
58
114
  baselineSrc: hasBaseline[selected.id] ? snapshotImageUrl(project, build, selected, "baseline") : null,
59
115
  currentSrc: snapshotImageUrl(project, build, selected, "image"),
@@ -61,7 +117,8 @@ function DiffViewer(props) {
61
117
  subject: `${selected.storyTitle} / ${selected.storyName}`,
62
118
  diffEmpty: diffEmptyText(selected.status),
63
119
  diffMeta: percent ?? void 0
64
- })
120
+ }),
121
+ design && design.length > 0 ? /* @__PURE__ */ jsx(DesignReferences, { items: design }) : null
65
122
  ] });
66
123
  }
67
124
  //#endregion