@storyshelf/app 0.5.3

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 (268) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/app-types.d.mts +54 -0
  4. package/dist/asset-manifest.mjs +41 -0
  5. package/dist/asset-manifest.mjs.map +1 -0
  6. package/dist/asset-paths.mjs +23 -0
  7. package/dist/asset-paths.mjs.map +1 -0
  8. package/dist/assets/htmx.min.js +3304 -0
  9. package/dist/assets/mark.svg +1 -0
  10. package/dist/capture-setup.mjs +139 -0
  11. package/dist/capture-setup.mjs.map +1 -0
  12. package/dist/credential-keys.mjs +20 -0
  13. package/dist/credential-keys.mjs.map +1 -0
  14. package/dist/credentials.mjs +56 -0
  15. package/dist/credentials.mjs.map +1 -0
  16. package/dist/index.d.mts +18 -0
  17. package/dist/index.mjs +310 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/lifecycle.mjs +132 -0
  20. package/dist/lifecycle.mjs.map +1 -0
  21. package/dist/middleware/auth-gate.mjs +36 -0
  22. package/dist/middleware/auth-gate.mjs.map +1 -0
  23. package/dist/middleware/csrf.mjs +75 -0
  24. package/dist/middleware/csrf.mjs.map +1 -0
  25. package/dist/middleware/rate-limit.mjs +68 -0
  26. package/dist/middleware/rate-limit.mjs.map +1 -0
  27. package/dist/middleware/request-log.mjs +30 -0
  28. package/dist/middleware/request-log.mjs.map +1 -0
  29. package/dist/middleware/server-timing.mjs +39 -0
  30. package/dist/middleware/server-timing.mjs.map +1 -0
  31. package/dist/middleware/setup-gate.mjs +37 -0
  32. package/dist/middleware/setup-gate.mjs.map +1 -0
  33. package/dist/middleware/store-scope.mjs +33 -0
  34. package/dist/middleware/store-scope.mjs.map +1 -0
  35. package/dist/notify.d.mts +17 -0
  36. package/dist/notify.mjs +141 -0
  37. package/dist/notify.mjs.map +1 -0
  38. package/dist/pages/admin-system.mjs +243 -0
  39. package/dist/pages/admin-system.mjs.map +1 -0
  40. package/dist/pages/build-detail.mjs +392 -0
  41. package/dist/pages/build-detail.mjs.map +1 -0
  42. package/dist/pages/build-diff-actions.mjs +162 -0
  43. package/dist/pages/build-diff-actions.mjs.map +1 -0
  44. package/dist/pages/build-diff-comments.mjs +104 -0
  45. package/dist/pages/build-diff-comments.mjs.map +1 -0
  46. package/dist/pages/build-diff-header.mjs +117 -0
  47. package/dist/pages/build-diff-header.mjs.map +1 -0
  48. package/dist/pages/build-diff-model.mjs +48 -0
  49. package/dist/pages/build-diff-model.mjs.map +1 -0
  50. package/dist/pages/build-diff-nav.mjs +122 -0
  51. package/dist/pages/build-diff-nav.mjs.map +1 -0
  52. package/dist/pages/build-diff-shortcuts.mjs +78 -0
  53. package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
  54. package/dist/pages/build-diff-stale.mjs +70 -0
  55. package/dist/pages/build-diff-stale.mjs.map +1 -0
  56. package/dist/pages/build-diff-viewer.mjs +70 -0
  57. package/dist/pages/build-diff-viewer.mjs.map +1 -0
  58. package/dist/pages/build-diff.mjs +110 -0
  59. package/dist/pages/build-diff.mjs.map +1 -0
  60. package/dist/pages/compute-jobs.mjs +165 -0
  61. package/dist/pages/compute-jobs.mjs.map +1 -0
  62. package/dist/pages/invite.mjs +91 -0
  63. package/dist/pages/invite.mjs.map +1 -0
  64. package/dist/pages/label-detail.mjs +154 -0
  65. package/dist/pages/label-detail.mjs.map +1 -0
  66. package/dist/pages/library.mjs +343 -0
  67. package/dist/pages/library.mjs.map +1 -0
  68. package/dist/pages/login.mjs +117 -0
  69. package/dist/pages/login.mjs.map +1 -0
  70. package/dist/pages/passkey-ceremony.mjs +97 -0
  71. package/dist/pages/passkey-ceremony.mjs.map +1 -0
  72. package/dist/pages/profile.mjs +364 -0
  73. package/dist/pages/profile.mjs.map +1 -0
  74. package/dist/pages/project-builds-table.mjs +151 -0
  75. package/dist/pages/project-builds-table.mjs.map +1 -0
  76. package/dist/pages/project-builds.mjs +143 -0
  77. package/dist/pages/project-builds.mjs.map +1 -0
  78. package/dist/pages/project-create.mjs +90 -0
  79. package/dist/pages/project-create.mjs.map +1 -0
  80. package/dist/pages/project-settings.mjs +95 -0
  81. package/dist/pages/project-settings.mjs.map +1 -0
  82. package/dist/pages/projects-card.mjs +194 -0
  83. package/dist/pages/projects-card.mjs.map +1 -0
  84. package/dist/pages/projects.mjs +129 -0
  85. package/dist/pages/projects.mjs.map +1 -0
  86. package/dist/pages/root.mjs +98 -0
  87. package/dist/pages/root.mjs.map +1 -0
  88. package/dist/pages/settings-general.mjs +138 -0
  89. package/dist/pages/settings-general.mjs.map +1 -0
  90. package/dist/pages/settings-labels.mjs +107 -0
  91. package/dist/pages/settings-labels.mjs.map +1 -0
  92. package/dist/pages/settings-members.mjs +192 -0
  93. package/dist/pages/settings-members.mjs.map +1 -0
  94. package/dist/pages/settings-notifications.mjs +240 -0
  95. package/dist/pages/settings-notifications.mjs.map +1 -0
  96. package/dist/pages/settings-status.mjs +137 -0
  97. package/dist/pages/settings-status.mjs.map +1 -0
  98. package/dist/pages/settings-tests.mjs +111 -0
  99. package/dist/pages/settings-tests.mjs.map +1 -0
  100. package/dist/pages/settings-tokens.mjs +90 -0
  101. package/dist/pages/settings-tokens.mjs.map +1 -0
  102. package/dist/pages/settings-webhooks.mjs +104 -0
  103. package/dist/pages/settings-webhooks.mjs.map +1 -0
  104. package/dist/pages/storybook.mjs +116 -0
  105. package/dist/pages/storybook.mjs.map +1 -0
  106. package/dist/retention-timer.mjs +65 -0
  107. package/dist/retention-timer.mjs.map +1 -0
  108. package/dist/routers/admin-pages.mjs +48 -0
  109. package/dist/routers/admin-pages.mjs.map +1 -0
  110. package/dist/routers/admin.mjs +113 -0
  111. package/dist/routers/admin.mjs.map +1 -0
  112. package/dist/routers/assets.mjs +64 -0
  113. package/dist/routers/assets.mjs.map +1 -0
  114. package/dist/routers/attempts.mjs +96 -0
  115. package/dist/routers/attempts.mjs.map +1 -0
  116. package/dist/routers/auth-sync.mjs +64 -0
  117. package/dist/routers/auth-sync.mjs.map +1 -0
  118. package/dist/routers/auth.mjs +236 -0
  119. package/dist/routers/auth.mjs.map +1 -0
  120. package/dist/routers/baseline-guard.mjs +83 -0
  121. package/dist/routers/baseline-guard.mjs.map +1 -0
  122. package/dist/routers/builds.handlers.mjs +217 -0
  123. package/dist/routers/builds.handlers.mjs.map +1 -0
  124. package/dist/routers/builds.mjs +381 -0
  125. package/dist/routers/builds.mjs.map +1 -0
  126. package/dist/routers/comments.mjs +109 -0
  127. package/dist/routers/comments.mjs.map +1 -0
  128. package/dist/routers/flash.mjs +35 -0
  129. package/dist/routers/flash.mjs.map +1 -0
  130. package/dist/routers/health-report.mjs +159 -0
  131. package/dist/routers/health-report.mjs.map +1 -0
  132. package/dist/routers/health.mjs +46 -0
  133. package/dist/routers/health.mjs.map +1 -0
  134. package/dist/routers/helpers.mjs +110 -0
  135. package/dist/routers/helpers.mjs.map +1 -0
  136. package/dist/routers/htmx.mjs +24 -0
  137. package/dist/routers/htmx.mjs.map +1 -0
  138. package/dist/routers/labels.mjs +118 -0
  139. package/dist/routers/labels.mjs.map +1 -0
  140. package/dist/routers/media.mjs +53 -0
  141. package/dist/routers/media.mjs.map +1 -0
  142. package/dist/routers/members.mjs +177 -0
  143. package/dist/routers/members.mjs.map +1 -0
  144. package/dist/routers/notification-channels.mjs +190 -0
  145. package/dist/routers/notification-channels.mjs.map +1 -0
  146. package/dist/routers/notification-subscriptions.mjs +120 -0
  147. package/dist/routers/notification-subscriptions.mjs.map +1 -0
  148. package/dist/routers/profile.mjs +276 -0
  149. package/dist/routers/profile.mjs.map +1 -0
  150. package/dist/routers/projects.mjs +133 -0
  151. package/dist/routers/projects.mjs.map +1 -0
  152. package/dist/routers/review-next.mjs +47 -0
  153. package/dist/routers/review-next.mjs.map +1 -0
  154. package/dist/routers/schemas.mjs +337 -0
  155. package/dist/routers/schemas.mjs.map +1 -0
  156. package/dist/routers/settings-general.mjs +104 -0
  157. package/dist/routers/settings-general.mjs.map +1 -0
  158. package/dist/routers/settings-labels.mjs +52 -0
  159. package/dist/routers/settings-labels.mjs.map +1 -0
  160. package/dist/routers/settings-members.mjs +58 -0
  161. package/dist/routers/settings-members.mjs.map +1 -0
  162. package/dist/routers/settings-notifications.mjs +108 -0
  163. package/dist/routers/settings-notifications.mjs.map +1 -0
  164. package/dist/routers/settings-status.mjs +77 -0
  165. package/dist/routers/settings-status.mjs.map +1 -0
  166. package/dist/routers/settings-tokens.mjs +40 -0
  167. package/dist/routers/settings-tokens.mjs.map +1 -0
  168. package/dist/routers/settings-webhooks.mjs +67 -0
  169. package/dist/routers/settings-webhooks.mjs.map +1 -0
  170. package/dist/routers/settings.handlers.mjs +149 -0
  171. package/dist/routers/settings.handlers.mjs.map +1 -0
  172. package/dist/routers/settings.mjs +25 -0
  173. package/dist/routers/settings.mjs.map +1 -0
  174. package/dist/routers/snapshots.mjs +240 -0
  175. package/dist/routers/snapshots.mjs.map +1 -0
  176. package/dist/routers/status-configs.mjs +108 -0
  177. package/dist/routers/status-configs.mjs.map +1 -0
  178. package/dist/routers/storybook.mjs +171 -0
  179. package/dist/routers/storybook.mjs.map +1 -0
  180. package/dist/routers/tokens.mjs +91 -0
  181. package/dist/routers/tokens.mjs.map +1 -0
  182. package/dist/routers/ui.mjs +166 -0
  183. package/dist/routers/ui.mjs.map +1 -0
  184. package/dist/routers/webhooks.mjs +97 -0
  185. package/dist/routers/webhooks.mjs.map +1 -0
  186. package/dist/routers/well-known.mjs +43 -0
  187. package/dist/routers/well-known.mjs.map +1 -0
  188. package/dist/runtime.mjs +52 -0
  189. package/dist/runtime.mjs.map +1 -0
  190. package/dist/store.mjs +20 -0
  191. package/dist/store.mjs.map +1 -0
  192. package/dist/ui/avatar.mjs +76 -0
  193. package/dist/ui/avatar.mjs.map +1 -0
  194. package/dist/ui/buttons.mjs +205 -0
  195. package/dist/ui/buttons.mjs.map +1 -0
  196. package/dist/ui/code-block.mjs +47 -0
  197. package/dist/ui/code-block.mjs.map +1 -0
  198. package/dist/ui/compare-stage.mjs +344 -0
  199. package/dist/ui/compare-stage.mjs.map +1 -0
  200. package/dist/ui/csrf-field.mjs +20 -0
  201. package/dist/ui/csrf-field.mjs.map +1 -0
  202. package/dist/ui/css.mjs +36 -0
  203. package/dist/ui/css.mjs.map +1 -0
  204. package/dist/ui/document.mjs +130 -0
  205. package/dist/ui/document.mjs.map +1 -0
  206. package/dist/ui/dropdown.mjs +181 -0
  207. package/dist/ui/dropdown.mjs.map +1 -0
  208. package/dist/ui/feedback.mjs +341 -0
  209. package/dist/ui/feedback.mjs.map +1 -0
  210. package/dist/ui/filter-input.mjs +58 -0
  211. package/dist/ui/filter-input.mjs.map +1 -0
  212. package/dist/ui/forms.mjs +291 -0
  213. package/dist/ui/forms.mjs.map +1 -0
  214. package/dist/ui/icons/icon.mjs +53 -0
  215. package/dist/ui/icons/icon.mjs.map +1 -0
  216. package/dist/ui/icons/paths.mjs +61 -0
  217. package/dist/ui/icons/paths.mjs.map +1 -0
  218. package/dist/ui/icons/sprite.mjs +26 -0
  219. package/dist/ui/icons/sprite.mjs.map +1 -0
  220. package/dist/ui/kbd.mjs +33 -0
  221. package/dist/ui/kbd.mjs.map +1 -0
  222. package/dist/ui/layout.mjs +173 -0
  223. package/dist/ui/layout.mjs.map +1 -0
  224. package/dist/ui/progress.mjs +44 -0
  225. package/dist/ui/progress.mjs.map +1 -0
  226. package/dist/ui/segmented.mjs +77 -0
  227. package/dist/ui/segmented.mjs.map +1 -0
  228. package/dist/ui/shell/account.mjs +161 -0
  229. package/dist/ui/shell/account.mjs.map +1 -0
  230. package/dist/ui/shell/bare.mjs +69 -0
  231. package/dist/ui/shell/bare.mjs.map +1 -0
  232. package/dist/ui/shell/boot-script.mjs +46 -0
  233. package/dist/ui/shell/boot-script.mjs.map +1 -0
  234. package/dist/ui/shell/client-script.mjs +259 -0
  235. package/dist/ui/shell/client-script.mjs.map +1 -0
  236. package/dist/ui/shell/nav.mjs +111 -0
  237. package/dist/ui/shell/nav.mjs.map +1 -0
  238. package/dist/ui/shell/review-script.mjs +195 -0
  239. package/dist/ui/shell/review-script.mjs.map +1 -0
  240. package/dist/ui/shell/sidebar.mjs +323 -0
  241. package/dist/ui/shell/sidebar.mjs.map +1 -0
  242. package/dist/ui/shell/topbar.mjs +268 -0
  243. package/dist/ui/shell/topbar.mjs.map +1 -0
  244. package/dist/ui/stacks.mjs +82 -0
  245. package/dist/ui/stacks.mjs.map +1 -0
  246. package/dist/ui/styles/base.mjs +41 -0
  247. package/dist/ui/styles/base.mjs.map +1 -0
  248. package/dist/ui/styles/behavior.mjs +43 -0
  249. package/dist/ui/styles/behavior.mjs.map +1 -0
  250. package/dist/ui/styles/review-layout.mjs +347 -0
  251. package/dist/ui/styles/review-layout.mjs.map +1 -0
  252. package/dist/ui/styles/review.mjs +86 -0
  253. package/dist/ui/styles/review.mjs.map +1 -0
  254. package/dist/ui/styles/tokens.mjs +132 -0
  255. package/dist/ui/styles/tokens.mjs.map +1 -0
  256. package/dist/ui/styles.mjs +19 -0
  257. package/dist/ui/styles.mjs.map +1 -0
  258. package/dist/ui/subnav.mjs +91 -0
  259. package/dist/ui/subnav.mjs.map +1 -0
  260. package/dist/ui/table.mjs +73 -0
  261. package/dist/ui/table.mjs.map +1 -0
  262. package/dist/ui/theme.mjs +64 -0
  263. package/dist/ui/theme.mjs.map +1 -0
  264. package/dist/ui/thumbnail.mjs +51 -0
  265. package/dist/ui/thumbnail.mjs.map +1 -0
  266. package/dist/ui/time.mjs +50 -0
  267. package/dist/ui/time.mjs.map +1 -0
  268. package/package.json +92 -0
