@invisibleloop/pulse 0.1.21

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 (344) hide show
  1. package/.claude/commands/build-page.md +59 -0
  2. package/.claude/commands/new-doc-page.md +45 -0
  3. package/.claude/commands/verify.md +52 -0
  4. package/.claude/pulse-checklist.md +111 -0
  5. package/.claude/settings.local.json +102 -0
  6. package/.github/workflows/ci.yml +22 -0
  7. package/.github/workflows/publish.yml +41 -0
  8. package/.pulse/load-reports/home/1773432711417.json +22 -0
  9. package/CLAUDE.md +383 -0
  10. package/README.md +95 -0
  11. package/docs/.claude/pulse-checklist.md +111 -0
  12. package/docs/public/.pulse-ui-version +1 -0
  13. package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
  14. package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
  15. package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
  16. package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
  17. package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
  18. package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
  19. package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
  20. package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
  21. package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
  22. package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
  23. package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
  24. package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
  25. package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
  26. package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
  27. package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
  28. package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
  29. package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
  30. package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
  31. package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
  32. package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
  33. package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
  34. package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
  35. package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
  36. package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
  37. package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
  38. package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
  39. package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
  40. package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
  41. package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
  42. package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
  43. package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
  44. package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
  45. package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
  46. package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
  47. package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
  48. package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
  49. package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
  50. package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
  51. package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
  52. package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
  53. package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
  54. package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
  55. package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
  56. package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
  57. package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
  58. package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
  59. package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
  60. package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
  61. package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
  62. package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
  63. package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
  64. package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
  65. package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
  66. package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
  67. package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
  68. package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
  69. package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
  70. package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
  71. package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
  72. package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
  73. package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
  74. package/docs/public/dist/docs-8e3d4b5c.css +1 -0
  75. package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
  76. package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
  77. package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
  78. package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
  79. package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
  80. package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
  81. package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
  82. package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
  83. package/docs/public/dist/manifest.json +94 -0
  84. package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
  85. package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
  86. package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
  87. package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
  88. package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
  89. package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
  90. package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
  91. package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
  92. package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
  93. package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
  94. package/docs/public/dist/runtime-B73WLANC.js +1 -0
  95. package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
  96. package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
  97. package/docs/public/dist/runtime-QFURDKA2.js +5 -0
  98. package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
  99. package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
  100. package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
  101. package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
  102. package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
  103. package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
  104. package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
  105. package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
  106. package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
  107. package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
  108. package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
  109. package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
  110. package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
  111. package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
  112. package/docs/public/docs.css +2020 -0
  113. package/docs/public/menu.js +83 -0
  114. package/docs/public/pulse-ui.css +2739 -0
  115. package/docs/public/pulse-ui.js +236 -0
  116. package/docs/server.js +192 -0
  117. package/docs/src/lib/component-page.js +47 -0
  118. package/docs/src/lib/highlight.js +255 -0
  119. package/docs/src/lib/layout.js +131 -0
  120. package/docs/src/lib/metrics-store.js +6 -0
  121. package/docs/src/lib/nav.js +159 -0
  122. package/docs/src/lib/stats.js +81 -0
  123. package/docs/src/pages/accessibility.js +157 -0
  124. package/docs/src/pages/actions.js +191 -0
  125. package/docs/src/pages/auth.js +177 -0
  126. package/docs/src/pages/caching.js +95 -0
  127. package/docs/src/pages/components/accordion.js +48 -0
  128. package/docs/src/pages/components/alert.js +35 -0
  129. package/docs/src/pages/components/app-badge.js +41 -0
  130. package/docs/src/pages/components/avatar.js +35 -0
  131. package/docs/src/pages/components/badge.js +36 -0
  132. package/docs/src/pages/components/banner.js +45 -0
  133. package/docs/src/pages/components/breadcrumbs.js +94 -0
  134. package/docs/src/pages/components/button.js +84 -0
  135. package/docs/src/pages/components/card.js +225 -0
  136. package/docs/src/pages/components/carousel.js +72 -0
  137. package/docs/src/pages/components/charts.js +278 -0
  138. package/docs/src/pages/components/checkbox.js +129 -0
  139. package/docs/src/pages/components/cluster.js +47 -0
  140. package/docs/src/pages/components/code-window.js +57 -0
  141. package/docs/src/pages/components/container.js +40 -0
  142. package/docs/src/pages/components/cta.js +53 -0
  143. package/docs/src/pages/components/divider.js +37 -0
  144. package/docs/src/pages/components/empty.js +36 -0
  145. package/docs/src/pages/components/feature.js +60 -0
  146. package/docs/src/pages/components/fieldset.js +65 -0
  147. package/docs/src/pages/components/fileupload.js +127 -0
  148. package/docs/src/pages/components/footer.js +58 -0
  149. package/docs/src/pages/components/grid.js +165 -0
  150. package/docs/src/pages/components/heading.js +107 -0
  151. package/docs/src/pages/components/hero.js +65 -0
  152. package/docs/src/pages/components/icons.js +285 -0
  153. package/docs/src/pages/components/image.js +71 -0
  154. package/docs/src/pages/components/input.js +51 -0
  155. package/docs/src/pages/components/list.js +112 -0
  156. package/docs/src/pages/components/media.js +51 -0
  157. package/docs/src/pages/components/modal.js +111 -0
  158. package/docs/src/pages/components/nav.js +86 -0
  159. package/docs/src/pages/components/pricing.js +68 -0
  160. package/docs/src/pages/components/progress.js +102 -0
  161. package/docs/src/pages/components/prose.js +111 -0
  162. package/docs/src/pages/components/pullquote.js +71 -0
  163. package/docs/src/pages/components/radio.js +194 -0
  164. package/docs/src/pages/components/rating.js +106 -0
  165. package/docs/src/pages/components/search.js +61 -0
  166. package/docs/src/pages/components/section.js +59 -0
  167. package/docs/src/pages/components/segmented.js +121 -0
  168. package/docs/src/pages/components/select.js +45 -0
  169. package/docs/src/pages/components/slider.js +114 -0
  170. package/docs/src/pages/components/spinner.js +73 -0
  171. package/docs/src/pages/components/stack.js +48 -0
  172. package/docs/src/pages/components/stat.js +55 -0
  173. package/docs/src/pages/components/stepper.js +66 -0
  174. package/docs/src/pages/components/table.js +45 -0
  175. package/docs/src/pages/components/testimonial.js +49 -0
  176. package/docs/src/pages/components/textarea.js +31 -0
  177. package/docs/src/pages/components/timeline.js +227 -0
  178. package/docs/src/pages/components/toggle.js +84 -0
  179. package/docs/src/pages/components/tooltip.js +48 -0
  180. package/docs/src/pages/components.js +204 -0
  181. package/docs/src/pages/config.js +193 -0
  182. package/docs/src/pages/constraints.js +99 -0
  183. package/docs/src/pages/deploy.js +233 -0
  184. package/docs/src/pages/extending.js +198 -0
  185. package/docs/src/pages/faq.js +96 -0
  186. package/docs/src/pages/getting-started.js +106 -0
  187. package/docs/src/pages/guard.js +121 -0
  188. package/docs/src/pages/home.js +401 -0
  189. package/docs/src/pages/how-it-works.js +183 -0
  190. package/docs/src/pages/hydration.js +98 -0
  191. package/docs/src/pages/images.js +121 -0
  192. package/docs/src/pages/meta.js +120 -0
  193. package/docs/src/pages/mutations.js +106 -0
  194. package/docs/src/pages/navigation.js +85 -0
  195. package/docs/src/pages/performance.js +157 -0
  196. package/docs/src/pages/persist.js +88 -0
  197. package/docs/src/pages/project-structure.js +90 -0
  198. package/docs/src/pages/prompt-examples.js +186 -0
  199. package/docs/src/pages/raw-responses.js +124 -0
  200. package/docs/src/pages/routing.js +99 -0
  201. package/docs/src/pages/server-api.js +281 -0
  202. package/docs/src/pages/server-data.js +185 -0
  203. package/docs/src/pages/slash-commands.js +55 -0
  204. package/docs/src/pages/spec.js +207 -0
  205. package/docs/src/pages/state.js +101 -0
  206. package/docs/src/pages/store.js +181 -0
  207. package/docs/src/pages/streaming.js +108 -0
  208. package/docs/src/pages/stripe.js +193 -0
  209. package/docs/src/pages/supabase.js +323 -0
  210. package/docs/src/pages/testing.js +198 -0
  211. package/docs/src/pages/validation.js +138 -0
  212. package/examples/contact.js +166 -0
  213. package/examples/counter.js +94 -0
  214. package/examples/dev.server.js +91 -0
  215. package/examples/examples.test.js +394 -0
  216. package/examples/pricing.js +244 -0
  217. package/examples/products.js +191 -0
  218. package/examples/quiz.js +208 -0
  219. package/examples/shared.js +78 -0
  220. package/examples/todos.js +162 -0
  221. package/package.json +75 -0
  222. package/public/.pulse-ui-version +1 -0
  223. package/public/chippy-bird.css +246 -0
  224. package/public/examples/contact.css +119 -0
  225. package/public/examples/counter.css +79 -0
  226. package/public/examples/pricing.css +132 -0
  227. package/public/examples/products.css +100 -0
  228. package/public/examples/quiz.css +200 -0
  229. package/public/examples/todos.css +137 -0
  230. package/public/favicon.ico +0 -0
  231. package/public/log-dashboard.css +383 -0
  232. package/public/pulse-ui.css +2740 -0
  233. package/public/pulse-ui.js +236 -0
  234. package/public/pulse.css +149 -0
  235. package/scripts/build.js +411 -0
  236. package/src/agent/checklist.md +111 -0
  237. package/src/agent/coverage-check.js +66 -0
  238. package/src/agent/guide-components.md +274 -0
  239. package/src/agent/guide-examples.md +54 -0
  240. package/src/agent/guide-routing.md +36 -0
  241. package/src/agent/guide-server.md +258 -0
  242. package/src/agent/guide-spec.md +103 -0
  243. package/src/agent/guide-styles.md +191 -0
  244. package/src/agent/guide.md +979 -0
  245. package/src/agent/identity.md +106 -0
  246. package/src/agent/workflow.md +108 -0
  247. package/src/cli/cli.test.js +82 -0
  248. package/src/cli/dev.js +195 -0
  249. package/src/cli/discover.js +113 -0
  250. package/src/cli/index.js +361 -0
  251. package/src/cli/load-report.js +91 -0
  252. package/src/cli/load-runner.js +121 -0
  253. package/src/cli/report-server.js +723 -0
  254. package/src/cli/report.js +116 -0
  255. package/src/cli/scaffold.archive.js +1371 -0
  256. package/src/cli/scaffold.js +349 -0
  257. package/src/cli/start.js +74 -0
  258. package/src/html.js +19 -0
  259. package/src/mcp/server.js +884 -0
  260. package/src/mcp/validate-worker.js +110 -0
  261. package/src/runtime/image.js +74 -0
  262. package/src/runtime/image.test.js +111 -0
  263. package/src/runtime/index.js +621 -0
  264. package/src/runtime/navigate.js +146 -0
  265. package/src/runtime/runtime.test.js +773 -0
  266. package/src/runtime/ssr.js +464 -0
  267. package/src/runtime/ssr.test.js +421 -0
  268. package/src/runtime/store.js +92 -0
  269. package/src/runtime/toast.js +163 -0
  270. package/src/server/index.js +1386 -0
  271. package/src/server/server.test.js +1248 -0
  272. package/src/spec/schema.js +428 -0
  273. package/src/spec/schema.test.js +291 -0
  274. package/src/store/index.js +102 -0
  275. package/src/store/store.test.js +210 -0
  276. package/src/testing/html.js +283 -0
  277. package/src/testing/index.js +249 -0
  278. package/src/testing/testing.test.js +450 -0
  279. package/src/ui/accordion.js +28 -0
  280. package/src/ui/alert.js +43 -0
  281. package/src/ui/app-badge.js +48 -0
  282. package/src/ui/avatar.js +47 -0
  283. package/src/ui/badge.js +24 -0
  284. package/src/ui/banner.js +26 -0
  285. package/src/ui/breadcrumbs.js +38 -0
  286. package/src/ui/button.js +66 -0
  287. package/src/ui/card.js +34 -0
  288. package/src/ui/carousel.js +59 -0
  289. package/src/ui/charts.js +321 -0
  290. package/src/ui/checkbox.js +65 -0
  291. package/src/ui/cluster.js +44 -0
  292. package/src/ui/code-window.js +39 -0
  293. package/src/ui/container.js +24 -0
  294. package/src/ui/cta.js +37 -0
  295. package/src/ui/divider.js +29 -0
  296. package/src/ui/empty.js +33 -0
  297. package/src/ui/feature.js +33 -0
  298. package/src/ui/fieldset.js +37 -0
  299. package/src/ui/fileupload.js +89 -0
  300. package/src/ui/footer.js +38 -0
  301. package/src/ui/grid.js +36 -0
  302. package/src/ui/heading.js +45 -0
  303. package/src/ui/hero.js +37 -0
  304. package/src/ui/icons.js +161 -0
  305. package/src/ui/index.js +89 -0
  306. package/src/ui/input.js +74 -0
  307. package/src/ui/list.js +36 -0
  308. package/src/ui/media.js +44 -0
  309. package/src/ui/modal.js +80 -0
  310. package/src/ui/nav.js +61 -0
  311. package/src/ui/pricing.js +56 -0
  312. package/src/ui/progress.js +62 -0
  313. package/src/ui/prose.js +29 -0
  314. package/src/ui/pullquote.js +34 -0
  315. package/src/ui/radio.js +102 -0
  316. package/src/ui/rating.js +93 -0
  317. package/src/ui/search.js +77 -0
  318. package/src/ui/section.js +69 -0
  319. package/src/ui/segmented.js +50 -0
  320. package/src/ui/select.js +77 -0
  321. package/src/ui/slider.js +84 -0
  322. package/src/ui/spinner.js +34 -0
  323. package/src/ui/stack.js +36 -0
  324. package/src/ui/stat.js +52 -0
  325. package/src/ui/stepper.js +46 -0
  326. package/src/ui/switch.js +57 -0
  327. package/src/ui/table.js +45 -0
  328. package/src/ui/testimonial.js +48 -0
  329. package/src/ui/textarea.js +72 -0
  330. package/src/ui/timeline.js +72 -0
  331. package/src/ui/tooltip.js +28 -0
  332. package/src/ui/ui.test.js +1241 -0
  333. package/src/ui/uiimage.js +65 -0
  334. package/tsconfig.json +13 -0
  335. package/types/html.d.ts +17 -0
  336. package/types/image.d.ts +70 -0
  337. package/types/index.d.ts +7 -0
  338. package/types/navigate.d.ts +38 -0
  339. package/types/runtime.d.ts +63 -0
  340. package/types/schema.d.ts +243 -0
  341. package/types/server.d.ts +145 -0
  342. package/types/ssr.d.ts +110 -0
  343. package/types/testing.d.ts +154 -0
  344. package/types/ui.d.ts +704 -0
