@tokenoftrust/storefront-runner 1.4.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (474) hide show
  1. package/apps/storefront/astro.config.mjs +17 -1
  2. package/apps/storefront/env.d.ts +54 -5
  3. package/apps/storefront/integrations/materialize-guard.mjs +54 -0
  4. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  5. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  6. package/apps/storefront/migrations-apps/0004_deep_morph.sql +36 -0
  7. package/apps/storefront/migrations-apps/0005_common_mystique.sql +23 -0
  8. package/apps/storefront/migrations-apps/0006_broad_microchip.sql +16 -0
  9. package/apps/storefront/migrations-apps/0007_brave_outlaw_kid.sql +15 -0
  10. package/apps/storefront/migrations-apps/0008_blue_dazzler.sql +11 -0
  11. package/apps/storefront/migrations-apps/0009_bored_shinobi_shaw.sql +15 -0
  12. package/apps/storefront/migrations-apps/0010_ws4_verified_provider_measurement.sql +35 -0
  13. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  14. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  15. package/apps/storefront/migrations-apps/meta/0004_snapshot.json +1474 -0
  16. package/apps/storefront/migrations-apps/meta/0005_snapshot.json +1617 -0
  17. package/apps/storefront/migrations-apps/meta/0006_snapshot.json +1716 -0
  18. package/apps/storefront/migrations-apps/meta/0007_snapshot.json +1812 -0
  19. package/apps/storefront/migrations-apps/meta/0008_snapshot.json +1881 -0
  20. package/apps/storefront/migrations-apps/meta/0009_snapshot.json +1979 -0
  21. package/apps/storefront/migrations-apps/meta/_journal.json +56 -0
  22. package/apps/storefront/public/apps/tot.demo.analytics/0.1.0/entry.js +42 -0
  23. package/apps/storefront/public/js/dashboard-team.js +38 -2
  24. package/apps/storefront/public/platform/amplitude-http/v1/entry.mjs +19 -0
  25. package/apps/storefront/public/platform/google-measurement/v1/entry.mjs +19 -0
  26. package/apps/storefront/public/shared/commerce-chrome.css +20 -2
  27. package/apps/storefront/scripts/build-v84-clean-clone-standalone.mjs +68 -0
  28. package/apps/storefront/scripts/deny-v84-clean-clone-standalone-capabilities.mjs +111 -0
  29. package/apps/storefront/scripts/deny-v84-dogfood-capabilities.mjs +2 -0
  30. package/apps/storefront/scripts/deny-v84-lifecycle-capabilities.mjs +247 -0
  31. package/apps/storefront/scripts/dogfood-v84-nonportable-routes.ts +73 -0
  32. package/apps/storefront/scripts/fixed-v84-native-install-producer.ts +79 -0
  33. package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +10982 -0
  34. package/apps/storefront/scripts/import-v84-through-ws2.ts +38 -0
  35. package/apps/storefront/scripts/initialize-v84-lifecycle-tooling-dependency.ts +2 -0
  36. package/apps/storefront/scripts/initialize-v84-lifecycle-tooling.ts +4 -0
  37. package/apps/storefront/scripts/install-v84-native-through-ws1.ts +185 -0
  38. package/apps/storefront/scripts/prove-clone-isolation.ts +506 -0
  39. package/apps/storefront/scripts/replay-v84-clean-clone-pass.ts +148 -0
  40. package/apps/storefront/scripts/replay-v84-clean-clone-standalone.ts +35 -0
  41. package/apps/storefront/scripts/replay-v84-clean-clone.ts +214 -0
  42. package/apps/storefront/scripts/run-fixed-v84-native-install-producer.mjs +18 -0
  43. package/apps/storefront/scripts/run-v84-clone-isolation-proof.mjs +18 -0
  44. package/apps/storefront/scripts/run-v84-clone-lifecycle-proof.mjs +18 -0
  45. package/apps/storefront/scripts/run-v84-import-through-ws2-proof.mjs +17 -0
  46. package/apps/storefront/scripts/run-v84-nonportable-route-dogfood.mjs +15 -0
  47. package/apps/storefront/scripts/sanitize-v84-proof-environment.mjs +21 -0
  48. package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +42 -0
  49. package/apps/storefront/scripts/v84CleanCloneSourceGraph.ts +145 -0
  50. package/apps/storefront/scripts/validate-v84-clone-lifecycle.ts +18 -0
  51. package/apps/storefront/src/components/Img.astro +1 -1
  52. package/apps/storefront/src/components/Seo.astro +65 -1
  53. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  54. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1435 -0
  55. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  56. package/apps/storefront/src/components/admin/AdminIntegrationsTab.astro +105 -0
  57. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1980 -2564
  58. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
  59. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  60. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +5 -5
  61. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  62. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  63. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  64. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  65. package/apps/storefront/src/components/chrome/NavDropdown.astro +12 -0
  66. package/apps/storefront/src/components/chrome/SiteFooter.astro +7 -2
  67. package/apps/storefront/src/components/chrome/SiteHeader.astro +46 -10
  68. package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +1 -1
  69. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +1 -2
  70. package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +1 -2
  71. package/apps/storefront/src/components/compliance/PactActNotice.astro +1 -1
  72. package/apps/storefront/src/components/compliance/Prop65Warning.astro +1 -2
  73. package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +2 -3
  74. package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +2 -2
  75. package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +2 -3
  76. package/apps/storefront/src/components/content/TrustStrip.astro +4 -4
  77. package/apps/storefront/src/components/islands/MobileNav.tsx +26 -1
  78. package/apps/storefront/src/components/islands/VariantSelector.tsx +27 -2
  79. package/apps/storefront/src/components/islands/useCheckoutHandoffCorrelation.ts +75 -0
  80. package/apps/storefront/src/components/membership/IllustrativeLabel.astro +1 -2
  81. package/apps/storefront/src/components/membership/TierCards.astro +2 -2
  82. package/apps/storefront/src/components/membership/TierComparisonTable.astro +2 -2
  83. package/apps/storefront/src/components/subscription/SavingsExplainer.astro +2 -2
  84. package/apps/storefront/src/config/adminTenantLookup.ts +35 -0
  85. package/apps/storefront/src/config/devTenantSeed.ts +9 -9
  86. package/apps/storefront/src/config/resolver.ts +4 -4
  87. package/apps/storefront/src/layouts/Layout.astro +146 -21
  88. package/apps/storefront/src/lib/activity/alerts.ts +17 -19
  89. package/apps/storefront/src/lib/activity/candidateSubmitLease.ts +87 -0
  90. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +89 -9
  91. package/apps/storefront/src/lib/activity/deployVersion.ts +20 -27
  92. package/apps/storefront/src/lib/activity/ingest.ts +8 -8
  93. package/apps/storefront/src/lib/activity/ingestAuth.ts +1 -1
  94. package/apps/storefront/src/lib/activity/killSwitch.ts +6 -6
  95. package/apps/storefront/src/lib/activity/query.ts +9 -9
  96. package/apps/storefront/src/lib/activity/recordActivity.ts +11 -15
  97. package/apps/storefront/src/lib/activity/store.ts +4 -6
  98. package/apps/storefront/src/lib/activity/uiActor.ts +12 -12
  99. package/apps/storefront/src/lib/activity/workerCommit.ts +6 -6
  100. package/apps/storefront/src/lib/admin/adminShell.ts +292 -0
  101. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  102. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  103. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  104. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  105. package/apps/storefront/src/lib/admin/envelope.ts +1 -2
  106. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  107. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  108. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  109. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  110. package/apps/storefront/src/lib/apps/adminService.ts +14 -4
  111. package/apps/storefront/src/lib/apps/adminSession.ts +54 -45
  112. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  113. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  114. package/apps/storefront/src/lib/apps/appVersionStore.ts +281 -0
  115. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  116. package/apps/storefront/src/lib/apps/consentConfigStore.ts +228 -0
  117. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  118. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  119. package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +447 -0
  120. package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +367 -0
  121. package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +315 -0
  122. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  123. package/apps/storefront/src/lib/apps/integrationLifecycle.ts +614 -0
  124. package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +125 -0
  125. package/apps/storefront/src/lib/apps/integrationReadModel.ts +563 -0
  126. package/apps/storefront/src/lib/apps/integrationsTab.ts +547 -0
  127. package/apps/storefront/src/lib/apps/integrationsView.ts +106 -0
  128. package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +599 -0
  129. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  130. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  131. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  132. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  133. package/apps/storefront/src/lib/apps/orders/orderConversionD1.ts +150 -0
  134. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +9 -1
  135. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  136. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +7 -7
  137. package/apps/storefront/src/lib/apps/orders/verifiedProviderMeasurement.ts +117 -0
  138. package/apps/storefront/src/lib/apps/registryService.ts +270 -16
  139. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  140. package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +170 -0
  141. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  142. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  143. package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +16 -0
  144. package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +105 -0
  145. package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +177 -0
  146. package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +251 -0
  147. package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +241 -0
  148. package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +211 -0
  149. package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +409 -0
  150. package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +368 -0
  151. package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +188 -0
  152. package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +291 -0
  153. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  154. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  155. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  156. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  157. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  158. package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +95 -0
  159. package/apps/storefront/src/lib/auth/adminApiGuard.ts +66 -0
  160. package/apps/storefront/src/lib/auth/adminEntry.ts +11 -11
  161. package/apps/storefront/src/lib/auth/adminLanding.ts +88 -0
  162. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  163. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  164. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +2 -4
  165. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  166. package/apps/storefront/src/lib/auth/session.ts +7 -8
  167. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  168. package/apps/storefront/src/lib/basePath.ts +9 -2
  169. package/apps/storefront/src/lib/blog/access.ts +29 -0
  170. package/apps/storefront/src/lib/blog/collection.ts +400 -0
  171. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  172. package/apps/storefront/src/lib/blog/provider.ts +62 -3
  173. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  174. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  175. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  176. package/apps/storefront/src/lib/blog/types.ts +34 -0
  177. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  178. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome-assignments.example.json +6 -0
  179. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +79 -0
  180. package/apps/storefront/src/lib/chrome/model.ts +48 -253
  181. package/apps/storefront/src/lib/chrome/navCurrent.ts +29 -0
  182. package/apps/storefront/src/lib/chrome/parseConfig.ts +18 -32
  183. package/apps/storefront/src/lib/chrome/variant.ts +39 -0
  184. package/apps/storefront/src/lib/commerce/checkoutHandoffCorrelation.ts +105 -0
  185. package/apps/storefront/src/lib/compliance/tamper-sentinel-boot.ts +152 -0
  186. package/apps/storefront/src/lib/compliance/tamper-sentinel.ts +327 -0
  187. package/apps/storefront/src/lib/content/index.ts +5 -5
  188. package/apps/storefront/src/lib/content-edit/applyDiff.ts +1 -1
  189. package/apps/storefront/src/lib/content-edit/route.ts +1 -1
  190. package/apps/storefront/src/lib/d1/catalog.ts +14 -2
  191. package/apps/storefront/src/lib/d1/schema-apps.ts +372 -17
  192. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  193. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  194. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  195. package/apps/storefront/src/lib/dev/cockpit/ask.ts +49 -0
  196. package/apps/storefront/src/lib/dev/cockpit/clickToSource.ts +71 -0
  197. package/apps/storefront/src/lib/dev/cockpit/copyCommand.ts +227 -0
  198. package/apps/storefront/src/lib/dev/cockpit/files.ts +249 -0
  199. package/apps/storefront/src/lib/dev/cockpit/hostedActivity.ts +500 -0
  200. package/apps/storefront/src/lib/dev/cockpit/infoPopover.ts +12 -0
  201. package/apps/storefront/src/lib/dev/cockpit/mcpCopy.ts +34 -0
  202. package/apps/storefront/src/lib/dev/cockpit/monitor.ts +107 -0
  203. package/apps/storefront/src/lib/dev/cockpit/stepper.ts +116 -0
  204. package/apps/storefront/src/lib/dev/cockpit/themeToggle.ts +23 -0
  205. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  206. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  207. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  208. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  209. package/apps/storefront/src/lib/dev/runtimeStore.ts +1 -1
  210. package/apps/storefront/src/lib/edgeCache.ts +34 -5
  211. package/apps/storefront/src/lib/email/candidateConflictEmail.ts +80 -0
  212. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  213. package/apps/storefront/src/lib/i18n.ts +25 -0
  214. package/apps/storefront/src/lib/integration/browserEnvironment.ts +137 -0
  215. package/apps/storefront/src/lib/integration/managedInstallDeclarations.ts +72 -0
  216. package/apps/storefront/src/lib/integration/nonceStamp.ts +49 -0
  217. package/apps/storefront/src/lib/integration/tenantDeclarations.ts +116 -0
  218. package/apps/storefront/src/lib/jsonld.ts +118 -0
  219. package/apps/storefront/src/lib/membership/eligibility.ts +1 -1
  220. package/apps/storefront/src/lib/membership/model.ts +1 -2
  221. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  222. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  223. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  224. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  225. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  226. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  227. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  228. package/apps/storefront/src/lib/previewReload.ts +57 -1
  229. package/apps/storefront/src/lib/publish/adminPublishTab.ts +4625 -0
  230. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  231. package/apps/storefront/src/lib/publish/buildStream.ts +269 -0
  232. package/apps/storefront/src/lib/publish/collapsePersistence.ts +72 -0
  233. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  234. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  235. package/apps/storefront/src/lib/publish/lifecycleState.ts +369 -0
  236. package/apps/storefront/src/lib/publish/liveReleaseSummary.ts +29 -0
  237. package/apps/storefront/src/lib/publish/mergeDoctorOnFailure.ts +78 -0
  238. package/apps/storefront/src/lib/publish/mergeHealthBanner.ts +62 -0
  239. package/apps/storefront/src/lib/publish/mergeRowFindings.ts +124 -0
  240. package/apps/storefront/src/lib/publish/mergeSupportEscalation.ts +75 -0
  241. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  242. package/apps/storefront/src/lib/publish/publish-guard.ts +88 -0
  243. package/apps/storefront/src/lib/publish/publishFormat.ts +165 -0
  244. package/apps/storefront/src/lib/publish/publishHero.ts +177 -0
  245. package/apps/storefront/src/lib/publish/reviewRelease/changeState.ts +66 -0
  246. package/apps/storefront/src/lib/publish/reviewRelease/copy.ts +141 -0
  247. package/apps/storefront/src/lib/publish/reviewRelease/index.ts +41 -0
  248. package/apps/storefront/src/lib/publish/reviewRelease/permissions.ts +47 -0
  249. package/apps/storefront/src/lib/publish/reviewRelease/releaseState.ts +42 -0
  250. package/apps/storefront/src/lib/publish/reviewRelease/types.ts +59 -0
  251. package/apps/storefront/src/lib/publish/reviewRelease/viewModel.ts +440 -0
  252. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/actions.ts +161 -0
  253. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/controller.ts +325 -0
  254. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/render.ts +241 -0
  255. package/apps/storefront/src/lib/publish/shipWorkspace.ts +121 -51
  256. package/apps/storefront/src/lib/publish/sseRoute.ts +119 -0
  257. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  258. package/apps/storefront/src/lib/publish/streamLogConsole.ts +142 -0
  259. package/apps/storefront/src/lib/publish/versionNumber.ts +52 -0
  260. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  261. package/apps/storefront/src/lib/rawChrome.ts +125 -47
  262. package/apps/storefront/src/lib/rawMarketingHtml.ts +74 -3
  263. package/apps/storefront/src/lib/search/index.ts +2 -2
  264. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  265. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  266. package/apps/storefront/src/lib/social/golive.ts +212 -0
  267. package/apps/storefront/src/lib/social/notify.ts +32 -0
  268. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  269. package/apps/storefront/src/lib/storyblok/content-model.ts +7 -33
  270. package/apps/storefront/src/lib/storyblok/provider.ts +168 -60
  271. package/apps/storefront/src/lib/subscription/model.ts +4 -4
  272. package/apps/storefront/src/lib/the-build/provider.ts +32 -0
  273. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  274. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  275. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  276. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  277. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  278. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  279. package/apps/storefront/src/middleware/index.ts +385 -52
  280. package/apps/storefront/src/pages/[...slug].astro +9 -4
  281. package/apps/storefront/src/pages/admin/review-release.astro +265 -0
  282. package/apps/storefront/src/pages/admin/select.astro +96 -0
  283. package/apps/storefront/src/pages/admin.astro +25 -1313
  284. package/apps/storefront/src/pages/api/activity.ts +8 -8
  285. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +5 -20
  286. package/apps/storefront/src/pages/api/admin/orders/[id].ts +7 -2
  287. package/apps/storefront/src/pages/api/admin/orders.ts +8 -2
  288. package/apps/storefront/src/pages/api/apps/admin/gtm-migration-install.ts +174 -0
  289. package/apps/storefront/src/pages/api/apps/admin/gtm-migration.ts +96 -0
  290. package/apps/storefront/src/pages/api/apps/admin/integration-lifecycle.ts +118 -0
  291. package/apps/storefront/src/pages/api/apps/admin/integrations.ts +89 -0
  292. package/apps/storefront/src/pages/api/apps/admin/platform-adapters.ts +85 -0
  293. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +74 -2
  294. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  295. package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +77 -0
  296. package/apps/storefront/src/pages/api/compliance/tamper.ts +88 -0
  297. package/apps/storefront/src/pages/api/integrations/v1/[operation].ts +108 -0
  298. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  299. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  300. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  301. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  302. package/apps/storefront/src/pages/api/rum/vitals.ts +20 -2
  303. package/apps/storefront/src/pages/blog/[slug].astro +99 -19
  304. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  305. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  306. package/apps/storefront/src/pages/blog/index.astro +4 -2
  307. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  308. package/apps/storefront/src/pages/cockpit.astro +77 -1353
  309. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  310. package/apps/storefront/src/pages/index.astro +10 -5
  311. package/apps/storefront/src/pages/llms.txt.ts +1 -1
  312. package/apps/storefront/src/pages/products/[handle].astro +7 -6
  313. package/apps/storefront/src/pages/search.astro +1 -1
  314. package/apps/storefront/src/pages/sitemap.xml.ts +21 -1
  315. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +31 -27
  316. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +1 -1
  317. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +38 -2
  318. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  319. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  320. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  321. package/apps/storefront/src/styles/admin.css +1106 -0
  322. package/apps/storefront/tsconfig.json +6 -1
  323. package/package.json +1 -1
  324. package/packages/public-runtime/package.json +3 -1
  325. package/packages/public-runtime/schemas/managed-analytics/v1/adapter-definition.schema.json +427 -0
  326. package/packages/public-runtime/schemas/managed-analytics/v1/create-integration-install-request.schema.json +161 -0
  327. package/packages/public-runtime/schemas/managed-analytics/v1/create-migration-draft-request.schema.json +157 -0
  328. package/packages/public-runtime/schemas/managed-analytics/v1/delete-integration-install-request.schema.json +84 -0
  329. package/packages/public-runtime/schemas/managed-analytics/v1/delete-migration-draft-request.schema.json +84 -0
  330. package/packages/public-runtime/schemas/managed-analytics/v1/error-envelope.schema.json +180 -0
  331. package/packages/public-runtime/schemas/managed-analytics/v1/get-adapter-definition-request.schema.json +52 -0
  332. package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-install-request.schema.json +52 -0
  333. package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-revision-request.schema.json +79 -0
  334. package/packages/public-runtime/schemas/managed-analytics/v1/get-migration-draft-request.schema.json +52 -0
  335. package/packages/public-runtime/schemas/managed-analytics/v1/integration-install.schema.json +167 -0
  336. package/packages/public-runtime/schemas/managed-analytics/v1/integration-revision.schema.json +208 -0
  337. package/packages/public-runtime/schemas/managed-analytics/v1/list-adapter-definitions-request.schema.json +53 -0
  338. package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-installs-request.schema.json +53 -0
  339. package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-revisions-request.schema.json +82 -0
  340. package/packages/public-runtime/schemas/managed-analytics/v1/migration-draft.schema.json +218 -0
  341. package/packages/public-runtime/schemas/managed-analytics/v1/mutation-request.schema.json +52 -0
  342. package/packages/public-runtime/schemas/managed-analytics/v1/mutation-success-envelope.schema.json +49 -0
  343. package/packages/public-runtime/schemas/managed-analytics/v1/read-request.schema.json +20 -0
  344. package/packages/public-runtime/schemas/managed-analytics/v1/read-success-envelope.schema.json +45 -0
  345. package/packages/public-runtime/schemas/managed-analytics/v1/revise-integration-install-request.schema.json +156 -0
  346. package/packages/public-runtime/schemas/managed-analytics/v1/set-integration-install-status-request.schema.json +92 -0
  347. package/packages/public-runtime/schemas/managed-analytics/v1/update-migration-draft-request.schema.json +184 -0
  348. package/packages/public-runtime/scripts/generate-managed-analytics-api-v1-schemas.mjs +13 -0
  349. package/packages/public-runtime/src/activity/catalog.ts +89 -33
  350. package/packages/public-runtime/src/activity/event.ts +6 -6
  351. package/packages/public-runtime/src/activity/index.ts +2 -2
  352. package/packages/public-runtime/src/activity/redaction.ts +4 -4
  353. package/packages/public-runtime/src/candidate-index.ts +42 -8
  354. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  355. package/packages/public-runtime/src/checkout-orders.ts +2 -2
  356. package/packages/public-runtime/src/checkout-style-publish.ts +2 -2
  357. package/packages/public-runtime/src/checkout-style.ts +7 -7
  358. package/packages/public-runtime/src/checkout.ts +3 -3
  359. package/packages/public-runtime/src/chrome.ts +777 -0
  360. package/packages/public-runtime/src/compliance-obligations.ts +310 -0
  361. package/packages/public-runtime/src/csp.ts +99 -0
  362. package/packages/public-runtime/src/customization-preview.ts +25 -2
  363. package/packages/public-runtime/src/customization-runtime.ts +156 -3
  364. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  365. package/packages/public-runtime/src/destination-webhook/contract.ts +353 -0
  366. package/packages/public-runtime/src/embed-catalog.d.mts +9 -0
  367. package/packages/public-runtime/src/embed-catalog.mjs +40 -0
  368. package/packages/public-runtime/src/evidence.ts +288 -0
  369. package/packages/public-runtime/src/gtm-migration/bounds.ts +138 -0
  370. package/packages/public-runtime/src/gtm-migration/disposition-rules.ts +361 -0
  371. package/packages/public-runtime/src/gtm-migration/dispositions.ts +181 -0
  372. package/packages/public-runtime/src/gtm-migration/draft-generator.ts +400 -0
  373. package/packages/public-runtime/src/gtm-migration/graph.ts +468 -0
  374. package/packages/public-runtime/src/gtm-migration/index.ts +95 -0
  375. package/packages/public-runtime/src/gtm-migration/parse.ts +410 -0
  376. package/packages/public-runtime/src/gtm-migration/references.ts +266 -0
  377. package/packages/public-runtime/src/gtm-migration/types.ts +86 -0
  378. package/packages/public-runtime/src/index.ts +17 -1
  379. package/packages/public-runtime/src/integration/assurance-decision.ts +259 -0
  380. package/packages/public-runtime/src/integration/assurance.ts +228 -0
  381. package/packages/public-runtime/src/integration/candidate-evidence.ts +377 -0
  382. package/packages/public-runtime/src/integration/commerce-broker.ts +497 -0
  383. package/packages/public-runtime/src/integration/consent-broker.ts +337 -0
  384. package/packages/public-runtime/src/integration/consent-cmp-demand.ts +115 -0
  385. package/packages/public-runtime/src/integration/consent-config.ts +260 -0
  386. package/packages/public-runtime/src/integration/consent-preview.ts +125 -0
  387. package/packages/public-runtime/src/integration/correlation-token.ts +444 -0
  388. package/packages/public-runtime/src/integration/csp-compiler.ts +267 -0
  389. package/packages/public-runtime/src/integration/csp.ts +105 -0
  390. package/packages/public-runtime/src/integration/digest.ts +43 -0
  391. package/packages/public-runtime/src/integration/environment.ts +228 -0
  392. package/packages/public-runtime/src/integration/events-catalog.ts +186 -0
  393. package/packages/public-runtime/src/integration/events.ts +701 -0
  394. package/packages/public-runtime/src/integration/g2-action-boundary.ts +106 -0
  395. package/packages/public-runtime/src/integration/index.ts +90 -0
  396. package/packages/public-runtime/src/integration/interim-bridge.ts +138 -0
  397. package/packages/public-runtime/src/integration/lifecycle-boot.ts +317 -0
  398. package/packages/public-runtime/src/integration/lifecycle-runtime.ts +393 -0
  399. package/packages/public-runtime/src/integration/lifecycle.ts +94 -0
  400. package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.d.mts +2 -0
  401. package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.mjs +10 -0
  402. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +382 -0
  403. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +684 -0
  404. package/packages/public-runtime/src/integration/managed-analytics-bridge.ts +383 -0
  405. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +14 -0
  406. package/packages/public-runtime/src/integration/managed-analytics.ts +277 -0
  407. package/packages/public-runtime/src/integration/manifest.ts +470 -0
  408. package/packages/public-runtime/src/integration/native-amplitude.ts +362 -0
  409. package/packages/public-runtime/src/integration/native-ga4.ts +352 -0
  410. package/packages/public-runtime/src/integration/observability.ts +451 -0
  411. package/packages/public-runtime/src/integration/order-conversion.ts +0 -0
  412. package/packages/public-runtime/src/integration/phases.ts +182 -0
  413. package/packages/public-runtime/src/integration/plan.ts +112 -0
  414. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +146 -0
  415. package/packages/public-runtime/src/integration/platform-adapters.ts +671 -0
  416. package/packages/public-runtime/src/integration/promotion-gate.ts +451 -0
  417. package/packages/public-runtime/src/integration/provider-checkout-extension.ts +425 -0
  418. package/packages/public-runtime/src/integration/receipt-measurement.ts +370 -0
  419. package/packages/public-runtime/src/integration/regulated-parent-cert.ts +311 -0
  420. package/packages/public-runtime/src/integration/render-phases.ts +267 -0
  421. package/packages/public-runtime/src/integration/resolve.ts +465 -0
  422. package/packages/public-runtime/src/integration/result.ts +239 -0
  423. package/packages/public-runtime/src/integration/secrets.ts +92 -0
  424. package/packages/public-runtime/src/integration/tamper-evidence.ts +205 -0
  425. package/packages/public-runtime/src/integration/tamper-recovery.ts +127 -0
  426. package/packages/public-runtime/src/integration/version.ts +92 -0
  427. package/packages/public-runtime/src/integration-capabilities.ts +520 -0
  428. package/packages/public-runtime/src/membership.ts +2 -2
  429. package/packages/public-runtime/src/raw-html-policy.ts +2 -4
  430. package/packages/public-runtime/src/regulated-commerce/broker/consent-dedupe.ts +144 -0
  431. package/packages/public-runtime/src/regulated-commerce/correlation/contract.ts +177 -0
  432. package/packages/public-runtime/src/regulated-commerce/diagnostics/local-dispatch.ts +24 -0
  433. package/packages/public-runtime/src/regulated-commerce/diagnostics/read-model.ts +69 -0
  434. package/packages/public-runtime/src/regulated-commerce/dispatch/core.ts +134 -0
  435. package/packages/public-runtime/src/regulated-commerce/events/contract.ts +113 -0
  436. package/packages/public-runtime/src/regulated-commerce/ledger/core.ts +81 -0
  437. package/packages/public-runtime/src/regulated-commerce/mappings/ga4-ads.ts +87 -0
  438. package/packages/public-runtime/src/regulated-commerce/provider-order/bridge.ts +52 -0
  439. package/packages/public-runtime/src/regulated-commerce/purchase-truth/contract.ts +252 -0
  440. package/packages/public-runtime/src/regulated-commerce/sources/accepted-browser-operation.ts +67 -0
  441. package/packages/public-runtime/src/regulated-commerce/testing/fake-destination.ts +93 -0
  442. package/packages/public-runtime/src/static-bundle.ts +357 -0
  443. package/packages/public-runtime/src/tenant-assets.ts +143 -4
  444. package/packages/public-runtime/src/tenant.ts +81 -17
  445. package/packages/public-runtime/tests/fixtures/integration/browser-environment.example.json +8 -0
  446. package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +44 -0
  447. package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +44 -0
  448. package/packages/public-runtime/tests/fixtures/integration/compliance-assurance-record.example.json +50 -0
  449. package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +120 -0
  450. package/packages/public-runtime/tests/fixtures/integration/managed-analytics-v1/fake-adapter-catalog.json +35 -0
  451. package/packages/public-runtime/tests/fixtures/integration/tenant-action-registry.example.json +11 -0
  452. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/README.md +22 -0
  453. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/entry.js +30 -0
  454. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +75 -0
  455. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/public-config.json +3 -0
  456. package/packages/public-runtime/tests/fixtures/regulated-commerce/fake-destinations.ts +38 -0
  457. package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.provenance.json +29 -0
  458. package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.redacted.json +3397 -0
  459. package/scripts/build/copy-tenant-assets.mjs +14 -4
  460. package/scripts/dev/ai-edit.mjs +1 -1
  461. package/scripts/dev/checkout-watch.mjs +2 -2
  462. package/scripts/dev/file-browser.mjs +4 -6
  463. package/scripts/dev/file-writer.mjs +3 -3
  464. package/scripts/dev/git-status.mjs +2 -2
  465. package/scripts/dev/integration-guardrails.mjs +498 -0
  466. package/scripts/dev/locate-handler.mjs +3 -3
  467. package/scripts/dev/port-check.mjs +3 -3
  468. package/scripts/dev/publish.mjs +5 -9
  469. package/scripts/dev/safe-path.mjs +2 -2
  470. package/scripts/dev/shot.mjs +1 -1
  471. package/scripts/dev/tenant-source-root.mjs +1 -1
  472. package/scripts/dev/unified-diff.mjs +8 -3
  473. package/scripts/tot-dev.mjs +8 -11
  474. package/apps/storefront/src/lib/dev/vcBinding.ts +0 -80