@@ -0,0 +1,70 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Badge, statusTone } from "../ui/feedback.mjs";
5
+ import { VStack } from "../ui/stacks.mjs";
6
+ import { CompareStage } from "../ui/compare-stage.mjs";
7
+ import { StaleSnapshotNotice } from "./build-diff-stale.mjs";
8
+ import { viewerHead, viewerMeta, viewerTitle } from "../ui/styles/review-layout.mjs";
9
+ import { diffPercent, snapshotImageUrl } from "./build-diff-model.mjs";
10
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
11
+ //#region src/pages/build-diff-viewer.tsx
12
+ /** Why a snapshot has no diff image, phrased for the empty pane. */
13
+ function diffEmptyText(status) {
14
+ return status === "unchanged" || status === "approved" ? "No diff — within threshold" : "No diff yet";
15
+ }
16
+ /** Story heading plus the comparison stage for the selected snapshot. */
17
+ function DiffViewer(props) {
18
+ const { project, build, selected, hasBaseline, drifted } = props;
19
+ const percent = diffPercent(selected);
20
+ return /* @__PURE__ */ jsxs(VStack, { children: [
21
+ /* @__PURE__ */ jsxs("div", {
22
+ class: viewerHead,
23
+ children: [/* @__PURE__ */ jsxs("h2", {
24
+ class: viewerTitle,
25
+ children: [
26
+ selected.storyTitle,
27
+ " / ",
28
+ selected.storyName
29
+ ]
30
+ }), /* @__PURE__ */ jsxs("p", {
31
+ class: viewerMeta,
32
+ children: [
33
+ /* @__PURE__ */ jsx(Badge, {
34
+ tone: statusTone(selected.status),
35
+ icon: true,
36
+ children: selected.status
37
+ }),
38
+ /* @__PURE__ */ jsxs("span", { children: [
39
+ selected.viewportName,
40
+ " · ",
41
+ selected.viewportWidth,
42
+ "×",
43
+ selected.viewportHeight
44
+ ] }),
45
+ selected.diffPixels === null || selected.diffPixels === void 0 ? null : /* @__PURE__ */ jsxs("span", {
46
+ class: "mono",
47
+ children: [selected.diffPixels, " px"]
48
+ }),
49
+ percent ? /* @__PURE__ */ jsx("span", {
50
+ class: "mono",
51
+ children: percent
52
+ }) : null
53
+ ]
54
+ })]
55
+ }),
56
+ drifted ? /* @__PURE__ */ jsx(StaleSnapshotNotice, { status: drifted }) : null,
57
+ /* @__PURE__ */ jsx(CompareStage, {
58
+ baselineSrc: hasBaseline[selected.id] ? snapshotImageUrl(project, build, selected, "baseline") : null,
59
+ currentSrc: snapshotImageUrl(project, build, selected, "image"),
60
+ diffSrc: selected.diffPath ? snapshotImageUrl(project, build, selected, "diff") : null,
61
+ subject: `${selected.storyTitle} / ${selected.storyName}`,
62
+ diffEmpty: diffEmptyText(selected.status),
63
+ diffMeta: percent ?? void 0
64
+ })
65
+ ] });
66
+ }
67
+ //#endregion
68
+ export { DiffViewer };
69
+
70
+ //# sourceMappingURL=build-diff-viewer.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-diff-viewer.mjs","names":[],"sources":["../../src/pages/build-diff-viewer.tsx"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Badge, CompareStage, VStack, statusTone } from \"../ui/components.tsx\";\nimport { viewerHead, viewerMeta, viewerTitle } from \"../ui/styles/review-layout.ts\";\nimport { diffPercent, snapshotImageUrl } from \"./build-diff-model.ts\";\nimport { StaleSnapshotNotice } from \"./build-diff-stale.tsx\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/** Viewer props for the selected snapshot. */\nexport interface DiffViewerProps {\n project: Project;\n build: Build;\n selected: Snapshot;\n hasBaseline: Record<string, boolean>;\n /** Set when the baseline changed after this snapshot's diff was computed. */\n drifted?: \"stale\" | \"removed\";\n}\n\n/** Why a snapshot has no diff image, phrased for the empty pane. */\nfunction diffEmptyText(status: string): string {\n return status === \"unchanged\" || status === \"approved\"\n ? \"No diff — within threshold\"\n : \"No diff yet\";\n}\n\n/** Story heading plus the comparison stage for the selected snapshot. */\nexport function DiffViewer(props: DiffViewerProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, selected, hasBaseline, drifted } = props;\n const percent = diffPercent(selected);\n return (\n <VStack>\n <div class={viewerHead}>\n <h2 class={viewerTitle}>\n {selected.storyTitle} / {selected.storyName}\n </h2>\n <p class={viewerMeta}>\n <Badge tone={statusTone(selected.status)} icon>\n {selected.status}\n </Badge>\n <span>\n {selected.viewportName} · {selected.viewportWidth}×{selected.viewportHeight}\n </span>\n {selected.diffPixels === null || selected.diffPixels === undefined ? null : (\n <span class=\"mono\">{selected.diffPixels} px</span>\n )}\n {percent ? <span class=\"mono\">{percent}</span> : null}\n </p>\n </div>\n {drifted ? <StaleSnapshotNotice status={drifted} /> : null}\n <CompareStage\n baselineSrc={\n hasBaseline[selected.id] ? snapshotImageUrl(project, build, selected, \"baseline\") : null\n }\n currentSrc={snapshotImageUrl(project, build, selected, \"image\")}\n diffSrc={selected.diffPath ? snapshotImageUrl(project, build, selected, \"diff\") : null}\n subject={`${selected.storyTitle} / ${selected.storyName}`}\n diffEmpty={diffEmptyText(selected.status)}\n diffMeta={percent ?? undefined}\n />\n </VStack>\n );\n}\n"],"mappings":";;;;;;;;;;;;AAoBA,SAAS,cAAc,QAAwB;CAC7C,OAAO,WAAW,eAAe,WAAW,aACxC,+BACA;AACN;;AAGA,SAAgB,WAAW,OAAwE;CACjG,MAAM,EAAE,SAAS,OAAO,UAAU,aAAa,YAAY;CAC3D,MAAM,UAAU,YAAY,QAAQ;CACpC,OACE,qBAAC,QAAD,EAAA,UAAA;EACE,qBAAC,OAAD;GAAK,OAAO;GAAZ,UAAA,CACE,qBAAC,MAAD;IAAI,OAAO;IAAX,UAAA;KACG,SAAS;KAAW;KAAI,SAAS;IAChC;GACJ,CAAA,GAAA,qBAAC,KAAD;IAAG,OAAO;IAAV,UAAA;KACE,oBAAC,OAAD;MAAO,MAAM,WAAW,SAAS,MAAM;MAAG,MAAA;MACvC,UAAA,SAAS;KACL,CAAA;KACP,qBAAC,QAAD,EAAA,UAAA;MACG,SAAS;MAAa;MAAI,SAAS;MAAc;MAAE,SAAS;KACzD,EAAA,CAAA;KACL,SAAS,eAAe,QAAQ,SAAS,eAAe,KAAA,IAAY,OACnE,qBAAC,QAAD;MAAM,OAAM;MAAZ,UAAA,CAAoB,SAAS,YAAW,KAAS;;KAElD,UAAU,oBAAC,QAAD;MAAM,OAAM;MAAQ,UAAA;KAAc,CAAA,IAAI;IAChD;GACA,CAAA,CAAA;;EACJ,UAAU,oBAAC,qBAAD,EAAqB,QAAQ,QAAU,CAAA,IAAI;EACtD,oBAAC,cAAD;GACE,aACE,YAAY,SAAS,MAAM,iBAAiB,SAAS,OAAO,UAAU,UAAU,IAAI;GAEtF,YAAY,iBAAiB,SAAS,OAAO,UAAU,OAAO;GAC9D,SAAS,SAAS,WAAW,iBAAiB,SAAS,OAAO,UAAU,MAAM,IAAI;GAClF,SAAS,GAAG,SAAS,WAAW,KAAK,SAAS;GAC9C,WAAW,cAAc,SAAS,MAAM;GACxC,UAAU,WAAW,KAAA;EACtB,CAAA;CACK,EAAA,CAAA;AAEZ"}
@@ -0,0 +1,110 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { EmptyState } from "../ui/feedback.mjs";
5
+ import { DocumentLayout } from "../ui/document.mjs";
6
+ import { StaleBaselineNotice } from "./build-diff-stale.mjs";
7
+ import { canvasColumn, workspace } from "../ui/styles/review-layout.mjs";
8
+ import { DiffActionBar } from "./build-diff-actions.mjs";
9
+ import { DiffComments } from "./build-diff-comments.mjs";
10
+ import { DiffHeader } from "./build-diff-header.mjs";
11
+ import { DiffNav } from "./build-diff-nav.mjs";
12
+ import { ShortcutsDialog } from "./build-diff-shortcuts.mjs";
13
+ import { DiffViewer } from "./build-diff-viewer.mjs";
14
+ import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
15
+ //#region src/pages/build-diff.tsx
16
+ /** Snapshot nav plus the viewer/comments column for the selected snapshot. */
17
+ function DiffReviewGrid(props) {
18
+ const { project, build, snapshots, comments, selected, canReview, hasBaseline, drifted } = props;
19
+ return /* @__PURE__ */ jsxs("div", {
20
+ class: workspace,
21
+ children: [/* @__PURE__ */ jsx(DiffNav, {
22
+ project,
23
+ build,
24
+ snapshots,
25
+ selectedId: selected?.id
26
+ }), /* @__PURE__ */ jsx("div", {
27
+ class: canvasColumn,
28
+ children: selected ? /* @__PURE__ */ jsxs(Fragment, { children: [
29
+ /* @__PURE__ */ jsx(DiffViewer, {
30
+ project,
31
+ build,
32
+ selected,
33
+ hasBaseline,
34
+ drifted: drifted[selected.id]
35
+ }),
36
+ /* @__PURE__ */ jsx(DiffComments, {
37
+ project,
38
+ build,
39
+ selectedId: selected.id,
40
+ comments,
41
+ canReview
42
+ }),
43
+ /* @__PURE__ */ jsx(DiffActionBar, {
44
+ project,
45
+ build,
46
+ snapshots,
47
+ selected,
48
+ canReview,
49
+ drifted: drifted[selected.id] !== void 0
50
+ })
51
+ ] }) : /* @__PURE__ */ jsx(EmptyState, { description: "Select a snapshot to review." })
52
+ })]
53
+ });
54
+ }
55
+ /** Sidebar/breadcrumb context for the review page: Builds / <build> / Review. */
56
+ function reviewNav(project, build) {
57
+ return {
58
+ active: "builds",
59
+ projectSlug: project.slug,
60
+ projectName: project.name,
61
+ trail: [{
62
+ label: `${build.gitBranch} · ${build.gitSha.slice(0, 7)}`,
63
+ href: `/projects/${project.slug}/builds/${build.id}`
64
+ }, { label: "Review" }]
65
+ };
66
+ }
67
+ /** Three-up diff review page: baseline, current, and diff with keyboard review. */
68
+ function renderBuildDiffPage(data) {
69
+ const { project, build, snapshots, comments, selectedId, canReview, hasBaseline, drifted } = data;
70
+ const selected = snapshots.find((s) => s.id === selectedId) ?? snapshots.find((s) => s.status === "changed" || s.status === "new") ?? snapshots[0];
71
+ const pending = snapshots.filter((s) => s.status === "new" || s.status === "changed");
72
+ return /* @__PURE__ */ jsxs(DocumentLayout, {
73
+ title: `Review · ${build.gitBranch}`,
74
+ nav: reviewNav(project, build),
75
+ layout: "wide",
76
+ children: [
77
+ /* @__PURE__ */ jsx(DiffHeader, {
78
+ project,
79
+ build,
80
+ snapshots,
81
+ pendingCount: pending.length,
82
+ canReview
83
+ }),
84
+ Object.keys(drifted).length > 0 ? /* @__PURE__ */ jsx(StaleBaselineNotice, {
85
+ project,
86
+ build,
87
+ staleCount: Object.keys(drifted).length,
88
+ canReview
89
+ }) : null,
90
+ snapshots.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
91
+ title: "No snapshots",
92
+ description: "This build has no snapshots yet. Capture may still be running."
93
+ }) : /* @__PURE__ */ jsx(DiffReviewGrid, {
94
+ project,
95
+ build,
96
+ snapshots,
97
+ comments,
98
+ selected,
99
+ canReview,
100
+ hasBaseline,
101
+ drifted
102
+ }),
103
+ /* @__PURE__ */ jsx(ShortcutsDialog, {})
104
+ ]
105
+ });
106
+ }
107
+ //#endregion
108
+ export { renderBuildDiffPage };
109
+
110
+ //# sourceMappingURL=build-diff.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-diff.mjs","names":[],"sources":["../../src/pages/build-diff.tsx"],"sourcesContent":["import type { Build } from \"@storyshelf/core/schema\";\nimport type { Comment } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { EmptyState } from \"../ui/components.tsx\";\nimport { DocumentLayout, type NavConfig, type RenderedContent } from \"../ui/document.tsx\";\nimport { canvasColumn, workspace } from \"../ui/styles/review-layout.ts\";\nimport { DiffActionBar } from \"./build-diff-actions.tsx\";\nimport { DiffComments } from \"./build-diff-comments.tsx\";\nimport { DiffHeader } from \"./build-diff-header.tsx\";\nimport { DiffNav } from \"./build-diff-nav.tsx\";\nimport { ShortcutsDialog } from \"./build-diff-shortcuts.tsx\";\nimport { StaleBaselineNotice } from \"./build-diff-stale.tsx\";\nimport { DiffViewer } from \"./build-diff-viewer.tsx\";\n\n/** Data required to render the three-up build diff review page. */\nexport interface BuildDiffData {\n project: Project;\n build: Build;\n snapshots: Snapshot[];\n comments: Comment[];\n selectedId?: string;\n canReview: boolean;\n hasBaseline: Record<string, boolean>;\n /** Open snapshots whose baseline changed after their diff was computed. */\n drifted: Record<string, \"stale\" | \"removed\">;\n}\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\ninterface DiffReviewGridProps {\n project: Project;\n build: Build;\n snapshots: Snapshot[];\n comments: Comment[];\n selected?: Snapshot;\n canReview: boolean;\n hasBaseline: Record<string, boolean>;\n drifted: Record<string, \"stale\" | \"removed\">;\n}\n\n/** Snapshot nav plus the viewer/comments column for the selected snapshot. */\nfunction DiffReviewGrid(\n props: DiffReviewGridProps,\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, snapshots, comments, selected, canReview, hasBaseline, drifted } = props;\n return (\n <div class={workspace}>\n <DiffNav project={project} build={build} snapshots={snapshots} selectedId={selected?.id} />\n <div class={canvasColumn}>\n {selected ? (\n <>\n <DiffViewer\n project={project}\n build={build}\n selected={selected}\n hasBaseline={hasBaseline}\n drifted={drifted[selected.id]}\n />\n <DiffComments\n project={project}\n build={build}\n selectedId={selected.id}\n comments={comments}\n canReview={canReview}\n />\n <DiffActionBar\n project={project}\n build={build}\n snapshots={snapshots}\n selected={selected}\n canReview={canReview}\n drifted={drifted[selected.id] !== undefined}\n />\n </>\n ) : (\n <EmptyState description=\"Select a snapshot to review.\" />\n )}\n </div>\n </div>\n );\n}\n\n/** Sidebar/breadcrumb context for the review page: Builds / <build> / Review. */\nfunction reviewNav(project: Project, build: Build): NavConfig {\n return {\n active: \"builds\",\n projectSlug: project.slug,\n projectName: project.name,\n trail: [\n {\n label: `${build.gitBranch} · ${build.gitSha.slice(0, 7)}`,\n href: `/projects/${project.slug}/builds/${build.id}`,\n },\n { label: \"Review\" },\n ],\n };\n}\n\n/** Three-up diff review page: baseline, current, and diff with keyboard review. */\nexport function renderBuildDiffPage(data: BuildDiffData): RenderedContent {\n const { project, build, snapshots, comments, selectedId, canReview, hasBaseline, drifted } = data;\n const selected =\n snapshots.find((s) => s.id === selectedId) ??\n snapshots.find((s) => s.status === \"changed\" || s.status === \"new\") ??\n snapshots[0];\n const pending = snapshots.filter((s) => s.status === \"new\" || s.status === \"changed\");\n\n return (\n <DocumentLayout\n title={`Review · ${build.gitBranch}`}\n nav={reviewNav(project, build)}\n layout=\"wide\"\n >\n <DiffHeader\n project={project}\n build={build}\n snapshots={snapshots}\n pendingCount={pending.length}\n canReview={canReview}\n />\n\n {Object.keys(drifted).length > 0 ? (\n <StaleBaselineNotice\n project={project}\n build={build}\n staleCount={Object.keys(drifted).length}\n canReview={canReview}\n />\n ) : null}\n\n {snapshots.length === 0 ? (\n <EmptyState\n title=\"No snapshots\"\n description=\"This build has no snapshots yet. Capture may still be running.\"\n />\n ) : (\n <DiffReviewGrid\n project={project}\n build={build}\n snapshots={snapshots}\n comments={comments}\n selected={selected}\n canReview={canReview}\n hasBaseline={hasBaseline}\n drifted={drifted}\n />\n )}\n <ShortcutsDialog />\n </DocumentLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA2CA,SAAS,eACP,OACgD;CAChD,MAAM,EAAE,SAAS,OAAO,WAAW,UAAU,UAAU,WAAW,aAAa,YAAY;CAC3F,OACE,qBAAC,OAAD;EAAK,OAAO;EAAZ,UAAA,CACE,oBAAC,SAAD;GAAkB;GAAgB;GAAkB;GAAW,YAAY,UAAU;EAAK,CAAA,GAC1F,oBAAC,OAAD;GAAK,OAAO;GACT,UAAA,WACC,qBAAA,UAAA,EAAA,UAAA;IACE,oBAAC,YAAD;KACW;KACF;KACG;KACG;KACb,SAAS,QAAQ,SAAS;IAC3B,CAAA;IACD,oBAAC,cAAD;KACW;KACF;KACP,YAAY,SAAS;KACX;KACC;IACZ,CAAA;IACD,oBAAC,eAAD;KACW;KACF;KACI;KACD;KACC;KACX,SAAS,QAAQ,SAAS,QAAQ,KAAA;IACnC,CAAA;GACD,EAAA,CAAA,IAEF,oBAAC,YAAD,EAAY,aAAY,+BAAgC,CAAA;EAEvD,CAAA,CACF;;AAET;;AAGA,SAAS,UAAU,SAAkB,OAAyB;CAC5D,OAAO;EACL,QAAQ;EACR,aAAa,QAAQ;EACrB,aAAa,QAAQ;EACrB,OAAO,CACL;GACE,OAAO,GAAG,MAAM,UAAU,KAAK,MAAM,OAAO,MAAM,GAAG,CAAC;GACtD,MAAM,aAAa,QAAQ,KAAK,UAAU,MAAM;EAClD,GACA,EAAE,OAAO,SAAS,CACpB;CACF;AACF;;AAGA,SAAgB,oBAAoB,MAAsC;CACxE,MAAM,EAAE,SAAS,OAAO,WAAW,UAAU,YAAY,WAAW,aAAa,YAAY;CAC7F,MAAM,WACJ,UAAU,MAAM,MAAM,EAAE,OAAO,UAAU,KACzC,UAAU,MAAM,MAAM,EAAE,WAAW,aAAa,EAAE,WAAW,KAAK,KAClE,UAAU;CACZ,MAAM,UAAU,UAAU,QAAQ,MAAM,EAAE,WAAW,SAAS,EAAE,WAAW,SAAS;CAEpF,OACE,qBAAC,gBAAD;EACE,OAAO,YAAY,MAAM;EACzB,KAAK,UAAU,SAAS,KAAK;EAC7B,QAAO;EAHT,UAAA;GAKE,oBAAC,YAAD;IACW;IACF;IACI;IACX,cAAc,QAAQ;IACX;GACZ,CAAA;GAEA,OAAO,KAAK,OAAO,CAAC,CAAC,SAAS,IAC7B,oBAAC,qBAAD;IACW;IACF;IACP,YAAY,OAAO,KAAK,OAAO,CAAC,CAAC;IACtB;GACZ,CAAA,IACC;GAEH,UAAU,WAAW,IACpB,oBAAC,YAAD;IACE,OAAM;IACN,aAAY;GACb,CAAA,IAED,oBAAC,gBAAD;IACW;IACF;IACI;IACD;IACA;IACC;IACE;IACJ;GACV,CAAA;GAEH,oBAAC,iBAAD,CAAkB,CAAA;EACJ;;AAEpB"}
@@ -0,0 +1,165 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { Button } from "../ui/buttons.mjs";
6
+ import { Alert, Badge, EmptyState, Meta, statusTone } from "../ui/feedback.mjs";
7
+ import { Card, PageHeader, SectionTitle } from "../ui/layout.mjs";
8
+ import { HStack } from "../ui/stacks.mjs";
9
+ import { Table } from "../ui/table.mjs";
10
+ import { DocumentLayout } from "../ui/document.mjs";
11
+ import { BuildModel, ProjectModel } from "@storyshelf/core/models";
12
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
13
+ //#region src/pages/compute-jobs.tsx
14
+ /** Live-refreshing partial showing the currently queued and running captures. */
15
+ function renderActiveQueue(slug, queueView) {
16
+ return /* @__PURE__ */ jsxs(Card, {
17
+ id: "active-queue",
18
+ "hx-get": `/projects/${slug}/jobs?partial=queue`,
19
+ "hx-trigger": "every 5s",
20
+ "hx-swap": "outerHTML",
21
+ "hx-target": "#active-queue",
22
+ children: [/* @__PURE__ */ jsx(SectionTitle, { children: "Active queue" }), queueView.length === 0 ? /* @__PURE__ */ jsx(Meta, { children: "No captures are currently queued or running." }) : /* @__PURE__ */ jsx(Table, { children: /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
23
+ /* @__PURE__ */ jsx("th", { children: "Build" }),
24
+ /* @__PURE__ */ jsx("th", { children: "Status" }),
25
+ /* @__PURE__ */ jsx("th", { children: "Queued" }),
26
+ /* @__PURE__ */ jsx("th", { children: "Started" })
27
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: queueView.map((job) => /* @__PURE__ */ jsxs("tr", { children: [
28
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx("a", {
29
+ href: `/projects/${slug}/builds/${job.buildId}`,
30
+ children: job.buildId.slice(0, 8)
31
+ }) }),
32
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Badge, {
33
+ tone: statusTone(job.status),
34
+ children: job.status
35
+ }) }),
36
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Meta, {
37
+ as: "span",
38
+ children: new Date(job.queuedAt).toLocaleTimeString()
39
+ }) }),
40
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Meta, {
41
+ as: "span",
42
+ children: job.startedAt ? new Date(job.startedAt).toLocaleTimeString() : "—"
43
+ }) })
44
+ ] }, job.buildId)) })] }) })]
45
+ });
46
+ }
47
+ /**
48
+ * A `capturing` build the in-process queue no longer tracks was cut short by a
49
+ * restart. Remote queues are invisible to the server, so nothing is flagged there.
50
+ */
51
+ function isStuckCapture(status, tracked, inProcessQueue) {
52
+ return inProcessQueue && status === "capturing" && !tracked;
53
+ }
54
+ /** Compute jobs page: live capture queue plus recent build history with retry. */
55
+ async function renderComputeJobsPage(slug, queueView, canRetry, inProcessQueue = false) {
56
+ const project = (await new ProjectModel(getStore().db).list()).find((item) => item.slug === slug);
57
+ if (!project) return null;
58
+ const recentBuilds = (await new BuildModel(getStore().db).list(project.id)).slice(0, 20);
59
+ const queueByBuild = new Map(queueView.map((job) => [job.buildId, job]));
60
+ const stuck = (id, status) => isStuckCapture(status, queueByBuild.has(id), inProcessQueue);
61
+ const stuckCount = recentBuilds.filter((build) => stuck(build.id, build.status)).length;
62
+ return /* @__PURE__ */ jsxs(DocumentLayout, {
63
+ title: `${project.name} · Compute jobs`,
64
+ nav: {
65
+ active: "jobs",
66
+ projectSlug: project.slug,
67
+ projectName: project.name
68
+ },
69
+ children: [
70
+ /* @__PURE__ */ jsx(PageHeader, {
71
+ title: "Compute jobs",
72
+ description: "Capture jobs run on this server. Queued and running jobs refresh live; recent history is below.",
73
+ actions: /* @__PURE__ */ jsx(Button, {
74
+ variant: "secondary",
75
+ href: `/projects/${project.slug}/builds`,
76
+ children: "Back to builds"
77
+ })
78
+ }),
79
+ stuckCount > 0 ? /* @__PURE__ */ jsxs(Alert, {
80
+ tone: "warning",
81
+ title: "Interrupted captures",
82
+ children: [
83
+ stuckCount,
84
+ " build",
85
+ stuckCount === 1 ? " was" : "s were",
86
+ " still capturing when the server restarted. On startup an interrupted build is requeued once; if it is interrupted again it is marked failed so a crash loop cannot repeat captures. Retry a build to run it again."
87
+ ]
88
+ }) : null,
89
+ renderActiveQueue(project.slug, queueView),
90
+ /* @__PURE__ */ jsxs(Card, { children: [
91
+ /* @__PURE__ */ jsx(SectionTitle, { children: "Recent builds" }),
92
+ /* @__PURE__ */ jsxs(Meta, { children: [
93
+ "Capture history for ",
94
+ project.name,
95
+ ". Failed and interrupted jobs can be retried."
96
+ ] }),
97
+ /* @__PURE__ */ jsx(Table, { children: /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
98
+ /* @__PURE__ */ jsx("th", { children: "Branch" }),
99
+ /* @__PURE__ */ jsx("th", { children: "Status" }),
100
+ /* @__PURE__ */ jsx("th", { children: "Snapshots" }),
101
+ /* @__PURE__ */ jsx("th", { children: "Created" }),
102
+ /* @__PURE__ */ jsx("th", { children: /* @__PURE__ */ jsx("span", {
103
+ class: "visually-hidden",
104
+ children: "Actions"
105
+ }) })
106
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: recentBuilds.map((build) => /* @__PURE__ */ jsxs("tr", { children: [
107
+ /* @__PURE__ */ jsxs("td", { children: [/* @__PURE__ */ jsx("strong", { children: build.gitBranch }), /* @__PURE__ */ jsx(Meta, {
108
+ as: "div",
109
+ mono: true,
110
+ children: build.gitSha.slice(0, 7)
111
+ })] }),
112
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Badge, {
113
+ tone: stuck(build.id, build.status) ? "warning" : statusTone(build.status),
114
+ children: stuck(build.id, build.status) ? "interrupted" : build.status
115
+ }) }),
116
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsxs(Meta, {
117
+ as: "span",
118
+ children: [
119
+ build.snapshotCount,
120
+ " total · ",
121
+ build.changedCount,
122
+ " changed"
123
+ ]
124
+ }) }),
125
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Meta, {
126
+ as: "span",
127
+ children: new Date(build.createdAt).toLocaleString()
128
+ }) }),
129
+ /* @__PURE__ */ jsxs("td", {
130
+ class: "nowrap",
131
+ children: [/* @__PURE__ */ jsxs(HStack, {
132
+ wrap: false,
133
+ children: [/* @__PURE__ */ jsx(Button, {
134
+ variant: "secondary",
135
+ size: "sm",
136
+ href: `/projects/${project.slug}/builds/${build.id}`,
137
+ children: "View"
138
+ }), canRetry && (build.status === "failed" || build.status === "pending" || stuck(build.id, build.status)) ? /* @__PURE__ */ jsx("form", {
139
+ method: "post",
140
+ action: `/api/v1/projects/${project.slug}/builds/${build.id}/retry`,
141
+ "hx-post": `/api/v1/projects/${project.slug}/builds/${build.id}/retry`,
142
+ "hx-target": "body",
143
+ children: /* @__PURE__ */ jsx(Button, {
144
+ variant: "ghost",
145
+ size: "sm",
146
+ type: "submit",
147
+ children: "Retry"
148
+ })
149
+ }) : null]
150
+ }), queueByBuild.get(build.id)?.error ? /* @__PURE__ */ jsx(Meta, {
151
+ as: "div",
152
+ tone: "danger",
153
+ children: queueByBuild.get(build.id)?.error
154
+ }) : null]
155
+ })
156
+ ] }, build.id)) })] }) }),
157
+ recentBuilds.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { description: "No builds yet." }) : null
158
+ ] })
159
+ ]
160
+ });
161
+ }
162
+ //#endregion
163
+ export { isStuckCapture, renderActiveQueue, renderComputeJobsPage };
164
+
165
+ //# sourceMappingURL=compute-jobs.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compute-jobs.mjs","names":[],"sources":["../../src/pages/compute-jobs.tsx"],"sourcesContent":["import { BuildModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { getStore } from \"../store.ts\";\nimport {\n Alert,\n Badge,\n Button,\n Card,\n EmptyState,\n HStack,\n Meta,\n PageHeader,\n SectionTitle,\n Table,\n statusTone,\n} from \"../ui/components.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\ninterface QueueView {\n buildId: string;\n status: string;\n queuedAt: string;\n startedAt?: string;\n finishedAt?: string;\n error?: string;\n}\n\n/** Live-refreshing partial showing the currently queued and running captures. */\nexport function renderActiveQueue(slug: string, queueView: QueueView[]): RenderedContent {\n return (\n <Card\n id=\"active-queue\"\n hx-get={`/projects/${slug}/jobs?partial=queue`}\n hx-trigger=\"every 5s\"\n hx-swap=\"outerHTML\"\n hx-target=\"#active-queue\"\n >\n <SectionTitle>Active queue</SectionTitle>\n {queueView.length === 0 ? (\n <Meta>No captures are currently queued or running.</Meta>\n ) : (\n <Table>\n <table>\n <thead>\n <tr>\n <th>Build</th>\n <th>Status</th>\n <th>Queued</th>\n <th>Started</th>\n </tr>\n </thead>\n <tbody>\n {queueView.map((job): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <tr key={job.buildId}>\n <td>\n <a href={`/projects/${slug}/builds/${job.buildId}`}>\n {job.buildId.slice(0, 8)}\n </a>\n </td>\n <td>\n <Badge tone={statusTone(job.status)}>{job.status}</Badge>\n </td>\n <td>\n <Meta as=\"span\">{new Date(job.queuedAt).toLocaleTimeString()}</Meta>\n </td>\n <td>\n <Meta as=\"span\">\n {job.startedAt ? new Date(job.startedAt).toLocaleTimeString() : \"—\"}\n </Meta>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </Table>\n )}\n </Card>\n );\n}\n\n/**\n * A `capturing` build the in-process queue no longer tracks was cut short by a\n * restart. Remote queues are invisible to the server, so nothing is flagged there.\n */\nexport function isStuckCapture(status: string, tracked: boolean, inProcessQueue: boolean): boolean {\n return inProcessQueue && status === \"capturing\" && !tracked;\n}\n\n/** Compute jobs page: live capture queue plus recent build history with retry. */\nexport async function renderComputeJobsPage(\n slug: string,\n queueView: QueueView[],\n canRetry: boolean,\n inProcessQueue = false,\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 builds = await new BuildModel(getStore().db).list(project.id);\n const recentBuilds = builds.slice(0, 20);\n const queueByBuild = new Map(queueView.map((job) => [job.buildId, job]));\n const stuck = (id: string, status: string): boolean =>\n isStuckCapture(status, queueByBuild.has(id), inProcessQueue);\n const stuckCount = recentBuilds.filter((build) => stuck(build.id, build.status)).length;\n\n return (\n <DocumentLayout\n title={`${project.name} · Compute jobs`}\n nav={{ active: \"jobs\", projectSlug: project.slug, projectName: project.name }}\n >\n <PageHeader\n title=\"Compute jobs\"\n description=\"Capture jobs run on this server. Queued and running jobs refresh live; recent history is below.\"\n actions={\n <Button variant=\"secondary\" href={`/projects/${project.slug}/builds`}>\n Back to builds\n </Button>\n }\n />\n\n {stuckCount > 0 ? (\n <Alert tone=\"warning\" title=\"Interrupted captures\">\n {stuckCount} build{stuckCount === 1 ? \" was\" : \"s were\"} still capturing when the server\n restarted. On startup an interrupted build is requeued once; if it is interrupted again it\n is marked failed so a crash loop cannot repeat captures. Retry a build to run it again.\n </Alert>\n ) : null}\n\n {renderActiveQueue(project.slug, queueView)}\n\n <Card>\n <SectionTitle>Recent builds</SectionTitle>\n <Meta>Capture history for {project.name}. Failed and interrupted jobs can be retried.</Meta>\n <Table>\n <table>\n <thead>\n <tr>\n <th>Branch</th>\n <th>Status</th>\n <th>Snapshots</th>\n <th>Created</th>\n <th>\n <span class=\"visually-hidden\">Actions</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {recentBuilds.map((build): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <tr key={build.id}>\n <td>\n <strong>{build.gitBranch}</strong>\n <Meta as=\"div\" mono>\n {build.gitSha.slice(0, 7)}\n </Meta>\n </td>\n <td>\n <Badge\n tone={stuck(build.id, build.status) ? \"warning\" : statusTone(build.status)}\n >\n {stuck(build.id, build.status) ? \"interrupted\" : build.status}\n </Badge>\n </td>\n <td>\n <Meta as=\"span\">\n {build.snapshotCount} total · {build.changedCount} changed\n </Meta>\n </td>\n <td>\n <Meta as=\"span\">{new Date(build.createdAt).toLocaleString()}</Meta>\n </td>\n <td class=\"nowrap\">\n <HStack wrap={false}>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n href={`/projects/${project.slug}/builds/${build.id}`}\n >\n View\n </Button>\n {canRetry &&\n (build.status === \"failed\" ||\n build.status === \"pending\" ||\n stuck(build.id, build.status)) ? (\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/retry`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/retry`}\n hx-target=\"body\"\n >\n <Button variant=\"ghost\" size=\"sm\" type=\"submit\">\n Retry\n </Button>\n </form>\n ) : null}\n </HStack>\n {queueByBuild.get(build.id)?.error ? (\n <Meta as=\"div\" tone=\"danger\">\n {queueByBuild.get(build.id)?.error}\n </Meta>\n ) : null}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </Table>\n {recentBuilds.length === 0 ? <EmptyState description=\"No builds yet.\" /> : null}\n </Card>\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,91 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Button } from "../ui/buttons.mjs";
5
+ import { Alert } from "../ui/feedback.mjs";
6
+ import { Field } from "../ui/forms.mjs";
7
+ import { Card, PageHeader } from "../ui/layout.mjs";
8
+ import { VStack } from "../ui/stacks.mjs";
9
+ import { csrfField } from "../ui/csrf-field.mjs";
10
+ import { DocumentLayout } from "../ui/document.mjs";
11
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
12
+ //#region src/pages/invite.tsx
13
+ /** Set-password page for invite acceptance (no auth required). */
14
+ function renderInvitePage(state) {
15
+ return /* @__PURE__ */ jsx(DocumentLayout, {
16
+ title: "Set password",
17
+ layout: "bare",
18
+ children: /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(VStack, {
19
+ gap: "lg",
20
+ children: [
21
+ /* @__PURE__ */ jsx(PageHeader, {
22
+ title: "Set your password",
23
+ description: state.email ? `for ${state.email}` : void 0
24
+ }),
25
+ state.error ? /* @__PURE__ */ jsx(Alert, {
26
+ tone: "danger",
27
+ title: "Invite failed",
28
+ children: state.error
29
+ }) : null,
30
+ /* @__PURE__ */ jsx("form", {
31
+ method: "post",
32
+ action: `/auth/invites/${state.inviteId}`,
33
+ novalidate: true,
34
+ children: /* @__PURE__ */ jsxs(VStack, { children: [
35
+ /* @__PURE__ */ jsx("input", {
36
+ type: "hidden",
37
+ name: "token",
38
+ value: state.token
39
+ }),
40
+ csrfField(),
41
+ /* @__PURE__ */ jsx(Field, {
42
+ label: "New password",
43
+ name: "password",
44
+ type: "password",
45
+ required: true,
46
+ autofocus: true,
47
+ autocomplete: "new-password"
48
+ }),
49
+ /* @__PURE__ */ jsx(Field, {
50
+ label: "Confirm password",
51
+ name: "confirm",
52
+ type: "password",
53
+ required: true,
54
+ autocomplete: "new-password"
55
+ }),
56
+ /* @__PURE__ */ jsx(Button, {
57
+ variant: "primary",
58
+ type: "submit",
59
+ children: "Set password and sign in"
60
+ })
61
+ ] })
62
+ })
63
+ ]
64
+ }) })
65
+ });
66
+ }
67
+ function renderInviteInvalidPage(error) {
68
+ return /* @__PURE__ */ jsx(DocumentLayout, {
69
+ title: "Invite",
70
+ layout: "bare",
71
+ children: /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(VStack, {
72
+ gap: "lg",
73
+ children: [
74
+ /* @__PURE__ */ jsx(PageHeader, { title: "Invite" }),
75
+ /* @__PURE__ */ jsx(Alert, {
76
+ tone: "danger",
77
+ title: "Invalid invite",
78
+ children: error
79
+ }),
80
+ /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx("a", {
81
+ href: "/auth/login",
82
+ children: "Back to sign in"
83
+ }) })
84
+ ]
85
+ }) })
86
+ });
87
+ }
88
+ //#endregion
89
+ export { renderInviteInvalidPage, renderInvitePage };
90
+
91
+ //# sourceMappingURL=invite.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invite.mjs","names":[],"sources":["../../src/pages/invite.tsx"],"sourcesContent":["import { Alert, Button, Card, Field, PageHeader, VStack } from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\n\nexport interface InvitePageState {\n inviteId: string;\n token: string;\n email?: string;\n name?: string;\n error?: string;\n}\n\n/** Set-password page for invite acceptance (no auth required). */\nexport function renderInvitePage(state: InvitePageState): RenderedContent {\n return (\n <DocumentLayout title=\"Set password\" layout=\"bare\">\n <Card>\n <VStack gap=\"lg\">\n <PageHeader\n title=\"Set your password\"\n description={state.email ? `for ${state.email}` : undefined}\n />\n {state.error ? (\n <Alert tone=\"danger\" title=\"Invite failed\">\n {state.error}\n </Alert>\n ) : null}\n <form method=\"post\" action={`/auth/invites/${state.inviteId}`} novalidate>\n <VStack>\n <input type=\"hidden\" name=\"token\" value={state.token} />\n {csrfField()}\n <Field\n label=\"New password\"\n name=\"password\"\n type=\"password\"\n required\n autofocus\n autocomplete=\"new-password\"\n />\n <Field\n label=\"Confirm password\"\n name=\"confirm\"\n type=\"password\"\n required\n autocomplete=\"new-password\"\n />\n <Button variant=\"primary\" type=\"submit\">\n Set password and sign in\n </Button>\n </VStack>\n </form>\n </VStack>\n </Card>\n </DocumentLayout>\n );\n}\n\nexport function renderInviteInvalidPage(error: string): RenderedContent {\n return (\n <DocumentLayout title=\"Invite\" layout=\"bare\">\n <Card>\n <VStack gap=\"lg\">\n <PageHeader title=\"Invite\" />\n <Alert tone=\"danger\" title=\"Invalid invite\">\n {error}\n </Alert>\n <p>\n <a href=\"/auth/login\">Back to sign in</a>\n </p>\n </VStack>\n </Card>\n </DocumentLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;;;AAaA,SAAgB,iBAAiB,OAAyC;CACxE,OACE,oBAAC,gBAAD;EAAgB,OAAM;EAAe,QAAO;EAC1C,UAAA,oBAAC,MAAD,EAAA,UACE,qBAAC,QAAD;GAAQ,KAAI;GAAZ,UAAA;IACE,oBAAC,YAAD;KACE,OAAM;KACN,aAAa,MAAM,QAAQ,OAAO,MAAM,UAAU,KAAA;IACnD,CAAA;IACA,MAAM,QACL,oBAAC,OAAD;KAAO,MAAK;KAAS,OAAM;KACxB,UAAA,MAAM;IACF,CAAA,IACL;IACJ,oBAAC,QAAD;KAAM,QAAO;KAAO,QAAQ,iBAAiB,MAAM;KAAY,YAAA;KAC7D,UAAA,qBAAC,QAAD,EAAA,UAAA;MACE,oBAAC,SAAD;OAAO,MAAK;OAAS,MAAK;OAAQ,OAAO,MAAM;MAAQ,CAAA;MACtD,UAAU;MACX,oBAAC,OAAD;OACE,OAAM;OACN,MAAK;OACL,MAAK;OACL,UAAA;OACA,WAAA;OACA,cAAa;MACd,CAAA;MACD,oBAAC,OAAD;OACE,OAAM;OACN,MAAK;OACL,MAAK;OACL,UAAA;OACA,cAAa;MACd,CAAA;MACD,oBAAC,QAAD;OAAQ,SAAQ;OAAU,MAAK;OAAS,UAAA;MAEhC,CAAA;KACF,EAAA,CAAA;IACJ,CAAA;GACA;EACJ,CAAA,EAAA,CAAA;CACQ,CAAA;AAEpB;AAEA,SAAgB,wBAAwB,OAAgC;CACtE,OACE,oBAAC,gBAAD;EAAgB,OAAM;EAAS,QAAO;EACpC,UAAA,oBAAC,MAAD,EAAA,UACE,qBAAC,QAAD;GAAQ,KAAI;GAAZ,UAAA;IACE,oBAAC,YAAD,EAAY,OAAM,SAAU,CAAA;IAC5B,oBAAC,OAAD;KAAO,MAAK;KAAS,OAAM;KACxB,UAAA;IACI,CAAA;IACP,oBAAC,KAAD,EAAA,UACE,oBAAC,KAAD;KAAG,MAAK;KAAc,UAAA;IAAkB,CAAA,EACvC,CAAA;GACG;EACJ,CAAA,EAAA,CAAA;CACQ,CAAA;AAEpB"}
@@ -0,0 +1,154 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { Button } from "../ui/buttons.mjs";
6
+ import { Badge, EmptyState, Meta, statusTone } from "../ui/feedback.mjs";
7
+ import { PageHeader } from "../ui/layout.mjs";
8
+ import { HStack } from "../ui/stacks.mjs";
9
+ import { Table } from "../ui/table.mjs";
10
+ import { DocumentLayout } from "../ui/document.mjs";
11
+ import { BuildModel, LabelModel, ProjectModel } from "@storyshelf/core/models";
12
+ import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
13
+ //#region src/pages/label-detail.tsx
14
+ /** Resolve a label type's `link_template` against a build, or return null. */
15
+ function resolveLabelLink(template, project, build, value) {
16
+ if (!template) return null;
17
+ const repo = project.gitRepository ?? "";
18
+ return template.replaceAll("{value}", value).replaceAll("{repo}", repo).replaceAll("{branch}", build.gitBranch);
19
+ }
20
+ /** Label page: every build bearing `key=value`, latest first (ADR 0013). */
21
+ async function renderLabelDetailPage(slug, key, value) {
22
+ const project = (await new ProjectModel(getStore().db).list()).find((item) => item.slug === slug);
23
+ if (!project) return null;
24
+ const [builds, labelType] = await Promise.all([new BuildModel(getStore().db).list(project.id, {
25
+ labelKey: key,
26
+ labelValue: value
27
+ }), new LabelModel(getStore().db).getType(project.id, key)]);
28
+ return /* @__PURE__ */ jsxs(DocumentLayout, {
29
+ title: `${key}: ${value}`,
30
+ nav: {
31
+ active: "labels",
32
+ projectSlug: project.slug,
33
+ projectName: project.name,
34
+ trail: [{ label: `${labelType ? labelType.name : key}: ${value}` }]
35
+ },
36
+ children: [/* @__PURE__ */ jsx(PageHeader, {
37
+ title: /* @__PURE__ */ jsxs(Fragment, { children: [
38
+ labelType ? labelType.name : key,
39
+ ": ",
40
+ value
41
+ ] }),
42
+ description: /* @__PURE__ */ jsxs(Fragment, { children: [
43
+ /* @__PURE__ */ jsx(Badge, {
44
+ tone: "neutral",
45
+ children: key
46
+ }),
47
+ " Builds bearing this",
48
+ " ",
49
+ labelType ? labelType.name.toLowerCase() : "label",
50
+ ", latest first."
51
+ ] }),
52
+ actions: /* @__PURE__ */ jsx(Button, {
53
+ variant: "ghost",
54
+ href: `/projects/${project.slug}/labels`,
55
+ children: "All labels"
56
+ })
57
+ }), builds.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
58
+ title: "No builds",
59
+ description: `No build currently bears the label ${key}: ${value}.`
60
+ }) : /* @__PURE__ */ jsx(Table, { children: /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
61
+ /* @__PURE__ */ jsx("th", { children: "Build" }),
62
+ /* @__PURE__ */ jsx("th", { children: "Status" }),
63
+ /* @__PURE__ */ jsx("th", { children: "Author" }),
64
+ /* @__PURE__ */ jsx("th", { children: "Created" }),
65
+ /* @__PURE__ */ jsx("th", { children: /* @__PURE__ */ jsx("span", {
66
+ class: "visually-hidden",
67
+ children: "Actions"
68
+ }) })
69
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: builds.map((build) => {
70
+ const link = labelType?.linkTemplate ? resolveLabelLink(labelType.linkTemplate, project, build, value) : null;
71
+ return /* @__PURE__ */ jsxs("tr", { children: [
72
+ /* @__PURE__ */ jsxs("td", { children: [/* @__PURE__ */ jsx("strong", { children: build.gitBranch }), /* @__PURE__ */ jsxs(Meta, {
73
+ as: "div",
74
+ mono: true,
75
+ children: [
76
+ build.gitSha.slice(0, 7),
77
+ " ",
78
+ build.message ? `· ${build.message.slice(0, 60)}` : ""
79
+ ]
80
+ })] }),
81
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Badge, {
82
+ tone: statusTone(build.status),
83
+ children: build.status
84
+ }) }),
85
+ /* @__PURE__ */ jsxs("td", { children: [/* @__PURE__ */ jsx("div", { children: build.authorName ?? "—" }), /* @__PURE__ */ jsx(Meta, {
86
+ as: "div",
87
+ children: build.authorEmail ?? ""
88
+ })] }),
89
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Meta, {
90
+ as: "span",
91
+ children: new Date(build.createdAt).toLocaleString()
92
+ }) }),
93
+ /* @__PURE__ */ jsx("td", {
94
+ class: "nowrap",
95
+ children: /* @__PURE__ */ jsxs(HStack, {
96
+ wrap: false,
97
+ children: [/* @__PURE__ */ jsx(Button, {
98
+ variant: "secondary",
99
+ size: "sm",
100
+ href: `/projects/${project.slug}/builds/${build.id}`,
101
+ children: "View"
102
+ }), link ? /* @__PURE__ */ jsx(Button, {
103
+ variant: "ghost",
104
+ size: "sm",
105
+ href: link,
106
+ target: "_blank",
107
+ rel: "noreferrer",
108
+ children: "Open link"
109
+ }) : null]
110
+ })
111
+ })
112
+ ] }, build.id);
113
+ }) })] }) })]
114
+ });
115
+ }
116
+ /** Label types overview page (architecture.md `GET /projects/:slug/labels`). */
117
+ async function renderLabelsPage(slug) {
118
+ const project = (await new ProjectModel(getStore().db).list()).find((item) => item.slug === slug);
119
+ if (!project) return null;
120
+ const labelTypes = await new LabelModel(getStore().db).listTypes(project.id);
121
+ return /* @__PURE__ */ jsxs(DocumentLayout, {
122
+ title: `Labels · ${project.name}`,
123
+ nav: {
124
+ active: "labels",
125
+ projectSlug: project.slug,
126
+ projectName: project.name
127
+ },
128
+ children: [/* @__PURE__ */ jsx(PageHeader, {
129
+ title: "Labels",
130
+ description: "Typed labels attach values to builds and link out to external systems."
131
+ }), labelTypes.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
132
+ title: "No label types",
133
+ description: "No label types are configured for this project."
134
+ }) : /* @__PURE__ */ jsx(Table, { children: /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
135
+ /* @__PURE__ */ jsx("th", { children: "Key" }),
136
+ /* @__PURE__ */ jsx("th", { children: "Name" }),
137
+ /* @__PURE__ */ jsx("th", { children: "Template" })
138
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: labelTypes.map((labelType) => /* @__PURE__ */ jsxs("tr", { children: [
139
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Badge, {
140
+ tone: "neutral",
141
+ children: labelType.key
142
+ }) }),
143
+ /* @__PURE__ */ jsx("td", { children: labelType.name }),
144
+ /* @__PURE__ */ jsx("td", {
145
+ class: "truncate max-w-cell",
146
+ children: labelType.linkTemplate ?? "—"
147
+ })
148
+ ] }, labelType.id)) })] }) })]
149
+ });
150
+ }
151
+ //#endregion
152
+ export { renderLabelDetailPage, renderLabelsPage, resolveLabelLink };
153
+
154
+ //# sourceMappingURL=label-detail.mjs.map