@@ -0,0 +1,723 @@
1
+ /**
2
+ * Pulse — Combined report server
3
+ *
4
+ * Serves a unified dashboard for both Lighthouse audit history and load test
5
+ * results, reading from .pulse/reports/ and .pulse/load-reports/.
6
+ *
7
+ * Usage:
8
+ * node src/cli/report-server.js [--root /path/to/project] [--port 3001]
9
+ */
10
+
11
+ import http from 'http'
12
+ import fs from 'fs'
13
+ import path from 'path'
14
+
15
+ const args = process.argv.slice(2)
16
+ const rootArg = args.indexOf('--root')
17
+ const portArg = args.indexOf('--port')
18
+
19
+ const ROOT = rootArg !== -1 ? path.resolve(args[rootArg + 1]) : process.cwd()
20
+ const PORT = portArg !== -1 ? parseInt(args[portArg + 1], 10) : 3001
21
+ const REPORTS_DIR = path.join(ROOT, '.pulse', 'reports')
22
+ const LOAD_REPORTS_DIR = path.join(ROOT, '.pulse', 'load-reports')
23
+ const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Data — Lighthouse
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function allSlugs() {
30
+ const perf = fs.existsSync(REPORTS_DIR)
31
+ ? fs.readdirSync(REPORTS_DIR).filter(f => fs.statSync(path.join(REPORTS_DIR, f)).isDirectory())
32
+ : []
33
+ const load = fs.existsSync(LOAD_REPORTS_DIR)
34
+ ? fs.readdirSync(LOAD_REPORTS_DIR).filter(f => fs.statSync(path.join(LOAD_REPORTS_DIR, f)).isDirectory())
35
+ : []
36
+ return [...new Set([...perf, ...load])].sort()
37
+ }
38
+
39
+ function loadReports(slug) {
40
+ const dir = path.join(REPORTS_DIR, slug)
41
+ const cutoff = Date.now() - THIRTY_DAYS
42
+ if (!fs.existsSync(dir)) return []
43
+ return fs.readdirSync(dir)
44
+ .filter(f => f.endsWith('.json'))
45
+ .sort()
46
+ .map(f => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) } catch { return null } })
47
+ .filter(r => r && new Date(r.timestamp).getTime() >= cutoff)
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Data — Load tests
52
+ // ---------------------------------------------------------------------------
53
+
54
+ function loadLoadReports(slug) {
55
+ const dir = path.join(LOAD_REPORTS_DIR, slug)
56
+ const cutoff = Date.now() - THIRTY_DAYS
57
+ if (!fs.existsSync(dir)) return []
58
+ return fs.readdirSync(dir)
59
+ .filter(f => f.endsWith('.json'))
60
+ .sort()
61
+ .map(f => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) } catch { return null } })
62
+ .filter(r => r && new Date(r.timestamp).getTime() >= cutoff)
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Data — Bundles
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function bundleSizes(slug) {
70
+ const manifestPath = path.join(ROOT, 'public', 'dist', 'manifest.json')
71
+ if (!fs.existsSync(manifestPath)) return null
72
+ try {
73
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
74
+ const sizes = {}
75
+ const runtimeEntry = Object.entries(manifest).find(([k]) => k.includes('runtime'))
76
+ if (runtimeEntry) {
77
+ const file = path.join(ROOT, 'public', runtimeEntry[1])
78
+ if (fs.existsSync(file)) sizes.runtime = fs.statSync(file).size
79
+ }
80
+ const pageEntry = Object.entries(manifest).find(([k]) =>
81
+ !k.includes('runtime') && (k.includes(slug) || k.includes(slug.replace(/-/g, '/')))
82
+ )
83
+ if (pageEntry) {
84
+ const file = path.join(ROOT, 'public', pageEntry[1])
85
+ if (fs.existsSync(file)) sizes.page = fs.statSync(file).size
86
+ }
87
+ return Object.keys(sizes).length ? sizes : null
88
+ } catch { return null }
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Visual helpers
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function scoreColor(n) {
96
+ if (n === undefined || n === null) return '#444'
97
+ if (n >= 90) return '#4ade80'
98
+ if (n >= 50) return '#fb923c'
99
+ return '#f87171'
100
+ }
101
+
102
+ function metricColor(key, val, prev) {
103
+ if (prev === undefined) return '#9b8dff'
104
+ const loBetter = ['lcp', 'cls', 'fcp', 'tbt', 'ttfb', 'si', 'pageWeight', 'jsBytes', 'cssBytes', 'requests',
105
+ 'latency.mean', 'latency.p50', 'latency.p95', 'latency.p99', 'latency.max', 'errors']
106
+ const improved = loBetter.includes(key) ? val < prev : val > prev
107
+ return improved ? '#4ade80' : val === prev ? '#666' : '#f87171'
108
+ }
109
+
110
+ function deltaLabel(key, cur, prv, decimals = 0) {
111
+ if (prv === undefined) return ''
112
+ const d = cur - prv
113
+ if (d === 0) return '<span style="color:#555">—</span>'
114
+ const loBetter = ['lcp', 'cls', 'fcp', 'tbt', 'ttfb', 'si', 'pageWeight', 'jsBytes', 'cssBytes', 'requests',
115
+ 'latency.mean', 'latency.p50', 'latency.p95', 'latency.p99', 'latency.max', 'errors']
116
+ const good = loBetter.includes(key) ? d < 0 : d > 0
117
+ const color = good ? '#4ade80' : '#f87171'
118
+ const arrow = d < 0 ? '▼' : '▲'
119
+ return `<span style="color:${color}">${arrow}${Math.abs(d).toFixed(decimals)}</span>`
120
+ }
121
+
122
+ function scoreGauge(score, size = 80) {
123
+ const display = score !== undefined && score !== null ? score : '—'
124
+ const pct = typeof score === 'number' ? score : 0
125
+ const color = scoreColor(score)
126
+ const r = 15.9
127
+ const circ = 2 * Math.PI * r
128
+ const fill = (pct / 100) * circ
129
+ const gap = circ - fill
130
+ const s = size
131
+ return `<svg width="${s}" height="${s}" viewBox="0 0 36 36" role="img">
132
+ <circle cx="18" cy="18" r="${r}" fill="none" stroke="#1e1e2a" stroke-width="3.2"/>
133
+ <circle cx="18" cy="18" r="${r}" fill="none" stroke="${color}" stroke-width="3.2"
134
+ stroke-dasharray="${fill.toFixed(2)} ${gap.toFixed(2)}"
135
+ stroke-linecap="round"
136
+ transform="rotate(-90 18 18)"/>
137
+ <text x="18" y="21" text-anchor="middle" font-size="8.5" font-weight="800" fill="${color}"
138
+ font-family="system-ui,sans-serif">${display}</text>
139
+ </svg>`
140
+ }
141
+
142
+ function sparkline(values, { width = 140, height = 36, yMin, yMax } = {}) {
143
+ const vals = values.filter(v => v !== undefined && v !== null)
144
+ if (!vals.length) return `<svg width="${width}" height="${height}"></svg>`
145
+ const lo = yMin ?? Math.min(...vals)
146
+ const hi = yMax ?? Math.max(...vals)
147
+ const range = hi - lo || 1
148
+ const pts = vals.map((v, i) => {
149
+ const x = vals.length < 2 ? width / 2 : (i / (vals.length - 1)) * (width - 4) + 2
150
+ const y = (height - 4) - ((v - lo) / range) * (height - 8) + 2
151
+ return [+x.toFixed(1), +y.toFixed(1)]
152
+ })
153
+ const last = pts[pts.length - 1]
154
+ if (pts.length === 1) {
155
+ return `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
156
+ <circle cx="${last[0]}" cy="${last[1]}" r="2.5" fill="#9b8dff"/></svg>`
157
+ }
158
+ const line = pts.map(([x, y]) => `${x},${y}`).join(' ')
159
+ const area = `M${pts[0][0]},${height} L${pts.map(([x, y]) => `${x},${y}`).join(' L')} L${last[0]},${height} Z`
160
+ return `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
161
+ <path d="${area}" fill="#9b8dff18"/>
162
+ <polyline points="${line}" fill="none" stroke="#9b8dff" stroke-width="1.5"
163
+ stroke-linejoin="round" stroke-linecap="round"/>
164
+ <circle cx="${last[0]}" cy="${last[1]}" r="2.5" fill="#9b8dff"/></svg>`
165
+ }
166
+
167
+ const SUSPECT_ZERO_SCORES = new Set(['performance'])
168
+ const SUSPECT_ZERO_METRICS = new Set(['lcp', 'fcp', 'ttfb', 'si', 'pageWeight', 'jsBytes', 'cssBytes', 'requests'])
169
+
170
+ function validScore(key, val) {
171
+ if (val === undefined || val === null) return undefined
172
+ if (val === 0 && SUSPECT_ZERO_SCORES.has(key)) return undefined
173
+ return val
174
+ }
175
+
176
+ function validMetric(key, val) {
177
+ if (val === undefined || val === null) return undefined
178
+ if (val === 0 && SUSPECT_ZERO_METRICS.has(key)) return undefined
179
+ return val
180
+ }
181
+
182
+ function fmtBytes(b) {
183
+ if (b === undefined || b === null || b === 0) return '—'
184
+ if (b < 1024) return `${b} B`
185
+ return `${(b / 1024).toFixed(1)} kB`
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Lighthouse sections
190
+ // ---------------------------------------------------------------------------
191
+
192
+ const CATEGORIES = [
193
+ { key: 'performance', label: 'Performance' },
194
+ { key: 'accessibility', label: 'Accessibility' },
195
+ { key: 'bestPractices', label: 'Best Practices' },
196
+ { key: 'seo', label: 'SEO' },
197
+ ]
198
+
199
+ const WEB_VITALS = [
200
+ { key: 'lcp', label: 'LCP', unit: 'ms', decimals: 0, desc: 'Largest Contentful Paint' },
201
+ { key: 'cls', label: 'CLS', unit: '', decimals: 2, desc: 'Cumulative Layout Shift' },
202
+ { key: 'fcp', label: 'FCP', unit: 'ms', decimals: 0, desc: 'First Contentful Paint' },
203
+ { key: 'tbt', label: 'TBT', unit: 'ms', decimals: 0, desc: 'Total Blocking Time' },
204
+ { key: 'ttfb', label: 'TTFB', unit: 'ms', decimals: 0, desc: 'Time to First Byte' },
205
+ { key: 'si', label: 'Speed Index', unit: 'ms', decimals: 0, desc: 'Speed Index' },
206
+ ]
207
+
208
+ const PAGE_WEIGHT = [
209
+ { key: 'pageWeight', label: 'Page weight', unit: 'kB', decimals: 1, desc: 'Total transfer size' },
210
+ { key: 'jsBytes', label: 'JS', unit: 'kB', decimals: 1, desc: 'JavaScript transfer size' },
211
+ { key: 'cssBytes', label: 'CSS', unit: 'kB', decimals: 1, desc: 'CSS transfer size' },
212
+ { key: 'requests', label: 'Requests', unit: '', decimals: 0, desc: 'Total network requests' },
213
+ ]
214
+
215
+ function renderScoreRow(reports) {
216
+ const latest = reports[reports.length - 1]
217
+ const prev = reports[reports.length - 2]
218
+ return `<section class="score-row">
219
+ ${CATEGORIES.map(({ key, label }) => {
220
+ const cur = validScore(key, latest?.scores?.[key])
221
+ const prv = validScore(key, prev?.scores?.[key])
222
+ const vals = reports.map(r => validScore(key, r.scores?.[key])).filter(n => n !== undefined)
223
+ return `
224
+ <div class="score-card">
225
+ <div class="score-gauge">${scoreGauge(cur, 80)}</div>
226
+ <div class="score-info">
227
+ <div class="score-label">${label}</div>
228
+ <div class="score-delta">${cur !== undefined && prv !== undefined ? deltaLabel(key, cur, prv) : cur === undefined ? '<span style="color:#555">no data</span>' : ''}</div>
229
+ <div class="score-spark">${sparkline(vals, { yMin: 0, yMax: 100, width: 100, height: 28 })}</div>
230
+ </div>
231
+ </div>`
232
+ }).join('')}
233
+ </section>`
234
+ }
235
+
236
+ function renderMetricGroup(title, defs, reports) {
237
+ const latest = reports[reports.length - 1]
238
+ const prev = reports[reports.length - 2]
239
+ const rows = defs.map(({ key, label, unit, decimals, desc }) => {
240
+ const raw = validMetric(key, latest?.metrics?.[key])
241
+ const prv = validMetric(key, prev?.metrics?.[key])
242
+ if (raw === undefined) return ''
243
+ const cur = raw
244
+ const fmt = v => typeof v === 'number' ? (decimals ? v.toFixed(decimals) : v) : v
245
+ const vals = reports.map(r => validMetric(key, r.metrics?.[key])).filter(n => n !== undefined)
246
+ const lo = Math.min(...vals) * 0.8
247
+ const hi = Math.max(...vals) * 1.2 || 1
248
+ const col = metricColor(key, cur, prv)
249
+ return `
250
+ <div class="metric-row">
251
+ <span class="metric-label" title="${desc}">${label}</span>
252
+ <span class="metric-value" style="color:${col}">${fmt(cur)}<span class="metric-unit">${unit}</span></span>
253
+ <span class="metric-delta">${prv !== undefined ? deltaLabel(key, cur, prv, decimals) : ''}</span>
254
+ <span class="metric-spark">${sparkline(vals, { yMin: lo, yMax: hi, width: 120, height: 28 })}</span>
255
+ </div>`
256
+ }).filter(Boolean).join('')
257
+ if (!rows) return ''
258
+ return `
259
+ <section class="panel">
260
+ <h3 class="panel-title">${title}</h3>
261
+ ${rows}
262
+ </section>`
263
+ }
264
+
265
+ function renderBundlePanel(slug, reports) {
266
+ const sizes = bundleSizes(slug)
267
+ const latest = reports[reports.length - 1]
268
+ const jsBytes = latest?.metrics?.jsBytes
269
+ const cssBytes = latest?.metrics?.cssBytes
270
+ if (!sizes && jsBytes == null && cssBytes == null) return ''
271
+ const items = []
272
+ if (sizes?.runtime !== undefined) items.push(`
273
+ <div class="bundle-item">
274
+ <span class="bundle-name">Runtime bundle</span>
275
+ <span class="bundle-size">${fmtBytes(sizes.runtime)}</span>
276
+ <span class="bundle-note">shared across all pages</span>
277
+ </div>`)
278
+ if (sizes?.page !== undefined) items.push(`
279
+ <div class="bundle-item">
280
+ <span class="bundle-name">Page bundle</span>
281
+ <span class="bundle-size">${fmtBytes(sizes.page)}</span>
282
+ <span class="bundle-note">this page only</span>
283
+ </div>`)
284
+ if (jsBytes !== undefined) {
285
+ const vals = reports.map(r => r.metrics?.jsBytes).filter(n => n !== undefined)
286
+ items.push(`
287
+ <div class="bundle-item">
288
+ <span class="bundle-name">JS transferred</span>
289
+ <span class="bundle-size">${fmtBytes(jsBytes)}</span>
290
+ <span class="bundle-spark">${sparkline(vals, { width: 80, height: 24 })}</span>
291
+ </div>`)
292
+ }
293
+ if (cssBytes !== undefined) {
294
+ const vals = reports.map(r => r.metrics?.cssBytes).filter(n => n !== undefined)
295
+ items.push(`
296
+ <div class="bundle-item">
297
+ <span class="bundle-name">CSS transferred</span>
298
+ <span class="bundle-size">${fmtBytes(cssBytes)}</span>
299
+ <span class="bundle-spark">${sparkline(vals, { width: 80, height: 24 })}</span>
300
+ </div>`)
301
+ }
302
+ if (!items.length) return ''
303
+ return `
304
+ <section class="panel">
305
+ <h3 class="panel-title">Bundle sizes</h3>
306
+ <div class="bundle-grid">${items.join('')}</div>
307
+ </section>`
308
+ }
309
+
310
+ function renderHistory(reports) {
311
+ const rows = [...reports].reverse().slice(0, 20).map(r => {
312
+ const time = new Date(r.timestamp).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })
313
+ const scores = CATEGORIES.map(({ key }) => {
314
+ const v = validScore(key, r.scores?.[key])
315
+ return `<td style="color:${scoreColor(v)};font-weight:700">${v ?? '—'}</td>`
316
+ }).join('')
317
+ return `<tr><td class="time-col">${time}</td>${scores}</tr>`
318
+ }).join('')
319
+ return `
320
+ <section class="panel">
321
+ <h3 class="panel-title">Audit history <span class="panel-sub">· last 30 days</span></h3>
322
+ <table class="history-table">
323
+ <thead><tr><th>Time</th>${CATEGORIES.map(c => `<th>${c.label}</th>`).join('')}</tr></thead>
324
+ <tbody>${rows}</tbody>
325
+ </table>
326
+ </section>`
327
+ }
328
+
329
+ function renderPerfTab(slug, reports) {
330
+ if (!reports.length) return '<p class="empty-state">No Lighthouse audits yet. Run <code>/pulse-report</code> to capture one.</p>'
331
+ const latest = reports[reports.length - 1]
332
+ const count = reports.length
333
+ const when = new Date(latest.timestamp).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
334
+ const scores = latest.scores || {}
335
+ const present = CATEGORIES.filter(({ key }) => validScore(key, scores[key]) !== undefined)
336
+ const allGood = present.length === 4 && present.every(({ key }) => validScore(key, scores[key]) >= 90)
337
+ const anyBad = present.some(({ key }) => validScore(key, scores[key]) < 50)
338
+ const incomplete = present.length < 4
339
+ const health = incomplete
340
+ ? ['Incomplete — re-run audit', '#fb923c']
341
+ : allGood ? ['All scores 90+', '#4ade80']
342
+ : anyBad ? ['Needs attention', '#f87171']
343
+ : ['Room to improve', '#fb923c']
344
+ const missingScores = CATEGORIES.filter(({ key }) => validScore(key, scores[key]) === undefined).map(c => c.label)
345
+ const staleBanner = missingScores.length
346
+ ? `<div class="stale-banner">Missing scores: <strong>${missingScores.join(', ')}</strong> — run <code>/pulse-report</code> to capture a full audit.</div>`
347
+ : ''
348
+ return `
349
+ ${staleBanner}
350
+ <div class="page-header">
351
+ <div>
352
+ <h2 class="page-heading">${latest.url || '/' + slug}</h2>
353
+ <p class="page-meta">${count} audit${count !== 1 ? 's' : ''} · last run ${when}</p>
354
+ </div>
355
+ <div class="health-badge" style="color:${health[1]};border-color:${health[1]}20;background:${health[1]}10">${health[0]}</div>
356
+ </div>
357
+ ${renderScoreRow(reports)}
358
+ ${renderMetricGroup('Core Web Vitals', WEB_VITALS, reports)}
359
+ ${renderMetricGroup('Page weight', PAGE_WEIGHT, reports)}
360
+ ${renderBundlePanel(slug, reports)}
361
+ ${renderHistory(reports)}`
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Load test sections
366
+ // ---------------------------------------------------------------------------
367
+
368
+ const LOAD_LATENCY = [
369
+ { key: 'mean', label: 'Mean', unit: 'ms', decimals: 1, desc: 'Average response time' },
370
+ { key: 'p50', label: 'P50', unit: 'ms', decimals: 0, desc: '50th percentile latency' },
371
+ { key: 'p95', label: 'P95', unit: 'ms', decimals: 0, desc: '95th percentile latency' },
372
+ { key: 'p99', label: 'P99', unit: 'ms', decimals: 0, desc: '99th percentile latency' },
373
+ { key: 'max', label: 'Max', unit: 'ms', decimals: 0, desc: 'Worst-case latency' },
374
+ ]
375
+
376
+ function fmtRps(v) {
377
+ if (v === undefined || v === null) return '—'
378
+ if (v >= 100) return Math.round(v).toLocaleString()
379
+ if (v >= 10) return v.toFixed(1)
380
+ return v.toFixed(2)
381
+ }
382
+
383
+ function renderLoadTab(slug, reports) {
384
+ if (!reports.length) return '<p class="empty-state">No load tests yet. Run <code>/pulse-load</code> to capture one.</p>'
385
+
386
+ const latest = reports[reports.length - 1]
387
+ const prev = reports[reports.length - 2]
388
+ const count = reports.length
389
+ const when = new Date(latest.timestamp).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
390
+ const isLocalhost = latest.url?.includes('localhost') || latest.url?.includes('127.0.0.1')
391
+
392
+ // Summary row
393
+ const summaryItems = [
394
+ { label: 'Req / sec', value: fmtRps(latest.rps) },
395
+ { label: 'Connections', value: latest.config?.connections ?? '—' },
396
+ { label: 'Duration', value: latest.config?.duration ? `${latest.config.duration}s` : '—' },
397
+ { label: 'Total req', value: latest.requests?.total ?? '—' },
398
+ { label: 'Errors', value: latest.requests?.errors ?? 0, color: (latest.requests?.errors || 0) > 0 ? '#f87171' : '#4ade80' },
399
+ ]
400
+
401
+ const summary = `
402
+ <section class="load-summary">
403
+ ${summaryItems.map(({ label, value, color }) => `
404
+ <div class="load-stat">
405
+ <div class="load-stat-value" style="${color ? `color:${color}` : ''}">${value}</div>
406
+ <div class="load-stat-label">${label}</div>
407
+ </div>`).join('')}
408
+ </section>`
409
+
410
+ // Latency panel
411
+ const latencyRows = LOAD_LATENCY.map(({ key, label, unit, decimals, desc }) => {
412
+ const cur = latest.latency?.[key]
413
+ const prv = prev?.latency?.[key]
414
+ if (cur === undefined) return ''
415
+ const vals = reports.map(r => r.latency?.[key]).filter(n => n !== undefined)
416
+ const lo = Math.min(...vals) * 0.8
417
+ const hi = Math.max(...vals) * 1.2 || 1
418
+ const col = metricColor('latency.' + key, cur, prv)
419
+ const fmt = v => decimals ? v.toFixed(decimals) : Math.round(v)
420
+ return `
421
+ <div class="metric-row">
422
+ <span class="metric-label" title="${desc}">${label}</span>
423
+ <span class="metric-value" style="color:${col}">${fmt(cur)}<span class="metric-unit">${unit}</span></span>
424
+ <span class="metric-delta">${prv !== undefined ? deltaLabel('latency.' + key, cur, prv, decimals) : ''}</span>
425
+ <span class="metric-spark">${sparkline(vals, { yMin: lo, yMax: hi, width: 120, height: 28 })}</span>
426
+ </div>`
427
+ }).filter(Boolean).join('')
428
+
429
+ const rpsVals = reports.map(r => r.rps).filter(n => n !== undefined)
430
+ const rpsPanel = `
431
+ <section class="panel">
432
+ <h3 class="panel-title">Throughput</h3>
433
+ <div class="metric-row">
434
+ <span class="metric-label">Req / sec</span>
435
+ <span class="metric-value" style="color:${metricColor('rps', latest.rps, prev?.rps)}">${fmtRps(latest.rps)}</span>
436
+ <span class="metric-delta">${prev?.rps !== undefined ? deltaLabel('rps', latest.rps, prev.rps, 1) : ''}</span>
437
+ <span class="metric-spark">${sparkline(rpsVals, { width: 120, height: 28 })}</span>
438
+ </div>
439
+ </section>`
440
+
441
+ const latencyPanel = latencyRows ? `
442
+ <section class="panel">
443
+ <h3 class="panel-title">Latency percentiles</h3>
444
+ ${latencyRows}
445
+ </section>` : ''
446
+
447
+ // History table
448
+ const histRows = [...reports].reverse().slice(0, 20).map(r => {
449
+ const time = new Date(r.timestamp).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })
450
+ const errCol = (r.requests?.errors || 0) > 0 ? '#f87171' : '#4ade80'
451
+ return `<tr>
452
+ <td class="time-col">${time}</td>
453
+ <td>${r.config?.duration ?? '—'}s</td>
454
+ <td>${r.config?.connections ?? '—'}</td>
455
+ <td style="font-weight:700;color:#9b8dff">${fmtRps(r.rps)}</td>
456
+ <td>${r.latency?.p50 ?? '—'}</td>
457
+ <td>${r.latency?.p95 ?? '—'}</td>
458
+ <td>${r.latency?.p99 ?? '—'}</td>
459
+ <td style="color:${errCol};font-weight:700">${r.requests?.errors ?? 0}</td>
460
+ </tr>`
461
+ }).join('')
462
+
463
+ const histPanel = `
464
+ <section class="panel">
465
+ <h3 class="panel-title">Test history <span class="panel-sub">· last 30 days</span></h3>
466
+ <table class="history-table">
467
+ <thead><tr>
468
+ <th>Time</th><th>Duration</th><th>Connections</th>
469
+ <th>Req/s</th><th>P50</th><th>P95</th><th>P99</th><th>Errors</th>
470
+ </tr></thead>
471
+ <tbody>${histRows}</tbody>
472
+ </table>
473
+ </section>`
474
+
475
+ const localhostBanner = isLocalhost
476
+ ? `<div class="stale-banner">These results are from <strong>localhost</strong> — no network latency, OS scheduling, or real-world conditions. Run against a staging environment for production-representative numbers.</div>`
477
+ : ''
478
+
479
+ return `
480
+ ${localhostBanner}
481
+ <div class="page-header">
482
+ <div>
483
+ <h2 class="page-heading">${latest.url || '/' + slug}</h2>
484
+ <p class="page-meta">${count} test${count !== 1 ? 's' : ''} · last run ${when}</p>
485
+ </div>
486
+ </div>
487
+ ${summary}
488
+ ${rpsPanel}
489
+ ${latencyPanel}
490
+ ${histPanel}`
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Index
495
+ // ---------------------------------------------------------------------------
496
+
497
+ function renderIndex(slugs) {
498
+ if (!slugs.length) return `
499
+ <div class="empty-state">
500
+ <p>No reports yet.</p>
501
+ <p>Run <code>/pulse-report</code> to audit a page or <code>/pulse-load</code> to run a load test.</p>
502
+ </div>`
503
+
504
+ const cards = slugs.map(slug => {
505
+ const reports = loadReports(slug)
506
+ const loadRpts = loadLoadReports(slug)
507
+ const latest = reports[reports.length - 1]
508
+ const latestLd = loadRpts[loadRpts.length - 1]
509
+ const scores = latest?.scores
510
+
511
+ const gauges = CATEGORIES.map(({ key, label }) =>
512
+ `<span title="${label}">${scoreGauge(validScore(key, scores?.[key]), 44)}</span>`
513
+ ).join('')
514
+
515
+ const loadBadge = latestLd
516
+ ? `<span class="load-badge" title="Latest load test">${latestLd.rps?.toFixed(0) ?? '—'} req/s</span>`
517
+ : ''
518
+
519
+ return `
520
+ <a href="/${slug}" class="index-card">
521
+ <span class="index-url">${latest?.url || latestLd?.url || '/' + slug}</span>
522
+ <span class="index-gauges">${gauges}</span>
523
+ ${loadBadge}
524
+ </a>`
525
+ }).join('')
526
+
527
+ return `<h2 class="page-heading" style="margin-bottom:1.25rem">Pages</h2><div class="index-list">${cards}</div>`
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Layout + CSS
532
+ // ---------------------------------------------------------------------------
533
+
534
+ const CSS = `
535
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
536
+ :root{
537
+ --bg:#0c0c11;--surface:#14141c;--surface2:#1a1a25;
538
+ --border:#22222f;--text:#dddde8;--muted:#666678;
539
+ --accent:#9b8dff;--radius:10px;
540
+ }
541
+ html{font-size:14px}
542
+ body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;min-height:100vh}
543
+ a{color:var(--accent);text-decoration:none}
544
+ code{font-family:ui-monospace,monospace;font-size:.85em;background:var(--surface2);padding:2px 6px;border-radius:4px}
545
+
546
+ .layout{display:flex;min-height:100vh}
547
+
548
+ /* Sidebar */
549
+ .sidebar{width:210px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);padding:1.25rem 0}
550
+ .sidebar-logo{display:flex;align-items:center;gap:.5rem;font-weight:700;font-size:.95rem;padding:.2rem 1.1rem 1.25rem;color:var(--text)}
551
+ .sidebar-logo svg{color:var(--accent)}
552
+ .sidebar-section{font-size:.6rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);padding:.75rem 1.1rem .25rem}
553
+ .sidebar-link{display:block;padding:.35rem 1.1rem;font-size:.8rem;color:var(--muted);border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
554
+ .sidebar-link:hover{color:var(--text);background:var(--surface2)}
555
+ .sidebar-link.active{color:var(--text);border-left-color:var(--accent)}
556
+
557
+ /* Main */
558
+ .main{flex:1;padding:2rem 2.25rem;max-width:960px;overflow:auto}
559
+
560
+ /* Tabs */
561
+ .tabs{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1.75rem}
562
+ .tab{padding:.5rem 1.1rem;font-size:.8rem;font-weight:600;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .1s}
563
+ .tab:hover{color:var(--text)}
564
+ .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
565
+
566
+ /* Page header */
567
+ .page-header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:1.75rem}
568
+ .page-heading{font-size:1.2rem;font-weight:700;margin-bottom:.2rem}
569
+ .page-meta{color:var(--muted);font-size:.78rem}
570
+ .health-badge{font-size:.72rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;border:1px solid;border-radius:20px;padding:.25rem .75rem;white-space:nowrap}
571
+
572
+ /* Score row */
573
+ .score-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.75rem;margin-bottom:1.25rem}
574
+ .score-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;display:flex;align-items:center;gap:.875rem}
575
+ .score-gauge svg{display:block;flex-shrink:0}
576
+ .score-info{flex:1;min-width:0}
577
+ .score-label{font-size:.65rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);margin-bottom:.15rem}
578
+ .score-delta{font-size:.8rem;min-height:1.1rem;margin-bottom:.35rem}
579
+ .score-spark svg{display:block}
580
+
581
+ /* Load summary */
582
+ .load-summary{display:flex;gap:.75rem;margin-bottom:1.25rem;flex-wrap:wrap}
583
+ .load-stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:.875rem 1.25rem;min-width:110px;text-align:center}
584
+ .load-stat-value{font-size:1.5rem;font-weight:800;color:var(--accent);line-height:1;margin-bottom:.3rem}
585
+ .load-stat-label{font-size:.6rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
586
+
587
+ /* Panels */
588
+ .panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.1rem;margin-bottom:1.1rem}
589
+ .panel-title{font-size:.6rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:.875rem}
590
+ .panel-sub{font-weight:400;text-transform:none;letter-spacing:0;font-size:.7rem}
591
+
592
+ /* Metric rows */
593
+ .metric-row{display:grid;grid-template-columns:100px 110px 70px 1fr;align-items:center;gap:1rem;padding:.45rem 0;border-bottom:1px solid var(--border)}
594
+ .metric-row:last-child{border-bottom:none}
595
+ .metric-label{font-size:.72rem;font-weight:600;color:var(--muted);cursor:default}
596
+ .metric-value{font-size:1.05rem;font-weight:800}
597
+ .metric-unit{font-size:.65rem;color:var(--muted);margin-left:1px;font-weight:400}
598
+ .metric-delta{font-size:.78rem}
599
+ .metric-spark svg{display:block}
600
+
601
+ /* Bundle */
602
+ .bundle-grid{display:flex;flex-direction:column;gap:.6rem}
603
+ .bundle-item{display:flex;align-items:center;gap:1rem;padding:.35rem 0;border-bottom:1px solid var(--border)}
604
+ .bundle-item:last-child{border-bottom:none}
605
+ .bundle-name{font-size:.78rem;font-weight:600;color:var(--muted);width:140px;flex-shrink:0}
606
+ .bundle-size{font-size:.95rem;font-weight:800;width:80px}
607
+ .bundle-note{font-size:.72rem;color:var(--muted)}
608
+ .bundle-spark svg{display:block}
609
+
610
+ /* History */
611
+ .history-table{width:100%;border-collapse:collapse;font-size:.8rem}
612
+ .history-table th{text-align:left;font-size:.6rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:.4rem .7rem;border-bottom:1px solid var(--border)}
613
+ .history-table td{padding:.42rem .7rem;border-bottom:1px solid var(--border)}
614
+ .history-table tr:last-child td{border-bottom:none}
615
+ .time-col{color:var(--muted)!important;font-size:.74rem!important;font-weight:400!important}
616
+
617
+ /* Index */
618
+ .index-list{display:flex;flex-direction:column;gap:.5rem}
619
+ .index-card{display:flex;align-items:center;gap:1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:.75rem 1.1rem;transition:border-color .15s}
620
+ .index-card:hover{border-color:var(--accent)}
621
+ .index-url{flex:1;font-size:.875rem;color:var(--text)}
622
+ .index-gauges{display:flex;gap:.5rem;align-items:center}
623
+ .index-gauges svg{display:block}
624
+ .load-badge{font-size:.7rem;font-weight:700;color:#9b8dff;background:#9b8dff18;border:1px solid #9b8dff30;border-radius:20px;padding:.2rem .6rem;white-space:nowrap}
625
+
626
+ .stale-banner{background:#fb923c12;border:1px solid #fb923c40;border-radius:var(--radius);padding:.6rem 1rem;font-size:.78rem;color:#fb923c;margin-bottom:1.25rem}
627
+ .stale-banner strong{font-weight:700}
628
+ .stale-banner code{background:#fb923c18;color:#fb923c}
629
+ .empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.75rem;padding:5rem 2rem;color:var(--muted);text-align:center}
630
+ `
631
+
632
+ function renderHTML({ title, content, slugs, activeSlug, activeTab, hasBoth }) {
633
+ const links = slugs.map(slug => {
634
+ const reports = loadReports(slug)
635
+ const url = reports[reports.length - 1]?.url
636
+ const loadRpts = loadLoadReports(slug)
637
+ const ldUrl = loadRpts[loadRpts.length - 1]?.url
638
+ const display = url || ldUrl || '/' + slug
639
+ return `<a href="/${slug}" class="sidebar-link${slug === activeSlug ? ' active' : ''}">${display}</a>`
640
+ }).join('')
641
+
642
+ const tabs = activeSlug && hasBoth ? `
643
+ <div class="tabs">
644
+ <a href="/${activeSlug}" class="tab${activeTab === 'perf' ? ' active' : ''}">Performance</a>
645
+ <a href="/${activeSlug}/load" class="tab${activeTab === 'load' ? ' active' : ''}">Load Tests</a>
646
+ </div>` : activeSlug ? `
647
+ <div class="tabs">
648
+ ${hasBoth === false && activeTab === 'perf'
649
+ ? `<a href="/${activeSlug}" class="tab active">Performance</a><a href="/${activeSlug}/load" class="tab" style="opacity:.4">Load Tests</a>`
650
+ : `<a href="/${activeSlug}" class="tab" style="opacity:.4">Performance</a><a href="/${activeSlug}/load" class="tab active">Load Tests</a>`
651
+ }
652
+ </div>` : ''
653
+
654
+ return `<!DOCTYPE html>
655
+ <html lang="en">
656
+ <head>
657
+ <meta charset="UTF-8">
658
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
659
+ <title>${title} — Pulse Reports</title>
660
+ <style>${CSS}</style>
661
+ </head>
662
+ <body>
663
+ <div class="layout">
664
+ <aside class="sidebar">
665
+ <a href="/" class="sidebar-logo">
666
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
667
+ <path d="M13 2L4.5 13.5H11L10 22L19.5 10.5H13L13 2Z" fill="currentColor" stroke="currentColor" stroke-width="1" stroke-linejoin="round"/>
668
+ </svg>
669
+ Pulse Reports
670
+ </a>
671
+ ${slugs.length ? `<div class="sidebar-section">Pages</div>${links}` : ''}
672
+ </aside>
673
+ <main id="main-content" class="main">
674
+ ${tabs}
675
+ ${content}
676
+ </main>
677
+ </div>
678
+ </body>
679
+ </html>`
680
+ }
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // Server
684
+ // ---------------------------------------------------------------------------
685
+
686
+ http.createServer((req, res) => {
687
+ const urlPath = req.url.split('?')[0]
688
+ const parts = urlPath.slice(1).split('/')
689
+ const slug = parts[0] || ''
690
+ const tab = parts[1] || 'perf' // 'perf' | 'load'
691
+ const slugs = allSlugs()
692
+
693
+ let title, content, activeTab = tab, hasBoth = false
694
+
695
+ if (!slug) {
696
+ title = 'Overview'
697
+ content = renderIndex(slugs)
698
+ } else if (slugs.includes(slug)) {
699
+ const perfReports = loadReports(slug)
700
+ const loadReports_ = loadLoadReports(slug)
701
+ hasBoth = perfReports.length > 0 && loadReports_.length > 0
702
+
703
+ if (tab === 'load') {
704
+ title = (loadReports_[loadReports_.length - 1]?.url || '/' + slug) + ' — Load'
705
+ content = renderLoadTab(slug, loadReports_)
706
+ activeTab = 'load'
707
+ } else {
708
+ title = perfReports[perfReports.length - 1]?.url || '/' + slug
709
+ content = renderPerfTab(slug, perfReports)
710
+ activeTab = 'perf'
711
+ }
712
+ } else {
713
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
714
+ res.end('Not found')
715
+ return
716
+ }
717
+
718
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
719
+ res.end(renderHTML({ title, content, slugs, activeSlug: slug, activeTab, hasBoth }))
720
+
721
+ }).listen(PORT, () => {
722
+ console.log(`\n⚡ Pulse report server running at http://localhost:${PORT}\n`)
723
+ })