@@ -0,0 +1,4625 @@
1
+ /**
2
+ * Client island for the admin Publish tab (`components/admin/AdminPublishTab.astro`).
3
+ *
4
+ * The tab's template exposes every runtime endpoint + capability flag as `data-*`
5
+ * attributes on `#publish`; this module reads them and wires the queue, aggregate,
6
+ * versions, branch-retention, and domain panels — the whole go-live surface. It runs
7
+ * from a PROCESSED `<script>` (Astro bundles + type-checks it), so the pure logic it
8
+ * used to hand-mirror inline is imported from its tested source of truth instead of
9
+ * duplicated (see docs/architecture/decisions/0013-client-island-pattern-and-inline-drift-guard.md).
10
+ */
11
+ import { createKeyedCache, createCoalescer } from "@/lib/publish/panelPolling";
12
+ import {
13
+ shortSha,
14
+ shortDigest,
15
+ age,
16
+ linesNode,
17
+ chip,
18
+ cell,
19
+ subtle,
20
+ changeSummaryItem,
21
+ } from "@/lib/publish/publishFormat";
22
+ import { candidatePrUrl } from "@/lib/preview/candidateDashboard";
23
+ import { currentPreviewUrl } from "@/lib/preview/tenantPreview";
24
+ import { classifyResolveResponse } from "@/lib/change/views/resolve-conflict";
25
+ import { mergeHealthBanner } from "@/lib/publish/mergeHealthBanner";
26
+ import { mergeRowFindings, mergeRowKey } from "@/lib/publish/mergeRowFindings";
27
+ import { mergeSupportEscalation } from "@/lib/publish/mergeSupportEscalation";
28
+ // C5 — the doctor PUSH: on a failed accept/integrate or a refused ship, reduce the
29
+ // SAME /api/changes payload to the one-line diagnosis and append it where the failure
30
+ // already surfaced (so the developer doesn't have to go read the banner/chips).
31
+ import { integrateFailureDoctor, shipFailureDoctor } from "@/lib/publish/mergeDoctorOnFailure";
32
+ // Real-time build/check streaming: pure SSE parsing + progress reduction + copy text.
33
+ import {
34
+ BUILD_STAGES,
35
+ parseSseChunk,
36
+ reduceBuildProgress,
37
+ initialBuildProgress,
38
+ buildFailureDetail,
39
+ assembleBuildFailureCopy,
40
+ type BuildProgress,
41
+ type BuildResult,
42
+ } from "@/lib/publish/buildStream";
43
+ // The SHARED scrolling live-log console reused across build/integrate/rebuild/ship:
44
+ // pure phase→line formatting + terminal-line extraction (DOM glue verified live).
45
+ import {
46
+ logLineForPhase,
47
+ terminalLogLine,
48
+ type StreamTerminalResult,
49
+ } from "@/lib/publish/streamLogConsole";
50
+ // Fail-visible, never fail-silent: pure state-derivation for the publish lifecycle.
51
+ // classifyIntegrateOutcome derives an integrate attempt's HONEST result from the
52
+ // actual merge/build signals (killing the false "Integration did not land");
53
+ // classifyBuildLink withholds a rev deep-link that would 404 on an unbuilt sha.
54
+ import {
55
+ classifyIntegrateOutcome,
56
+ classifyBuildLink,
57
+ type IntegrateOutcomeView,
58
+ } from "@/lib/publish/lifecycleState";
59
+ // r01 — the SINGLE "next action" hero state machine. Reduces the same
60
+ // /api/changes read (queue count + merge health B1 + escalation B3 + ship plan)
61
+ // into ONE sentence + ONE CTA; folds the two former standalone banners in.
62
+ import { publishHeroState } from "@/lib/publish/publishHero";
63
+ import { computePublishGuard } from "@/lib/publish/publish-guard";
64
+ import { liveReleaseSummaryState } from "@/lib/publish/liveReleaseSummary";
65
+ // r05 — per-store persistence of each collapsible section's open/closed state.
66
+ import {
67
+ readPersistedOpen,
68
+ writePersistedOpen,
69
+ type CollapseSectionId,
70
+ } from "@/lib/publish/collapsePersistence";
71
+
72
+ export function initAdminPublishTab(): void {
73
+ const root: any = document.getElementById("publish");
74
+ if (!root) return;
75
+ const ds = root.dataset;
76
+ const canShip = ds.canShip === "true";
77
+ const repo = ds.repo || "";
78
+ const appDomain = ds.appDomain || "";
79
+ const publicSiteHost = ds.publicSiteHost || "";
80
+
81
+ // r08 — shareable, view-only `?__preview` capability links an owner can hand to
82
+ // a non-technical stakeholder (no sign-in). The token is minted server-side
83
+ // (see AdminPublishTab.astro) and rides in on `data-preview-share-token`; empty
84
+ // when signing isn't configured, in which case the copy affordances stay hidden.
85
+ // ONE token authorizes both the shared next-release preview and per-candidate
86
+ // links (it claims the tenant_id both verifiers accept).
87
+ const previewShareToken = ds.previewShareToken || "";
88
+ const canonicalDomain = ds.canonicalDomain || "";
89
+ // The shared preview channel = the next-release aggregate build.
90
+ const sharedPreviewUrl = (): string | null =>
91
+ previewShareToken && canonicalDomain
92
+ ? currentPreviewUrl(window.location.origin, canonicalDomain, previewShareToken)
93
+ : null;
94
+ // A single candidate PR's preview (the friendly `/preview/pr/<N>` alias, which
95
+ // accepts the same tenant-scoped token for a session-less viewer).
96
+ const candidatePreviewShareUrl = (prNumber?: any): string | null => {
97
+ if (!previewShareToken || prNumber == null) return null;
98
+ const path = candidatePrUrl(appDomain, prNumber);
99
+ return path
100
+ ? window.location.origin + path + "?__preview=" + encodeURIComponent(previewShareToken)
101
+ : null;
102
+ };
103
+ // Copy `url` and flash a brief "Copied" on the button's label element (icon+text
104
+ // stays intact — accessible, never color-only). Clipboard denial is silent (the
105
+ // link is selectable text on failure).
106
+ const copyPreviewLink = (url: string | null, labelEl?: any, restore = "Copy preview link") => {
107
+ if (!url || !labelEl) return;
108
+ navigator.clipboard.writeText(url).then(
109
+ () => {
110
+ labelEl.textContent = "Copied";
111
+ setTimeout(() => { labelEl.textContent = restore; }, 1600);
112
+ },
113
+ () => { /* clipboard denied — the link is selectable text */ },
114
+ );
115
+ };
116
+ // Build an inline `#i-copy` icon (matches the SSR sprite-`use` convention).
117
+ const copyIcon = (): SVGSVGElement => {
118
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
119
+ const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
120
+ use.setAttribute("href", "#i-copy");
121
+ svg.append(use);
122
+ return svg;
123
+ };
124
+
125
+ const statusEl = root.querySelector("[data-publish-status]");
126
+ const queueBody = root.querySelector("[data-publish-queue-body]");
127
+ const queueChip = root.querySelector("[data-publish-queue-chip]");
128
+ const queueCount = root.querySelector("[data-publish-queue-count]");
129
+ // "See all" closed/rejected history (§ see-all-rejected).
130
+ const closedPanel = root.querySelector("[data-publish-closed-panel]");
131
+ const closedBody = root.querySelector("[data-publish-closed-body]");
132
+ const closedChip = root.querySelector("[data-publish-closed-chip]");
133
+ const degradedStrip = root.querySelector("[data-publish-degraded]");
134
+ // r01: the SINGLE state hero. Folds in what used to be two standalone banners
135
+ // — the merge-doctor health summary (B1) and the platform-fault escalation
136
+ // (B3) — plus the queue count + ship plan, into one sentence + one CTA.
137
+ // Toggled by renderHero() off the same /api/changes read.
138
+ const heroEl = root.querySelector("[data-publish-hero]");
139
+ const heroIconEl = root.querySelector("[data-publish-hero-icon]");
140
+ const heroKickerEl = root.querySelector("[data-publish-hero-kicker]");
141
+ const heroHeadlineEl = root.querySelector("[data-publish-hero-headline]");
142
+ const heroDetailEl = root.querySelector("[data-publish-hero-detail]");
143
+ const heroCtaEl = root.querySelector("[data-publish-hero-cta]");
144
+ const heroCtaLinkEl = root.querySelector("[data-publish-hero-cta-link]");
145
+
146
+ // rux7 — in-flight UI state the auto-refresh poll must preserve across a
147
+ // re-render. These are the AUTHORITATIVE source of truth (never scraped
148
+ // back out of the DOM): an action's async closure holds a reference to the
149
+ // OLD button, so after a poll swaps the row the completion signal lands on a
150
+ // detached node — reading `disabled`/`data-building` off the fresh DOM would
151
+ // wedge forever. buildPreview/integrate/reject add on start and
152
+ // delete in `finally` (before their own loadQueue), so the next render
153
+ // reflects the true busy set. Open diff panes are the exception: the DOM IS
154
+ // their source of truth (toggleDiff owns open/close), so renderQueue detaches
155
+ // and re-attaches the actual pane node — see renderQueue.
156
+ const inflightBuilds = new Set(); // prNumber currently building
157
+ const busyChanges = new Set(); // changeId with an in-flight integrate/reject
158
+ let aggregatePublishing = false; // the aggregate Publish POST is in flight
159
+ let aggregateRetrying = false; // u15: the aggregate Retry-build POST is in flight
160
+
161
+
162
+ // A fingerprint of the current publish state (candidate head shas + live
163
+ // version), updated on every /api/changes read. Heavy panels key their
164
+ // client cache on it: unchanged fingerprint ⇒ no re-fetch. See changesSignature.
165
+ let currentStateKey: string | null = null;
166
+
167
+ // Visibility state, driven by an IntersectionObserver (see Wiring). Panels
168
+ // don't fetch until first viewed, and the queue poll only runs while the tab
169
+ // is active — a backgrounded/inactive Publish tab must not fire heavy reads.
170
+ let tabActive = false;
171
+ let branchesVisible = false;
172
+
173
+ const setStatus = (el?: any, message?: any, state?: any) => {
174
+ if (!el) return;
175
+ el.textContent = message || "";
176
+ if (state) el.dataset.state = state;
177
+ else delete el.dataset.state;
178
+ };
179
+
180
+ // r12 — a status chip must read as icon + TEXT, never colour alone. The leading
181
+ // glyph is keyed by tone so every chip across the console speaks one visual
182
+ // language: ✓ good, ⚠ needs-attention, ✕ failed, • neutral/in-progress.
183
+ const CHIP_GLYPH: Record<string, string> = {
184
+ green: "✓",
185
+ teal: "✓",
186
+ amber: "⚠",
187
+ red: "✕",
188
+ blue: "•",
189
+ "": "•",
190
+ };
191
+ // Repaint an EXISTING chip element (SSR-rendered, re-used across polls) with a
192
+ // state word + tone + a leading glyph — the in-place analogue of publishFormat's
193
+ // `chip()` node builder, for the chips that live in the template rather than being
194
+ // created per render.
195
+ const paintChip = (el?: any, text?: string, tone?: string, glyph?: string) => {
196
+ if (!el) return;
197
+ el.className = "chip" + (tone ? " " + tone : "");
198
+ el.textContent = "";
199
+ if (glyph) {
200
+ const g = document.createElement("span");
201
+ g.className = "chip-glyph";
202
+ g.setAttribute("aria-hidden", "true");
203
+ g.textContent = glyph;
204
+ el.append(g);
205
+ }
206
+ el.append(document.createTextNode(text ?? ""));
207
+ };
208
+
209
+ // C5 — the doctor PUSH (admin side). On a failed accept/integrate or a refused
210
+ // ship, fetch the freshest /api/changes and, IF the merge-doctor has an opinion on
211
+ // this failure, append its one-line diagnosis to the failure's own outcome copy —
212
+ // so the developer sees WHY right where it failed, not only in the top-of-tab
213
+ // banner / row chips. Best-effort throughout: it never throws and never alters the
214
+ // action's outcome/status/exit path. `hintFor` runs the SAME pure analyzer the
215
+ // banner + row chips use (no new endpoint). `sinks` are elements to append to (the
216
+ // liftoff/shipoff outcome detail — stable, unlike statusEl which loadQueue rewrites).
217
+ const appendDoctorText = (el?: any, text?: string) => {
218
+ if (!el || !text) return;
219
+ const base = (el.textContent || "").trim();
220
+ el.textContent = base ? base + " " + text : text;
221
+ };
222
+ const surfaceDoctorHint = (
223
+ hintFor: (payload: any) => { show: boolean; text: string },
224
+ sinks: any[],
225
+ ) => {
226
+ void (async () => {
227
+ try {
228
+ const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
229
+ if (!res.ok) return;
230
+ const hint = hintFor(await res.json());
231
+ if (!hint.show) return;
232
+ for (const el of sinks) appendDoctorText(el, hint.text);
233
+ } catch { /* best-effort — the failure's own copy already stands */ }
234
+ })();
235
+ };
236
+
237
+ // ── The SHARED scrolling live-log console ─────────────────────────────────
238
+ // ONE "CI build log" panel, reused across all four long-running flows
239
+ // (build/integrate/rebuild/ship). `makeLogConsole` binds it to a scrollable
240
+ // `<div data-*-log>`; `reset()` clears + stamps the clock start, `pushEvent`
241
+ // appends one timestamped human line per server `phase` event (auto-scrolled
242
+ // to the bottom), and `finish` appends the terminal ✓/✗ line. Pure formatting
243
+ // lives in the tested streamLogConsole.ts; this is the DOM glue only.
244
+ const makeLogConsole = (logEl?: any) => {
245
+ let startedAt = Date.now();
246
+ const append = (line?: string | null) => {
247
+ if (!logEl || !line) return;
248
+ const div = document.createElement("div");
249
+ div.className = "stream-log-line";
250
+ div.textContent = line;
251
+ logEl.appendChild(div);
252
+ logEl.scrollTop = logEl.scrollHeight; // keep the newest line in view
253
+ };
254
+ return {
255
+ reset() {
256
+ startedAt = Date.now();
257
+ if (logEl) logEl.textContent = "";
258
+ },
259
+ pushEvent(ev: { event: string; data: string }) {
260
+ append(logLineForPhase(ev, Date.now() - startedAt));
261
+ },
262
+ finish(result: StreamTerminalResult) {
263
+ append(terminalLogLine(result, Date.now() - startedAt).line);
264
+ },
265
+ };
266
+ };
267
+ type LogConsole = ReturnType<typeof makeLogConsole>;
268
+
269
+ // Consume a streamed (SSE) or buffered (JSON) response uniformly. In stream
270
+ // mode each `phase` event feeds the shared log console + the optional `onPhase`
271
+ // (a flow's own stage-rail), and the terminal `result` event's payload is
272
+ // returned; in JSON mode (a buffering proxy, or a non-streaming server) the whole
273
+ // body is read once and treated as that terminal payload — so every flow completes
274
+ // honestly either way. Never throws for a well-formed response; the caller keeps
275
+ // its own try/catch for network faults. `res.ok`/`res.status` stay authoritative
276
+ // for the caller (a stream is always HTTP 200; the route front-loads its non-2xx
277
+ // auth/validation refusals as plain JSON BEFORE any stream).
278
+ const streamWithLog = async (
279
+ res: any,
280
+ logc?: LogConsole | null,
281
+ onPhase?: (ev: { event: string; data: string }) => void,
282
+ ): Promise<{ data: any }> => {
283
+ const ct = res.headers.get("content-type") || "";
284
+ if (res.body && ct.indexOf("text/event-stream") !== -1) {
285
+ const reader = res.body.getReader();
286
+ const decoder = new TextDecoder();
287
+ let buf = "";
288
+ let terminal: any = undefined;
289
+ for (;;) {
290
+ const { value, done } = await reader.read();
291
+ if (done) break;
292
+ buf += decoder.decode(value, { stream: true });
293
+ const parsed = parseSseChunk(buf);
294
+ buf = parsed.rest;
295
+ for (const ev of parsed.events) {
296
+ if (ev.event === "phase") {
297
+ if (logc) logc.pushEvent(ev);
298
+ if (onPhase) onPhase(ev);
299
+ } else if (ev.event === "result") {
300
+ try { terminal = JSON.parse(ev.data); } catch { terminal = {}; }
301
+ if (logc) logc.finish(terminal);
302
+ }
303
+ }
304
+ }
305
+ return { data: terminal ?? {} };
306
+ }
307
+ const data = await res.json().catch(() => ({}));
308
+ if (logc) logc.finish(data);
309
+ return { data };
310
+ };
311
+
312
+
313
+ const STATUS_CHIP: Record<string, string> = {
314
+ queued: "",
315
+ reconciling: "blue",
316
+ ready: "green",
317
+ blocked: "red",
318
+ };
319
+ // r04 — humanize the raw candidate-status word for the queue chip. The raw
320
+ // status still drives all logic; only the displayed label is merchant-facing
321
+ // (dev words like "reconciling" never reach the operator).
322
+ const STATUS_LABEL: Record<string, string> = {
323
+ queued: "Waiting",
324
+ reconciling: "Checking",
325
+ ready: "Ready",
326
+ blocked: "Needs attention",
327
+ };
328
+
329
+ // ---- ?pr=N deep link ---------------------------------------------------
330
+ // A deep link like /admin#publish?pr=42 asks us to surface a specific PR's
331
+ // row once the queue has loaded. The param rides in the hash fragment
332
+ // (after the tab anchor) because that's how the admin shell routes tabs;
333
+ // fall back to a real query string too. Resolve exactly once — so the
334
+ // scroll/highlight never re-fires on the reloads that follow every ship
335
+ // action — and until it resolves, keep an honest "not in the queue yet"
336
+ // callout up rather than silently doing nothing.
337
+
338
+ const readRequestedPr = () => {
339
+ const hash = window.location.hash || "";
340
+ const q = hash.indexOf("?");
341
+ let raw = new URLSearchParams(q >= 0 ? hash.slice(q + 1) : "").get("pr");
342
+ if (raw == null) raw = new URLSearchParams(window.location.search).get("pr");
343
+ if (raw == null) return null;
344
+ const n = Number.parseInt(raw, 10);
345
+ return Number.isInteger(n) && n > 0 ? n : null;
346
+ };
347
+
348
+ const requestedPr = readRequestedPr();
349
+ let prDeeplinkResolved = false;
350
+
351
+ const applyPrDeeplink = () => {
352
+ if (requestedPr == null || prDeeplinkResolved) return;
353
+ const callout = root.querySelector("[data-publish-pr-callout]");
354
+ const row = queueBody.querySelector('tr[data-pr-number="' + requestedPr + '"]');
355
+ if (!row) {
356
+ if (callout) {
357
+ callout.hidden = false;
358
+ callout.textContent = "PR #" + requestedPr + " isn't in the queue yet.";
359
+ }
360
+ return;
361
+ }
362
+ prDeeplinkResolved = true;
363
+ if (callout) {
364
+ callout.hidden = true;
365
+ callout.textContent = "";
366
+ }
367
+ row.scrollIntoView({ block: "center", behavior: "smooth" });
368
+ row.classList.remove("publish-row-highlight");
369
+ void row.offsetWidth; // restart the flash animation if re-applied
370
+ row.classList.add("publish-row-highlight");
371
+ window.setTimeout(() => row.classList.remove("publish-row-highlight"), 2600);
372
+ };
373
+
374
+ // ---- Open change queue -------------------------------------------------
375
+
376
+ // r01: the SINGLE state hero. Computed from the SAME /api/changes payload the
377
+ // queue + aggregate already have — no duplicate read. It folds in the merge-
378
+ // doctor health summary (B1, mergeHealthBanner) and the platform-fault
379
+ // escalation (B3, mergeSupportEscalation), plus the queue count and ship plan,
380
+ // and expresses whichever is the most urgent true state as one sentence + one
381
+ // CTA (publishHeroState orders the precedence). There is no separate B1/B3
382
+ // banner any more — a conflict/platform blocker turns THIS element red/urgent.
383
+ const HERO_ICON: Record<string, string> = {
384
+ blocker: "#i-alert",
385
+ warn: "#i-alert",
386
+ attention: "#i-box",
387
+ ready: "#i-export",
388
+ calm: "#i-check",
389
+ };
390
+ const scrollToPanel = (sel: string) => {
391
+ const el = document.getElementById(sel);
392
+ if (el) el.scrollIntoView({ block: "start", behavior: "smooth" });
393
+ };
394
+ const renderHero = (data?: any) => {
395
+ if (!heroEl) return;
396
+ const payload = data || {};
397
+ const changes = Array.isArray(payload.changes) ? payload.changes : [];
398
+ const agg = payload.aggregate || null;
399
+ const stagedSha = agg && agg.aggregateSha;
400
+ const liveId = payload.current && payload.current.versionId;
401
+ const view = publishHeroState({
402
+ changeCount: changes.length,
403
+ health: mergeHealthBanner(payload),
404
+ escalation: mergeSupportEscalation(payload),
405
+ plan: payload.plan || null,
406
+ aheadCount: (agg && agg.includedPrs ? agg.includedPrs.length : 0),
407
+ matchesLive: Boolean(stagedSha && liveId && stagedSha === liveId),
408
+ canShip,
409
+ publicSiteHost,
410
+ });
411
+
412
+ (heroEl as any).dataset.tone = view.tone;
413
+ if (heroIconEl) {
414
+ const use = heroIconEl.querySelector("use");
415
+ if (use) use.setAttribute("href", HERO_ICON[view.tone] || "#i-check");
416
+ }
417
+ if (heroKickerEl) (heroKickerEl as any).hidden = !view.urgent;
418
+ if (heroHeadlineEl) heroHeadlineEl.textContent = view.headline;
419
+ if (heroDetailEl) heroDetailEl.textContent = view.detail;
420
+
421
+ // ONE CTA: a mailto link for the "report to support" escalation, a button
422
+ // for every in-page action, nothing for the calm caught-up state.
423
+ const cta = view.cta;
424
+ if (cta.kind === "report-support") {
425
+ if (heroCtaEl) (heroCtaEl as any).hidden = true;
426
+ if (heroCtaLinkEl) {
427
+ heroCtaLinkEl.textContent = cta.label;
428
+ if (cta.href) heroCtaLinkEl.setAttribute("href", cta.href);
429
+ (heroCtaLinkEl as any).hidden = false;
430
+ }
431
+ } else {
432
+ if (heroCtaLinkEl) (heroCtaLinkEl as any).hidden = true;
433
+ if (heroCtaEl) {
434
+ if (cta.kind === "none") {
435
+ (heroCtaEl as any).hidden = true;
436
+ (heroCtaEl as any).onclick = null;
437
+ } else {
438
+ heroCtaEl.textContent = cta.label;
439
+ (heroCtaEl as any).hidden = false;
440
+ (heroCtaEl as any).onclick = () => {
441
+ if (cta.kind === "publish") publishAggregate(heroCtaEl);
442
+ else if (cta.kind === "review-queue") scrollToPanel("publish-queue");
443
+ else if (cta.kind === "goto-release") scrollToPanel("publish-next-release");
444
+ };
445
+ }
446
+ }
447
+ }
448
+ (heroEl as any).hidden = false;
449
+ };
450
+
451
+ // B2: per-row merge-doctor finding. Reads THIS row's dominant finding out of
452
+ // the map computed once per render (mergeRowFindings over the same payload the
453
+ // queue already holds — no extra fetch, no per-row analysis) and renders its
454
+ // ownership tag as a chip. When the finding carries an action it LIGHTS UP the
455
+ // row's EXISTING mapped button (resolve → the conflict-recovery button, build/
456
+ // retry → the build button) via a "primed" class + the remedy as its tooltip —
457
+ // it does NOT re-declare the API call. A finding with no inline button to prime
458
+ // (e.g. a promotable-yet-stalled platform retry) gets a fallback CTA that still
459
+ // calls the SAME existing action function. An informational finding (action:
460
+ // null) shows the chip only; a healthy row has no finding, so no chip.
461
+ const rowFindingFor = (findings: any, change: any) => {
462
+ const byChange =
463
+ change.changeId != null ? findings.get(mergeRowKey(change.changeId, null)) : undefined;
464
+ return (
465
+ byChange ||
466
+ (change.prNumber != null ? findings.get(mergeRowKey(null, change.prNumber)) : undefined)
467
+ );
468
+ };
469
+
470
+ const applyRowFinding = (actionsCell: any, change: any, findings: any, buttons: any = {}) => {
471
+ const finding = rowFindingFor(findings, change);
472
+ if (!finding) return;
473
+ const tag = chip(finding.ownershipLabel, finding.tone);
474
+ tag.classList.add("publish-finding-chip");
475
+ tag.title = finding.detail;
476
+ actionsCell.prepend(tag);
477
+ if (!finding.cta) return; // informational-only diagnosis — chip, no button
478
+ const mapped = finding.cta.kind === "resolve" ? buttons.resolve : buttons.build;
479
+ if (mapped) {
480
+ mapped.classList.add("publish-cta-primed");
481
+ mapped.title = finding.remedy;
482
+ return;
483
+ }
484
+ // No inline button maps to this finding — add a CTA that reuses the SAME
485
+ // existing action function (never a re-declared fetch). Only when the viewer
486
+ // can actually ship; otherwise the chip alone diagnoses it (the row already
487
+ // carries a sign-in hint), never a dead disabled duplicate.
488
+ if (!canShip) return;
489
+ const cta = document.createElement("button");
490
+ cta.type = "button";
491
+ cta.className = "btn publish-cta-primed";
492
+ cta.textContent = finding.cta.label;
493
+ cta.title = finding.remedy;
494
+ cta.addEventListener("click", () =>
495
+ finding.cta.kind === "resolve"
496
+ ? openConflictRecovery(change, cta)
497
+ : buildPreview(change, cta),
498
+ );
499
+ actionsCell.append(cta);
500
+ };
501
+
502
+ const renderQueue = (payload?: any) => {
503
+ const changes = Array.isArray(payload.changes) ? payload.changes : [];
504
+ const rowFindings = mergeRowFindings(payload);
505
+ // Ledger truth (fb-1787916141762-owjzis): which changes are ALREADY integrated
506
+ // into the staged aggregate, read straight from aggregate.includedPrs (the
507
+ // ledger), DECOUPLED from any per-row mergeable/rebuild signal. A change in
508
+ // this set has merged; its row must never offer conflict-recovery (which would
509
+ // try to refresh an already-merged candidate and error "No open candidate").
510
+ const stagedChangeIds = new Set<string>();
511
+ const stagedPrNumbers = new Set<number>();
512
+ for (const p of (payload.aggregate && payload.aggregate.includedPrs) || []) {
513
+ if (p && typeof p.changeId === "string") stagedChangeIds.add(p.changeId);
514
+ if (p && typeof p.prNumber === "number") stagedPrNumbers.add(p.prNumber);
515
+ }
516
+ const isIntegrated = (change: any) =>
517
+ stagedChangeIds.has(change.changeId) ||
518
+ (change.prNumber != null && stagedPrNumbers.has(change.prNumber));
519
+ // A change already staged into the next release has, by that act, been
520
+ // APPROVED — this panel asks "What needs your approval?", so it must not list
521
+ // it. It reappears here only if it's pulled back out of the next release (the
522
+ // "Next release" panel below owns that Undo/revert). So the review queue shows
523
+ // only changes NOT yet in the aggregate; the integrated ones live in "Next
524
+ // release". (Full `changes` is still used for the top "Open changes" metric,
525
+ // which counts everything waiting for review OR go-live.)
526
+ const pending = changes.filter((change: any) => !isIntegrated(change));
527
+ // rux1: surface a forge-listing failure as a visible strip — never let a
528
+ // degraded (short) list read as a genuine empty queue.
529
+ if (degradedStrip) degradedStrip.hidden = payload.degraded !== true;
530
+ queueBody.textContent = "";
531
+ if (queueCount) queueCount.textContent = String(changes.length);
532
+ if (queueChip) {
533
+ queueChip.textContent = pending.length === 1 ? "1 open" : pending.length + " open";
534
+ queueChip.className = "chip" + (pending.length ? " blue" : "");
535
+ }
536
+
537
+ if (!pending.length) {
538
+ const tr = document.createElement("tr");
539
+ // Distinguish "nothing here at all" from "everything open is already added" —
540
+ // the latter is the common state once a merchant has staged their changes, and
541
+ // "No changes waiting" would read as if their work vanished.
542
+ const td = cell(
543
+ subtle(
544
+ changes.length
545
+ ? "Nothing waiting for your approval — every open change is already in your next " +
546
+ "release (see below). Pull one back out there if you want to review it again."
547
+ : "No changes waiting. Make an edit in your store and submit it — it'll show up here to " +
548
+ "review and add to your next release.",
549
+ ),
550
+ );
551
+ td.colSpan = 6;
552
+ tr.append(td);
553
+ queueBody.append(tr);
554
+ return;
555
+ }
556
+
557
+ for (const change of pending) {
558
+ const tr = document.createElement("tr");
559
+ if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
560
+
561
+ // Change id + branch / PR + preview link.
562
+ const idCell = document.createElement("td");
563
+ const idStrong = document.createElement("strong");
564
+ idStrong.textContent = change.changeId || "—";
565
+ idCell.append(idStrong);
566
+ const meta = [];
567
+ if (change.branch) meta.push(change.branch);
568
+ if (change.prNumber != null) meta.push("PR #" + change.prNumber);
569
+ if (meta.length) idCell.append(subtle(meta.join(" · ")));
570
+ // Owner-facing "who did this" — accept takes priority over submit/build (a
571
+ // candidate that's been accepted-to-preview is more meaningfully
572
+ // "acted on" than merely submitted or built). Reject never shows here: a rejected
573
+ // candidate is closed and has already left the open queue.
574
+ const actorRecord = change.actors && (change.actors.accept || change.actors.submit || change.actors.build);
575
+ if (actorRecord) {
576
+ const label = change.actors.accept
577
+ ? "Added by "
578
+ : change.actors.submit
579
+ ? "Submitted by "
580
+ : "Built by ";
581
+ idCell.append(subtle(label + (actorRecord.name || actorRecord.email)));
582
+ }
583
+ // Prefer the synthesized friendly per-PR route (handles every state);
584
+ // fall back to any server-provided previewUrl for change-first rows
585
+ // that have no PR number yet.
586
+ const previewUrl = candidatePrUrl(appDomain, change.prNumber) || change.previewUrl;
587
+ if (previewUrl) {
588
+ const preview = document.createElement("a");
589
+ preview.href = previewUrl;
590
+ preview.target = "_blank";
591
+ preview.rel = "noopener";
592
+ preview.textContent = "Open preview";
593
+ const wrap = document.createElement("span");
594
+ wrap.className = "subtle";
595
+ wrap.append(preview);
596
+ idCell.append(wrap);
597
+ }
598
+ // r08: copy a shareable, view-only preview link for THIS candidate to hand
599
+ // to a non-technical stakeholder (no sign-in). Shown only when a share token
600
+ // was minted and the row has a PR number to point at.
601
+ const candidateShareUrl = candidatePreviewShareUrl(change.prNumber);
602
+ if (candidateShareUrl) {
603
+ const copyBtn = document.createElement("button");
604
+ copyBtn.type = "button";
605
+ copyBtn.className = "btn btn-compact publish-copy-preview";
606
+ const lbl = document.createElement("span");
607
+ lbl.textContent = "Copy preview link";
608
+ copyBtn.append(copyIcon(), lbl);
609
+ copyBtn.addEventListener("click", () => copyPreviewLink(candidateShareUrl, lbl));
610
+ const copyWrap = document.createElement("span");
611
+ copyWrap.className = "subtle";
612
+ copyWrap.append(copyBtn);
613
+ idCell.append(copyWrap);
614
+ }
615
+ tr.append(idCell);
616
+
617
+ // Reconcile status. u13 DISPLAY HONESTY: when the server could not confirm
618
+ // this row's live state against the forge (liveStateConfirmed === false — the
619
+ // forge listing failed), its stored label may be stale (e.g. a merged PR still
620
+ // reads "ready"). Render "State unknown" instead of asserting the stale label
621
+ // as current fact; keep the last-known label as a subtle, clearly-hedged note.
622
+ let statusCell;
623
+ if (change.liveStateConfirmed === false) {
624
+ statusCell = cell(chip("State unknown", "amber", CHIP_GLYPH.amber));
625
+ statusCell.append(
626
+ subtle("Status couldn't be confirmed — last known: " +
627
+ (STATUS_LABEL[change.status] || change.status || "unknown")),
628
+ );
629
+ } else {
630
+ const tone = STATUS_CHIP[change.status] ?? "amber";
631
+ statusCell = cell(
632
+ chip(STATUS_LABEL[change.status] || change.status || "unknown", tone, CHIP_GLYPH[tone]),
633
+ );
634
+ if (change.lastError) statusCell.append(subtle(change.lastError));
635
+ }
636
+ tr.append(statusCell);
637
+
638
+ // Evidence / compliance verdict.
639
+ const checks = (change.evidence && change.evidence.checks) || [];
640
+ const failing = checks.filter((c: any) => c.status === "fail");
641
+ const promotable = Boolean(change.evidence && change.evidence.promotable);
642
+ const checksCell = cell(
643
+ promotable
644
+ ? chip("Checks passed", "green", CHIP_GLYPH.green)
645
+ : chip("Checks blocked", "amber", CHIP_GLYPH.amber),
646
+ );
647
+ if (failing.length) {
648
+ checksCell.append(subtle("Failing: " + failing.map((c: any) => c.label || c.id).join(", ")));
649
+ } else if (!checks.length) {
650
+ checksCell.append(subtle("No checks reported yet"));
651
+ }
652
+ tr.append(checksCell);
653
+
654
+ // Head sha + age.
655
+ const headCell = cell(shortSha(change.headSha));
656
+ headCell.classList.add("publish-mono");
657
+ tr.append(headCell);
658
+ tr.append(cell(age(change.updatedAt)));
659
+
660
+ // Actions.
661
+ const actions = document.createElement("td");
662
+ actions.className = "action publish-actions";
663
+
664
+ // A synthetic not-built forge PR can be reviewed at the lifecycle level
665
+ // without paying to materialize it. Candidate-shaped branches carry a
666
+ // derived changeId, so the operator may Reject immediately; Build preview
667
+ // remains available when visual review is useful.
668
+ if (change.built === false || change.status === "not-built") {
669
+ let notBuiltBuild: any;
670
+ if (canShip) {
671
+ const build = document.createElement("button");
672
+ build.type = "button";
673
+ build.className = "btn primary";
674
+ build.title = "Materialize this PR's hosted preview on demand (does not go live)";
675
+ // rux7: if a build for this PR is mid-flight, a poll re-render must
676
+ // reflect the spinner/disabled state (not a fresh clickable button
677
+ // that would let the operator double-submit). The in-flight closure
678
+ // re-renders on completion, clearing this.
679
+ if (change.prNumber != null && inflightBuilds.has(change.prNumber)) {
680
+ build.textContent = "Building…";
681
+ build.disabled = true;
682
+ build.dataset.building = "true";
683
+ } else {
684
+ build.textContent = "Build preview";
685
+ }
686
+ build.addEventListener("click", () => buildPreview(change, build));
687
+ actions.append(build);
688
+ notBuiltBuild = build;
689
+ if (change.changeId) {
690
+ const reject = document.createElement("button");
691
+ reject.type = "button";
692
+ reject.className = "btn";
693
+ reject.textContent = "Reject";
694
+ reject.disabled = busyChanges.has(change.changeId);
695
+ reject.title = "Close this change without building or publishing it";
696
+ reject.addEventListener("click", () => rejectChange(change, reject));
697
+ actions.append(reject);
698
+ }
699
+ } else {
700
+ actions.append(subtle("Owner/admin sign-in required to build or reject"));
701
+ }
702
+ applyRowFinding(actions, change, rowFindings, { build: notBuiltBuild });
703
+ tr.append(actions);
704
+ queueBody.append(tr);
705
+ continue;
706
+ }
707
+
708
+ // rux7: while an integrate/reject for this change is in flight,
709
+ // keep its mutating actions disabled across a poll re-render so the
710
+ // operator can't double-submit against a freshly-minted button.
711
+ const changeBusy = busyChanges.has(change.changeId);
712
+
713
+ // A candidate that is still waiting for its reconciliation/evidence — or
714
+ // whose build FAILED (blocked) — has never become eligible for Accept. A
715
+ // disabled "Accept to preview" is a dead CTA; the safe recovery already
716
+ // exists at POST /api/preview/build, so make it the primary action: it
717
+ // re-materializes this exact PR head and re-runs its checks (does not merge
718
+ // or publish). `blocked` is included so a build-failed candidate keeps a
719
+ // live "Rebuild" nudge and never sits on a dead end (fail-visible, rule 3).
720
+ const buildFailed = change.status === "blocked";
721
+ const needsChecks = !promotable &&
722
+ (change.status === "queued" || change.status === "reconciling" || buildFailed);
723
+
724
+ // rux6 Review: expand the row into a single "review moment" pane —
725
+ // preview iframe + evidence chips + diff + Accept/Reject — so a reviewer
726
+ // never leaves the queue. Supersedes the standalone Diff button (the dg7
727
+ // diff now lives inside the pane, reusing renderDiff). The row's own
728
+ // Accept/Reject buttons below are unchanged.
729
+ const reviewBtn = document.createElement("button");
730
+ reviewBtn.type = "button";
731
+ reviewBtn.className = "btn";
732
+ reviewBtn.textContent = "Review";
733
+ reviewBtn.setAttribute("aria-expanded", "false");
734
+ reviewBtn.title = "Open one pane to preview, diff, and act on this change";
735
+ reviewBtn.addEventListener("click", () => toggleReview(change, reviewBtn, tr));
736
+ actions.append(reviewBtn);
737
+ let runChecksBuild: any;
738
+ if (needsChecks) {
739
+ const build = document.createElement("button");
740
+ build.type = "button";
741
+ build.className = "btn primary";
742
+ build.title = buildFailed
743
+ ? "This change's preview build failed — rebuild it (re-materializes this PR head and re-runs checks; does not merge or go live)"
744
+ : "Re-run this PR's preview build and checks (does not merge or go live)";
745
+ if (change.prNumber != null && inflightBuilds.has(change.prNumber)) {
746
+ build.textContent = buildFailed ? "Rebuilding…" : "Running checks…";
747
+ build.disabled = true;
748
+ build.dataset.building = "true";
749
+ } else {
750
+ build.textContent = buildFailed ? "Rebuild" : "Run checks";
751
+ build.disabled = !canShip || changeBusy;
752
+ }
753
+ build.addEventListener("click", () => buildPreview(change, build));
754
+ actions.append(build);
755
+ runChecksBuild = build;
756
+ }
757
+ // b11 SPLIT: the candidate-queue primary action is now ACCEPT TO PREVIEW
758
+ // (b08 integrate = advance the shared preview aggregate), NOT go-live.
759
+ // Integrate flips NO live channel, so it has NO paywall (entitled) gate —
760
+ // it holds only the ship-on-behalf capability floor. Going live is the
761
+ // aggregate card's Publish action, once the aggregate is green.
762
+ const accept = document.createElement("button");
763
+ accept.type = "button";
764
+ accept.className = "btn primary";
765
+ // A ready candidate with passed evidence but `mergeable:false` is a
766
+ // forge-settled conflict with the current preview tip. Make the existing
767
+ // automatic rebuild/re-integrate recovery reachable from the queue rather
768
+ // than presenting a generic, disabled "not ready" action. Other false
769
+ // mergeability states remain disabled: a rebuild cannot fix an incomplete
770
+ // reconcile or failed evidence.
771
+ // A change already in the staged aggregate has integrated — its stored
772
+ // `mergeable` can read false once the forge settles the just-merged PR
773
+ // against the tip, but that is NOT a conflict to recover. Read integration
774
+ // truth from the ledger and never route it to recovery.
775
+ const alreadyIntegrated = isIntegrated(change);
776
+ const needsPreviewUpdate =
777
+ !alreadyIntegrated && change.status === "ready" && promotable && change.mergeable === false;
778
+ // Only ONE primary action at a time: while checks are pending, "Run checks"
779
+ // above is the whole story — a second disabled "Run checks first" button
780
+ // next to it said nothing new and just looked broken.
781
+ if (!needsChecks) {
782
+ if (alreadyIntegrated) {
783
+ accept.textContent = "Already added";
784
+ accept.disabled = true;
785
+ accept.title = "This change is already in your next release.";
786
+ } else {
787
+ accept.textContent = needsPreviewUpdate ? "Update change" : "Add to release";
788
+ accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable) || changeBusy;
789
+ accept.title = !canShip
790
+ ? "Requires a ship-on-behalf grant on your account — ask the store owner"
791
+ : needsPreviewUpdate
792
+ ? "Preview changed while you worked — update this change, then add it"
793
+ : !change.mergeable
794
+ ? "This change isn't ready to add yet"
795
+ : "Add this change to your next release (doesn't go live yet)";
796
+ accept.addEventListener("click", () =>
797
+ needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
798
+ );
799
+ }
800
+ actions.append(accept);
801
+ }
802
+ const reject = document.createElement("button");
803
+ reject.type = "button";
804
+ reject.className = "btn";
805
+ reject.textContent = "Reject";
806
+ reject.disabled = !canShip || changeBusy;
807
+ reject.title = canShip
808
+ ? "Close this change without publishing"
809
+ : "Requires a ship-on-behalf grant on your account — ask the store owner";
810
+ reject.addEventListener("click", () => rejectChange(change, reject));
811
+ actions.append(reject);
812
+ // B2: light up this row's mapped button — the build button when the row's
813
+ // finding is a build/retry, the conflict-recovery "Update change" accept
814
+ // button when it's a resolve.
815
+ applyRowFinding(actions, change, rowFindings, {
816
+ build: runChecksBuild,
817
+ resolve: needsPreviewUpdate ? accept : undefined,
818
+ });
819
+ tr.append(actions);
820
+
821
+ queueBody.append(tr);
822
+
823
+ }
824
+ };
825
+
826
+ // "See all" closed/rejected history. A rejected change leaves the open queue
827
+ // (server filters terminal status out of `changes`); the server now also returns
828
+ // the closed ones as `closedChanges` so a reject is recoverable-from-history
829
+ // rather than a black hole. Read-only: id + who-closed-it + a still-reachable
830
+ // preview link. The whole panel is HIDDEN when there are none (no empty chrome).
831
+ const renderClosed = (payload?: any) => {
832
+ if (!closedBody || !closedPanel) return;
833
+ const closed = Array.isArray(payload && payload.closedChanges) ? payload.closedChanges : [];
834
+ closedBody.textContent = "";
835
+ if (closedChip) closedChip.textContent = String(closed.length);
836
+ closedPanel.hidden = closed.length === 0;
837
+ if (!closed.length) return;
838
+
839
+ for (const change of closed) {
840
+ const tr = document.createElement("tr");
841
+ if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
842
+
843
+ // Change id + branch/PR + who closed it + a still-reachable preview link.
844
+ const idCell = document.createElement("td");
845
+ const idStrong = document.createElement("strong");
846
+ idStrong.textContent = change.changeId || "—";
847
+ idCell.append(idStrong);
848
+ const meta = [];
849
+ if (change.branch) meta.push(change.branch);
850
+ if (change.prNumber != null) meta.push("PR #" + change.prNumber);
851
+ if (meta.length) idCell.append(subtle(meta.join(" · ")));
852
+ // "Closed by" — the reject actor when known (fall back to the last actor
853
+ // who touched it, so the row is never anonymous when we have any attribution).
854
+ const closer = change.actors && (change.actors.reject || change.actors.accept || change.actors.build);
855
+ if (closer) idCell.append(subtle("Closed by " + (closer.name || closer.email)));
856
+ const previewUrl = candidatePrUrl(appDomain, change.prNumber) || change.previewUrl;
857
+ if (previewUrl) {
858
+ const preview = document.createElement("a");
859
+ preview.href = previewUrl;
860
+ preview.target = "_blank";
861
+ preview.rel = "noopener";
862
+ preview.textContent = "Open preview";
863
+ const wrap = document.createElement("span");
864
+ wrap.className = "subtle";
865
+ wrap.append(preview);
866
+ idCell.append(wrap);
867
+ }
868
+ tr.append(idCell);
869
+
870
+ // Closed (relative time of the last projection update = when it was closed).
871
+ tr.append(cell(age(change.updatedAt)));
872
+
873
+ // Head sha.
874
+ const headCell = cell(shortSha(change.headSha));
875
+ headCell.classList.add("publish-mono");
876
+ tr.append(headCell);
877
+
878
+ closedBody.append(tr);
879
+ }
880
+ };
881
+
882
+ const renderCurrent = (current?: any, shippedBy?: any) => {
883
+ // r02: the "Live version" metric is a HUMAN label — "v42" linked to the live
884
+ // store — not a raw SHA. The SHA is demoted to a "Technical details"
885
+ // disclosure. The counter (current.versionNumber) is derived from the
886
+ // promotion history server-side; a version with no number (unpromoted seed)
887
+ // falls back to its short SHA rather than fabricating one.
888
+ const linkEl = root.querySelector("[data-publish-live-version-link]");
889
+ const noneEl = root.querySelector("[data-publish-live-version-none]");
890
+ const techEl = root.querySelector("[data-publish-live-tech]");
891
+ const shaEl = root.querySelector("[data-publish-live-sha]");
892
+ const sourceEl = root.querySelector("[data-publish-live-source]");
893
+ const atEl = root.querySelector("[data-publish-live-at]");
894
+ const shippedByEl = root.querySelector("[data-publish-live-shipped-by]");
895
+ if (current && current.versionId) {
896
+ const label =
897
+ current.versionNumber != null ? "v" + current.versionNumber : shortSha(current.versionId);
898
+ if (linkEl) {
899
+ linkEl.textContent = label;
900
+ linkEl.setAttribute("href", ds.liveHref || "/");
901
+ (linkEl as any).hidden = false;
902
+ }
903
+ if (noneEl) (noneEl as any).hidden = true;
904
+ if (shaEl) shaEl.textContent = shortSha(current.versionId);
905
+ if (techEl) (techEl as any).hidden = false;
906
+ if (sourceEl) {
907
+ sourceEl.textContent =
908
+ (current.source === "commit" ? "Published from a change" : "Published version") +
909
+ (current.artifactCount != null ? " · " + current.artifactCount + " files" : "");
910
+ }
911
+ if (atEl) atEl.textContent = current.createdAt ? age(current.createdAt) : "—";
912
+ // Owner-facing "who did this" — see changeActorAttribution.ts. Absent for
913
+ // any version published before this attribution existed, which is honest,
914
+ // not a bug.
915
+ if (shippedByEl) {
916
+ shippedByEl.textContent = shippedBy ? "Shipped by " + (shippedBy.name || shippedBy.email) : "";
917
+ }
918
+ } else {
919
+ if (linkEl) (linkEl as any).hidden = true;
920
+ if (noneEl) {
921
+ noneEl.textContent = "Not live yet";
922
+ (noneEl as any).hidden = false;
923
+ }
924
+ if (techEl) (techEl as any).hidden = true;
925
+ if (shaEl) shaEl.textContent = "—";
926
+ if (sourceEl) sourceEl.textContent = "Nothing has been published for this store";
927
+ if (atEl) atEl.textContent = "—";
928
+ if (shippedByEl) shippedByEl.textContent = "";
929
+ }
930
+ };
931
+
932
+ // ---- rux7: auto-refresh (poll GET /api/changes) ------------------------
933
+ // Keep the queue + aggregate live without a manual reload: poll on an
934
+ // interval that BACKS OFF while nothing changes (grow the delay up to a cap)
935
+ // and SNAPS BACK to the base the moment something does. Pause entirely while
936
+ // the tab is hidden (document.hidden) — a backgrounded admin tab must not
937
+ // hammer the endpoint — and refresh promptly on return. A poll re-render
938
+ // reuses renderQueue/renderAggregate, which already preserve open diff panes
939
+ // and in-flight actions (inflightBuilds / busyChanges / aggregatePublishing),
940
+ // and applyPrDeeplink stays a one-time effect (prDeeplinkResolved), so the
941
+ // ?pr scroll never re-fires on a tick.
942
+ const POLL_BASE_MS = 8000;
943
+ const POLL_MAX_MS = 60000;
944
+ const POLL_GROWTH = 1.6;
945
+ let pollDelay = POLL_BASE_MS;
946
+ let pollTimer: any = null;
947
+ let pollBusy = false;
948
+ let lastChangesSignature: string | null = null;
949
+
950
+ // A compact projection of everything the queue/aggregate render VISIBLY off,
951
+ // so a no-op poll (identical data) neither re-renders nor interrupts a text
952
+ // selection — it just backs off. Relative ages are intentionally excluded;
953
+ // they drift at most one backoff interval and refresh on any real change.
954
+ const changesSignature = (data?: any) => {
955
+ const cur = data.current || {};
956
+ const agg = data.aggregate || {};
957
+ const run = agg.activeRun || agg.lastRun || {};
958
+ const plan = data.plan || {};
959
+ const rows = (Array.isArray(data.changes) ? data.changes : []).map((c: any) => [
960
+ c.changeId,
961
+ c.prNumber,
962
+ c.status,
963
+ c.headSha,
964
+ c.mergeable,
965
+ c.built,
966
+ c.lastError || null,
967
+ c.title || null,
968
+ c.description || null,
969
+ c.intent || null,
970
+ c.verification || null,
971
+ c.riskRollback || null,
972
+ !!(c.evidence && c.evidence.promotable),
973
+ c.actors ? Object.keys(c.actors).sort().join(",") : "",
974
+ ]);
975
+ const summary = data.releaseSummary || (agg.releaseSummary) || null;
976
+ return JSON.stringify({
977
+ degraded: data.degraded === true,
978
+ current: [cur.versionId || null, cur.createdAt || null, cur.artifactCount ?? null],
979
+ shippedBy: (data.shippedBy && data.shippedBy.email) || null,
980
+ aggregate: [
981
+ agg.queueState || null,
982
+ agg.aggregateSha || null,
983
+ run.state || null,
984
+ run.finishedAt || null,
985
+ // include per-PR identity + title + stats so enrichment (a title/scope
986
+ // arriving) re-renders
987
+ (agg.includedPrs || []).map((p: any) => [
988
+ p.prNumber ?? p.changeId ?? null, p.headSha || null, p.title || null,
989
+ p.fileCount ?? null, p.linesAdded ?? null, p.linesRemoved ?? null,
990
+ Array.isArray(p.diff) ? p.diff.length : 0,
991
+ ]),
992
+ ],
993
+ summary: summary ? [summary.lead || summary.text || null, (summary.highlights || []).length] : null,
994
+ scope: data.stagedScope
995
+ ? [data.stagedScope.files, data.stagedScope.added, data.stagedScope.removed]
996
+ : null,
997
+ plan: [plan.ok === true, plan.pinnedSha || null, plan.artifactDigest || null, plan.alreadyShipped === true],
998
+ rows,
999
+ });
1000
+ };
1001
+
1002
+ const clearPoll = () => {
1003
+ if (pollTimer) {
1004
+ clearTimeout(pollTimer);
1005
+ pollTimer = null;
1006
+ }
1007
+ };
1008
+
1009
+ const schedulePoll = () => {
1010
+ clearPoll();
1011
+ // Paused while the browser tab is hidden (visibilitychange resumes) OR
1012
+ // while the Publish admin-tab is not the active one (the IntersectionObserver
1013
+ // resumes it on activation) — an inactive tab must not poll heavy reads.
1014
+ if (document.hidden || !tabActive) return;
1015
+ pollTimer = setTimeout(runPoll, pollDelay);
1016
+ };
1017
+
1018
+ // One poll tick: fetch, and only re-render when the projection actually
1019
+ // changed. Returns an outcome the caller uses to size the next interval.
1020
+ const pollChanges = async () => {
1021
+ let res;
1022
+ try {
1023
+ res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1024
+ } catch {
1025
+ return "error"; // network blip — keep the last good view, just back off
1026
+ }
1027
+ if (res.status === 401 || res.status === 403) return "auth"; // session gone
1028
+ const data = await res.json().catch(() => null);
1029
+ if (!res.ok || !data) return "error";
1030
+ const sig = changesSignature(data);
1031
+ currentStateKey = sig; // heavy panels cache against this fingerprint
1032
+ if (sig === lastChangesSignature) return "unchanged";
1033
+ lastChangesSignature = sig;
1034
+ renderCurrent(data.current, data.shippedBy);
1035
+ renderQueue(data);
1036
+ renderClosed(data);
1037
+ applyPrDeeplink();
1038
+ renderAggregate(data);
1039
+ renderHero(data);
1040
+ return "changed";
1041
+ };
1042
+
1043
+ const runPoll = async () => {
1044
+ pollTimer = null;
1045
+ if (document.hidden || !tabActive || pollBusy) {
1046
+ schedulePoll();
1047
+ return;
1048
+ }
1049
+ pollBusy = true;
1050
+ let outcome;
1051
+ try {
1052
+ outcome = await pollChanges();
1053
+ } finally {
1054
+ pollBusy = false;
1055
+ }
1056
+ if (outcome === "auth") {
1057
+ clearPoll(); // stop polling — a sign-in is required; a manual refresh retries
1058
+ return;
1059
+ }
1060
+ // Branch retention rides the SAME cadence as the queue so its rows reflect
1061
+ // live forge state, not a frozen page-load snapshot (b1: a stale "PR #11
1062
+ // open / Keep" 30+ min after the PR closed) — but ONLY while the branches
1063
+ // panel is actually on screen (lazy: don't fetch a panel the operator
1064
+ // isn't looking at). loadBranches coalesces overlapping ticks and owns its
1065
+ // own no-change guard and unreachable fallback.
1066
+ if (branchesVisible) loadBranches();
1067
+ pollDelay =
1068
+ outcome === "changed"
1069
+ ? POLL_BASE_MS
1070
+ : Math.min(POLL_MAX_MS, Math.round(pollDelay * POLL_GROWTH));
1071
+ schedulePoll();
1072
+ };
1073
+
1074
+ const loadQueue = async () => {
1075
+ try {
1076
+ const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1077
+ if (res.status === 401 || res.status === 403) {
1078
+ if (queueChip) queueChip.textContent = "Sign-in required";
1079
+ if (degradedStrip) degradedStrip.hidden = true;
1080
+ if (heroEl) (heroEl as any).hidden = true;
1081
+ queueBody.textContent = "";
1082
+ const tr = document.createElement("tr");
1083
+ const td = cell(subtle("Sign in as a store owner or admin to view the publish queue."));
1084
+ td.colSpan = 6;
1085
+ tr.append(td);
1086
+ queueBody.append(tr);
1087
+ return;
1088
+ }
1089
+ const data = await res.json();
1090
+ if (!res.ok) {
1091
+ setStatus(statusEl, data.error || "Could not load the change queue (" + res.status + ")", "err");
1092
+ return;
1093
+ }
1094
+ renderCurrent(data.current, data.shippedBy);
1095
+ renderQueue(data);
1096
+ renderClosed(data);
1097
+ applyPrDeeplink();
1098
+ renderAggregate(data);
1099
+ renderHero(data);
1100
+ // rux7: this is fresh authoritative data — resync the poll clock so the
1101
+ // next tick dedupes against it, and reset the cadence to responsive
1102
+ // (a manual refresh / a just-completed action likely means more coming).
1103
+ lastChangesSignature = changesSignature(data);
1104
+ currentStateKey = lastChangesSignature; // heavy panels cache against this
1105
+ pollDelay = POLL_BASE_MS;
1106
+ schedulePoll();
1107
+ } catch {
1108
+ setStatus(statusEl, "Could not load the change queue — network error", "err");
1109
+ }
1110
+ };
1111
+
1112
+ // ---- b11: the aggregate card + exact-digest Publish ---------------------
1113
+ // Reads the aggregate/runs/plan projection GET /api/changes now returns
1114
+ // (AggregateCardView + AggregateShipPlanView, built by aggregateView.ts):
1115
+ // - the aggregate's queueState + build sha + reviewed digest,
1116
+ // - the COMBINED EVIDENCE = the last integration run's honest state +
1117
+ // statusMessage (never re-derived),
1118
+ // - the included-PR batch,
1119
+ // - the b09 ship PLAN (pinned SHA/digest/PRs/rollback/paywall + run link),
1120
+ // shown inline so the confirm never has to POST to preview it.
1121
+
1122
+ const aggPanel = root.querySelector("[data-publish-aggregate]");
1123
+ const AGG_STATE_CHIP: Record<string, string> = {
1124
+ empty: "",
1125
+ queued: "amber",
1126
+ integrating: "blue",
1127
+ green: "green",
1128
+ red: "red",
1129
+ shipped: "green",
1130
+ };
1131
+ // r04 — the Next-release chip in merchant words; the raw queueState still drives
1132
+ // logic, only the label is humanized (no "integrating"/"green"/"red" jargon).
1133
+ const AGG_STATE_LABEL: Record<string, string> = {
1134
+ empty: "No changes yet",
1135
+ queued: "Waiting",
1136
+ integrating: "Building…",
1137
+ green: "Ready to publish",
1138
+ red: "Needs attention",
1139
+ shipped: "Published",
1140
+ };
1141
+ // The plan we last rendered — the exact-digest guard the Publish POST sends
1142
+ // back so a stale plan (queue moved / artifact changed) is refused, never
1143
+ // shipped blind.
1144
+ let currentPlan: any = null;
1145
+
1146
+ const runLink = (revHref?: any, label?: any) => {
1147
+ if (!revHref) return null;
1148
+ const a = document.createElement("a");
1149
+ a.href = revHref;
1150
+ a.target = "_blank";
1151
+ a.rel = "noopener";
1152
+ a.textContent = label || "Open build";
1153
+ return a;
1154
+ };
1155
+
1156
+ const renderAggregate = (data?: any) => {
1157
+ if (!aggPanel) return;
1158
+ const agg = data.aggregate || null;
1159
+ const plan = data.plan || null;
1160
+ currentPlan = plan && plan.ok ? plan : null;
1161
+
1162
+ // AI-assembled release summary — plain-language "what am I publishing?".
1163
+ // OPTIONAL server enrichment: data.releaseSummary { lead|text, highlights?:
1164
+ // [{title, detail}] }. Absent → the whole card stays hidden and the panel
1165
+ // degrades to the technical chain below (graceful fallback, never a stub).
1166
+ const summaryWrap = aggPanel.querySelector("[data-aggregate-summary]");
1167
+ if (summaryWrap) {
1168
+ const summary = data.releaseSummary || (agg && agg.releaseSummary) || null;
1169
+ const lead = summary && (summary.lead || summary.text);
1170
+ if (!lead) {
1171
+ summaryWrap.hidden = true;
1172
+ } else {
1173
+ summaryWrap.hidden = false;
1174
+ const leadEl = summaryWrap.querySelector("[data-aggregate-summary-lead]");
1175
+ const pointsEl = summaryWrap.querySelector("[data-aggregate-summary-points]");
1176
+ if (leadEl) leadEl.textContent = lead;
1177
+ if (pointsEl) {
1178
+ pointsEl.textContent = "";
1179
+ const points = Array.isArray(summary.highlights) ? summary.highlights : [];
1180
+ for (const pt of points) {
1181
+ const li = document.createElement("li");
1182
+ const tick = document.createElement("span");
1183
+ tick.className = "agg-tick";
1184
+ tick.setAttribute("aria-hidden", "true");
1185
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1186
+ const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
1187
+ use.setAttribute("href", "#i-check");
1188
+ svg.append(use);
1189
+ tick.append(svg);
1190
+ const text = document.createElement("span");
1191
+ if (pt && typeof pt === "object" && pt.title) {
1192
+ const b = document.createElement("b");
1193
+ b.textContent = pt.title;
1194
+ text.append(b);
1195
+ if (pt.detail) text.append(document.createTextNode(" — " + pt.detail));
1196
+ } else {
1197
+ text.textContent = typeof pt === "string" ? pt : (pt && pt.detail) || "";
1198
+ }
1199
+ li.append(tick, text);
1200
+ pointsEl.append(li);
1201
+ }
1202
+ pointsEl.hidden = points.length === 0;
1203
+ }
1204
+ }
1205
+ }
1206
+
1207
+ const chipEl = aggPanel.querySelector("[data-aggregate-chip]");
1208
+ const shaEl = aggPanel.querySelector("[data-aggregate-sha]");
1209
+ const runLinkEl = aggPanel.querySelector("[data-aggregate-run-link]");
1210
+ const evEl = aggPanel.querySelector("[data-aggregate-evidence]");
1211
+ const evDetailEl = aggPanel.querySelector("[data-aggregate-evidence-detail]");
1212
+ const digestEl = aggPanel.querySelector("[data-aggregate-digest]");
1213
+ const prsWrap = aggPanel.querySelector("[data-aggregate-prs-wrap]");
1214
+ const prsTitle = aggPanel.querySelector("[data-aggregate-prs-title]");
1215
+ const deltaNote = aggPanel.querySelector("[data-aggregate-delta-note]");
1216
+ const scopeEl = aggPanel.querySelector("[data-aggregate-scope]");
1217
+ const prsList = aggPanel.querySelector("[data-aggregate-prs]");
1218
+ const publishBtn = aggPanel.querySelector("[data-aggregate-publish]");
1219
+ const publishDetail = aggPanel.querySelector("[data-aggregate-publish-detail]");
1220
+ const retryRow = aggPanel.querySelector("[data-aggregate-retry-row]");
1221
+ const retryBtn = aggPanel.querySelector("[data-aggregate-retry-build]");
1222
+
1223
+ const state = (agg && agg.queueState) || "empty";
1224
+ // u15 — only offer a retry while the aggregate is actually red; a poll
1225
+ // refresh landing mid-retry must not re-enable a button whose click is
1226
+ // already in flight (rux7's own guard, mirrored here).
1227
+ if (retryRow) retryRow.hidden = state !== "red";
1228
+ if (retryBtn) retryBtn.disabled = aggregateRetrying;
1229
+ if (chipEl) {
1230
+ const tone = AGG_STATE_CHIP[state] || "";
1231
+ paintChip(chipEl, AGG_STATE_LABEL[state] || state, tone, CHIP_GLYPH[tone]);
1232
+ }
1233
+ // r11 — the human release name (aggregate.name), shown beside "Next release".
1234
+ // Display-only: absent/null → hidden, and the section header reads as before.
1235
+ const nameEl = aggPanel.querySelector("[data-aggregate-name]");
1236
+ if (nameEl) {
1237
+ const releaseName = agg && agg.name;
1238
+ if (releaseName) {
1239
+ nameEl.textContent = releaseName;
1240
+ (nameEl as any).hidden = false;
1241
+ } else {
1242
+ nameEl.textContent = "";
1243
+ (nameEl as any).hidden = true;
1244
+ }
1245
+ }
1246
+ if (shaEl) shaEl.textContent = agg && agg.aggregateSha ? shortSha(agg.aggregateSha) : "—";
1247
+ // Build-link honesty — the card's own sha is always the last-GREEN
1248
+ // materialized sha (a red cycle leaves it untouched), so it's renderable
1249
+ // whenever present: opening it reaches the last-good preview even while the
1250
+ // current cycle is red/in-flight. When the aggregate has NEVER gone green
1251
+ // there is no renderable sha — surface "build failed — rebuild" or "no build
1252
+ // yet", never a link that 404s (rules 1 & 3).
1253
+ const cardSha = agg && agg.aggregateSha;
1254
+ const buildLink = classifyBuildLink({
1255
+ sha: cardSha,
1256
+ built: Boolean(cardSha),
1257
+ building: Boolean(agg && (agg.activeRun || agg.queueState === "integrating")),
1258
+ attempted: Boolean(agg && (agg.lastRun || agg.queueState === "red")),
1259
+ });
1260
+ if (runLinkEl) {
1261
+ runLinkEl.textContent = "";
1262
+ if (buildLink.available && agg && agg.revHref) {
1263
+ runLinkEl.append(runLink(agg.revHref, buildLink.label)!);
1264
+ } else if (buildLink.kind === "failed") {
1265
+ runLinkEl.append(document.createTextNode(buildLink.note + " "));
1266
+ if (canShip && buildLink.action) {
1267
+ const rebuild = document.createElement("button");
1268
+ rebuild.type = "button";
1269
+ rebuild.className = "btn btn-compact publish-cta-primed";
1270
+ rebuild.textContent = buildLink.action.label;
1271
+ rebuild.disabled = aggregateRetrying;
1272
+ rebuild.addEventListener("click", () => retryBuild(rebuild));
1273
+ runLinkEl.append(rebuild);
1274
+ }
1275
+ } else {
1276
+ runLinkEl.append(document.createTextNode(buildLink.note));
1277
+ }
1278
+ }
1279
+ // "Open preview" next to the Next-release header — a one-click hop to the
1280
+ // current preview build. Same honesty: shown only when a genuinely-built
1281
+ // revision exists, so it can never lead to a 404.
1282
+ const openPreviewEl = aggPanel.querySelector("[data-aggregate-open-preview]");
1283
+ if (openPreviewEl) {
1284
+ if (buildLink.available && agg && agg.revHref) {
1285
+ openPreviewEl.setAttribute("href", agg.revHref);
1286
+ (openPreviewEl as any).hidden = false;
1287
+ } else {
1288
+ (openPreviewEl as any).hidden = true;
1289
+ }
1290
+ // The Next-release header is now a <summary> (collapsible panel). This link
1291
+ // lives inside it, so a click would ALSO toggle the panel — stop that so
1292
+ // "Open preview" only opens the preview. Idempotent-guarded so re-renders
1293
+ // don't stack listeners.
1294
+ if (!(openPreviewEl as any).__noToggleBound) {
1295
+ openPreviewEl.addEventListener("click", (e: Event) => e.stopPropagation());
1296
+ (openPreviewEl as any).__noToggleBound = true;
1297
+ }
1298
+ }
1299
+
1300
+ // Combined evidence = the last (or in-flight) integration run's honest state.
1301
+ const lastRun = agg && (agg.activeRun || agg.lastRun);
1302
+ if (evEl) {
1303
+ if (!lastRun) {
1304
+ evEl.textContent = "No runs yet";
1305
+ evEl.dataset.state = "none";
1306
+ } else if (lastRun.state === "passed") {
1307
+ evEl.textContent = "Passed";
1308
+ evEl.dataset.state = "passed";
1309
+ } else if (lastRun.state === "failed") {
1310
+ evEl.textContent = "Failed";
1311
+ evEl.dataset.state = "failed";
1312
+ } else {
1313
+ evEl.textContent = "Integrating…";
1314
+ evEl.dataset.state = "running";
1315
+ }
1316
+ }
1317
+ if (evDetailEl) {
1318
+ evDetailEl.textContent = "";
1319
+ if (lastRun) {
1320
+ const parts = [];
1321
+ if (lastRun.statusMessage) parts.push(lastRun.statusMessage);
1322
+ else if (lastRun.state === "passed") parts.push("Combined evidence passed.");
1323
+ else if (lastRun.state === "failed") parts.push("Combined evidence did not pass.");
1324
+ if (lastRun.finishedAt) parts.push(age(lastRun.finishedAt));
1325
+ evDetailEl.append(document.createTextNode(parts.join(" · ")));
1326
+ // A run's sha is renderable IFF that run PASSED — a failed run's sha never
1327
+ // materialized, so linking it 404s (the exact Open-Build trap). Link only a
1328
+ // passed run; a failed run shows its rebuild affordance via runLinkEl above.
1329
+ const runBuild = classifyBuildLink({
1330
+ sha: lastRun.aggregateSha,
1331
+ built: lastRun.state === "passed",
1332
+ building: lastRun.state === "running" || lastRun.state === "pending",
1333
+ attempted: true,
1334
+ });
1335
+ if (runBuild.available && lastRun.revHref) {
1336
+ evDetailEl.append(document.createTextNode(" · "));
1337
+ evDetailEl.append(runLink(lastRun.revHref, "Run build")!);
1338
+ }
1339
+ } else {
1340
+ evDetailEl.append(document.createTextNode("The last integration run's verdict."));
1341
+ }
1342
+ }
1343
+
1344
+ // Reviewed digest: only known once the plan pins it (b09). Never fabricated.
1345
+ if (digestEl) {
1346
+ digestEl.textContent = currentPlan ? shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) : "—";
1347
+ }
1348
+
1349
+ // u14 — the STAGED delta vs live. The included-PR batch is the "missing
1350
+ // middle": what's in the staged preview but NOT yet on the live site.
1351
+ // `includedPrs` never clears on ship (a shipped aggregate still lists its
1352
+ // PRs), so "ahead of live" MUST key on the shas — staged aggregateSha vs the
1353
+ // live version id — not on the list length alone.
1354
+ const prs = (agg && agg.includedPrs) || [];
1355
+ const stagedSha = agg && agg.aggregateSha;
1356
+ const liveId = data.current && data.current.versionId;
1357
+ const matchesLive = Boolean(stagedSha && liveId && stagedSha === liveId);
1358
+ const aheadCount = prs.length;
1359
+
1360
+ if (prsWrap) prsWrap.hidden = prs.length === 0;
1361
+ if (prsTitle) {
1362
+ prsTitle.textContent =
1363
+ aheadCount && !matchesLive
1364
+ ? "Staged changes — " + aheadCount + " ahead of live"
1365
+ : "Included changes";
1366
+ }
1367
+ if (deltaNote) {
1368
+ let msg = "";
1369
+ if (matchesLive) {
1370
+ msg = "This staged build is exactly what's live — nothing is waiting to publish.";
1371
+ } else if (!liveId) {
1372
+ msg = "Nothing is live yet — publishing makes this staged build your first version.";
1373
+ } else if (aheadCount) {
1374
+ msg =
1375
+ "In the staged preview (" + shortSha(stagedSha) + ") but not on your live site (" +
1376
+ shortSha(liveId) + ") until you publish.";
1377
+ }
1378
+ deltaNote.textContent = msg;
1379
+ deltaNote.hidden = !msg;
1380
+ }
1381
+
1382
+ // Staged → live delta strip: the staged aggregate sha ahead of the live
1383
+ // pointer. Shown only when there IS a delta (ahead of a real live version).
1384
+ const deltaStrip = aggPanel.querySelector("[data-aggregate-delta-strip]");
1385
+ if (deltaStrip) {
1386
+ const showStrip = Boolean(aheadCount && !matchesLive && stagedSha && liveId);
1387
+ deltaStrip.hidden = !showStrip;
1388
+ if (showStrip) {
1389
+ const stagedEl = deltaStrip.querySelector("[data-agg-delta-staged]");
1390
+ const liveEl = deltaStrip.querySelector("[data-agg-delta-live]");
1391
+ const countEl = deltaStrip.querySelector("[data-agg-delta-count]");
1392
+ if (stagedEl) stagedEl.textContent = shortSha(stagedSha);
1393
+ if (liveEl) liveEl.textContent = shortSha(liveId);
1394
+ if (countEl) countEl.textContent = aheadCount + " ahead";
1395
+ }
1396
+ }
1397
+
1398
+ // Top-level scope-of-change: "Scope · N files · +A −R" across the staged batch.
1399
+ // A risk-at-a-glance measure; hidden when no staged change carries size stats.
1400
+ if (scopeEl) {
1401
+ const scope = data.stagedScope;
1402
+ const hasScope = Boolean(scope && (scope.files > 0 || scope.added > 0 || scope.removed > 0));
1403
+ scopeEl.textContent = "";
1404
+ scopeEl.hidden = !hasScope;
1405
+ if (hasScope) {
1406
+ const label = document.createElement("span");
1407
+ label.className = "agg-scope-label";
1408
+ label.textContent = "Scope";
1409
+ scopeEl.append(label);
1410
+ const files = document.createElement("span");
1411
+ files.textContent = scope.files + (scope.files === 1 ? " file" : " files");
1412
+ scopeEl.append(files);
1413
+ if (scope.added || scope.removed) {
1414
+ scopeEl.append(linesNode(scope.added || 0, scope.removed || 0));
1415
+ }
1416
+ if (scope.partial) {
1417
+ const partial = document.createElement("span");
1418
+ partial.className = "agg-scope-partial";
1419
+ partial.textContent = "partial";
1420
+ partial.title = "Some staged changes had no size stats — the totals cover the rest.";
1421
+ scopeEl.append(partial);
1422
+ }
1423
+ }
1424
+ }
1425
+
1426
+ // Merges what used to be two separate "Included changes" lists (this staged-
1427
+ // delta table + the Publish plan's own duplicate <details> list) into ONE:
1428
+ // `agg.includedPrs` is already server-enriched with the same title/description/
1429
+ // fileCount/lines/diff the plan's list used (see enrichIncludedPrs in
1430
+ // api/changes/index.ts) — no separate cross-reference against `plan.includedPrs`
1431
+ // is needed here, the row already carries everything.
1432
+ if (prsList) {
1433
+ prsList.textContent = "";
1434
+ for (const pr of prs) {
1435
+ const tr = document.createElement("tr");
1436
+ tr.className = "agg-included-row";
1437
+
1438
+ // Change: PR # + title (+ description underneath, + diff disclosure if any).
1439
+ const changeCell = document.createElement("td");
1440
+ const prEl = document.createElement("div");
1441
+ prEl.className = "agg-included-pr";
1442
+ prEl.textContent = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
1443
+ changeCell.append(prEl);
1444
+ if (pr.title) {
1445
+ const t = document.createElement("div");
1446
+ t.className = "agg-included-title";
1447
+ t.textContent = pr.title;
1448
+ changeCell.append(t);
1449
+ }
1450
+ const description = pr.description || pr.summary;
1451
+ if (description) {
1452
+ const desc = document.createElement("p");
1453
+ desc.className = "agg-included-desc";
1454
+ desc.textContent = description;
1455
+ changeCell.append(desc);
1456
+ }
1457
+ // A trivial change carries its own bounded diff — offer it in a "View diff"
1458
+ // disclosure (for a tiny change the diff IS the summary). Lines are colored
1459
+ // by their leading +/- marker.
1460
+ if (Array.isArray(pr.diff) && pr.diff.length) {
1461
+ const details = document.createElement("details");
1462
+ details.className = "agg-diff";
1463
+ const summaryEl = document.createElement("summary");
1464
+ summaryEl.textContent = "View diff";
1465
+ details.append(summaryEl);
1466
+ for (const file of pr.diff) {
1467
+ const fileEl = document.createElement("div");
1468
+ fileEl.className = "agg-diff-file";
1469
+ const path = document.createElement("div");
1470
+ path.className = "agg-diff-path publish-mono";
1471
+ path.textContent = file.path;
1472
+ fileEl.append(path);
1473
+ if (file.patch) {
1474
+ const pre = document.createElement("pre");
1475
+ pre.className = "agg-diff-patch publish-mono";
1476
+ for (const line of String(file.patch).split("\n")) {
1477
+ const ln = document.createElement("span");
1478
+ ln.className =
1479
+ line.startsWith("+") && !line.startsWith("+++") ? "agg-diff-add"
1480
+ : line.startsWith("-") && !line.startsWith("---") ? "agg-diff-del"
1481
+ : line.startsWith("@@") ? "agg-diff-hunk" : "";
1482
+ ln.textContent = line + "\n";
1483
+ pre.append(ln);
1484
+ }
1485
+ fileEl.append(pre);
1486
+ } else {
1487
+ const binary = document.createElement("div");
1488
+ binary.className = "subtle";
1489
+ binary.textContent = "(no line-level diff available)";
1490
+ fileEl.append(binary);
1491
+ }
1492
+ details.append(fileEl);
1493
+ }
1494
+ changeCell.append(details);
1495
+ }
1496
+ tr.append(changeCell);
1497
+
1498
+ // Head sha.
1499
+ const shaCell = document.createElement("td");
1500
+ const sha = document.createElement("span");
1501
+ sha.className = "publish-mono sha-token";
1502
+ sha.textContent = shortSha(pr.headSha);
1503
+ shaCell.append(sha);
1504
+ tr.append(shaCell);
1505
+
1506
+ // Files changed.
1507
+ const filesCell = document.createElement("td");
1508
+ const fileCount = pr.fileCount != null ? pr.fileCount : pr.filesChanged;
1509
+ filesCell.textContent = fileCount != null ? String(fileCount) : "—";
1510
+ tr.append(filesCell);
1511
+
1512
+ // Lines added/removed (green/red).
1513
+ const linesCell = document.createElement("td");
1514
+ if (typeof pr.linesAdded === "number" || typeof pr.linesRemoved === "number") {
1515
+ linesCell.append(linesNode(pr.linesAdded, pr.linesRemoved));
1516
+ } else {
1517
+ linesCell.textContent = "—";
1518
+ }
1519
+ tr.append(linesCell);
1520
+
1521
+ // Integrated when/by.
1522
+ const whenCell = document.createElement("td");
1523
+ const bits = [];
1524
+ if (pr.integratedAt) bits.push("added " + age(pr.integratedAt));
1525
+ if (pr.integratedBy && pr.integratedBy.email) bits.push("by " + pr.integratedBy.email);
1526
+ whenCell.textContent = bits.length ? bits.join(" ") : "—";
1527
+ tr.append(whenCell);
1528
+
1529
+ // Actions: u14 Undo — per-change revert: roll THIS change OUT of the staged
1530
+ // preview (a revert commit + rebuild); the live site is untouched. Only for
1531
+ // a PR-numbered, still-ahead-of-live change; gated on ship capability.
1532
+ const actionsCell = document.createElement("td");
1533
+ actionsCell.className = "action";
1534
+ if (canShip && pr.prNumber != null && !matchesLive) {
1535
+ const undo = document.createElement("button");
1536
+ undo.type = "button";
1537
+ undo.className = "btn btn-compact";
1538
+ undo.textContent = "Undo";
1539
+ undo.title =
1540
+ "Revert PR #" + pr.prNumber + " out of the staged preview (your live site is untouched)";
1541
+ undo.addEventListener("click", () => revertStaged(pr, undo));
1542
+ actionsCell.append(undo);
1543
+ }
1544
+ tr.append(actionsCell);
1545
+
1546
+ prsList.append(tr);
1547
+ }
1548
+ }
1549
+
1550
+ renderPlan(data.plan);
1551
+
1552
+ // The Publish button: enabled only for a shippable, paywall-cleared plan and
1553
+ // a viewer who can ship. A paywalled plan is SHOWN (upsell), never hidden. Bug 3
1554
+ // publish-safety: computePublishGuard ALSO hard-blocks a red aggregate (the
1555
+ // latest rebuild failed) regardless of a possibly-stale shippable plan, and
1556
+ // surfaces a "serving stale build" indicator — see publish-guard.ts.
1557
+ const servingBuildAtLabel = (() => {
1558
+ const iso = data.current && data.current.createdAt;
1559
+ if (!iso) return null;
1560
+ const d = new Date(iso);
1561
+ return Number.isNaN(d.getTime()) ? null : d.toLocaleString();
1562
+ })();
1563
+ const guard = computePublishGuard({
1564
+ queueState: (agg && agg.queueState) || null,
1565
+ aggregatePublishing,
1566
+ canShip,
1567
+ shippable: Boolean(currentPlan),
1568
+ paywalled: Boolean(currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed),
1569
+ alreadyShipped: Boolean(currentPlan && currentPlan.alreadyShipped),
1570
+ aheadCount,
1571
+ servingBuildAtLabel,
1572
+ });
1573
+ if (publishBtn) {
1574
+ // rux7: never re-enable the Publish button while its POST is in flight —
1575
+ // a poll refresh landing mid-publish must not open a double-submit (folded
1576
+ // into the guard's `aggregatePublishing` input).
1577
+ publishBtn.disabled = guard.disabled;
1578
+ publishBtn.title = guard.title;
1579
+ }
1580
+
1581
+ // The collapsed destination panel answers the release question in its
1582
+ // summary: green only when the staged release is already live; otherwise
1583
+ // expose the action that advances live to that release. Public-site parity
1584
+ // with the *old* live pointer must never paint this summary green.
1585
+ const liveReleaseState: any = root.querySelector("[data-live-release-state]");
1586
+ const liveReleasePublish: any = root.querySelector("[data-live-release-publish]");
1587
+ if (liveReleaseState && liveReleasePublish) {
1588
+ const liveReleaseView = liveReleaseSummaryState({
1589
+ matchesLive,
1590
+ hasStagedRelease: Boolean(stagedSha),
1591
+ publishDisabled: guard.disabled,
1592
+ publishTitle: guard.title,
1593
+ });
1594
+ if (liveReleaseView.kind === "current") {
1595
+ paintChip(liveReleaseState, liveReleaseView.label, "green", CHIP_GLYPH.green);
1596
+ liveReleaseState.hidden = false;
1597
+ liveReleasePublish.hidden = true;
1598
+ } else if (liveReleaseView.kind === "publish") {
1599
+ liveReleaseState.hidden = true;
1600
+ liveReleasePublish.hidden = false;
1601
+ liveReleasePublish.textContent = liveReleaseView.label;
1602
+ liveReleasePublish.disabled = liveReleaseView.disabled;
1603
+ liveReleasePublish.title = liveReleaseView.title;
1604
+ } else {
1605
+ paintChip(liveReleaseState, liveReleaseView.label, "", "");
1606
+ liveReleaseState.hidden = false;
1607
+ liveReleasePublish.hidden = true;
1608
+ }
1609
+ }
1610
+ if (publishDetail) {
1611
+ if (guard.staleIndicator) {
1612
+ // A failed rebuild is holding new work back — lead with the honest stale
1613
+ // notice instead of the normal "publishes the exact build" copy.
1614
+ publishDetail.textContent = guard.staleIndicator;
1615
+ } else if (currentPlan && currentPlan.alreadyShipped) {
1616
+ publishDetail.textContent =
1617
+ "This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live. ";
1618
+ const view = document.createElement("a");
1619
+ view.href = ds.liveHref || "/";
1620
+ view.target = "_blank";
1621
+ view.rel = "noopener";
1622
+ view.textContent = "View your live store →";
1623
+ publishDetail.append(view);
1624
+ } else if (currentPlan) {
1625
+ publishDetail.textContent = publicSiteHost
1626
+ ? "Publishes the exact build you reviewed (" + shortSha(currentPlan.pinnedSha) +
1627
+ ") to the hosted store immediately, then automatically republishes " + publicSiteHost + "."
1628
+ : "Publishes the exact build you reviewed (" + shortSha(currentPlan.pinnedSha) + ") — no rebuild.";
1629
+ } else {
1630
+ publishDetail.textContent =
1631
+ "Publishes the exact build you reviewed — no rebuild. Available once your next release is ready and its checks pass.";
1632
+ }
1633
+ }
1634
+
1635
+ // u14 — orient the owner between the staged aggregate (above) and the live
1636
+ // versions list (below): how many staged changes are still unpublished and
1637
+ // what Publish does to the versions timeline. Owned here (its OWN element) so
1638
+ // this render never races renderVersions' own poll.
1639
+ const versionsOrientation = root.querySelector("[data-versions-orientation]");
1640
+ if (versionsOrientation) {
1641
+ if (aheadCount && !matchesLive) {
1642
+ versionsOrientation.textContent =
1643
+ aheadCount + " staged change" + (aheadCount === 1 ? " is" : "s are") +
1644
+ " not yet published — Publish live creates the next version here, and the current version becomes your rollback target.";
1645
+ versionsOrientation.hidden = false;
1646
+ } else {
1647
+ versionsOrientation.hidden = true;
1648
+ }
1649
+ }
1650
+ };
1651
+
1652
+ const renderPlan = (plan?: any) => {
1653
+ const planBox = aggPanel && aggPanel.querySelector("[data-aggregate-plan]");
1654
+ if (!planBox) return;
1655
+ const chipEl = planBox.querySelector("[data-aggregate-plan-chip]");
1656
+ const grid = planBox.querySelector("[data-aggregate-plan-grid]");
1657
+ const note = planBox.querySelector("[data-aggregate-plan-note]");
1658
+ if (grid) grid.textContent = "";
1659
+
1660
+ // No plan at all (degenerate: no store bound) — hide the box.
1661
+ if (!plan) {
1662
+ planBox.hidden = true;
1663
+ return;
1664
+ }
1665
+ planBox.hidden = false;
1666
+
1667
+ if (!plan.ok) {
1668
+ // An honest refusal — show WHY it isn't ready, never a fake plan.
1669
+ paintChip(chipEl, "Not ready to publish", "amber", CHIP_GLYPH.amber);
1670
+ if (note) note.textContent = plan.message || ("Not ready to publish (" + (plan.reason || "refused") + ").");
1671
+ return;
1672
+ }
1673
+
1674
+ const paywalled = plan.paywall && !plan.paywall.allowed;
1675
+ if (chipEl) {
1676
+ const label = plan.alreadyShipped ? "Already live" : paywalled ? "Upgrade required" : "Ready to publish";
1677
+ const tone = plan.alreadyShipped ? "green" : paywalled ? "amber" : "green";
1678
+ paintChip(chipEl, label, tone, CHIP_GLYPH[tone]);
1679
+ }
1680
+
1681
+ const row = (term?: any, value?: any, mono?: any) => {
1682
+ const dt = document.createElement("dt");
1683
+ dt.textContent = term;
1684
+ const dd = document.createElement("dd");
1685
+ if (value instanceof Node) dd.append(value);
1686
+ else {
1687
+ dd.textContent = value;
1688
+ if (mono) dd.className = "publish-mono";
1689
+ }
1690
+ grid.append(dt, dd);
1691
+ };
1692
+
1693
+ row("Reviewed build", shortSha(plan.pinnedSha), true);
1694
+ row("Reviewed digest", shortSha(plan.artifactDigest.replace(/^sha256:/, "")), true);
1695
+ const runCell = document.createElement("span");
1696
+ runCell.append(document.createTextNode(plan.integrationRunId));
1697
+ const planRunLink = runLink(plan.pinnedRevHref, "Open build");
1698
+ if (planRunLink) {
1699
+ runCell.append(document.createTextNode(" · "));
1700
+ runCell.append(planRunLink);
1701
+ }
1702
+ row("Integration run", runCell);
1703
+ if (plan.rollbackTarget) {
1704
+ row("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true);
1705
+ } else {
1706
+ row("Rollback to", "None (first publish)");
1707
+ }
1708
+ row("Subscription", plan.paywall && plan.paywall.allowed ? "Enabled" : "Not enabled");
1709
+ // Included changes: rendered ONCE, in the staged-delta table above (prsList) —
1710
+ // the plan's own includedPrs is the same canonical set, so no second list here.
1711
+
1712
+ if (note) {
1713
+ note.textContent = paywalled
1714
+ ? (plan.paywall.message || "Publishing to your live store requires the paid plan.")
1715
+ : plan.alreadyShipped
1716
+ ? "This reviewed build is already live — nothing to publish."
1717
+ : "Publish sends the exact build you reviewed to your live store. No rebuild.";
1718
+ }
1719
+ };
1720
+
1721
+ // The exact-digest Publish (b10 ship = b09 orchestrator.ship). Confirms the
1722
+ // pinned plan, then POSTs { confirmGoLive, expectedAggregateSha,
1723
+ // expectedArtifactDigest } — the reviewed-digest guard: a stale plan (queue
1724
+ // moved / artifact changed) is refused by the server, never shipped blind.
1725
+ // ── Publish-live ceremony (shipoff) ──────────────────────────────────────
1726
+ // The go-live counterpart of the accept liftoff below (shares its shell,
1727
+ // styles, planRow + celebrate helpers — referenced at runtime, defined a few
1728
+ // sections down). One honest in-flight state: ship is a single POST that
1729
+ // promotes the pinned digest, so no staged theater — just plan → publishing →
1730
+ // outcome with a real "View your live store" link.
1731
+ const shipoff = root.querySelector("[data-shipoff]");
1732
+ const shipoffCard = shipoff && shipoff.querySelector(".liftoff-card");
1733
+ const shipoffEls: any = shipoff
1734
+ ? {
1735
+ plan: shipoff.querySelector("[data-shipoff-plan]"),
1736
+ flight: shipoff.querySelector("[data-shipoff-flight]"),
1737
+ outcome: shipoff.querySelector("[data-shipoff-outcome]"),
1738
+ log: shipoff.querySelector("[data-shipoff-log]"),
1739
+ grid: shipoff.querySelector("[data-shipoff-grid]"),
1740
+ proceed: shipoff.querySelector("[data-shipoff-proceed]"),
1741
+ cancel: shipoff.querySelector("[data-shipoff-cancel]"),
1742
+ live: shipoff.querySelector("[data-shipoff-live]"),
1743
+ elapsed: shipoff.querySelector("[data-shipoff-elapsed]"),
1744
+ hide: shipoff.querySelector("[data-shipoff-hide]"),
1745
+ seal: shipoff.querySelector("[data-shipoff-seal]"),
1746
+ outcomeTitle: shipoff.querySelector("[data-shipoff-outcome-title]"),
1747
+ outcomeDetail: shipoff.querySelector("[data-shipoff-outcome-detail]"),
1748
+ failure: shipoff.querySelector("[data-shipoff-failure]"),
1749
+ failureDetail: shipoff.querySelector("[data-shipoff-failure-detail]"),
1750
+ copy: shipoff.querySelector("[data-shipoff-copy]"),
1751
+ view: shipoff.querySelector("[data-shipoff-view]"),
1752
+ undo: shipoff.querySelector("[data-shipoff-undo]"),
1753
+ done: shipoff.querySelector("[data-shipoff-done]"),
1754
+ confetti: shipoff.querySelector("[data-shipoff-confetti]"),
1755
+ }
1756
+ : null;
1757
+ let shipoffTimer: any = null;
1758
+ // r12 — the element that opened the publish ceremony, so closing returns focus
1759
+ // to it (the Publish button or the hero CTA) rather than dropping it to <body>.
1760
+ let shipoffOpenedFrom: any = null;
1761
+ const shipLog = makeLogConsole(shipoffEls && shipoffEls.log);
1762
+ let shipFailureCopyText = "";
1763
+ // A paste-ready brief for a coding agent when a publish-live did not complete —
1764
+ // the honest reason + the pinned build the human was shipping (mirrors
1765
+ // assembleBuildFailureCopy's framing).
1766
+ const assembleShipFailureCopy = (plan?: any, data?: any, message?: string) => {
1767
+ const tenant = appDomain || repo;
1768
+ return [
1769
+ "In my " + tenant + " store, publishing the reviewed build " +
1770
+ (plan && plan.pinnedSha ? shortSha(plan.pinnedSha) + " " : "") +
1771
+ "to my live store did not complete. Diagnose why from the detail below, then tell me the exact fix.",
1772
+ "",
1773
+ "What publish reported:",
1774
+ message || "(no detail was reported)",
1775
+ data && data.state ? "\nOutcome state: " + data.state : "",
1776
+ data && data.reason ? "Reason: " + data.reason : "",
1777
+ "\nContext: this is a Token of Trust storefront tenant. Go-live pins the exact reviewed " +
1778
+ "aggregate digest and promotes it to the live channel — it never rebuilds. A refusal " +
1779
+ "(not_green / paywall / sha_mismatch / bundle_unstaged) leaves the live store unchanged.",
1780
+ ]
1781
+ .filter((l) => l !== "")
1782
+ .join("\n");
1783
+ };
1784
+ if (shipoffEls && shipoffEls.copy) {
1785
+ shipoffEls.copy.addEventListener("click", () => {
1786
+ navigator.clipboard.writeText(shipFailureCopyText).then(
1787
+ () => {
1788
+ shipoffEls.copy.textContent = "Copied";
1789
+ setTimeout(() => { shipoffEls.copy.textContent = "Copy for your AI agent"; }, 1600);
1790
+ },
1791
+ () => { /* clipboard denied — the detail is selectable text */ },
1792
+ );
1793
+ });
1794
+ }
1795
+ const closeShipoff = () => {
1796
+ if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1797
+ if (shipoff) shipoff.hidden = true;
1798
+ if (shipoffEls) shipoffEls.confetti.textContent = "";
1799
+ if (shipoffOpenedFrom && document.contains(shipoffOpenedFrom)) shipoffOpenedFrom.focus();
1800
+ shipoffOpenedFrom = null;
1801
+ };
1802
+ const showShipoffPhase = (phase?: any) => {
1803
+ if (!shipoffEls) return;
1804
+ shipoffEls.plan.hidden = phase !== "plan";
1805
+ shipoffEls.flight.hidden = phase !== "flight";
1806
+ shipoffEls.outcome.hidden = phase !== "outcome";
1807
+ if (shipoffCard) shipoffCard.dataset.mode = phase;
1808
+ };
1809
+
1810
+ const buildPublishPlanText = (plan?: any) => {
1811
+ const lines = ["Publish plan (exact reviewed digest — no rebuild):"];
1812
+ lines.push(" tenant: " + (appDomain || repo));
1813
+ lines.push(" build: " + shortSha(plan.pinnedSha));
1814
+ lines.push(" digest: " + plan.artifactDigest);
1815
+ lines.push(" changes: " + (plan.includedPrs || []).length + " included");
1816
+ if (plan.rollbackTarget) lines.push(" rollback: " + shortSha(plan.rollbackTarget.aggregateSha));
1817
+ lines.push(" effect: publish this exact digest to your LIVE store.");
1818
+ return lines.join("\n");
1819
+ };
1820
+
1821
+ const publishAggregate = (button?: any) => {
1822
+ if (!currentPlan) return;
1823
+ const plan = currentPlan;
1824
+ shipoffOpenedFrom = button || null; // r12: return focus here on close
1825
+ if (!shipoff || !shipoffEls) {
1826
+ // No-markup fallback only — the ceremony IS the confirm now.
1827
+ if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
1828
+ runShip(plan, button);
1829
+ return;
1830
+ }
1831
+ shipoffEls.grid.textContent = "";
1832
+ shipoffEls.grid.append(
1833
+ ...planRow("Store", appDomain || repo),
1834
+ ...planRow("Build", shortSha(plan.pinnedSha), true),
1835
+ ...planRow("Digest", shortDigest(plan.artifactDigest), true),
1836
+ ...planRow("Changes", (plan.includedPrs || []).length + " included"),
1837
+ ...(plan.rollbackTarget
1838
+ ? planRow("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true)
1839
+ : []),
1840
+ );
1841
+ showShipoffPhase("plan");
1842
+ shipoff.hidden = false;
1843
+ shipoffEls.proceed.focus();
1844
+ shipoffEls.cancel.onclick = () => closeShipoff();
1845
+ shipoffEls.done.onclick = () => closeShipoff();
1846
+ shipoffEls.proceed.onclick = () => runShip(plan, button);
1847
+ };
1848
+
1849
+ const runShip = async (plan?: any, button?: any) => {
1850
+ if (button) button.disabled = true;
1851
+ aggregatePublishing = true; // rux7: hold the button disabled across polls
1852
+ setStatus(aggStatusEl, "Publishing the reviewed build (" + shortSha(plan.pinnedSha) + ") live…");
1853
+ const startedAt = Date.now();
1854
+ if (shipoffEls) {
1855
+ showShipoffPhase("flight");
1856
+ shipLog.reset();
1857
+ if (shipoffEls.failure) shipoffEls.failure.hidden = true;
1858
+ shipoffEls.live.textContent = "Pinning the exact reviewed build to your live channel…";
1859
+ shipoffEls.elapsed.textContent = "0:00";
1860
+ if (shipoffEls.hide) shipoffEls.hide.onclick = () => { shipoff.hidden = true; }; // stream keeps running
1861
+ shipoffTimer = setInterval(() => {
1862
+ const s = Math.floor((Date.now() - startedAt) / 1000);
1863
+ shipoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
1864
+ }, 1000);
1865
+ }
1866
+ const outcome = (state?: any, title?: any, detail?: any, showView?: any, failureDetail?: any) => {
1867
+ if (!shipoffEls) return;
1868
+ if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1869
+ shipoffEls.seal.dataset.state = state;
1870
+ shipoffEls.outcomeTitle.textContent = title;
1871
+ shipoffEls.outcomeDetail.textContent = detail;
1872
+ shipoffEls.view.hidden = !showView;
1873
+ if (showView) shipoffEls.view.href = ds.liveHref || "/";
1874
+ // r10: the Undo affordance is wired only on a successful ship (below); reset
1875
+ // it hidden for every outcome so a failure never shows a stale Undo.
1876
+ if (shipoffEls.undo) shipoffEls.undo.hidden = true;
1877
+ // A failure gets the honest detail + a "Copy for your AI agent" brief (same
1878
+ // affordance as the build flight); a clean success hides it.
1879
+ if (shipoffEls.failure) {
1880
+ if (failureDetail) {
1881
+ shipoffEls.failure.hidden = false;
1882
+ shipoffEls.failureDetail.textContent = failureDetail;
1883
+ } else {
1884
+ shipoffEls.failure.hidden = true;
1885
+ }
1886
+ }
1887
+ if (shipoff.hidden) shipoff.hidden = false; // re-surface if backgrounded
1888
+ showShipoffPhase("outcome");
1889
+ shipoffEls.done.focus();
1890
+ if (state === "ok") celebrate(shipoffEls.confetti);
1891
+ };
1892
+ try {
1893
+ const res = await fetch(ds.shipEndpoint, {
1894
+ method: "POST",
1895
+ headers: {
1896
+ "content-type": "application/json",
1897
+ // Real-time server-pushed progress (pin → promote → verify → record) over
1898
+ // SSE; falls back to buffered JSON for a non-streaming proxy.
1899
+ accept: "text/event-stream",
1900
+ "x-tot-capability": "ship-on-behalf",
1901
+ },
1902
+ body: JSON.stringify({
1903
+ aggregateId: plan.aggregateId,
1904
+ // The HUMAN go-live gate + the reviewed-digest guards (b09 ship input).
1905
+ confirmGoLive: true,
1906
+ expectedAggregateSha: plan.pinnedSha,
1907
+ expectedArtifactDigest: plan.artifactDigest,
1908
+ }),
1909
+ });
1910
+ const { data } = await streamWithLog(res, shipLog);
1911
+ // b09 AggregateShipResult: the ONLY live claim is state === "shipped".
1912
+ if (data.state === "shipped") {
1913
+ setStatus(
1914
+ aggStatusEl,
1915
+ "Published live · build " + shortSha(data.pinnedSha) + " (receipt " + (data.receiptId || "—") + ").",
1916
+ "ok",
1917
+ );
1918
+ void autoSweepBranches();
1919
+ outcome(
1920
+ "ok",
1921
+ "Your store is live",
1922
+ "Build " + shortSha(data.pinnedSha) + " — exactly what you reviewed — is now serving" +
1923
+ (data.receiptId ? " (receipt " + data.receiptId + ")." : "."),
1924
+ true,
1925
+ );
1926
+ // r10: right after publishing, offer a one-click Undo that jumps to the
1927
+ // rollback row for the version this release just REPLACED (the plan's
1928
+ // rollback target = the prior live version), pre-targeted. Reuses the Live
1929
+ // Site rollback action; only offered when there IS a prior version.
1930
+ const undoTarget = plan.rollbackTarget && plan.rollbackTarget.aggregateSha;
1931
+ if (shipoffEls && shipoffEls.undo && undoTarget) {
1932
+ shipoffEls.undo.hidden = false;
1933
+ shipoffEls.undo.onclick = () => {
1934
+ closeShipoff();
1935
+ focusRollbackTarget(undoTarget);
1936
+ };
1937
+ }
1938
+ } else {
1939
+ let message;
1940
+ if (data.state === "promote_failed" || data.state === "record_failed") {
1941
+ // Honest partial-failure truth — recoverable, never a false "shipped".
1942
+ message = data.message || "Publish did not complete (" + data.state + "). Retry.";
1943
+ } else if (data.reason === "paywall") {
1944
+ message = data.message || "Publishing to your live store is part of the paid plan.";
1945
+ } else {
1946
+ message = data.message || data.error || "Publish refused (" + (data.reason || data.status || res.status) + ")";
1947
+ }
1948
+ setStatus(aggStatusEl, message, "err");
1949
+ shipFailureCopyText = assembleShipFailureCopy(plan, data, message);
1950
+ outcome("err", "Publish did not complete", message + " Your live store is unchanged.", false, message);
1951
+ // C5 — a refused/failed publish is an aggregate-level event (most tellingly
1952
+ // "not green"); append the health-banner roll-up of what's keeping it red to
1953
+ // the outcome detail (best-effort, additive).
1954
+ surfaceDoctorHint((p) => shipFailureDoctor(p), [shipoffEls && shipoffEls.outcomeDetail, aggStatusEl]);
1955
+ }
1956
+ } catch {
1957
+ setStatus(aggStatusEl, "Publish failed — network error", "err");
1958
+ outcome(
1959
+ "err",
1960
+ "Publish did not complete",
1961
+ "Network error mid-publish — refresh to see the honest state; your live store serves the last published version.",
1962
+ false,
1963
+ );
1964
+ } finally {
1965
+ aggregatePublishing = false;
1966
+ if (button) button.disabled = false;
1967
+ loadQueue();
1968
+ loadVersions({ force: true }); // a publish changed the versions list
1969
+ }
1970
+ };
1971
+
1972
+ const aggStatusEl = aggPanel && aggPanel.querySelector("[data-aggregate-status]");
1973
+ aggPanel
1974
+ ?.querySelector("[data-aggregate-publish]")
1975
+ ?.addEventListener("click", (event: any) => publishAggregate(event.currentTarget));
1976
+ root
1977
+ .querySelector("[data-live-release-publish]")
1978
+ ?.addEventListener("click", (event: any) => {
1979
+ // This action lives inside <summary>; publishing must not also toggle the
1980
+ // disclosure open or closed.
1981
+ event.preventDefault();
1982
+ event.stopPropagation();
1983
+ publishAggregate(event.currentTarget);
1984
+ });
1985
+ root
1986
+ .querySelector("[data-publish-stale-site-link]")
1987
+ ?.addEventListener("click", () => {
1988
+ // The destination panel is collapsed by default; an alert promising the
1989
+ // full breakdown must reveal it before the anchor scroll lands.
1990
+ const details: any = root.querySelector(".live-site-details");
1991
+ if (details) details.open = true;
1992
+ });
1993
+ aggPanel
1994
+ ?.querySelector("[data-aggregate-retry-build]")
1995
+ ?.addEventListener("click", (event: any) => retryBuild(event.currentTarget));
1996
+ // r08: the Next-release "Copy preview link" — shown only when a share token was
1997
+ // minted. The shared preview URL is static (token + canonical domain), so it's
1998
+ // wired once. stopPropagation keeps a copy from also toggling the collapsible.
1999
+ const aggCopyPreviewBtn: any = aggPanel && aggPanel.querySelector("[data-aggregate-copy-preview]");
2000
+ if (aggCopyPreviewBtn && sharedPreviewUrl()) {
2001
+ aggCopyPreviewBtn.hidden = false;
2002
+ const lbl = aggCopyPreviewBtn.querySelector("[data-copy-preview-label]");
2003
+ aggCopyPreviewBtn.addEventListener("click", (e: Event) => {
2004
+ e.stopPropagation();
2005
+ copyPreviewLink(sharedPreviewUrl(), lbl);
2006
+ });
2007
+ }
2008
+
2009
+ // ── Accept-to-preview LIFTOFF ─────────────────────────────────────────────
2010
+ // The plan confirm + live integration flight + outcome ceremony (replaces
2011
+ // the old window.confirm plan popup). Same b11 contract as before: accept =
2012
+ // `POST /api/changes/integrate` (b08) → integrate into the protected
2013
+ // `preview` aggregate, rebuild + combined-evidence it (b05). Flips NO live
2014
+ // channel — going live is the aggregate card's Publish.
2015
+ //
2016
+ // HONESTY RULE for the stage rail: a stage only advances on a REAL signal —
2017
+ // the outcome POST resolving, or GET /api/changes polling showing the
2018
+ // candidate inside `aggregate.includedPrs` (merge landed) / an `activeRun`
2019
+ // (build+evidence running, statusMessage relayed verbatim). No timers ever
2020
+ // fake progress.
2021
+ const liftoff = root.querySelector("[data-liftoff]");
2022
+ const liftoffCard = liftoff && liftoff.querySelector(".liftoff-card");
2023
+ const liftoffEls: any = liftoff
2024
+ ? {
2025
+ plan: liftoff.querySelector("[data-liftoff-plan]"),
2026
+ flight: liftoff.querySelector("[data-liftoff-flight]"),
2027
+ outcome: liftoff.querySelector("[data-liftoff-outcome]"),
2028
+ log: liftoff.querySelector("[data-liftoff-log]"),
2029
+ title: liftoff.querySelector("[data-liftoff-title]"),
2030
+ grid: liftoff.querySelector("[data-liftoff-grid]"),
2031
+ proceed: liftoff.querySelector("[data-liftoff-proceed]"),
2032
+ cancel: liftoff.querySelector("[data-liftoff-cancel]"),
2033
+ flightTitle: liftoff.querySelector("[data-liftoff-flight-title]"),
2034
+ live: liftoff.querySelector("[data-liftoff-live]"),
2035
+ elapsed: liftoff.querySelector("[data-liftoff-elapsed]"),
2036
+ hide: liftoff.querySelector("[data-liftoff-hide]"),
2037
+ seal: liftoff.querySelector("[data-liftoff-seal]"),
2038
+ outcomeTitle: liftoff.querySelector("[data-liftoff-outcome-title]"),
2039
+ outcomeDetail: liftoff.querySelector("[data-liftoff-outcome-detail]"),
2040
+ review: liftoff.querySelector("[data-liftoff-review]"),
2041
+ rebuild: liftoff.querySelector("[data-liftoff-rebuild]"),
2042
+ done: liftoff.querySelector("[data-liftoff-done]"),
2043
+ confetti: liftoff.querySelector("[data-liftoff-confetti]"),
2044
+ conflict: liftoff.querySelector("[data-liftoff-conflict]"),
2045
+ conflictCommands: liftoff.querySelector("[data-conflict-commands]"),
2046
+ conflictCopy: liftoff.querySelector("[data-conflict-copy]"),
2047
+ conflictCopyPrompt: liftoff.querySelector("[data-conflict-copy-prompt]"),
2048
+ conflictRaw: liftoff.querySelector("[data-conflict-raw]"),
2049
+ // u4 — the automate-first recovery surface layered over the expert fallback.
2050
+ conflictSummary: liftoff.querySelector("[data-conflict-summary]"),
2051
+ conflictAuto: liftoff.querySelector("[data-conflict-auto]"),
2052
+ conflictPrimaryStatus: liftoff.querySelector("[data-conflict-primary-status]"),
2053
+ conflictCards: liftoff.querySelector("[data-conflict-cards]"),
2054
+ conflictFiles: liftoff.querySelector("[data-conflict-files]"),
2055
+ conflictCardsStatus: liftoff.querySelector("[data-conflict-cards-status]"),
2056
+ conflictCardButtons: liftoff.querySelectorAll("[data-conflict-card]"),
2057
+ conflictExpert: liftoff.querySelector("[data-conflict-expert]"),
2058
+ }
2059
+ : null;
2060
+
2061
+ // The terminal recovery for a merge-refused candidate: verify the checkout
2062
+ // actually backs THIS change (`tot pr` marks the current checkout's candidate
2063
+ // "(active)" — rebasing the wrong clone/branch is the classic footgun), then
2064
+ // rebase onto the moved preview and re-submit. The open PR updates in place
2065
+ // on re-push. DELIBERATELY comment-free: a live paste-through showed zsh
2066
+ // (no interactivecomments) chokes on `#` lines — every word of guidance
2067
+ // lives in the prose above the block, the block is pure paste-safe commands.
2068
+ const conflictCommandsFor = () => ["tot pr", "git fetch origin", "git rebase origin/preview", "tot preview"].join("\n");
2069
+
2070
+ // The same recovery shaped as a PROMPT for the operator's coding agent
2071
+ // (Claude Code, etc.) — the multi-step, judgment-required version. Every
2072
+ // step below earned its place in a live 2026-08-20 recovery run:
2073
+ // the git-status preflight (an ABANDONED half-rebase with committed
2074
+ // conflict markers was lurking and poisoned the first attempt), the
2075
+ // two-stage auth self-heal (tot login fixes the session, NOT the token
2076
+ // baked into the remote URL — u10 credential-helper removes this), the
2077
+ // conflict-intent framing, and the never-`--fork-candidate` rule.
2078
+ const conflictPromptFor = (change?: any, rawMessage?: any) => {
2079
+ const tenant = appDomain || repo;
2080
+ const pr = change && change.prNumber != null ? "PR #" + change.prNumber : "my open candidate PR";
2081
+ return [
2082
+ "In my " + tenant + " store checkout on this machine, " + pr + " could not be integrated: " +
2083
+ "the shared `preview` branch changed after my change branched, and the two now conflict. " +
2084
+ "Bring my change up to date and re-submit it.",
2085
+ "",
2086
+ "1. Run `git status` first. If a rebase/merge is already in progress (a previous recovery attempt may have been abandoned), inspect it, then `git rebase --abort` to start clean — and check the tracked files for committed `<<<<<<<` conflict markers while you're there.",
2087
+ "2. Run `tot pr` and confirm " + pr + " is listed and marked (active) for this checkout — if it isn't, stop and tell me before touching anything.",
2088
+ "3. Run `git fetch origin`, then `git rebase origin/preview`. On an authentication failure: `tot login`, retry; if fetch STILL fails, the token embedded in the remote URL has rotated — replace the remote with a freshly minted one: `git remote set-url origin \"$(tot clone " + tenant + " --remote-only --print-remote)\"` (this matches how the checkout was originally configured; ask me first if you're unsure).",
2089
+ "4. Resolve any conflicts git flags, preserving the intent of my change on the lines it edited; keep preview's version everywhere else. Show me the resolution if the same lines were edited on both sides. Never commit a file still containing conflict markers; finish with `git rebase --continue` — a half-done rebase must never be left behind.",
2090
+ "5. Re-submit with `tot preview` (plain — never `--fork-candidate`, which would fork a separate PR). " + pr + " updates in place.",
2091
+ "6. Confirm the push succeeded and the submit output reports the preview building. Then end by reminding me how to FINISH adding it once the build is ready: back in the store admin's Publish tab (" + adminPublishUrl() + "), " + pr + " → Add to release" +
2092
+ (change && change.prNumber != null
2093
+ ? " — or from this terminal: `tot accept --tenant " + tenant + " --pr " + change.prNumber + "`."
2094
+ : "."),
2095
+ rawMessage ? "\nThe system's refusal, for reference: " + rawMessage : "",
2096
+ ].join("\n");
2097
+ };
2098
+
2099
+ // The Publish tab's own URL — where the operator copying this prompt is
2100
+ // standing right now — so the agent's closing hand-back can name it exactly.
2101
+ const adminPublishUrl = () => window.location.origin + window.location.pathname + "#publish";
2102
+
2103
+ // ── u4: automate-first conflict recovery ──────────────────────────────────
2104
+ // The per-change resolve route (u3): POST /api/changes/<changeId>/resolve.
2105
+ // Derived off changesEndpoint so it inherits the tenant surface's basePath.
2106
+ const resolveEndpointFor = (changeId?: any) =>
2107
+ ds.changesEndpoint + "/" + encodeURIComponent(changeId) + "/resolve";
2108
+
2109
+
2110
+ // Reveal the three option cards, naming the exact files diff3 could not
2111
+ // auto-combine (never a bare list with no next step).
2112
+ const revealConflictCards = (unresolved?: any) => {
2113
+ if (!liftoffEls.conflictCards) return;
2114
+ liftoffEls.conflictCards.hidden = false;
2115
+ if (liftoffEls.conflictFiles) {
2116
+ liftoffEls.conflictFiles.textContent = "";
2117
+ (unresolved || []).forEach((f: any) => {
2118
+ const li = document.createElement("li");
2119
+ li.textContent = f;
2120
+ liftoffEls.conflictFiles.append(li);
2121
+ });
2122
+ liftoffEls.conflictFiles.hidden = (unresolved || []).length === 0;
2123
+ }
2124
+ };
2125
+
2126
+ // Open the collapsed expert/terminal fallback and bring it into view — the
2127
+ // honest destination for a same-line overlap only a human/agent can combine,
2128
+ // and the graceful landing when automation isn't deployed (501).
2129
+ const revealExpertFallback = () => {
2130
+ if (liftoffEls.conflictExpert) {
2131
+ liftoffEls.conflictExpert.open = true;
2132
+ liftoffEls.conflictExpert.scrollIntoView({ block: "nearest" });
2133
+ }
2134
+ };
2135
+
2136
+ const setConflictStatus = (el?: any, message?: any, state?: any) => {
2137
+ if (!el) return;
2138
+ el.hidden = !message;
2139
+ el.textContent = message || "";
2140
+ if (state) el.dataset.state = state;
2141
+ else delete el.dataset.state;
2142
+ };
2143
+
2144
+ // POST /resolve with `strategy`, then act on the classified outcome. From the
2145
+ // primary action `source` is "primary" (a clean rebuild reveals cards on the
2146
+ // rare overlap); from a card it's "card" (a merge card that re-hits the overlap
2147
+ // escalates to the expert fallback rather than looping). A clean rebuild ALWAYS
2148
+ // auto-re-integrates exactly once via the existing runIntegration flow.
2149
+ const resolveThenReintegrate = async (change?: any, strategy?: any, button?: any, source?: any) => {
2150
+ const statusEl2 = source === "card" ? liftoffEls.conflictCardsStatus : liftoffEls.conflictPrimaryStatus;
2151
+ const controls = [liftoffEls.conflictAuto, ...Array.from(liftoffEls.conflictCardButtons || [])].filter(Boolean);
2152
+ controls.forEach((b) => { b.disabled = true; });
2153
+ setConflictStatus(statusEl2, "Updating your change…");
2154
+ try {
2155
+ const res = await fetch(resolveEndpointFor(change.changeId), {
2156
+ method: "POST",
2157
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
2158
+ body: JSON.stringify({ repo, strategy }),
2159
+ });
2160
+ const body = await res.json().catch(() => ({}));
2161
+ const action = classifyResolveResponse(change, res.status, body);
2162
+ if (action.kind === "reintegrate") {
2163
+ setConflictStatus(statusEl2, "");
2164
+ // Auto-re-integrate ONCE with the refreshed change (new PR/head).
2165
+ runIntegration(action.change, button);
2166
+ } else if (action.kind === "already-integrated") {
2167
+ // The change already merged/integrated — recovery is a clean no-op success,
2168
+ // NOT an error (fb-1787916141762-owjzis). Close the panel and refresh the
2169
+ // queue so the now-stale row drops off; never reveal the expert fallback.
2170
+ setConflictStatus(statusEl2, "This change is already in your next release — nothing to recover.", "ok");
2171
+ if (liftoff) liftoff.hidden = true;
2172
+ loadQueue();
2173
+ } else if (action.kind === "cards") {
2174
+ if (source === "card") {
2175
+ // A merge card that STILL overlaps — no safe auto-combine exists; hand
2176
+ // the specific files to the expert/terminal path.
2177
+ setConflictStatus(
2178
+ statusEl2,
2179
+ "These files need a hand-merge — see “Do it manually instead” below.",
2180
+ "err",
2181
+ );
2182
+ revealConflictCards(action.unresolved);
2183
+ revealExpertFallback();
2184
+ } else {
2185
+ setConflictStatus(statusEl2, "");
2186
+ revealConflictCards(action.unresolved);
2187
+ }
2188
+ } else if (action.kind === "unavailable") {
2189
+ setConflictStatus(
2190
+ statusEl2,
2191
+ "Automatic update isn't available on this store yet — use the manual steps below.",
2192
+ "err",
2193
+ );
2194
+ revealExpertFallback();
2195
+ } else {
2196
+ setConflictStatus(statusEl2, action.message, "err");
2197
+ revealExpertFallback();
2198
+ }
2199
+ } catch {
2200
+ setConflictStatus(statusEl2, "Couldn't reach the server — try the manual steps below.", "err");
2201
+ revealExpertFallback();
2202
+ } finally {
2203
+ controls.forEach((b) => { b.disabled = false; });
2204
+ }
2205
+ };
2206
+
2207
+ // Show/clear the merge-conflict guidance inside the outcome phase. Every
2208
+ // outcome path calls this — visible ONLY for the merge_failed refusal, so a
2209
+ // conflict block from a previous run can never leak into an unrelated outcome.
2210
+ const setConflictGuidance = (visible?: any, rawMessage?: any, change?: any) => {
2211
+ if (!liftoffEls || !liftoffEls.conflict) return;
2212
+ liftoffEls.conflict.hidden = !visible;
2213
+ if (!visible) return;
2214
+ liftoffEls.conflictCommands.textContent = conflictCommandsFor();
2215
+ // The AI prompt is rebuilt per outcome (change + raw message vary) and
2216
+ // stashed on the element; the one-time click handler reads the stash.
2217
+ liftoffEls.conflict.dataset.prompt = conflictPromptFor(change, rawMessage || "");
2218
+ // The forge's own words, kept small for support/diagnosis — the headline
2219
+ // above already says what happened in product language.
2220
+ liftoffEls.conflictRaw.textContent = rawMessage || "";
2221
+ const wireCopy = (btn?: any, label?: any, getText?: any) => {
2222
+ if (!btn || btn.dataset.wired) return;
2223
+ btn.dataset.wired = "1";
2224
+ btn.addEventListener("click", () => {
2225
+ navigator.clipboard.writeText(getText()).then(
2226
+ () => {
2227
+ btn.textContent = "Copied";
2228
+ setTimeout(() => { btn.textContent = label; }, 1600);
2229
+ },
2230
+ () => { /* clipboard denied — the commands are selectable text */ },
2231
+ );
2232
+ });
2233
+ };
2234
+ wireCopy(liftoffEls.conflictCopy, "Copy commands", () => liftoffEls.conflictCommands.textContent);
2235
+ wireCopy(liftoffEls.conflictCopyPrompt, "Copy prompt for your AI agent", () => liftoffEls.conflict.dataset.prompt || "");
2236
+
2237
+ // u4 — reset the automate-first surface for THIS outcome so a prior run's
2238
+ // cards/status/expanded-expert never leak in, then bind the primary action +
2239
+ // option cards to the current change (`.onclick` overwrites, never stacks).
2240
+ if (liftoffEls.conflictCards) liftoffEls.conflictCards.hidden = true;
2241
+ if (liftoffEls.conflictFiles) liftoffEls.conflictFiles.textContent = "";
2242
+ if (liftoffEls.conflictExpert) liftoffEls.conflictExpert.open = false;
2243
+ setConflictStatus(liftoffEls.conflictPrimaryStatus, "");
2244
+ setConflictStatus(liftoffEls.conflictCardsStatus, "");
2245
+ if (liftoffEls.conflictSummary) {
2246
+ // The only structured collision data that survives to the client is the
2247
+ // forge's refusal text (integrate flattens u2's {files,competing} to a
2248
+ // string) + the diff3 `unresolved` list on the resolve response. Name the
2249
+ // competing PR when the refusal mentions one; else stay generic.
2250
+ const prRef = /pr[ #]*([0-9]+)/i.exec(rawMessage || "");
2251
+ liftoffEls.conflictSummary.textContent = prRef
2252
+ ? "Another change (PR #" + prRef[1] + ") reached the store first and touched the same content."
2253
+ : "";
2254
+ liftoffEls.conflictSummary.hidden = !prRef;
2255
+ }
2256
+ if (liftoffEls.conflictAuto) {
2257
+ liftoffEls.conflictAuto.onclick = () =>
2258
+ resolveThenReintegrate(change, "merge", liftoffEls.conflictAuto, "primary");
2259
+ }
2260
+ (liftoffEls.conflictCardButtons || []).forEach((card: any) => {
2261
+ card.onclick = () =>
2262
+ resolveThenReintegrate(change, card.dataset.strategy, card, "card");
2263
+ });
2264
+ };
2265
+
2266
+ // A settled conflict is visible in the queue before an integration POST.
2267
+ // Open the same recovery surface normally shown after `merge_failed`, without
2268
+ // claiming that an integration was attempted or changing the aggregate.
2269
+ const openConflictRecovery = (change?: any, button?: any) => {
2270
+ if (!liftoff || !liftoffEls) {
2271
+ setStatus(
2272
+ statusEl,
2273
+ "This change needs updating against the latest preview before it can be added.",
2274
+ "err",
2275
+ );
2276
+ return;
2277
+ }
2278
+ liftoffOpenedFrom = button || null;
2279
+ const target = change.prNumber != null ? "PR #" + change.prNumber : "This change";
2280
+ liftoffEls.seal.dataset.state = "err";
2281
+ liftoffEls.outcomeTitle.textContent = "Preview changed while you worked";
2282
+ liftoffEls.outcomeDetail.textContent =
2283
+ target + " passed its checks, but preview changed after it was created and the two now " +
2284
+ "conflict. Nothing was added — update this change automatically, then review the result.";
2285
+ liftoffEls.review.hidden = true;
2286
+ setConflictGuidance(true, "", change);
2287
+ showPhase("outcome");
2288
+ liftoff.hidden = false;
2289
+ if (liftoffEls.conflictAuto) liftoffEls.conflictAuto.focus();
2290
+ };
2291
+
2292
+ const liftoffStage = (name?: any) =>
2293
+ liftoff && liftoff.querySelector('[data-liftoff-stage="' + name + '"]');
2294
+ const LIFTOFF_STAGES = ["merge", "build", "evidence", "green"];
2295
+ const setStage = (name?: any, state?: any) => {
2296
+ const el = liftoffStage(name);
2297
+ if (el) el.dataset.state = state; // pending | active | done | fail
2298
+ };
2299
+ // Advance the rail so everything before `name` is done and `name` is active.
2300
+ const railTo = (name?: any) => {
2301
+ const idx = LIFTOFF_STAGES.indexOf(name);
2302
+ LIFTOFF_STAGES.forEach((s, i) => {
2303
+ const el = liftoffStage(s);
2304
+ const cur = el && el.dataset.state;
2305
+ if (cur === "fail") return; // never un-fail
2306
+ setStage(s, i < idx ? "done" : i === idx ? "active" : "pending");
2307
+ });
2308
+ };
2309
+
2310
+ let liftoffTimer: any = null;
2311
+ let liftoffPoll: any = null;
2312
+ let liftoffOpenedFrom: any = null;
2313
+ const liftoffLog = makeLogConsole(liftoffEls && liftoffEls.log);
2314
+ // Map a server-pushed integrate phase onto the four-stage rail — the SAME rail,
2315
+ // now driven by REAL boundaries instead of a guessing poll: `merging` → Merge,
2316
+ // the aggregate rebuild's `reading`/`publishing` → Rebuild, `checking` → Evidence.
2317
+ const railFromIntegratePhase = (ev: { event: string; data: string }) => {
2318
+ if (ev.event !== "phase") return;
2319
+ let phase = "";
2320
+ try { phase = JSON.parse(ev.data).phase || ""; } catch { /* ignore */ }
2321
+ if (phase === "merging") railTo("merge");
2322
+ else if (phase === "reading" || phase === "publishing") railTo("build");
2323
+ else if (phase === "checking") railTo("evidence");
2324
+ };
2325
+ const stopLiftoffLoops = () => {
2326
+ if (liftoffTimer) { clearInterval(liftoffTimer); liftoffTimer = null; }
2327
+ if (liftoffPoll) { clearInterval(liftoffPoll); liftoffPoll = null; }
2328
+ };
2329
+ const closeLiftoff = () => {
2330
+ stopLiftoffLoops();
2331
+ if (liftoff) liftoff.hidden = true;
2332
+ if (liftoffEls) liftoffEls.confetti.textContent = "";
2333
+ if (liftoffOpenedFrom && document.contains(liftoffOpenedFrom)) liftoffOpenedFrom.focus();
2334
+ liftoffOpenedFrom = null;
2335
+ };
2336
+ const showPhase = (phase?: any) => {
2337
+ if (!liftoffEls) return;
2338
+ liftoffEls.plan.hidden = phase !== "plan";
2339
+ liftoffEls.flight.hidden = phase !== "flight";
2340
+ liftoffEls.outcome.hidden = phase !== "outcome";
2341
+ if (liftoffCard) liftoffCard.dataset.mode = phase;
2342
+ };
2343
+
2344
+ const planRow = (dt?: any, dd?: any, mono?: any) => {
2345
+ const t = document.createElement("dt");
2346
+ t.textContent = dt;
2347
+ const d = document.createElement("dd");
2348
+ d.textContent = dd;
2349
+ if (mono) d.className = "publish-mono";
2350
+ return [t, d];
2351
+ };
2352
+
2353
+ const celebrate = (container?: any) => {
2354
+ if (!container) return;
2355
+ if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
2356
+ const colors = ["#0f9d8f", "#25b8a8", "#f4b942", "#5b8def", "#e2634f"];
2357
+ for (let i = 0; i < 42; i++) {
2358
+ const p = document.createElement("i");
2359
+ p.style.left = Math.random() * 100 + "%";
2360
+ p.style.background = colors[i % colors.length]!;
2361
+ p.style.animationDelay = Math.random() * 0.35 + "s";
2362
+ p.style.animationDuration = 0.9 + Math.random() * 0.9 + "s";
2363
+ p.style.setProperty("--liftoff-drift", (Math.random() * 160 - 80).toFixed(0) + "px");
2364
+ p.style.setProperty("--liftoff-spin", (Math.random() * 640 - 320).toFixed(0) + "deg");
2365
+ container.append(p);
2366
+ }
2367
+ setTimeout(() => { container.textContent = ""; }, 2400);
2368
+ };
2369
+
2370
+
2371
+ const openLiftoff = (change?: any, button?: any) => {
2372
+ if (!liftoff || !liftoffEls) return false;
2373
+ liftoffOpenedFrom = button || null;
2374
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
2375
+ liftoffEls.title.textContent = "Add " + target + " to your next release";
2376
+ liftoffEls.grid.textContent = "";
2377
+ liftoffEls.grid.append(
2378
+ ...planRow("Store", appDomain || repo),
2379
+ ...(change.prNumber != null ? planRow("Pull request", "#" + change.prNumber) : []),
2380
+ ...planRow("Change", change.changeId, true),
2381
+ ...(change.headSha ? planRow("Head", shortSha(change.headSha), true) : []),
2382
+ );
2383
+ LIFTOFF_STAGES.forEach((s) => setStage(s, "pending"));
2384
+ liftoffEls.live.textContent = "Working…";
2385
+ liftoffEls.elapsed.textContent = "0:00";
2386
+ showPhase("plan");
2387
+ liftoff.hidden = false;
2388
+ liftoffEls.proceed.focus();
2389
+
2390
+ liftoffEls.cancel.onclick = () => closeLiftoff();
2391
+ liftoffEls.done.onclick = () => closeLiftoff();
2392
+ liftoffEls.hide.onclick = () => { liftoff.hidden = true; }; // flight keeps running
2393
+ liftoffEls.proceed.onclick = () => runIntegration(change, button);
2394
+ return true;
2395
+ };
2396
+
2397
+ // Esc: close in plan/outcome; during flight just hide (the POST keeps going).
2398
+ // Handles BOTH ceremonies (accept liftoff + publish shipoff).
2399
+ document.addEventListener("keydown", (ev) => {
2400
+ if (ev.key !== "Escape") return;
2401
+ if (liftoff && !liftoff.hidden) {
2402
+ const mode = liftoffCard && liftoffCard.dataset.mode;
2403
+ if (mode === "flight") liftoff.hidden = true;
2404
+ else closeLiftoff();
2405
+ }
2406
+ if (shipoff && !shipoff.hidden) {
2407
+ const mode = shipoffCard && shipoffCard.dataset.mode;
2408
+ if (mode === "flight") shipoff.hidden = true;
2409
+ else closeShipoff();
2410
+ }
2411
+ if (buildoff && !buildoff.hidden) {
2412
+ const mode = buildoffCard && buildoffCard.dataset.mode;
2413
+ if (mode === "flight") buildoff.hidden = true; // stream keeps running
2414
+ else closeBuildoff();
2415
+ }
2416
+ if (rebuildoff && !rebuildoff.hidden) {
2417
+ const mode = rebuildoffCard && rebuildoffCard.dataset.mode;
2418
+ if (mode === "flight") rebuildoff.hidden = true; // stream keeps running
2419
+ else closeRebuildoff();
2420
+ }
2421
+ });
2422
+
2423
+ // r12 — focus trap. While a modal ceremony is open (aria-modal), Tab must cycle
2424
+ // WITHIN its card, never escaping to the page behind it. Only the visible phase's
2425
+ // controls are tabbable (the hidden phases' buttons fail the offsetParent test), and
2426
+ // a backgrounded ("Continue in background", hidden) dialog is not trapped.
2427
+ document.addEventListener("keydown", (ev) => {
2428
+ if (ev.key !== "Tab") return;
2429
+ const open: any = [liftoff, shipoff, buildoff, rebuildoff].find((d: any) => d && !d.hidden);
2430
+ if (!open) return;
2431
+ const card: any = open.querySelector(".liftoff-card");
2432
+ if (!card) return;
2433
+ const nodes = Array.from(
2434
+ card.querySelectorAll(
2435
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',
2436
+ ),
2437
+ ).filter((el: any) => el.offsetParent !== null || el === document.activeElement) as any[];
2438
+ if (!nodes.length) {
2439
+ ev.preventDefault();
2440
+ card.focus();
2441
+ return;
2442
+ }
2443
+ const first = nodes[0];
2444
+ const last = nodes[nodes.length - 1];
2445
+ const active = document.activeElement;
2446
+ if (ev.shiftKey) {
2447
+ if (active === first || active === card || !card.contains(active)) {
2448
+ ev.preventDefault();
2449
+ last.focus();
2450
+ }
2451
+ } else if (active === last || active === card || !card.contains(active)) {
2452
+ ev.preventDefault();
2453
+ first.focus();
2454
+ }
2455
+ });
2456
+
2457
+ // Render one classified IntegrateOutcomeView onto the liftoff outcome phase —
2458
+ // the SINGLE place the honest result becomes UI. It reads the derived view
2459
+ // (never re-deciding from raw fields), so "Integration did not land" can only
2460
+ // appear when the merge genuinely did not land; a merge that landed with a
2461
+ // failed build reads "Integrated — preview build failed" and offers Rebuild;
2462
+ // an indeterminate outcome reads "unknown" and offers Re-check. Never a 404
2463
+ // review link (only shown when a genuinely-built revision exists).
2464
+ const applyIntegrateOutcome = (view: IntegrateOutcomeView, change?: any) => {
2465
+ if (!liftoffEls) return;
2466
+ // A successful seal (green OR already-integrated) shows every stage done; a
2467
+ // failure marks its honest failing stage.
2468
+ if (view.seal === "ok") {
2469
+ LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
2470
+ } else if (view.failStage) {
2471
+ setStage(view.failStage, "fail");
2472
+ }
2473
+ liftoffEls.seal.dataset.state = view.seal;
2474
+ liftoffEls.outcomeTitle.textContent = view.title;
2475
+ liftoffEls.outcomeDetail.textContent = view.detail;
2476
+ setConflictGuidance(
2477
+ view.conflictGuidance,
2478
+ view.conflictGuidance ? view.detail : "",
2479
+ change,
2480
+ );
2481
+ // Review deep-link — shown only for a genuinely-built revision (rule 3: no 404).
2482
+ if (view.review.show && view.review.sha) {
2483
+ liftoffEls.review.href =
2484
+ "/" + encodeURIComponent(appDomain) + "/preview/rev/" + view.review.sha + "/";
2485
+ liftoffEls.review.hidden = false;
2486
+ } else {
2487
+ liftoffEls.review.hidden = true;
2488
+ }
2489
+ // The concrete next-step action button (rebuild / re-check). Conflict recovery
2490
+ // owns its own primary button inside the conflict panel, so no duplicate here.
2491
+ const rebuildBtn = liftoffEls.rebuild;
2492
+ if (rebuildBtn) {
2493
+ const act = view.action;
2494
+ const inlineAction =
2495
+ act && (act.kind === "rebuild" || act.kind === "recheck") ? act : null;
2496
+ if (inlineAction) {
2497
+ rebuildBtn.hidden = false;
2498
+ rebuildBtn.textContent = inlineAction.label;
2499
+ rebuildBtn.onclick = () => {
2500
+ closeLiftoff();
2501
+ if (inlineAction.kind === "rebuild") retryBuild(rebuildBtn);
2502
+ else loadQueue();
2503
+ };
2504
+ } else {
2505
+ rebuildBtn.hidden = true;
2506
+ rebuildBtn.onclick = null;
2507
+ }
2508
+ }
2509
+ if (liftoff.hidden) liftoff.hidden = false; // surface even if backgrounded
2510
+ showPhase("outcome");
2511
+ liftoffEls.done.focus();
2512
+ if (view.ok) celebrate(liftoffEls.confetti);
2513
+ };
2514
+
2515
+ // b08 integrate returns an IntegrateOutcome — { ok, queueState, runState,
2516
+ // aggregateSha, pointerMoved, statusMessage, reason? } — NEVER a bare
2517
+ // merged:true. Render it honestly via classifyIntegrateOutcome: green advances
2518
+ // the aggregate; a landed-but-red merge says the build failed (not "did not
2519
+ // land"); an indeterminate result is an explicit unknown, never a silent pass.
2520
+ const runIntegration = async (change?: any, button?: any) => {
2521
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
2522
+ if (button) button.disabled = true;
2523
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
2524
+ setStatus(statusEl, "Adding " + change.changeId + " to your next release…");
2525
+ const startedAt = Date.now();
2526
+ if (liftoffEls) {
2527
+ showPhase("flight");
2528
+ liftoffLog.reset();
2529
+ liftoffEls.flightTitle.textContent = "Adding " + target + " to your next release";
2530
+ railTo("merge");
2531
+ liftoffEls.live.textContent = "Adding your change to the next release…";
2532
+ liftoffTimer = setInterval(() => {
2533
+ const s = Math.floor((Date.now() - startedAt) / 1000);
2534
+ liftoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
2535
+ }, 1000);
2536
+ }
2537
+ try {
2538
+ const res = await fetch(ds.integrateEndpoint, {
2539
+ method: "POST",
2540
+ headers: {
2541
+ "content-type": "application/json",
2542
+ // Real-time server-pushed progress (merge → rebuild → evidence) over SSE;
2543
+ // falls back to buffered JSON for a non-streaming proxy.
2544
+ accept: "text/event-stream",
2545
+ // Mirror of the server-side capability model (UX): the server
2546
+ // re-derives capability and stays authoritative.
2547
+ "x-tot-capability": "ship-on-behalf",
2548
+ },
2549
+ body: JSON.stringify({
2550
+ repo,
2551
+ changeId: change.changeId,
2552
+ prNumber: change.prNumber ?? undefined,
2553
+ expectedHeadSha: change.headSha || undefined,
2554
+ }),
2555
+ });
2556
+ // Drive the stage-rail + scrolling log from the REAL streamed phases (no
2557
+ // more client-side guessing poll); the terminal `result` carries the honest
2558
+ // IntegrateOutcome the branches below render.
2559
+ const { data } = await streamWithLog(res, liftoffLog, railFromIntegratePhase);
2560
+ stopLiftoffLoops();
2561
+ // HONEST RESULT — derived from the actual merge/build signals, never a
2562
+ // separate optimistic flag. `aggregateSha` is the discriminator: the
2563
+ // orchestrator writes it IFF the forge merge landed, so a landed merge
2564
+ // whose build failed reads "Integrated — preview build failed" (with a
2565
+ // Rebuild), and only a genuinely-unlanded merge reads "did not land".
2566
+ const view = classifyIntegrateOutcome({
2567
+ httpOk: res.ok,
2568
+ ok: data.ok === true,
2569
+ aggregateSha: data.aggregateSha ?? null,
2570
+ queueState: data.queueState ?? null,
2571
+ reason: data.reason ?? null,
2572
+ statusMessage: data.statusMessage ?? null,
2573
+ error: data.error ?? null,
2574
+ target,
2575
+ });
2576
+ setStatus(statusEl, view.detail, view.seal);
2577
+ if (view.ok) void autoSweepBranches();
2578
+ if (view.seal === "err") {
2579
+ // C5 — append the merge-doctor's read of THIS candidate's failure to the
2580
+ // outcome detail (best-effort, additive; the view's copy stands on its own).
2581
+ surfaceDoctorHint(
2582
+ (p) => integrateFailureDoctor(p, { changeId: change.changeId, prNumber: change.prNumber }),
2583
+ [liftoffEls && liftoffEls.outcomeDetail, statusEl],
2584
+ );
2585
+ }
2586
+ applyIntegrateOutcome(view, change);
2587
+ } catch {
2588
+ stopLiftoffLoops();
2589
+ // A network fault means we DON'T KNOW whether the merge landed — unknown =
2590
+ // error, never a silent "did not land". Re-check the queue, don't assume.
2591
+ const view = classifyIntegrateOutcome({ httpOk: false, networkError: true, target });
2592
+ setStatus(statusEl, view.detail, "err");
2593
+ applyIntegrateOutcome(view, change);
2594
+ } finally {
2595
+ busyChanges.delete(change.changeId);
2596
+ if (button) button.disabled = false;
2597
+ loadQueue();
2598
+ }
2599
+ };
2600
+
2601
+ const integrateCandidate = (change?: any, button?: any) => {
2602
+ // The liftoff dialog IS the plan confirm now; window.confirm stays only as
2603
+ // the no-dialog fallback (markup missing = fail open to the old popup).
2604
+ if (openLiftoff(change, button)) return;
2605
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
2606
+ const plan =
2607
+ "Add to release plan:\n store: " + (appDomain || repo) +
2608
+ (change.prNumber != null ? "\n PR: #" + change.prNumber : "") +
2609
+ "\n change id: " + change.changeId +
2610
+ (change.headSha ? "\n head sha: " + shortSha(change.headSha) : "") +
2611
+ "\n effect: add " + target + " to your next release, then rebuild the preview and " +
2612
+ "re-run its checks. Does NOT go live — you publish your release separately.";
2613
+ if (!window.confirm(plan + "\n\nProceed?")) return;
2614
+ runIntegration(change, button);
2615
+ };
2616
+
2617
+ const rejectChange = async (change?: any, button?: any) => {
2618
+ const reason = window.prompt(
2619
+ "Reject " + change.changeId +
2620
+ " — this closes the PR and removes its hosted preview and candidate branch. " +
2621
+ "Add a short note for the submitter:",
2622
+ );
2623
+ if (reason == null) return;
2624
+ if (!reason.trim()) {
2625
+ setStatus(statusEl, "A reject note is required.", "err");
2626
+ return;
2627
+ }
2628
+ button.disabled = true;
2629
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
2630
+ setStatus(statusEl, "Rejecting " + change.changeId + "…");
2631
+ try {
2632
+ const res = await fetch(
2633
+ ds.changesEndpoint + "/" + encodeURIComponent(change.changeId) + "/reject",
2634
+ {
2635
+ method: "POST",
2636
+ headers: {
2637
+ "content-type": "application/json",
2638
+ "x-tot-capability": "ship-on-behalf",
2639
+ },
2640
+ body: JSON.stringify({ repo, reason: reason.trim() }),
2641
+ },
2642
+ );
2643
+ const data = await res.json().catch(() => ({}));
2644
+ if (res.ok) {
2645
+ setStatus(
2646
+ statusEl,
2647
+ data.cleanupWarning
2648
+ ? "Change " + change.changeId + " rejected. " + data.cleanupWarning + "."
2649
+ : "Change " +
2650
+ change.changeId +
2651
+ " rejected; its PR, preview, and candidate branch are cleaned up.",
2652
+ data.cleanupWarning ? "err" : "ok",
2653
+ );
2654
+ void autoSweepBranches();
2655
+ } else {
2656
+ setStatus(statusEl, data.error || "Reject failed (" + res.status + ")", "err");
2657
+ }
2658
+ } catch {
2659
+ setStatus(statusEl, "Reject failed — network error", "err");
2660
+ } finally {
2661
+ busyChanges.delete(change.changeId);
2662
+ button.disabled = false;
2663
+ loadQueue();
2664
+ }
2665
+ };
2666
+
2667
+ // u14 — per-change REVERT (Undo): roll ONE change OUT of the shared staged
2668
+ // preview. Appends a revert commit + rebuilds the `preview` aggregate; it NEVER
2669
+ // touches the live channel (no paywall, `/api/changes/revert`). The outcome is
2670
+ // rendered HONESTLY per the queue's reason — `already_reverted` / `target_not_found`
2671
+ // are calm informational states (the change is simply already out of staging), not
2672
+ // errors, so they never flash red.
2673
+ const revertStaged = async (pr?: any, button?: any) => {
2674
+ const target = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
2675
+ const plan =
2676
+ "Undo " + target + " from the staged preview?\n\n" +
2677
+ " tenant: " + (appDomain || repo) + "\n" +
2678
+ " target: " + target + "\n" +
2679
+ " effect: append a revert commit to the staged preview and rebuild — the change\n" +
2680
+ " comes OUT of staging. Your live site is untouched.\n\n" +
2681
+ "Proceed?";
2682
+ if (!window.confirm(plan)) return;
2683
+ button.disabled = true;
2684
+ setStatus(statusEl, "Reverting " + target + " out of the staged preview…");
2685
+ try {
2686
+ const res = await fetch(ds.changesEndpoint + "/revert", {
2687
+ method: "POST",
2688
+ headers: {
2689
+ "content-type": "application/json",
2690
+ "x-tot-capability": "ship-on-behalf",
2691
+ },
2692
+ body: JSON.stringify({ repo, prNumber: pr.prNumber }),
2693
+ });
2694
+ const data = await res.json().catch(() => ({}));
2695
+ if (res.ok && data.ok) {
2696
+ setStatus(statusEl, "Reverted " + target + " — the staged preview is rebuilding.", "ok");
2697
+ } else if (res.ok && (data.reason === "already_reverted" || data.reason === "target_not_found")) {
2698
+ // Not an error: the change is already out of staging (or was never in it).
2699
+ setStatus(
2700
+ statusEl,
2701
+ data.reason === "already_reverted"
2702
+ ? target + " was already removed from the staged preview."
2703
+ : target + " isn't in the staged preview — nothing to undo.",
2704
+ );
2705
+ } else {
2706
+ setStatus(statusEl, data.error || data.statusMessage || "Undo failed (" + res.status + ")", "err");
2707
+ }
2708
+ } catch {
2709
+ setStatus(statusEl, "Undo failed — network error", "err");
2710
+ } finally {
2711
+ button.disabled = false;
2712
+ loadQueue();
2713
+ }
2714
+ };
2715
+
2716
+ // u15 — retry a DEAD aggregate rebuild, no new merge: the last candidate's
2717
+ // PR already merged into preview, but the build/evidence step that follows
2718
+ // died (e.g. a transient MCP error). `POST /api/changes/rebuild` re-runs
2719
+ // ONLY that step against the sha the forge already carries — never a
2720
+ // second merge attempt, never a membership change. It streams its
2721
+ // reading/publishing/checking phases into the SAME shared scrolling live-log
2722
+ // console the other flights use, with Continue-in-background + a Copy-for-AI
2723
+ // failure brief.
2724
+ const rebuildoff: any = root.querySelector("[data-rebuildoff]");
2725
+ const rebuildoffCard = rebuildoff && rebuildoff.querySelector(".liftoff-card");
2726
+ const rebuildoffEls: any = rebuildoff
2727
+ ? {
2728
+ flight: rebuildoff.querySelector("[data-rebuildoff-flight]"),
2729
+ outcome: rebuildoff.querySelector("[data-rebuildoff-outcome]"),
2730
+ log: rebuildoff.querySelector("[data-rebuildoff-log]"),
2731
+ live: rebuildoff.querySelector("[data-rebuildoff-live]"),
2732
+ elapsed: rebuildoff.querySelector("[data-rebuildoff-elapsed]"),
2733
+ hide: rebuildoff.querySelector("[data-rebuildoff-hide]"),
2734
+ seal: rebuildoff.querySelector("[data-rebuildoff-seal]"),
2735
+ outcomeTitle: rebuildoff.querySelector("[data-rebuildoff-outcome-title]"),
2736
+ outcomeDetail: rebuildoff.querySelector("[data-rebuildoff-outcome-detail]"),
2737
+ failure: rebuildoff.querySelector("[data-rebuildoff-failure]"),
2738
+ failureDetail: rebuildoff.querySelector("[data-rebuildoff-failure-detail]"),
2739
+ copy: rebuildoff.querySelector("[data-rebuildoff-copy]"),
2740
+ done: rebuildoff.querySelector("[data-rebuildoff-done]"),
2741
+ }
2742
+ : null;
2743
+ let rebuildoffTimer: any = null;
2744
+ let rebuildoffOpenedFrom: any = null; // r12: return focus here on close
2745
+ let rebuildFailureCopyText = "";
2746
+ const rebuildLog = makeLogConsole(rebuildoffEls && rebuildoffEls.log);
2747
+ const stopRebuildoffTimer = () => {
2748
+ if (rebuildoffTimer) { clearInterval(rebuildoffTimer); rebuildoffTimer = null; }
2749
+ };
2750
+ const closeRebuildoff = () => {
2751
+ stopRebuildoffTimer();
2752
+ if (rebuildoff) rebuildoff.hidden = true;
2753
+ if (rebuildoffOpenedFrom && document.contains(rebuildoffOpenedFrom)) rebuildoffOpenedFrom.focus();
2754
+ rebuildoffOpenedFrom = null;
2755
+ };
2756
+ const showRebuildoffPhase = (phase?: any) => {
2757
+ if (!rebuildoffEls) return;
2758
+ rebuildoffEls.flight.hidden = phase !== "flight";
2759
+ rebuildoffEls.outcome.hidden = phase !== "outcome";
2760
+ if (rebuildoffCard) rebuildoffCard.dataset.mode = phase;
2761
+ };
2762
+ if (rebuildoffEls) {
2763
+ rebuildoffEls.done.onclick = () => closeRebuildoff();
2764
+ if (rebuildoffEls.hide) rebuildoffEls.hide.onclick = () => { rebuildoff.hidden = true; }; // stream keeps running
2765
+ if (rebuildoffEls.copy) {
2766
+ rebuildoffEls.copy.addEventListener("click", () => {
2767
+ navigator.clipboard.writeText(rebuildFailureCopyText).then(
2768
+ () => {
2769
+ rebuildoffEls.copy.textContent = "Copied";
2770
+ setTimeout(() => { rebuildoffEls.copy.textContent = "Copy for your AI agent"; }, 1600);
2771
+ },
2772
+ () => { /* clipboard denied — the detail is selectable text */ },
2773
+ );
2774
+ });
2775
+ }
2776
+ }
2777
+
2778
+ const retryBuild = async (button?: any) => {
2779
+ if (!window.confirm(
2780
+ "Retry the build for " + (appDomain || repo) + "?\n\n" +
2781
+ " effect: rebuild your next release and re-run its checks —\n" +
2782
+ " nothing new is added.\n\n" +
2783
+ "Proceed?",
2784
+ )) return;
2785
+ button.disabled = true;
2786
+ rebuildoffOpenedFrom = button || null; // r12: return focus here on close
2787
+ aggregateRetrying = true; // rux7-style: hold disabled across a poll landing mid-retry
2788
+ setStatus(aggStatusEl, "Retrying the build…");
2789
+ const startedAt = Date.now();
2790
+ if (rebuildoffEls) {
2791
+ showRebuildoffPhase("flight");
2792
+ rebuildLog.reset();
2793
+ if (rebuildoffEls.failure) rebuildoffEls.failure.hidden = true;
2794
+ rebuildoffEls.live.textContent = "Rebuilding your next release preview…";
2795
+ rebuildoffEls.elapsed.textContent = "0:00";
2796
+ rebuildoff.hidden = false;
2797
+ stopRebuildoffTimer();
2798
+ rebuildoffTimer = setInterval(() => {
2799
+ const s = Math.floor((Date.now() - startedAt) / 1000);
2800
+ rebuildoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
2801
+ }, 1000);
2802
+ }
2803
+ const outcome = (state?: any, title?: any, detail?: any, failureDetail?: any) => {
2804
+ stopRebuildoffTimer();
2805
+ if (!rebuildoffEls) return;
2806
+ rebuildoffEls.seal.dataset.state = state;
2807
+ rebuildoffEls.outcomeTitle.textContent = title;
2808
+ rebuildoffEls.outcomeDetail.textContent = detail;
2809
+ if (rebuildoffEls.failure) {
2810
+ if (failureDetail) {
2811
+ rebuildoffEls.failure.hidden = false;
2812
+ rebuildoffEls.failureDetail.textContent = failureDetail;
2813
+ } else {
2814
+ rebuildoffEls.failure.hidden = true;
2815
+ }
2816
+ }
2817
+ if (rebuildoff.hidden) rebuildoff.hidden = false; // re-surface if backgrounded
2818
+ showRebuildoffPhase("outcome");
2819
+ rebuildoffEls.done.focus();
2820
+ };
2821
+ try {
2822
+ const res = await fetch(ds.changesEndpoint + "/rebuild", {
2823
+ method: "POST",
2824
+ headers: {
2825
+ "content-type": "application/json",
2826
+ // Real-time server-pushed progress (reading → publishing → checking) over
2827
+ // SSE; falls back to buffered JSON for a non-streaming proxy.
2828
+ accept: "text/event-stream",
2829
+ "x-tot-capability": "ship-on-behalf",
2830
+ },
2831
+ body: JSON.stringify({ repo }),
2832
+ });
2833
+ const { data } = await streamWithLog(res, rebuildLog);
2834
+ if (data.ok) {
2835
+ setStatus(aggStatusEl, "Build retry passed — your next release is ready.", "ok");
2836
+ outcome("ok", "Your next release is ready", "The rebuild passed all your store's checks. Nothing is live yet.");
2837
+ } else if (data.reason === "nothing_to_rebuild") {
2838
+ setStatus(aggStatusEl, "Nothing to rebuild — there's no earlier build to retry yet.");
2839
+ outcome(
2840
+ "ok",
2841
+ "Nothing to rebuild",
2842
+ "There's no earlier preview build for this store to retry yet.",
2843
+ );
2844
+ } else {
2845
+ const detail =
2846
+ data.statusMessage || data.error || "your next release did not pass its checks (" + (data.reason || data.queueState) + ")";
2847
+ setStatus(aggStatusEl, "Build retry did not pass: " + detail, "err");
2848
+ rebuildFailureCopyText = [
2849
+ "In my " + (appDomain || repo) + " store, retrying the preview build did not pass. " +
2850
+ "Diagnose why from the detail below, then tell me the exact fix.",
2851
+ "",
2852
+ "What the rebuild reported:",
2853
+ detail,
2854
+ "\nContext: this re-runs ONLY the aggregate rebuild + combined evidence against the " +
2855
+ "last merged commit on the shared `preview` branch — no new merge, no membership change.",
2856
+ ].join("\n");
2857
+ outcome("err", "The rebuild didn't pass", detail + " Your next release is unchanged.", detail);
2858
+ surfaceDoctorHint((p) => shipFailureDoctor(p), [rebuildoffEls && rebuildoffEls.outcomeDetail, aggStatusEl]);
2859
+ }
2860
+ } catch {
2861
+ setStatus(aggStatusEl, "Retry failed — network error", "err");
2862
+ outcome(
2863
+ "err",
2864
+ "The rebuild didn't finish",
2865
+ "Network error mid-rebuild — refresh the queue to see your next release's honest state.",
2866
+ );
2867
+ } finally {
2868
+ aggregateRetrying = false;
2869
+ button.disabled = false;
2870
+ loadQueue();
2871
+ }
2872
+ };
2873
+
2874
+ // ---- rux5: build-on-demand for a synthetic not-built PR ------------------
2875
+ // Mirror of the integrate plan affordance: state EXACTLY what build
2876
+ // does — materialize this PR's hosted preview at its forge head — and that it
2877
+ // is INERT (flips no live channel). POSTs { pr, headSha?, baseSha? }; the row
2878
+ // already carries the head sha GET /api/changes resolved via forge PR-read, and
2879
+ // the server re-resolves it authoritatively when the body omits it. Shows an
2880
+ // in-progress state (disabled + "Building…") while the build runs, then reloads
2881
+ // the queue so the freshly-built PR reappears as a built candidate.
2882
+
2883
+ const buildPreviewPlan = (change?: any) => {
2884
+ const lines = ["Build preview plan:", " tenant: " + (appDomain || repo)];
2885
+ lines.push(" PR: #" + change.prNumber);
2886
+ if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
2887
+ lines.push(
2888
+ " effect: materialize this PR's hosted preview at its head — INERT " +
2889
+ "(does NOT go live).",
2890
+ );
2891
+ return lines.join("\n");
2892
+ };
2893
+
2894
+ // ── Build-flight dialog (real-time SSE build/check progress) ─────────────
2895
+ // Mirrors the accept liftoff's flight → outcome ceremony (same liftoff-* markup)
2896
+ // but is driven by the phase events `POST /api/preview/build` streams over SSE.
2897
+ // "Continue in background" hides the dialog while the stream keeps running; on a
2898
+ // FAILURE the outcome shows the honest detail + a "Copy for your AI agent" button.
2899
+ const buildoff: any = root.querySelector("[data-buildoff]");
2900
+ const buildoffCard = buildoff && buildoff.querySelector(".liftoff-card");
2901
+ const buildoffEls: any = buildoff
2902
+ ? {
2903
+ flight: buildoff.querySelector("[data-buildoff-flight]"),
2904
+ outcome: buildoff.querySelector("[data-buildoff-outcome]"),
2905
+ log: buildoff.querySelector("[data-buildoff-log]"),
2906
+ flightTitle: buildoff.querySelector("[data-buildoff-flight-title]"),
2907
+ live: buildoff.querySelector("[data-buildoff-live]"),
2908
+ elapsed: buildoff.querySelector("[data-buildoff-elapsed]"),
2909
+ hide: buildoff.querySelector("[data-buildoff-hide]"),
2910
+ seal: buildoff.querySelector("[data-buildoff-seal]"),
2911
+ outcomeTitle: buildoff.querySelector("[data-buildoff-outcome-title]"),
2912
+ outcomeDetail: buildoff.querySelector("[data-buildoff-outcome-detail]"),
2913
+ failure: buildoff.querySelector("[data-buildoff-failure]"),
2914
+ failureDetail: buildoff.querySelector("[data-buildoff-failure-detail]"),
2915
+ copy: buildoff.querySelector("[data-buildoff-copy]"),
2916
+ review: buildoff.querySelector("[data-buildoff-review]"),
2917
+ done: buildoff.querySelector("[data-buildoff-done]"),
2918
+ }
2919
+ : null;
2920
+
2921
+ let buildoffTimer: any = null;
2922
+ let buildoffOpenedFrom: any = null;
2923
+ let buildFailureCopyText = "";
2924
+ const buildLog = makeLogConsole(buildoffEls && buildoffEls.log);
2925
+ const stopBuildoffTimer = () => {
2926
+ if (buildoffTimer) { clearInterval(buildoffTimer); buildoffTimer = null; }
2927
+ };
2928
+ const closeBuildoff = () => {
2929
+ stopBuildoffTimer();
2930
+ if (buildoff) buildoff.hidden = true;
2931
+ if (buildoffOpenedFrom && document.contains(buildoffOpenedFrom)) buildoffOpenedFrom.focus();
2932
+ buildoffOpenedFrom = null;
2933
+ };
2934
+ const showBuildoffPhase = (phase?: any) => {
2935
+ if (!buildoffEls) return;
2936
+ buildoffEls.flight.hidden = phase !== "flight";
2937
+ buildoffEls.outcome.hidden = phase !== "outcome";
2938
+ if (buildoffCard) buildoffCard.dataset.mode = phase;
2939
+ };
2940
+ const setBuildRail = (stageState?: any) => {
2941
+ if (!buildoff) return;
2942
+ BUILD_STAGES.forEach((s) => {
2943
+ const el = buildoff.querySelector('[data-buildoff-stage="' + s + '"]');
2944
+ if (el) el.dataset.state = stageState[s];
2945
+ });
2946
+ };
2947
+ if (buildoffEls) {
2948
+ // Copy-for-AI wiring (once): the honest failure brief for a coding agent.
2949
+ buildoffEls.copy.addEventListener("click", () => {
2950
+ navigator.clipboard.writeText(buildFailureCopyText).then(
2951
+ () => {
2952
+ buildoffEls.copy.textContent = "Copied";
2953
+ setTimeout(() => { buildoffEls.copy.textContent = "Copy for your AI agent"; }, 1600);
2954
+ },
2955
+ () => { /* clipboard denied — the detail is selectable text */ },
2956
+ );
2957
+ });
2958
+ buildoffEls.done.onclick = () => closeBuildoff();
2959
+ }
2960
+
2961
+ // Stream the build over SSE, reflecting each phase on the flight rail. Falls back
2962
+ // to a one-shot JSON read if the response wasn't an event-stream (a buffering
2963
+ // proxy, or a server that doesn't stream) so the action always completes honestly.
2964
+ const streamBuild = async (change?: any, button?: any) => {
2965
+ const label = "PR #" + change.prNumber;
2966
+ if (change.prNumber != null) inflightBuilds.add(change.prNumber);
2967
+ if (button) {
2968
+ button.disabled = true;
2969
+ button.dataset.building = "true";
2970
+ button.textContent = "Building…";
2971
+ }
2972
+ setStatus(statusEl, "Building preview for " + label + "…");
2973
+
2974
+ let progress: BuildProgress = initialBuildProgress();
2975
+ if (buildoffEls) {
2976
+ buildoffOpenedFrom = button || null;
2977
+ buildoffEls.flightTitle.textContent = "Building preview for " + label;
2978
+ setBuildRail(progress.stageState);
2979
+ buildLog.reset();
2980
+ buildoffEls.live.textContent = "Starting…";
2981
+ buildoffEls.elapsed.textContent = "0:00";
2982
+ if (buildoffEls.failure) buildoffEls.failure.hidden = true;
2983
+ if (buildoffEls.review) buildoffEls.review.hidden = true;
2984
+ showBuildoffPhase("flight");
2985
+ buildoff.hidden = false;
2986
+ const startedAt = Date.now();
2987
+ stopBuildoffTimer();
2988
+ buildoffTimer = setInterval(() => {
2989
+ const s = Math.floor((Date.now() - startedAt) / 1000);
2990
+ buildoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
2991
+ }, 1000);
2992
+ buildoffEls.hide.onclick = () => { buildoff.hidden = true; }; // stream keeps running
2993
+ buildoffEls.done.onclick = () => closeBuildoff();
2994
+ }
2995
+
2996
+ const applyEvent = (ev: any) => {
2997
+ if (ev && ev.event === "phase") buildLog.pushEvent(ev);
2998
+ progress = reduceBuildProgress(progress, ev);
2999
+ if (buildoffEls) {
3000
+ setBuildRail(progress.stageState);
3001
+ buildoffEls.live.textContent = progress.liveLabel;
3002
+ }
3003
+ };
3004
+
3005
+ const renderOutcome = (result: BuildResult) => {
3006
+ stopBuildoffTimer();
3007
+ buildLog.finish(result as StreamTerminalResult);
3008
+ if (result.ok) {
3009
+ setStatus(
3010
+ statusEl,
3011
+ "Built preview for " + label + " — it now appears as a built change in the queue.",
3012
+ "ok",
3013
+ );
3014
+ } else if (result.outcome === "blocked") {
3015
+ setStatus(
3016
+ statusEl,
3017
+ "Build did not complete for " + label + ": " + (buildFailureDetail(result) || "blocked"),
3018
+ "err",
3019
+ );
3020
+ } else {
3021
+ setStatus(
3022
+ statusEl,
3023
+ (result.errors && result.errors.join("; ")) || result.error || "Build failed",
3024
+ "err",
3025
+ );
3026
+ }
3027
+ if (!buildoffEls) return;
3028
+ if (result.ok) {
3029
+ buildoffEls.seal.dataset.state = "ok";
3030
+ buildoffEls.outcomeTitle.textContent = "Preview built";
3031
+ buildoffEls.outcomeDetail.textContent =
3032
+ label + " is materialized — it now appears as a built change in the queue, ready to review.";
3033
+ if (buildoffEls.failure) buildoffEls.failure.hidden = true;
3034
+ if (buildoffEls.review) {
3035
+ if (result.previewUrl) {
3036
+ buildoffEls.review.href = result.previewUrl;
3037
+ buildoffEls.review.hidden = false;
3038
+ } else {
3039
+ buildoffEls.review.hidden = true;
3040
+ }
3041
+ }
3042
+ } else {
3043
+ buildoffEls.seal.dataset.state = "err";
3044
+ buildoffEls.outcomeTitle.textContent =
3045
+ result.outcome === "blocked" ? "A check blocked this build" : "The build didn't finish";
3046
+ buildoffEls.outcomeDetail.textContent =
3047
+ result.outcome === "blocked"
3048
+ ? label + " built, but a check blocked it. Here's exactly what stopped it — copy it for your AI agent to fix:"
3049
+ : label + " couldn't be built. Here's what the build reported — copy it for your AI agent to diagnose:";
3050
+ if (buildoffEls.review) buildoffEls.review.hidden = true;
3051
+ buildFailureCopyText = assembleBuildFailureCopy({
3052
+ tenant: appDomain || repo,
3053
+ prNumber: change.prNumber,
3054
+ changeId: change.changeId,
3055
+ result,
3056
+ });
3057
+ if (buildoffEls.failure) {
3058
+ buildoffEls.failure.hidden = false;
3059
+ buildoffEls.failureDetail.textContent = buildFailureDetail(result) || "(no detail was reported)";
3060
+ }
3061
+ // Re-surface the dialog if it was dismissed to the background — a failure
3062
+ // needs the operator's eyes, unlike a clean success they already walked away from.
3063
+ if (buildoff.hidden) buildoff.hidden = false;
3064
+ }
3065
+ showBuildoffPhase("outcome");
3066
+ if (!buildoff.hidden) buildoffEls.done.focus();
3067
+ };
3068
+
3069
+ try {
3070
+ const res = await fetch(ds.buildEndpoint, {
3071
+ method: "POST",
3072
+ headers: {
3073
+ "content-type": "application/json",
3074
+ accept: "text/event-stream",
3075
+ // Mirror of the server-side capability model (UX); the server
3076
+ // re-derives capability (buildGate) and stays authoritative.
3077
+ "x-tot-capability": "ship-on-behalf",
3078
+ },
3079
+ body: JSON.stringify({
3080
+ pr: change.prNumber,
3081
+ // A row for an ALREADY-KNOWN candidate must carry its real changeId, or
3082
+ // the server falls back to synthesizing "pr-<N>" and writes/reads a
3083
+ // SEPARATE phantom record — the real candidate's ReviewEnvironment never
3084
+ // advances no matter how many times "Run checks" is clicked. Omit it only
3085
+ // for a genuinely not-yet-built row (change.changeId is null there).
3086
+ changeId: change.changeId || undefined,
3087
+ headSha: change.headSha || undefined,
3088
+ baseSha: change.baseSha || undefined,
3089
+ }),
3090
+ });
3091
+ const ct = res.headers.get("content-type") || "";
3092
+ if (res.body && ct.indexOf("text/event-stream") !== -1) {
3093
+ const reader = res.body.getReader();
3094
+ const decoder = new TextDecoder();
3095
+ let buf = "";
3096
+ for (;;) {
3097
+ const { value, done } = await reader.read();
3098
+ if (done) break;
3099
+ buf += decoder.decode(value, { stream: true });
3100
+ const parsed = parseSseChunk(buf);
3101
+ buf = parsed.rest;
3102
+ for (const ev of parsed.events) applyEvent(ev);
3103
+ if (progress.terminal) break;
3104
+ }
3105
+ if (!progress.terminal) {
3106
+ // Stream ended without a terminal result — treat as an honest failure.
3107
+ applyEvent({
3108
+ event: "result",
3109
+ data: JSON.stringify({ ok: false, outcome: "failed", errors: ["the build stream ended unexpectedly"] }),
3110
+ });
3111
+ }
3112
+ renderOutcome(progress.result as BuildResult);
3113
+ } else {
3114
+ // Non-streaming fallback: read the JSON body the endpoint returns to
3115
+ // non-streaming callers and normalize it into the same outcome shape.
3116
+ const data = await res.json().catch(() => ({}) as any);
3117
+ renderOutcome({
3118
+ ok: !!data.ok,
3119
+ outcome: data.outcome || (data.ok ? "ok" : res.status === 422 ? "blocked" : "failed"),
3120
+ tenantId: data.tenantId,
3121
+ changeId: data.changeId,
3122
+ headSha: data.headSha,
3123
+ previewUrl: data.previewUrl,
3124
+ errors: data.errors,
3125
+ error: data.error,
3126
+ findings: data.findings,
3127
+ });
3128
+ }
3129
+ } catch {
3130
+ setStatus(statusEl, "Build failed — network error", "err");
3131
+ if (buildoffEls) {
3132
+ stopBuildoffTimer();
3133
+ buildoffEls.seal.dataset.state = "err";
3134
+ buildoffEls.outcomeTitle.textContent = "The build didn't finish";
3135
+ buildoffEls.outcomeDetail.textContent =
3136
+ "Network error mid-build — refresh the queue to see the honest state.";
3137
+ if (buildoffEls.failure) buildoffEls.failure.hidden = true;
3138
+ if (buildoffEls.review) buildoffEls.review.hidden = true;
3139
+ if (buildoff.hidden) buildoff.hidden = false;
3140
+ showBuildoffPhase("outcome");
3141
+ }
3142
+ } finally {
3143
+ if (change.prNumber != null) inflightBuilds.delete(change.prNumber);
3144
+ if (button) {
3145
+ button.disabled = false;
3146
+ delete button.dataset.building;
3147
+ }
3148
+ loadQueue();
3149
+ }
3150
+ };
3151
+
3152
+ const buildPreview = async (change?: any, button?: any) => {
3153
+ if (!window.confirm(buildPreviewPlan(change) + "\n\nBuild the preview now?")) return;
3154
+ await streamBuild(change, button);
3155
+ };
3156
+
3157
+ // ---- rux6: unified review pane -----------------------------------------
3158
+ // "One review moment": expanding a candidate row reveals a single pane with
3159
+ // the live preview (iframed SAME-ORIGIN — the /<tenant>/preview/pr/<N> route
3160
+ // serves `frame-ancestors 'self'` + `X-Frame-Options: SAMEORIGIN`, so a
3161
+ // same-origin admin embed is permitted; a cross-origin embed is refused,
3162
+ // which is why the pane also always offers an "Open in new tab" link), the
3163
+ // evidence chips the row already carries (change.evidence), the dg7 diff
3164
+ // (reusing renderDiff below — GET /api/changes/<id>/diff, which never invents
3165
+ // a diff for a non-renderable candidate), and the SAME Accept-to-preview /
3166
+ // Reject actions. The reviewer never leaves the queue to review and act; the
3167
+ // server stays authoritative for every action (this is UI consolidation only).
3168
+
3169
+ const diffBase = ds.diffEndpointBase;
3170
+
3171
+ const reviewRowFor = (tr?: any) => {
3172
+ const next = tr.nextElementSibling;
3173
+ return next && next.dataset && next.dataset.publishReviewRow === tr.dataset.changeRowId
3174
+ ? next
3175
+ : null;
3176
+ };
3177
+
3178
+ const closeReview = (tr?: any, button?: any) => {
3179
+ const existing = reviewRowFor(tr);
3180
+ if (existing) existing.remove();
3181
+ if (button) {
3182
+ button.textContent = "Review";
3183
+ button.setAttribute("aria-expanded", "false");
3184
+ }
3185
+ };
3186
+
3187
+ // Evidence chips from the data the row already carries (change.evidence):
3188
+ // an overall promotable verdict + one chip per reported check.
3189
+ const renderEvidenceChips = (container?: any, change?: any) => {
3190
+ const checks = (change.evidence && change.evidence.checks) || [];
3191
+ const promotable = Boolean(change.evidence && change.evidence.promotable);
3192
+ container.append(
3193
+ promotable
3194
+ ? chip("Checks passed", "green", CHIP_GLYPH.green)
3195
+ : chip("Checks blocked", "amber", CHIP_GLYPH.amber),
3196
+ );
3197
+ for (const c of checks) {
3198
+ const tone = c.status === "pass" ? "green" : c.status === "fail" ? "red" : "amber";
3199
+ const label = (c.label || c.id || "check") + (c.status && c.status !== "pass" ? " · " + c.status : "");
3200
+ const el = chip(label, tone, CHIP_GLYPH[tone]);
3201
+ if (c.detail) el.title = c.detail;
3202
+ container.append(el);
3203
+ }
3204
+ if (!checks.length) container.append(subtle("No checks reported yet"));
3205
+ };
3206
+
3207
+ // Build the split pane. Returns the pane node + the diff container so the
3208
+ // caller can populate the diff asynchronously (reusing renderDiff).
3209
+ const buildReviewPane = (change?: any) => {
3210
+ const pane = document.createElement("div");
3211
+ pane.className = "review-pane";
3212
+
3213
+ // Preview column — same-origin iframe of the friendly per-PR route (it
3214
+ // handles every state: ready render, or the not-built page). Falls back to
3215
+ // any server-provided previewUrl for a change-first row with no PR number.
3216
+ const previewUrl = candidatePrUrl(appDomain, change.prNumber) || change.previewUrl;
3217
+ const previewCol = document.createElement("div");
3218
+ previewCol.className = "review-preview";
3219
+ const pHead = document.createElement("div");
3220
+ pHead.className = "review-pane-head";
3221
+ const pTitle = document.createElement("b");
3222
+ pTitle.textContent = "Preview";
3223
+ pHead.append(pTitle);
3224
+ if (previewUrl) {
3225
+ const openLink = document.createElement("a");
3226
+ openLink.href = previewUrl;
3227
+ openLink.target = "_blank";
3228
+ openLink.rel = "noopener";
3229
+ openLink.textContent = "Open in new tab ↗";
3230
+ pHead.append(openLink);
3231
+ }
3232
+ previewCol.append(pHead);
3233
+ if (previewUrl) {
3234
+ const frame = document.createElement("iframe");
3235
+ frame.className = "review-frame";
3236
+ frame.src = previewUrl;
3237
+ frame.loading = "lazy";
3238
+ frame.title =
3239
+ change.prNumber != null
3240
+ ? "Preview of PR #" + change.prNumber
3241
+ : "Preview of " + change.changeId;
3242
+ previewCol.append(frame);
3243
+ } else {
3244
+ previewCol.append(subtle("No preview is available for this change yet."));
3245
+ }
3246
+
3247
+ // Detail column — evidence chips + diff + the same Accept/Reject actions.
3248
+ const detailCol = document.createElement("div");
3249
+ detailCol.className = "review-detail";
3250
+
3251
+ const summaryValues = [
3252
+ ["Intent", change.intent],
3253
+ ["User-visible effect", change.description],
3254
+ ["Verification", change.verification],
3255
+ ["Risk / rollback", change.riskRollback],
3256
+ ].filter((entry) => typeof entry[1] === "string" && entry[1].trim());
3257
+ if ((typeof change.title === "string" && change.title.trim()) || summaryValues.length) {
3258
+ const summarySection = document.createElement("div");
3259
+ summarySection.className = "review-section";
3260
+ const summaryTitle = document.createElement("b");
3261
+ summaryTitle.textContent = change.title && change.title.trim()
3262
+ ? change.title.trim()
3263
+ : "About this change";
3264
+ summarySection.append(summaryTitle);
3265
+ for (const [label, value] of summaryValues) {
3266
+ const line = document.createElement("div");
3267
+ const labelEl = document.createElement("strong");
3268
+ labelEl.textContent = label + ": ";
3269
+ line.append(labelEl, document.createTextNode(value.trim()));
3270
+ summarySection.append(line);
3271
+ }
3272
+ detailCol.append(summarySection);
3273
+ }
3274
+
3275
+ const evSection = document.createElement("div");
3276
+ evSection.className = "review-section";
3277
+ const evTitle = document.createElement("b");
3278
+ evTitle.textContent = "Checks";
3279
+ const evChips = document.createElement("div");
3280
+ evChips.className = "review-chips";
3281
+ renderEvidenceChips(evChips, change);
3282
+ evSection.append(evTitle, evChips);
3283
+ detailCol.append(evSection);
3284
+
3285
+ const diffSection = document.createElement("div");
3286
+ diffSection.className = "review-section";
3287
+ const diffTitle = document.createElement("b");
3288
+ diffTitle.textContent = "Diff";
3289
+ const diffTarget = document.createElement("div");
3290
+ diffTarget.className = "review-diff-target";
3291
+ diffTarget.append(subtle("Loading the diff…"));
3292
+ diffSection.append(diffTitle, diffTarget);
3293
+ detailCol.append(diffSection);
3294
+
3295
+ // The SAME actions as the row — same handlers, same gates. Not a new authz
3296
+ // path: integrateCandidate/rejectChange POST to the same endpoints, and the
3297
+ // server re-authorizes.
3298
+ const actionRow = document.createElement("div");
3299
+ actionRow.className = "review-actions";
3300
+ const accept = document.createElement("button");
3301
+ accept.type = "button";
3302
+ accept.className = "btn primary";
3303
+ const needsPreviewUpdate =
3304
+ change.status === "ready" &&
3305
+ Boolean(change.evidence && change.evidence.promotable) &&
3306
+ change.mergeable === false;
3307
+ const needsChecks = !Boolean(change.evidence && change.evidence.promotable) &&
3308
+ (change.status === "queued" || change.status === "reconciling");
3309
+ if (needsChecks) {
3310
+ const build = document.createElement("button");
3311
+ build.type = "button";
3312
+ build.className = "btn primary";
3313
+ build.textContent = "Run checks";
3314
+ build.disabled = !canShip || (change.prNumber != null && inflightBuilds.has(change.prNumber));
3315
+ build.title = "Re-run this PR's preview build and checks (does not merge or go live)";
3316
+ build.addEventListener("click", () => buildPreview(change, build));
3317
+ actionRow.append(build);
3318
+ }
3319
+ // Only ONE primary action at a time — see the queue-row fix above.
3320
+ if (!needsChecks) {
3321
+ accept.textContent = needsPreviewUpdate ? "Update change" : "Add to release";
3322
+ accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable);
3323
+ accept.title = !canShip
3324
+ ? "Requires a ship-on-behalf grant on your account — ask the store owner"
3325
+ : needsPreviewUpdate
3326
+ ? "Preview changed while you worked — update this change, then add it"
3327
+ : !change.mergeable
3328
+ ? "This change isn't ready to add yet"
3329
+ : "Add this change to your next release (doesn't go live yet)";
3330
+ accept.addEventListener("click", () =>
3331
+ needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
3332
+ );
3333
+ }
3334
+ const reject = document.createElement("button");
3335
+ reject.type = "button";
3336
+ reject.className = "btn";
3337
+ reject.textContent = "Reject";
3338
+ reject.disabled = !canShip;
3339
+ reject.title = canShip
3340
+ ? "Close this change without publishing"
3341
+ : "Requires an owner or admin sign-in";
3342
+ reject.addEventListener("click", () => rejectChange(change, reject));
3343
+ actionRow.append(...(needsChecks ? [] : [accept]), reject);
3344
+ detailCol.append(actionRow);
3345
+
3346
+ pane.append(previewCol, detailCol);
3347
+ return { pane, diffTarget };
3348
+ };
3349
+
3350
+ // A candidate's diff at a fixed head sha is immutable, so re-opening a review
3351
+ // pane for the same changeId@headSha serves from cache without re-fetching.
3352
+ const diffCache = createKeyedCache();
3353
+ const loadReviewDiff = async (change?: any, diffTarget?: any) => {
3354
+ const cacheKey = change.changeId + "@" + (change.headSha || "");
3355
+ const cached = diffCache.get(cacheKey);
3356
+ if (cached) {
3357
+ diffTarget.textContent = "";
3358
+ renderDiff(diffTarget, cached);
3359
+ return;
3360
+ }
3361
+ try {
3362
+ const res = await fetch(diffBase + "/" + encodeURIComponent(change.changeId) + "/diff", {
3363
+ headers: { accept: "application/json" },
3364
+ });
3365
+ const data = await res.json().catch(() => ({}));
3366
+ diffTarget.textContent = "";
3367
+ if (!res.ok) {
3368
+ diffTarget.append(subtle(data.error || "Could not load the diff (" + res.status + ")"));
3369
+ } else {
3370
+ const diff = data.diff || {};
3371
+ diffCache.set(cacheKey, diff);
3372
+ renderDiff(diffTarget, diff);
3373
+ }
3374
+ } catch {
3375
+ diffTarget.textContent = "";
3376
+ diffTarget.append(subtle("Could not load the diff — network error"));
3377
+ }
3378
+ };
3379
+
3380
+ const toggleReview = (change?: any, button?: any, tr?: any) => {
3381
+ if (!tr.dataset.changeRowId) tr.dataset.changeRowId = change.changeId;
3382
+ if (reviewRowFor(tr)) {
3383
+ closeReview(tr, button);
3384
+ return;
3385
+ }
3386
+ const detailTr = document.createElement("tr");
3387
+ detailTr.dataset.publishReviewRow = tr.dataset.changeRowId;
3388
+ const td = document.createElement("td");
3389
+ td.colSpan = 6;
3390
+ td.className = "publish-review-cell";
3391
+ const { pane, diffTarget } = buildReviewPane(change);
3392
+ td.append(pane);
3393
+ detailTr.append(td);
3394
+ tr.after(detailTr);
3395
+ button.textContent = "Hide review";
3396
+ button.setAttribute("aria-expanded", "true");
3397
+ loadReviewDiff(change, diffTarget);
3398
+ };
3399
+
3400
+ const CHANGE_MARK: Record<string, string> = { added: "+", changed: "~", removed: "−" };
3401
+
3402
+ const renderDiff = (td?: any, diff?: any) => {
3403
+ td.textContent = "";
3404
+ const head = document.createElement("div");
3405
+ head.className = "publish-diff-head";
3406
+
3407
+ // Authority: honest label of whether the diff describes the exact head.
3408
+ const authority = diff.authority || {};
3409
+ const authTone = authority.authoritative ? "green" : authority.stale ? "amber" : "";
3410
+ const authChip = chip(
3411
+ authority.authoritative
3412
+ ? "Reviewed at head"
3413
+ : authority.stale
3414
+ ? "Last-good preview (stale vs head)"
3415
+ : authority.missing
3416
+ ? "No evidence yet"
3417
+ : "Head",
3418
+ authTone,
3419
+ CHIP_GLYPH[authTone],
3420
+ );
3421
+ head.append(authChip);
3422
+
3423
+ if (diff.headSha) {
3424
+ const sha = document.createElement("span");
3425
+ sha.className = "publish-mono subtle";
3426
+ sha.textContent = "head " + shortSha(diff.headSha);
3427
+ head.append(sha);
3428
+ }
3429
+ if (diff.revHref) {
3430
+ const rev = document.createElement("a");
3431
+ rev.href = diff.revHref;
3432
+ rev.target = "_blank";
3433
+ rev.rel = "noopener";
3434
+ rev.textContent = "Open reviewed revision";
3435
+ head.append(rev);
3436
+ }
3437
+ td.append(head);
3438
+
3439
+ if (!diff.renderable) {
3440
+ // Honest: a candidate that can't render has NO diff — say why (dg3), no fake.
3441
+ const reason = document.createElement("p");
3442
+ reason.className = "note publish-diff-blocked";
3443
+ reason.textContent =
3444
+ diff.blockedReason || "This change has no preview to compare yet.";
3445
+ td.append(reason);
3446
+ return;
3447
+ }
3448
+
3449
+ const files = Array.isArray(diff.files) ? diff.files : [];
3450
+ if (!files.length) {
3451
+ td.append(subtle("No file changes vs the current live version."));
3452
+ return;
3453
+ }
3454
+ const list = document.createElement("ul");
3455
+ list.className = "publish-diff-list";
3456
+ for (const f of files) {
3457
+ const li = document.createElement("li");
3458
+ li.className = "publish-diff-" + f.change;
3459
+ const mark = document.createElement("span");
3460
+ mark.className = "publish-diff-mark";
3461
+ mark.textContent = CHANGE_MARK[f.change] || "•";
3462
+ const path = document.createElement("code");
3463
+ path.textContent = f.path;
3464
+ li.append(mark, path);
3465
+ list.append(li);
3466
+ }
3467
+ td.append(list);
3468
+ };
3469
+
3470
+ // ---- dg7: versions / promotion history + rollback ----------------------
3471
+ // The promotion history (u2/u3 promotion_status) with the tenant Gitea SHA,
3472
+ // its /rev link (u5), and the current-live marker; a prior version rolls back
3473
+ // THROUGH the one orchestrator (/api/changes/rollback → u8), rendering only the
3474
+ // HONEST terminal state (rolled_back only when verified).
3475
+
3476
+ const versionsPanel = root.querySelector("[data-publish-versions]");
3477
+ const versionsBody = versionsPanel?.querySelector("[data-versions-body]");
3478
+ const versionsChip = versionsPanel?.querySelector("[data-versions-chip]");
3479
+ const versionsStatus = versionsPanel?.querySelector("[data-versions-status]");
3480
+ const versionsRollbackNote = versionsPanel?.querySelector("[data-versions-rollback-note]");
3481
+ // Hosted and static-publish stores both render the same instant "Roll back" UX
3482
+ // below — every rollback is an instant KV pointer flip.
3483
+
3484
+ // r10: the post-publish "Undo" deep-link. focusRollbackTarget stashes the
3485
+ // version-id to undo, opens the Live Site + Rollback disclosures, forces a fresh
3486
+ // versions read, and scrolls the panel into view; renderVersions then applies the
3487
+ // pending focus once that row exists (applyRollbackFocus) — highlighting it and
3488
+ // focusing its "Roll back" button so the operator lands pre-targeted.
3489
+ let pendingRollbackFocus: string | null = null;
3490
+ const openLiveSiteRollback = () => {
3491
+ const liveSite: any = root.querySelector(".live-site-details");
3492
+ if (liveSite) liveSite.open = true;
3493
+ const versionsDetails: any = versionsPanel && versionsPanel.querySelector(".versions-details");
3494
+ if (versionsDetails) versionsDetails.open = true;
3495
+ };
3496
+ const applyRollbackFocus = () => {
3497
+ if (!pendingRollbackFocus || !versionsBody) return;
3498
+ let target: any = null;
3499
+ versionsBody.querySelectorAll("tr[data-version-id]").forEach((r: any) => {
3500
+ if (r.dataset.versionId === pendingRollbackFocus) target = r;
3501
+ });
3502
+ if (!target) return; // not rendered yet — a pending fetch will re-run this
3503
+ pendingRollbackFocus = null;
3504
+ target.scrollIntoView({ block: "center", behavior: "smooth" });
3505
+ target.classList.remove("publish-row-highlight");
3506
+ void target.offsetWidth; // restart the flash if re-applied
3507
+ target.classList.add("publish-row-highlight");
3508
+ window.setTimeout(() => target.classList.remove("publish-row-highlight"), 2600);
3509
+ const btn: any = target.querySelector("button");
3510
+ if (btn) btn.focus();
3511
+ };
3512
+ const focusRollbackTarget = (versionId?: string) => {
3513
+ if (!versionId) return;
3514
+ pendingRollbackFocus = versionId;
3515
+ openLiveSiteRollback();
3516
+ loadVersions({ force: true }); // fresh read: the replaced version is now a prior row
3517
+ const panel = document.getElementById("publish-live-site");
3518
+ if (panel) panel.scrollIntoView({ block: "start", behavior: "smooth" });
3519
+ };
3520
+
3521
+ const renderVersions = (view?: any) => {
3522
+ if (!versionsBody) return;
3523
+ const versions = Array.isArray(view.versions) ? view.versions : [];
3524
+ versionsBody.textContent = "";
3525
+
3526
+ if (versionsChip) {
3527
+ versionsChip.textContent = versions.length === 1 ? "1 version" : versions.length + " versions";
3528
+ versionsChip.className = "chip" + (versions.length ? " blue" : "");
3529
+ }
3530
+
3531
+ // u14 — orient a store with exactly ONE live version: rollback needs a PRIOR
3532
+ // target, which only exists once a SECOND version is published. Only fill the
3533
+ // status line if nothing else has claimed it (an action/error message wins).
3534
+ if (versionsStatus && versions.length === 1 && !versionsStatus.textContent) {
3535
+ versionsStatus.textContent = "Rollback targets appear here once a second version is published.";
3536
+ }
3537
+
3538
+ // Every rollback is an instant KV pointer flip, so there is no CI-latency
3539
+ // note to show.
3540
+ if (versionsRollbackNote) versionsRollbackNote.hidden = true;
3541
+
3542
+ if (!versions.length) {
3543
+ const tr = document.createElement("tr");
3544
+ const td = cell(
3545
+ subtle(
3546
+ "Nothing published yet. When you publish your next release it becomes your first live " +
3547
+ "version — and any earlier versions you can roll back to will appear here.",
3548
+ ),
3549
+ );
3550
+ td.colSpan = 6;
3551
+ tr.append(td);
3552
+ versionsBody.append(tr);
3553
+ return;
3554
+ }
3555
+
3556
+ for (const v of versions) {
3557
+ const tr = document.createElement("tr");
3558
+ // r10: tag the row with its version id so the post-publish "Undo" can deep-
3559
+ // link straight to the replaced version's rollback row (applyRollbackFocus).
3560
+ tr.dataset.versionId = v.versionId;
3561
+
3562
+ // Version identity: the merchant-facing "v42" counter is the primary label;
3563
+ // the tenant Gitea SHA is demoted to a "Technical details" disclosure below
3564
+ // (r02 — a raw SHA is meaningless to a merchant). Falls back to the short SHA
3565
+ // only when a version carries no number (an unpromoted seed), never faked.
3566
+ const idCell = document.createElement("td");
3567
+ const strong = document.createElement("strong");
3568
+ // r11 — the human release name leads when present; the "v42" counter falls
3569
+ // back when it's null (and rides along as a subtle secondary when a name shows).
3570
+ const releaseName = v.name || null;
3571
+ strong.textContent = releaseName || (v.number != null ? "v" + v.number : shortSha(v.versionId));
3572
+ idCell.append(strong);
3573
+ if (releaseName && v.number != null) idCell.append(subtle("v" + v.number));
3574
+ if (v.source) idCell.append(subtle(v.source === "commit" ? "From a commit" : "Content hash"));
3575
+ const tech = document.createElement("details");
3576
+ tech.className = "ver-tech";
3577
+ const techSummary = document.createElement("summary");
3578
+ techSummary.textContent = "Technical details";
3579
+ tech.append(techSummary);
3580
+ const shaSpan = document.createElement("span");
3581
+ shaSpan.className = "publish-mono subtle";
3582
+ shaSpan.textContent = shortSha(v.versionId);
3583
+ tech.append(shaSpan);
3584
+ idCell.append(tech);
3585
+
3586
+ // "What shipped" — the SAME human summary the staged Publish console shows,
3587
+ // now describing the RELEASE: a plain-language lead + an openable list of the
3588
+ // PRs this version shipped (from its release receipt). OPTIONAL server
3589
+ // enrichment: absent (an older version with no linked receipt) → the cell
3590
+ // stays just the sha + source, exactly as before (graceful fallback).
3591
+ const summary = v.releaseSummary || null;
3592
+ if (summary && (summary.lead || summary.text)) {
3593
+ const lead = document.createElement("p");
3594
+ lead.className = "ver-summary";
3595
+ lead.textContent = summary.lead || summary.text;
3596
+ idCell.append(lead);
3597
+ }
3598
+ const changes = Array.isArray(v.changes) ? v.changes : [];
3599
+ if (changes.length) {
3600
+ const details = document.createElement("details");
3601
+ details.className = "agg-included-details ver-changes";
3602
+ const sm = document.createElement("summary");
3603
+ const smLead = document.createElement("span");
3604
+ smLead.className = "agg-included-lead";
3605
+ smLead.textContent = "What shipped";
3606
+ const badge = document.createElement("span");
3607
+ badge.className = "agg-included-badge";
3608
+ badge.textContent = String(changes.length);
3609
+ const chev = document.createElement("span");
3610
+ chev.className = "agg-chev";
3611
+ chev.setAttribute("aria-hidden", "true");
3612
+ const chevSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
3613
+ const chevPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
3614
+ chevPath.setAttribute("d", "M9 6l6 6-6 6");
3615
+ chevSvg.setAttribute("viewBox", "0 0 24 24");
3616
+ chevSvg.setAttribute("fill", "none");
3617
+ chevSvg.setAttribute("stroke", "currentColor");
3618
+ chevSvg.setAttribute("stroke-width", "2.2");
3619
+ chevSvg.append(chevPath);
3620
+ chev.append(chevSvg);
3621
+ sm.append(smLead, badge, chev);
3622
+ details.append(sm);
3623
+ const ul = document.createElement("ul");
3624
+ ul.className = "agg-included-list";
3625
+ for (const ch of changes) ul.append(changeSummaryItem(ch));
3626
+ details.append(ul);
3627
+ idCell.append(details);
3628
+ }
3629
+ tr.append(idCell);
3630
+
3631
+ // Digest (static current only — never fabricated for priors).
3632
+ const digestCell = cell(v.digest ? shortSha(v.digest) : "—");
3633
+ digestCell.classList.add("publish-mono");
3634
+ if (!v.digest) digestCell.classList.add("subtle");
3635
+ tr.append(digestCell);
3636
+
3637
+ tr.append(cell(age(v.promotedAt)));
3638
+
3639
+ // Preview: the u5 immutable /rev/<sha> link.
3640
+ const revCell = document.createElement("td");
3641
+ if (v.revHref) {
3642
+ const a = document.createElement("a");
3643
+ a.href = v.revHref;
3644
+ a.target = "_blank";
3645
+ a.rel = "noopener";
3646
+ a.textContent = "Revision";
3647
+ revCell.append(a);
3648
+ } else {
3649
+ revCell.append(subtle("—"));
3650
+ }
3651
+ tr.append(revCell);
3652
+
3653
+ // State: current live vs prior.
3654
+ tr.append(
3655
+ cell(
3656
+ v.isCurrent
3657
+ ? chip("Current live", "green", CHIP_GLYPH.green)
3658
+ : chip("Prior", "", CHIP_GLYPH[""]),
3659
+ ),
3660
+ );
3661
+
3662
+ // Action: roll back to a PRIOR version (never the current one).
3663
+ const act = document.createElement("td");
3664
+ act.className = "action publish-actions";
3665
+ if (!v.isCurrent) {
3666
+ const rollbackBtn = document.createElement("button");
3667
+ rollbackBtn.type = "button";
3668
+ rollbackBtn.className = "btn";
3669
+ rollbackBtn.textContent = "Roll back";
3670
+ rollbackBtn.disabled = !canShip;
3671
+ rollbackBtn.title = !canShip
3672
+ ? "Requires an owner or ship-on-behalf sign-in"
3673
+ : "Roll the live site back to this version";
3674
+ rollbackBtn.addEventListener("click", () => rollbackVersion(v, rollbackBtn));
3675
+ act.append(rollbackBtn);
3676
+ }
3677
+ tr.append(act);
3678
+
3679
+ versionsBody.append(tr);
3680
+ }
3681
+
3682
+ // r10: land a pending post-publish "Undo" on the replaced version's row now
3683
+ // that the rows exist.
3684
+ applyRollbackFocus();
3685
+ };
3686
+
3687
+ // Published versions only change when something is published, which moves the
3688
+ // change-queue fingerprint (currentStateKey). Cache the view against that key
3689
+ // so re-viewing the panel or a re-render doesn't re-fire the read; refetch
3690
+ // only when the fingerprint changes or on an explicit refresh (force).
3691
+ const versionsCache = createKeyedCache();
3692
+ const loadVersionsImpl = async () => {
3693
+ if (!versionsBody) return;
3694
+ const key = currentStateKey;
3695
+ if (key != null && versionsCache.has(key)) {
3696
+ renderVersions(versionsCache.get(key));
3697
+ return;
3698
+ }
3699
+ try {
3700
+ const res = await fetch(ds.versionsEndpoint, { headers: { accept: "application/json" } });
3701
+ if (res.status === 401 || res.status === 403) {
3702
+ if (versionsChip) versionsChip.textContent = "Sign-in required";
3703
+ versionsBody.textContent = "";
3704
+ const tr = document.createElement("tr");
3705
+ const td = cell(subtle("Sign in as a store owner or admin to view published versions."));
3706
+ td.colSpan = 6;
3707
+ tr.append(td);
3708
+ versionsBody.append(tr);
3709
+ return;
3710
+ }
3711
+ const data = await res.json().catch(() => ({}));
3712
+ if (!res.ok) {
3713
+ setStatus(versionsStatus, data.error || "Could not load versions (" + res.status + ")", "err");
3714
+ return;
3715
+ }
3716
+ const view = data.view || {};
3717
+ if (key != null) versionsCache.set(key, view);
3718
+ renderVersions(view);
3719
+ } catch {
3720
+ setStatus(versionsStatus, "Could not load versions — network error", "err");
3721
+ }
3722
+ };
3723
+ const versionsCoalescer = createCoalescer(loadVersionsImpl);
3724
+ const loadVersions = (opts?: any) => {
3725
+ // force (explicit refresh / post-action) drops the cached entry so the
3726
+ // next run re-fetches even if the fingerprint hasn't caught up yet.
3727
+ if (opts && opts.force && currentStateKey != null) versionsCache.delete(currentStateKey);
3728
+ return versionsCoalescer.trigger();
3729
+ };
3730
+
3731
+ // Interpret the rollback response into ONE honest terminal state. Kept local
3732
+ // (NOT imported from lib/publish/shipWorkspace's interpretRollbackResponse):
3733
+ // that module pulls lib/preview value dependencies, which must not be dragged
3734
+ // into this client bundle. `rolledBack` is
3735
+ // true ONLY for a verified `rolled_back` — a `publish_pending` is NEVER a
3736
+ // rolled-back claim.
3737
+ const interpretRollback = (httpOk?: any, body?: any) => {
3738
+ const toVersionId = typeof body.toVersionId === "string" ? body.toVersionId : null;
3739
+ if (typeof body.state === "string") {
3740
+ const state = ["rolled_back", "publish_pending", "publish_failed", "blocked"].includes(body.state)
3741
+ ? body.state
3742
+ : "error";
3743
+ return {
3744
+ state,
3745
+ rolledBack: state === "rolled_back",
3746
+ message: body.message || body.error || "Rollback returned state " + state + ".",
3747
+ };
3748
+ }
3749
+ if (httpOk && body.rolledBack === true) {
3750
+ return {
3751
+ state: "rolled_back",
3752
+ rolledBack: true,
3753
+ message: toVersionId ? "Rolled back live to " + shortSha(toVersionId) + "." : "Rolled back live.",
3754
+ };
3755
+ }
3756
+ return {
3757
+ state: "blocked",
3758
+ rolledBack: false,
3759
+ message: (body.error || "Rollback failed") + (body.reason ? " (" + body.reason + ")" : ""),
3760
+ };
3761
+ };
3762
+
3763
+ const rollbackVersion = async (version?: any, button?: any) => {
3764
+ // Every rollback is an instant KV pointer flip — the confirm/status copy is
3765
+ // the same for a hosted and a static-publish store.
3766
+ const confirmMsg =
3767
+ "Roll the live store back to version " + shortSha(version.versionId) +
3768
+ "? This publishes that prior version to the live site.";
3769
+ if (!window.confirm(confirmMsg)) return;
3770
+ button.disabled = true;
3771
+ setStatus(versionsStatus, "Rolling back to " + shortSha(version.versionId) + "…");
3772
+ try {
3773
+ // The console dispatches by version id only; a static prior version's
3774
+ // archive digest lives only in S3 (CI resolves it). Pass a digest ONLY when
3775
+ // we honestly have one (never for a static prior — its digest is always null).
3776
+ const payload: any = { targetVersionId: version.versionId };
3777
+ if (version.digest) payload.targetDigest = version.digest;
3778
+ const res = await fetch(ds.rollbackEndpoint, {
3779
+ method: "POST",
3780
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
3781
+ body: JSON.stringify(payload),
3782
+ });
3783
+ const data = await res.json().catch(() => ({}));
3784
+ const outcome = interpretRollback(res.ok, data);
3785
+ setStatus(versionsStatus, outcome.message, outcome.rolledBack ? "ok" : outcome.state === "publish_pending" ? "ok" : "err");
3786
+ } catch {
3787
+ setStatus(versionsStatus, "Rollback failed — network error", "err");
3788
+ } finally {
3789
+ button.disabled = false;
3790
+ loadVersions({ force: true }); // a rollback changed the live version
3791
+ loadQueue();
3792
+ }
3793
+ };
3794
+
3795
+ // ---- Domain section ----------------------------------------------------
3796
+
3797
+ const domainPanel = root.querySelector("[data-publish-domain]");
3798
+ let domainPollTimer: any = null;
3799
+
3800
+ const renderDomain = (payload?: any) => {
3801
+ if (!domainPanel) return;
3802
+ const domain = payload.domain || {};
3803
+ const stateEl = domainPanel.querySelector("[data-domain-state]");
3804
+ const stateChip = domainPanel.querySelector("[data-domain-state-chip]");
3805
+ const sinceEl = domainPanel.querySelector("[data-domain-since]");
3806
+ const captureEl = domainPanel.querySelector("[data-domain-capture]");
3807
+ const runEl = domainPanel.querySelector("[data-domain-run]");
3808
+ const runDetailEl = domainPanel.querySelector("[data-domain-run-detail]");
3809
+ const rollbackBtn = domainPanel.querySelector("[data-domain-rollback]");
3810
+ const rollbackDetail = domainPanel.querySelector("[data-domain-rollback-detail]");
3811
+ const connected = domain.state === "cloudfront";
3812
+
3813
+ if (stateEl) {
3814
+ stateEl.textContent = connected
3815
+ ? "Storefront CDN (cloudfront)"
3816
+ : "Previous host (pantheon)";
3817
+ }
3818
+ if (stateChip) {
3819
+ const tone = connected ? "green" : "amber";
3820
+ paintChip(stateChip, connected ? "Connected" : "Not connected", tone, CHIP_GLYPH[tone]);
3821
+ }
3822
+ if (sinceEl) {
3823
+ sinceEl.textContent = domain.since
3824
+ ? "Since " + new Date(domain.since).toLocaleString()
3825
+ : "Current host for " + (payload.appDomain || ds.appDomain);
3826
+ }
3827
+ if (captureEl) {
3828
+ captureEl.textContent = domain.priorCapturedAt
3829
+ ? "Captured " + new Date(domain.priorCapturedAt).toLocaleString()
3830
+ : "None captured";
3831
+ }
3832
+
3833
+ const run = domain.lastRun;
3834
+ // "dispatched" = awaiting the CI completion callback (terminal-only wire).
3835
+ const inFlight = run && run.status === "dispatched";
3836
+ if (runEl) {
3837
+ runEl.textContent = run
3838
+ ? run.action + (run.rehearsal ? " (rehearsal)" : "") + " · " + run.status
3839
+ : "None";
3840
+ }
3841
+ if (runDetailEl) {
3842
+ runDetailEl.textContent = "";
3843
+ if (run) {
3844
+ const parts = [];
3845
+ if (run.actor) parts.push("by " + run.actor);
3846
+ if (run.startedAt) parts.push(age(run.startedAt));
3847
+ runDetailEl.append(document.createTextNode(parts.join(" · ")));
3848
+ if (run.runUrl) {
3849
+ const link = document.createElement("a");
3850
+ link.href = run.runUrl;
3851
+ link.target = "_blank";
3852
+ link.rel = "noopener";
3853
+ link.textContent = "View run";
3854
+ runDetailEl.append(document.createTextNode(" · "));
3855
+ runDetailEl.append(link);
3856
+ }
3857
+ if (run.status === "failed" && run.error) {
3858
+ const err = document.createElement("span");
3859
+ err.className = "publish-error-detail";
3860
+ err.textContent = run.error;
3861
+ runDetailEl.append(document.createElement("br"));
3862
+ runDetailEl.append(err);
3863
+ }
3864
+ } else {
3865
+ runDetailEl.textContent = "Connect and rollback runs appear here.";
3866
+ }
3867
+ }
3868
+ if (rollbackBtn) {
3869
+ rollbackBtn.disabled =
3870
+ !canShip || inFlight || !domain.priorRecordsKey;
3871
+ }
3872
+ if (rollbackDetail && domain.priorCapturedAt) {
3873
+ rollbackDetail.textContent =
3874
+ "Restores the DNS records captured " +
3875
+ new Date(domain.priorCapturedAt).toLocaleString() + ".";
3876
+ }
3877
+
3878
+ // Poll while a run is in flight so completion lands without a manual refresh.
3879
+ if (domainPollTimer) {
3880
+ clearTimeout(domainPollTimer);
3881
+ domainPollTimer = null;
3882
+ }
3883
+ if (inFlight) domainPollTimer = setTimeout(loadDomain, 15000);
3884
+ };
3885
+
3886
+ const loadDomain = async () => {
3887
+ if (!domainPanel) return;
3888
+ try {
3889
+ const res = await fetch(ds.domainStatusEndpoint, {
3890
+ headers: { accept: "application/json" },
3891
+ });
3892
+ if (!res.ok) return;
3893
+ renderDomain(await res.json());
3894
+ } catch {
3895
+ /* leave the last rendered state */
3896
+ }
3897
+ // u9: refresh the readiness itemization alongside the status.
3898
+ loadReadiness();
3899
+ };
3900
+
3901
+ // ---- Cutover readiness (u9) --------------------------------------------
3902
+ // Reads the SAME server gate `tot go-live` reads (/api/domain/readiness) and
3903
+ // itemizes the preconditions. The Connect button additionally requires the
3904
+ // server to report ready — the display and the enforcement can't diverge.
3905
+ const readinessBox = domainPanel?.querySelector("[data-domain-readiness]");
3906
+ const connectBtn = domainPanel?.querySelector("[data-domain-connect]");
3907
+ let domainReady = false;
3908
+
3909
+ const renderReadiness = (payload?: any) => {
3910
+ if (!readinessBox) return;
3911
+ const readiness = (payload && payload.readiness) || {};
3912
+ const checks = Array.isArray(readiness.checks) ? readiness.checks : [];
3913
+ domainReady = readiness.ready === true;
3914
+ readinessBox.hidden = false;
3915
+
3916
+ const chip = readinessBox.querySelector("[data-domain-readiness-chip]");
3917
+ if (chip) {
3918
+ const tone = domainReady ? "green" : "amber";
3919
+ paintChip(chip, domainReady ? "Ready" : "Not ready", tone, CHIP_GLYPH[tone]);
3920
+ }
3921
+ const list = readinessBox.querySelector("[data-domain-readiness-list]");
3922
+ if (list) {
3923
+ list.textContent = "";
3924
+ for (const c of checks) {
3925
+ const li = document.createElement("li");
3926
+ li.className = c.ok ? "ok" : "blocked";
3927
+ // Header row: mark + bold label. The detail and remedy render on their
3928
+ // OWN lines below (not as inline siblings of the label) so the label and
3929
+ // its description never run together as one string.
3930
+ const head = document.createElement("div");
3931
+ head.className = "domain-readiness-item-head";
3932
+ const mark = document.createElement("span");
3933
+ mark.className = "domain-readiness-mark";
3934
+ mark.textContent = c.ok ? "✓" : "✗";
3935
+ const label = document.createElement("b");
3936
+ label.textContent = c.label || c.id || "";
3937
+ head.append(mark, label);
3938
+ li.append(head);
3939
+ if (c.detail) {
3940
+ const detail = document.createElement("p");
3941
+ detail.className = "domain-readiness-detail";
3942
+ detail.textContent = c.detail;
3943
+ li.append(detail);
3944
+ }
3945
+ // Blocked checks carry a "how to clear this" remedy from the SAME gate
3946
+ // `tot go-live` reads — the guide lives with the enforcement, never a
3947
+ // divergent side note.
3948
+ if (!c.ok && c.remedy) {
3949
+ const remedy = document.createElement("p");
3950
+ remedy.className = "domain-readiness-remedy";
3951
+ remedy.textContent = "→ " + c.remedy;
3952
+ li.append(remedy);
3953
+ }
3954
+ list.append(li);
3955
+ }
3956
+ }
3957
+ const note = readinessBox.querySelector("[data-domain-readiness-note]");
3958
+ if (note) {
3959
+ note.textContent = payload && payload.ownerCapable === false
3960
+ ? "Apex cutover is owner-only — sign in as the store owner to connect the domain."
3961
+ : domainReady
3962
+ ? "All preconditions are green — the cutover is permitted."
3963
+ : "Clear the failing preconditions above before connecting the apex.";
3964
+ }
3965
+ // Gate the Connect button on server readiness in ADDITION to owner (the
3966
+ // markup already disables it for non-owners); never enable it when blocked.
3967
+ if (connectBtn && !connectBtn.dataset.ownerLocked) {
3968
+ connectBtn.disabled = !domainReady;
3969
+ }
3970
+ };
3971
+
3972
+ const loadReadiness = async () => {
3973
+ if (!readinessBox || !ds.domainReadinessEndpoint) return;
3974
+ try {
3975
+ const res = await fetch(ds.domainReadinessEndpoint, {
3976
+ headers: { accept: "application/json", "x-tot-capability": "ship-on-behalf" },
3977
+ });
3978
+ if (!res.ok) return;
3979
+ renderReadiness(await res.json());
3980
+ } catch {
3981
+ /* leave the last rendered readiness */
3982
+ }
3983
+ };
3984
+
3985
+ const domainStatusEl = domainPanel?.querySelector("[data-domain-status]");
3986
+
3987
+ const domainAction = async (body?: any, button?: any) => {
3988
+ button.disabled = true;
3989
+ setStatus(domainStatusEl, (body.action === "connect" ? "Connecting" : "Rolling back") + "…");
3990
+ try {
3991
+ const res = await fetch(ds.domainDispatchEndpoint, {
3992
+ method: "POST",
3993
+ headers: {
3994
+ "content-type": "application/json",
3995
+ "x-tot-capability": "ship-on-behalf",
3996
+ },
3997
+ body: JSON.stringify(body),
3998
+ });
3999
+ const data = await res.json().catch(() => ({}));
4000
+ if (res.ok && data.dispatched) {
4001
+ setStatus(
4002
+ domainStatusEl,
4003
+ (body.action === "connect" ? "Connect" : "Rollback") +
4004
+ (body.rehearsal ? " rehearsal" : "") +
4005
+ " dispatched — the run status updates below as it progresses.",
4006
+ "ok",
4007
+ );
4008
+ } else {
4009
+ setStatus(domainStatusEl, data.error || "Request failed (" + res.status + ")", "err");
4010
+ }
4011
+ } catch {
4012
+ setStatus(domainStatusEl, "Request failed — network error", "err");
4013
+ } finally {
4014
+ button.disabled = false;
4015
+ loadDomain();
4016
+ }
4017
+ };
4018
+
4019
+ domainPanel?.querySelector("[data-domain-connect]")?.addEventListener("click", (event: any) => {
4020
+ const confirmInput = domainPanel.querySelector("[data-domain-confirm]");
4021
+ const rehearsal = Boolean(domainPanel.querySelector("[data-domain-rehearsal]")?.checked);
4022
+ domainAction(
4023
+ {
4024
+ action: "connect",
4025
+ confirmDomain: (confirmInput?.value || "").trim(),
4026
+ rehearsal,
4027
+ },
4028
+ event.currentTarget,
4029
+ );
4030
+ });
4031
+
4032
+ domainPanel?.querySelector("[data-domain-rollback]")?.addEventListener("click", (event: any) => {
4033
+ if (
4034
+ !window.confirm(
4035
+ "Roll back " + ds.appDomain + " to the previous host? DNS returns to the captured records in about a minute.",
4036
+ )
4037
+ ) {
4038
+ return;
4039
+ }
4040
+ domainAction({ action: "rollback" }, event.currentTarget);
4041
+ });
4042
+
4043
+ // ---- b17: branch retention ----------------------------------------------
4044
+ // Renders the GET /api/changes/branches dry-run classification (already
4045
+ // projected server-side by branchRetentionView.ts — this script only
4046
+ // paints it, same division of labor as renderQueue/renderVersions above)
4047
+ // and drives the guarded delete. The server (candidate_delete, b13)
4048
+ // re-classifies fresh on every delete, so the confirm below is the HUMAN
4049
+ // gate, not the safety guarantee — the safety guarantee is server-side.
4050
+
4051
+ const branchesPanel = root.querySelector("[data-publish-branches]");
4052
+ const branchesBody = branchesPanel?.querySelector("[data-branches-body]");
4053
+ const branchesChip = branchesPanel?.querySelector("[data-branches-chip]");
4054
+ const branchesStatus = branchesPanel?.querySelector("[data-branches-status]");
4055
+ const branchesActions = branchesPanel?.querySelector("[data-branches-actions]");
4056
+ const branchesSweepBtn = branchesPanel?.querySelector("[data-branches-sweep]");
4057
+
4058
+ // Kept local (NOT imported from branchRetentionView): that module pulls a
4059
+ // lib/forge value dependency, which must not be dragged into this client bundle.
4060
+ // Shown when a live classification fetch fails — the table must NEVER keep the
4061
+ // last-known chips (a confident stale label is exactly the b1 bug).
4062
+ const FORGE_UNREACHABLE_LABEL = "State unknown (forge unreachable)";
4063
+
4064
+ // The last classification the panel rendered from a SUCCESSFUL fetch. Kept so a
4065
+ // later fetch failure can re-stamp those same branches as "state unknown" (never
4066
+ // leave the stale confident chips), and so an unchanged poll can skip re-render.
4067
+ let lastBranchesRows: any[] = [];
4068
+ let lastBranchesSignature: string | null = null;
4069
+ // Refs the "Sweep dead branches" action deletes, as pre-computed server-side
4070
+ // (view.sweepableRefs): Dead + delete-eligible + reachable. The UI never re-derives
4071
+ // the delete set — it confirms these, then loops the guarded delete.
4072
+ let lastSweepableRefs: any[] = [];
4073
+
4074
+ const buildCleanupPlan = (row?: any) => {
4075
+ return [
4076
+ "Delete branch plan:",
4077
+ " repo: " + (appDomain || repo),
4078
+ " branch: " + row.ref,
4079
+ " tip sha: " + row.shortSha,
4080
+ " classification: " + row.classificationLabel,
4081
+ " reason: " + row.reason,
4082
+ "",
4083
+ "This deletes the Git BRANCH only. Reject already closes the PR and removes",
4084
+ "its hosted candidate artifacts. The server re-classifies this",
4085
+ "branch fresh before deleting; a moved tip or a branch that is no longer",
4086
+ "integrated/closed-or-rejected is refused, not silently skipped.",
4087
+ ].join("\n");
4088
+ };
4089
+
4090
+ // One guarded delete round-trip. Shared by the per-row "Clean up", the bulk
4091
+ // "Sweep dead branches", and the per-row orphan "Delete this branch" — the server
4092
+ // re-checks eligibility fresh on every ref. `opts` opts into the explicit HUMAN
4093
+ // delete (explicitConfirm + expectedClassification) for an orphan the default
4094
+ // eligibility path refuses; omitted, this is the default guarded delete.
4095
+ const postDeleteRef = async (ref?: any, expectedSha?: any, opts: any = {}) => {
4096
+ const body: any = { ref, expectedSha };
4097
+ if (opts.explicitConfirm) body.explicitConfirm = true;
4098
+ if (opts.expectedClassification) body.expectedClassification = opts.expectedClassification;
4099
+ const res = await fetch(ds.branchesEndpoint, {
4100
+ method: "POST",
4101
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
4102
+ body: JSON.stringify(body),
4103
+ });
4104
+ const data = await res.json().catch(() => ({}));
4105
+ return { ok: res.ok && data.ok, status: res.status, data };
4106
+ };
4107
+
4108
+ // Old branches should clean themselves up — a non-developer shouldn't need to
4109
+ // find and click "Sweep dead branches" for routine housekeeping. Runs silently
4110
+ // (no confirm dialog, no visible status) after a successful accept/publish;
4111
+ // the manual sweep in the advanced panel below still exists for anyone who
4112
+ // wants to trigger or inspect it directly.
4113
+ const autoSweepBranches = async () => {
4114
+ if (!canShip) return;
4115
+ try {
4116
+ const res = await fetch(ds.branchesEndpoint, { headers: { accept: "application/json" } });
4117
+ if (!res.ok) return;
4118
+ const view = await res.json().catch(() => null);
4119
+ if (!view) return;
4120
+ const rows = Array.isArray(view.rows) ? view.rows : [];
4121
+ const sweepable = Array.isArray(view.sweepableRefs) ? view.sweepableRefs : [];
4122
+ if (!sweepable.length) return;
4123
+ const rowsByRef = new Map<string, any>(rows.map((r: any): [string, any] => [r.ref, r]));
4124
+ for (const ref of sweepable) {
4125
+ const row = rowsByRef.get(ref);
4126
+ if (!row) continue;
4127
+ await postDeleteRef(row.ref, row.sha).catch(() => {});
4128
+ }
4129
+ loadBranches(); // refresh the advanced panel if it happens to be open
4130
+ } catch {
4131
+ // Best-effort — a manual sweep in the advanced panel still works.
4132
+ }
4133
+ };
4134
+
4135
+ const cleanupBranch = async (row?: any, button?: any) => {
4136
+ if (!window.confirm(buildCleanupPlan(row) + "\n\nDelete this branch?")) return;
4137
+ button.disabled = true;
4138
+ setStatus(branchesStatus, "Deleting " + row.shortRef + "…");
4139
+ try {
4140
+ const { ok, status, data } = await postDeleteRef(row.ref, row.sha);
4141
+ if (ok) {
4142
+ setStatus(
4143
+ branchesStatus,
4144
+ data.alreadyAbsent
4145
+ ? row.shortRef + " was already gone (nothing to delete)."
4146
+ : "Deleted " + row.shortRef + ".",
4147
+ "ok",
4148
+ );
4149
+ } else {
4150
+ setStatus(branchesStatus, data.error || "Delete refused (" + status + ")", "err");
4151
+ }
4152
+ } catch {
4153
+ setStatus(branchesStatus, "Delete failed — network error", "err");
4154
+ } finally {
4155
+ button.disabled = false;
4156
+ loadBranches();
4157
+ }
4158
+ };
4159
+
4160
+ // Per-row "Delete this branch": the deliberate, one-at-a-time HUMAN delete of an
4161
+ // ORPHAN — a branch the auto/bulk "Sweep" and one-click "Clean up" both refuse
4162
+ // (it is quarantined out of the sweep, and never eligibleForDelete). This is the
4163
+ // ONLY path that removes one. It routes through candidate_delete's guarded explicit
4164
+ // path (explicitConfirm + expectedClassification), which re-verifies the ref is
4165
+ // still an orphan at the same tip server-side and refuses (classification_changed /
4166
+ // sha_mismatch) if it drifted since this row was read — so the confirm below is the
4167
+ // human gate, the server re-check is the safety guarantee.
4168
+ const buildOrphanDeletePlan = (row?: any) => {
4169
+ return [
4170
+ "Delete ORPHANED branch:",
4171
+ " repo: " + (appDomain || repo),
4172
+ " branch: " + row.ref,
4173
+ " tip sha: " + row.shortSha,
4174
+ " classification: orphan",
4175
+ " reason: " + row.reason,
4176
+ "",
4177
+ "An orphan has no open PR and no integration record — nothing else will ever",
4178
+ "clean it up (the scheduled sweep and one-click cleanup both skip it). This",
4179
+ "deletes the Git BRANCH only — it never evicts a hosted preview build. The",
4180
+ "server re-checks this branch fresh before deleting; if it is no longer an",
4181
+ "orphan, or its tip has moved, the delete is refused — not silently skipped.",
4182
+ ].join("\n");
4183
+ };
4184
+
4185
+ const deleteOrphanBranch = async (row?: any, button?: any) => {
4186
+ if (!window.confirm(buildOrphanDeletePlan(row) + "\n\nPermanently delete this orphaned branch?")) return;
4187
+ button.disabled = true;
4188
+ setStatus(branchesStatus, "Deleting orphan " + row.shortRef + "…");
4189
+ try {
4190
+ const { ok, status, data } = await postDeleteRef(row.ref, row.sha, {
4191
+ explicitConfirm: true,
4192
+ expectedClassification: row.classification,
4193
+ });
4194
+ if (ok) {
4195
+ setStatus(
4196
+ branchesStatus,
4197
+ data.alreadyAbsent
4198
+ ? row.shortRef + " was already gone (nothing to delete)."
4199
+ : "Deleted orphaned branch " + row.shortRef + ".",
4200
+ "ok",
4201
+ );
4202
+ } else {
4203
+ setStatus(branchesStatus, data.error || "Delete refused (" + status + ")", "err");
4204
+ }
4205
+ } catch {
4206
+ setStatus(branchesStatus, "Delete failed — network error", "err");
4207
+ } finally {
4208
+ button.disabled = false;
4209
+ loadBranches();
4210
+ }
4211
+ };
4212
+
4213
+ // Bulk "Sweep dead branches": one confirm showing EXACTLY the refs it will delete,
4214
+ // then a guarded delete per ref (each re-checked fresh server-side — a ref that has
4215
+ // moved or is no longer eligible is refused, never silently skipped).
4216
+ const sweepDeadBranches = async () => {
4217
+ const rowsByRef = new Map<string, any>(lastBranchesRows.map((r: any): [string, any] => [r.ref, r]));
4218
+ const targets = lastSweepableRefs.map((ref) => rowsByRef.get(ref)).filter(Boolean);
4219
+ if (!targets.length) return;
4220
+ const preview = [
4221
+ "Sweep dead branches — this deletes " + targets.length + " branch(es):",
4222
+ "",
4223
+ ...targets.map((r: any) => " • " + r.ref + " @ " + r.shortSha + " (" + r.classificationLabel + ")"),
4224
+ "",
4225
+ "Each deletes the Git BRANCH only (never a hosted preview build). The server",
4226
+ "re-checks every ref fresh before deleting; anything no longer eligible is",
4227
+ "refused, not silently skipped.",
4228
+ ].join("\n");
4229
+ if (!window.confirm(preview + "\n\nDelete these branches?")) return;
4230
+ branchesSweepBtn.disabled = true;
4231
+ let deleted = 0;
4232
+ let refused = 0;
4233
+ for (const r of targets) {
4234
+ setStatus(branchesStatus, "Sweeping " + r.shortRef + "…");
4235
+ try {
4236
+ const { ok } = await postDeleteRef(r.ref, r.sha);
4237
+ if (ok) deleted++;
4238
+ else refused++;
4239
+ } catch {
4240
+ refused++;
4241
+ }
4242
+ }
4243
+ setStatus(
4244
+ branchesStatus,
4245
+ "Swept " + deleted + " branch(es)" + (refused ? ", " + refused + " refused" : "") + ".",
4246
+ refused ? "err" : "ok",
4247
+ );
4248
+ branchesSweepBtn.disabled = false;
4249
+ loadBranches();
4250
+ };
4251
+
4252
+ // Show/enable the sweep button only when there is a Dead+eligible set to sweep.
4253
+ const updateSweepButton = () => {
4254
+ if (!branchesActions || !branchesSweepBtn) return;
4255
+ const n = lastSweepableRefs.length;
4256
+ branchesActions.hidden = n === 0;
4257
+ branchesSweepBtn.disabled = !canShip || n === 0;
4258
+ branchesSweepBtn.title = canShip ? "" : "Requires an owner/admin sign-in.";
4259
+ branchesSweepBtn.textContent = n ? "Sweep dead branches (" + n + ")" : "Sweep dead branches";
4260
+ };
4261
+ branchesSweepBtn?.addEventListener("click", sweepDeadBranches);
4262
+
4263
+ // Paint the table body from a set of rows. `unreachable` re-stamps every row
4264
+ // as "state unknown" and withdraws its action — used when a live fetch fails,
4265
+ // so the operator never sees a confident classification the forge can no
4266
+ // longer confirm (the b1 bug: a stale "PR #11 open / Keep" long after close).
4267
+ const renderBranchRows = (rows?: any, { unreachable = false }: any = {}) => {
4268
+ branchesBody.textContent = "";
4269
+ if (!rows.length) {
4270
+ const tr = document.createElement("tr");
4271
+ const td = cell(
4272
+ subtle(
4273
+ unreachable
4274
+ ? "Branch state unknown — the forge could not be reached."
4275
+ : "No candidate branches found for this repo.",
4276
+ ),
4277
+ );
4278
+ td.colSpan = 7;
4279
+ tr.append(td);
4280
+ branchesBody.append(tr);
4281
+ return;
4282
+ }
4283
+
4284
+ for (const row of rows) {
4285
+ const rowUnknown = unreachable || row.forgeUnreachable === true;
4286
+ const tr = document.createElement("tr");
4287
+
4288
+ const branchCell = document.createElement("td");
4289
+ const branchStrong = document.createElement("strong");
4290
+ branchStrong.textContent = row.shortRef;
4291
+ branchCell.append(branchStrong, subtle(row.ref + " @ " + row.shortSha));
4292
+
4293
+ // Describe the branch by WHAT it changes — the SAME candidate_summary the
4294
+ // staged Publish console + versions list read. OPTIONAL server enrichment:
4295
+ // absent (no PR, or the summary read missed it) → the cell stays just the
4296
+ // ref, exactly as before (graceful fallback). Never shown on an unreachable
4297
+ // row: its classification is precisely what couldn't be confirmed.
4298
+ if (!rowUnknown && (row.prTitle || row.prDescription)) {
4299
+ if (row.prTitle) {
4300
+ const t = document.createElement("p");
4301
+ t.className = "branch-pr-title";
4302
+ t.textContent = row.prTitle;
4303
+ branchCell.append(t);
4304
+ }
4305
+ if (row.prDescription) {
4306
+ const d = document.createElement("p");
4307
+ d.className = "branch-pr-desc";
4308
+ d.textContent = row.prDescription;
4309
+ branchCell.append(d);
4310
+ }
4311
+ const fileCount = typeof row.fileCount === "number" ? row.fileCount : null;
4312
+ const hasLines = typeof row.linesAdded === "number" || typeof row.linesRemoved === "number";
4313
+ if (fileCount != null || hasLines || row.aiDrafted) {
4314
+ const meta = document.createElement("div");
4315
+ meta.className = "agg-included-meta branch-pr-meta";
4316
+ if (fileCount != null) {
4317
+ const f = document.createElement("span");
4318
+ f.textContent = fileCount + (fileCount === 1 ? " file" : " files");
4319
+ meta.append(f);
4320
+ }
4321
+ if (hasLines) meta.append(linesNode(row.linesAdded, row.linesRemoved));
4322
+ if (row.aiDrafted) {
4323
+ const ai = document.createElement("span");
4324
+ ai.className = "branch-pr-ai";
4325
+ ai.textContent = "AI summary";
4326
+ meta.append(ai);
4327
+ }
4328
+ branchCell.append(meta);
4329
+ }
4330
+ }
4331
+ tr.append(branchCell);
4332
+
4333
+ // Protected rows collapse to one line — branch + classification + a single
4334
+ // status. Nobody acts on them, so the PR/tip/age/evidence detail is just noise.
4335
+ if (row.compact && !rowUnknown) {
4336
+ tr.append(cell(chip(row.classificationLabel, row.tone || "")));
4337
+ const statusTd = cell(subtle(row.reason || "Protected — permanent"));
4338
+ statusTd.colSpan = 5;
4339
+ tr.append(statusTd);
4340
+ branchesBody.append(tr);
4341
+ continue;
4342
+ }
4343
+
4344
+ tr.append(
4345
+ cell(
4346
+ rowUnknown
4347
+ ? chip(FORGE_UNREACHABLE_LABEL, "amber")
4348
+ : chip(row.classificationLabel, row.tone || ""),
4349
+ ),
4350
+ );
4351
+
4352
+ const prCell = document.createElement("td");
4353
+ if (row.prNumber != null) {
4354
+ // Link to the INTERNAL friendly PR route (/<tenant>/preview/pr/<N>), never
4355
+ // the server-provided `row.prUrl` — that is the raw Gitea forge html_url the
4356
+ // MCP candidate_list projection emits (<forge-host>/<org>/<repo>/pulls/N),
4357
+ // and surfacing a raw forge link to an owner is exactly the boundary
4358
+ // noGiteaLinks.test.ts guards (the source scan can't catch a host that
4359
+ // arrives in data, so we refuse it here at render). Mirrors the candidate
4360
+ // queue, which already builds its preview link this way.
4361
+ const prHref = candidatePrUrl(appDomain, row.prNumber);
4362
+ if (prHref) {
4363
+ const a = document.createElement("a");
4364
+ a.href = prHref;
4365
+ a.target = "_blank";
4366
+ a.rel = "noopener";
4367
+ a.textContent = "PR #" + row.prNumber;
4368
+ prCell.append(a);
4369
+ } else {
4370
+ prCell.append(document.createTextNode("PR #" + row.prNumber));
4371
+ }
4372
+ prCell.append(subtle((row.prState || "") + (row.integratedIntoAggregate ? " · in aggregate" : "")));
4373
+ } else {
4374
+ prCell.append(subtle("No PR record"));
4375
+ }
4376
+ tr.append(prCell);
4377
+
4378
+ tr.append(cell(row.commitTimestampDisplay));
4379
+ tr.append(cell(row.ageLabel));
4380
+ // Evidence cell shows a summary only when the row actually HAS evidence —
4381
+ // never a "no evidence reported" placeholder in an otherwise empty column.
4382
+ tr.append(row.hasEvidence ? cell(row.evidenceSummary) : cell());
4383
+
4384
+ const actionsCell = document.createElement("td");
4385
+ actionsCell.className = "action";
4386
+ if (rowUnknown) {
4387
+ // No trustworthy classification → offer no delete; the row's own eligibility
4388
+ // is exactly what we could not confirm. A cleanup still re-checks server-side,
4389
+ // but we must not INVITE one off a state we know we could not read.
4390
+ actionsCell.append(subtle("—"));
4391
+ } else if (row.classification === "orphan") {
4392
+ // Orphan (human quarantined OR machine "Dead"): the sweep and one-click "Clean
4393
+ // up" both refuse it, so this deliberate per-row HUMAN delete is its only path
4394
+ // out. Distinct from "Clean up" (styled danger) and re-verified fresh server-side.
4395
+ const btn = document.createElement("button");
4396
+ btn.type = "button";
4397
+ btn.className = "btn branch-delete-orphan";
4398
+ btn.dataset.deleteOrphan = "1";
4399
+ btn.disabled = !canShip;
4400
+ btn.title = canShip ? "" : "Requires an owner/admin sign-in.";
4401
+ btn.textContent = "Delete this branch";
4402
+ btn.addEventListener("click", () => deleteOrphanBranch(row, btn));
4403
+ actionsCell.append(btn);
4404
+ } else if (row.eligibleForDelete) {
4405
+ const btn = document.createElement("button");
4406
+ btn.type = "button";
4407
+ btn.className = "btn";
4408
+ btn.disabled = !canShip;
4409
+ btn.title = canShip ? "" : "Requires an owner/admin sign-in.";
4410
+ btn.textContent = "Clean up";
4411
+ btn.addEventListener("click", () => cleanupBranch(row, btn));
4412
+ actionsCell.append(btn);
4413
+ } else {
4414
+ actionsCell.append(subtle("Keep"));
4415
+ }
4416
+ tr.append(actionsCell);
4417
+
4418
+ branchesBody.append(tr);
4419
+ }
4420
+ };
4421
+
4422
+ // Render a SUCCESSFUL live fetch: cache the rows (so a later failure can stamp
4423
+ // them unknown), skip re-render when nothing changed, and set the summary chip.
4424
+ const renderBranches = (view?: any) => {
4425
+ if (!branchesBody) return;
4426
+ const rows = Array.isArray(view.rows) ? view.rows : [];
4427
+ const sig = JSON.stringify(rows);
4428
+ lastBranchesRows = rows;
4429
+ lastSweepableRefs = Array.isArray(view.sweepableRefs) ? view.sweepableRefs : [];
4430
+ updateSweepButton();
4431
+ if (sig === lastBranchesSignature) return; // no visible change — don't churn the DOM
4432
+ lastBranchesSignature = sig;
4433
+ if (branchesChip) {
4434
+ const needsReview = rows.filter((r: any) => r.quarantine || r.eligibleForDelete).length;
4435
+ branchesChip.textContent = needsReview
4436
+ ? needsReview + " to review"
4437
+ : rows.length
4438
+ ? rows.length + " branches"
4439
+ : "0 branches";
4440
+ branchesChip.className = "chip" + (needsReview ? " amber" : "");
4441
+ }
4442
+ renderBranchRows(rows);
4443
+ };
4444
+
4445
+ // Render the forge-unreachable state: re-stamp the last-known branches as
4446
+ // "state unknown" (or a single notice if none were ever loaded), never leaving
4447
+ // the stale confident chips a fetch failure would otherwise freeze on screen.
4448
+ const renderBranchesUnreachable = () => {
4449
+ if (!branchesBody) return;
4450
+ lastBranchesSignature = null; // force a real re-render when the forge returns
4451
+ lastSweepableRefs = []; // can't confirm eligibility → nothing is sweepable
4452
+ updateSweepButton();
4453
+ if (branchesChip) {
4454
+ branchesChip.textContent = "State unknown";
4455
+ branchesChip.className = "chip amber";
4456
+ }
4457
+ renderBranchRows(lastBranchesRows, { unreachable: true });
4458
+ };
4459
+
4460
+ const loadBranchesImpl = async () => {
4461
+ if (!branchesBody) return;
4462
+ try {
4463
+ const res = await fetch(ds.branchesEndpoint, { headers: { accept: "application/json" } });
4464
+ if (res.status === 401 || res.status === 403) {
4465
+ if (branchesChip) branchesChip.textContent = "Sign-in required";
4466
+ lastSweepableRefs = [];
4467
+ updateSweepButton();
4468
+ branchesBody.textContent = "";
4469
+ const tr = document.createElement("tr");
4470
+ const td = cell(subtle("Sign in as a store owner or admin to view branch retention."));
4471
+ td.colSpan = 7;
4472
+ tr.append(td);
4473
+ branchesBody.append(tr);
4474
+ return;
4475
+ }
4476
+ const data = await res.json().catch(() => ({}));
4477
+ if (!res.ok) {
4478
+ // A non-2xx means the live classification is UNKNOWN — never keep the last
4479
+ // chips on screen (they may now be lies). Stamp the branches "state unknown".
4480
+ setStatus(branchesStatus, data.error || "Could not load branch classification (" + res.status + ")", "err");
4481
+ renderBranchesUnreachable();
4482
+ return;
4483
+ }
4484
+ renderBranches(data.report || {});
4485
+ } catch {
4486
+ setStatus(branchesStatus, "Could not load branch classification — network error", "err");
4487
+ renderBranchesUnreachable();
4488
+ }
4489
+ };
4490
+ // Coalesce overlapping branch reads: a poll tick, a reveal, and a post-action
4491
+ // refresh can all fire at once — only one runs, with at most one trailing run.
4492
+ const branchesCoalescer = createCoalescer(loadBranchesImpl);
4493
+ const loadBranches = () => branchesCoalescer.trigger();
4494
+
4495
+ // ---- Wiring ------------------------------------------------------------
4496
+
4497
+ // Progressive + lazy: the tab shell is already server-rendered; each heavy
4498
+ // panel fires its first read only when it (or the tab) actually becomes
4499
+ // visible, and the queue poll only runs while the tab is active. An
4500
+ // IntersectionObserver is the single visibility signal — a panel inside a
4501
+ // hidden admin-tab (display:none) never intersects, so nothing fetches until
4502
+ // the operator opens the Publish tab and scrolls the panel into view.
4503
+ // r05 — restore each collapsible section's open/closed state from localStorage
4504
+ // (per store, so one store's layout never bleeds into another) and persist it on
4505
+ // every toggle. When nothing is stored the section keeps its SSR default. Native
4506
+ // <details> carry no aria-expanded of their own, so we mirror it onto the summary
4507
+ // and keep it in sync — a later a11y bundle asserts against it.
4508
+ const setupCollapsePersistence = () => {
4509
+ let storage: Storage | null;
4510
+ try {
4511
+ storage = typeof window !== "undefined" ? window.localStorage : null;
4512
+ } catch {
4513
+ storage = null; // localStorage access can throw (private mode / disabled)
4514
+ }
4515
+ const sections: Array<{ id: CollapseSectionId; selector: string }> = [
4516
+ { id: "review-queue", selector: ".publish-queue-details" },
4517
+ { id: "closed-changes", selector: ".closed-changes-details" },
4518
+ { id: "next-release", selector: ".agg-console-details" },
4519
+ { id: "live-site", selector: ".live-site-details" },
4520
+ { id: "rollback", selector: ".versions-details" },
4521
+ ];
4522
+ for (const { id, selector } of sections) {
4523
+ const details: any = root.querySelector(selector);
4524
+ if (!details) continue;
4525
+ const summary = details.querySelector("summary");
4526
+ const syncAria = () => {
4527
+ if (summary) summary.setAttribute("aria-expanded", details.open ? "true" : "false");
4528
+ };
4529
+ if (storage) {
4530
+ const stored = readPersistedOpen(storage, appDomain, id);
4531
+ if (stored !== null) details.open = stored; // otherwise keep the SSR default
4532
+ }
4533
+ syncAria();
4534
+ details.addEventListener("toggle", () => {
4535
+ if (storage) writePersistedOpen(storage, appDomain, id, details.open);
4536
+ syncAria();
4537
+ });
4538
+ }
4539
+ };
4540
+
4541
+ const revealDomainOnce = (() => {
4542
+ let done = false;
4543
+ return () => {
4544
+ if (done) return;
4545
+ done = true;
4546
+ loadDomain();
4547
+ };
4548
+ })();
4549
+
4550
+ const onTabActivate = () => {
4551
+ if (tabActive) return;
4552
+ tabActive = true;
4553
+ // loadQueue renders + resyncs the poll clock + (re)starts the cadence.
4554
+ loadQueue();
4555
+ };
4556
+ const onTabDeactivate = () => {
4557
+ if (!tabActive) return;
4558
+ tabActive = false;
4559
+ clearPoll(); // an inactive tab must not keep polling heavy reads
4560
+ };
4561
+
4562
+ const observeVisibility = () => {
4563
+ if (typeof IntersectionObserver !== "function") {
4564
+ // No IO (very old/headless env): fall back to eager load — correctness
4565
+ // over efficiency. Behaves like the pre-progressive-render tab.
4566
+ tabActive = true;
4567
+ branchesVisible = true;
4568
+ loadQueue();
4569
+ loadVersions();
4570
+ revealDomainOnce();
4571
+ loadBranches();
4572
+ return;
4573
+ }
4574
+ const io = new IntersectionObserver(
4575
+ (entries) => {
4576
+ for (const e of entries) {
4577
+ const visible = e.isIntersecting;
4578
+ if (e.target === root) {
4579
+ if (visible) onTabActivate();
4580
+ else onTabDeactivate();
4581
+ } else if (e.target === versionsPanel) {
4582
+ if (visible) loadVersions();
4583
+ } else if (e.target === branchesPanel) {
4584
+ branchesVisible = visible;
4585
+ if (visible) loadBranches();
4586
+ } else if (e.target === domainPanel) {
4587
+ if (visible) revealDomainOnce();
4588
+ }
4589
+ }
4590
+ },
4591
+ { rootMargin: "200px" },
4592
+ );
4593
+ io.observe(root);
4594
+ if (versionsPanel) io.observe(versionsPanel);
4595
+ if (branchesPanel) io.observe(branchesPanel);
4596
+ if (domainPanel) io.observe(domainPanel);
4597
+ };
4598
+
4599
+ root.querySelector("[data-publish-refresh]")?.addEventListener("click", () => {
4600
+ setStatus(statusEl, "");
4601
+ // Explicit refresh: force past every client cache and re-read what's on screen.
4602
+ diffCache.clear();
4603
+ versionsCache.clear();
4604
+ loadQueue();
4605
+ loadVersions({ force: true });
4606
+ loadDomain();
4607
+ loadBranches();
4608
+ });
4609
+
4610
+ // rux7: pause polling while hidden; on return, refresh immediately (loadQueue
4611
+ // re-renders + resyncs the poll clock) rather than waiting out the interval.
4612
+ document.addEventListener("visibilitychange", () => {
4613
+ if (document.hidden) {
4614
+ clearPoll();
4615
+ } else if (tabActive) {
4616
+ // Only resume when the Publish tab is the active one — a background
4617
+ // browser-tab return must not wake a Publish tab the operator left.
4618
+ loadQueue();
4619
+ if (branchesVisible) loadBranches(); // refresh branch truth on return
4620
+ }
4621
+ });
4622
+
4623
+ setupCollapsePersistence();
4624
+ observeVisibility();
4625
+ }