@tokenoftrust/storefront-runner 1.4.2-rc.0 → 2.0.1

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 (370) hide show
  1. package/apps/storefront/astro.config.mjs +3 -3
  2. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +8 -3
  3. package/apps/storefront/env.d.ts +54 -5
  4. package/apps/storefront/integrations/materialize-guard.mjs +12 -14
  5. package/apps/storefront/migrations-apps/0004_deep_morph.sql +36 -0
  6. package/apps/storefront/migrations-apps/0005_common_mystique.sql +23 -0
  7. package/apps/storefront/migrations-apps/0006_broad_microchip.sql +16 -0
  8. package/apps/storefront/migrations-apps/0007_brave_outlaw_kid.sql +15 -0
  9. package/apps/storefront/migrations-apps/0008_blue_dazzler.sql +11 -0
  10. package/apps/storefront/migrations-apps/0009_bored_shinobi_shaw.sql +15 -0
  11. package/apps/storefront/migrations-apps/0010_ws4_verified_provider_measurement.sql +35 -0
  12. package/apps/storefront/migrations-apps/meta/0004_snapshot.json +1474 -0
  13. package/apps/storefront/migrations-apps/meta/0005_snapshot.json +1617 -0
  14. package/apps/storefront/migrations-apps/meta/0006_snapshot.json +1716 -0
  15. package/apps/storefront/migrations-apps/meta/0007_snapshot.json +1812 -0
  16. package/apps/storefront/migrations-apps/meta/0008_snapshot.json +1881 -0
  17. package/apps/storefront/migrations-apps/meta/0009_snapshot.json +1979 -0
  18. package/apps/storefront/migrations-apps/meta/_journal.json +42 -0
  19. package/apps/storefront/public/apps/tot.demo.analytics/0.1.0/entry.js +42 -0
  20. package/apps/storefront/public/platform/amplitude-http/v1/entry.mjs +19 -0
  21. package/apps/storefront/public/platform/google-measurement/v1/entry.mjs +19 -0
  22. package/apps/storefront/public/shared/commerce-chrome.css +20 -2
  23. package/apps/storefront/scripts/build-v84-clean-clone-standalone.mjs +68 -0
  24. package/apps/storefront/scripts/deny-v84-clean-clone-standalone-capabilities.mjs +111 -0
  25. package/apps/storefront/scripts/deny-v84-dogfood-capabilities.mjs +2 -0
  26. package/apps/storefront/scripts/deny-v84-lifecycle-capabilities.mjs +247 -0
  27. package/apps/storefront/scripts/dogfood-v84-nonportable-routes.ts +73 -0
  28. package/apps/storefront/scripts/fixed-v84-native-install-producer.ts +79 -0
  29. package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +10982 -0
  30. package/apps/storefront/scripts/import-v84-through-ws2.ts +38 -0
  31. package/apps/storefront/scripts/initialize-v84-lifecycle-tooling-dependency.ts +2 -0
  32. package/apps/storefront/scripts/initialize-v84-lifecycle-tooling.ts +4 -0
  33. package/apps/storefront/scripts/install-v84-native-through-ws1.ts +185 -0
  34. package/apps/storefront/scripts/prove-clone-isolation.ts +506 -0
  35. package/apps/storefront/scripts/replay-v84-clean-clone-pass.ts +148 -0
  36. package/apps/storefront/scripts/replay-v84-clean-clone-standalone.ts +35 -0
  37. package/apps/storefront/scripts/replay-v84-clean-clone.ts +214 -0
  38. package/apps/storefront/scripts/run-fixed-v84-native-install-producer.mjs +18 -0
  39. package/apps/storefront/scripts/run-v84-clone-isolation-proof.mjs +18 -0
  40. package/apps/storefront/scripts/run-v84-clone-lifecycle-proof.mjs +18 -0
  41. package/apps/storefront/scripts/run-v84-import-through-ws2-proof.mjs +17 -0
  42. package/apps/storefront/scripts/run-v84-nonportable-route-dogfood.mjs +15 -0
  43. package/apps/storefront/scripts/sanitize-v84-proof-environment.mjs +21 -0
  44. package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +42 -0
  45. package/apps/storefront/scripts/v84CleanCloneSourceGraph.ts +145 -0
  46. package/apps/storefront/scripts/validate-v84-clone-lifecycle.ts +18 -0
  47. package/apps/storefront/src/components/Img.astro +1 -1
  48. package/apps/storefront/src/components/admin/AdminBlogTab.astro +19 -16
  49. package/apps/storefront/src/components/admin/AdminIntegrationsTab.astro +105 -0
  50. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1057 -412
  51. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
  52. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +5 -5
  53. package/apps/storefront/src/components/chrome/NavDropdown.astro +12 -0
  54. package/apps/storefront/src/components/chrome/SiteFooter.astro +7 -2
  55. package/apps/storefront/src/components/chrome/SiteHeader.astro +46 -10
  56. package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +1 -1
  57. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +1 -2
  58. package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +1 -2
  59. package/apps/storefront/src/components/compliance/PactActNotice.astro +1 -1
  60. package/apps/storefront/src/components/compliance/Prop65Warning.astro +1 -2
  61. package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +2 -3
  62. package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +2 -2
  63. package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +2 -3
  64. package/apps/storefront/src/components/content/TrustStrip.astro +4 -4
  65. package/apps/storefront/src/components/islands/MobileNav.tsx +26 -1
  66. package/apps/storefront/src/components/islands/VariantSelector.tsx +27 -2
  67. package/apps/storefront/src/components/islands/useCheckoutHandoffCorrelation.ts +75 -0
  68. package/apps/storefront/src/components/membership/IllustrativeLabel.astro +1 -2
  69. package/apps/storefront/src/components/membership/TierCards.astro +2 -2
  70. package/apps/storefront/src/components/membership/TierComparisonTable.astro +2 -2
  71. package/apps/storefront/src/components/subscription/SavingsExplainer.astro +2 -2
  72. package/apps/storefront/src/config/adminTenantLookup.ts +35 -0
  73. package/apps/storefront/src/config/devTenantSeed.ts +9 -9
  74. package/apps/storefront/src/config/resolver.ts +18 -10
  75. package/apps/storefront/src/layouts/Layout.astro +100 -20
  76. package/apps/storefront/src/lib/activity/candidateSubmitLease.ts +87 -0
  77. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +86 -6
  78. package/apps/storefront/src/lib/activity/deployVersion.ts +1 -1
  79. package/apps/storefront/src/lib/admin/adminShell.ts +2 -0
  80. package/apps/storefront/src/lib/admin/envelope.ts +1 -2
  81. package/apps/storefront/src/lib/apps/adminService.ts +10 -0
  82. package/apps/storefront/src/lib/apps/adminSession.ts +1 -1
  83. package/apps/storefront/src/lib/apps/appVersionStore.ts +281 -0
  84. package/apps/storefront/src/lib/apps/consentConfigStore.ts +228 -0
  85. package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +447 -0
  86. package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +367 -0
  87. package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +315 -0
  88. package/apps/storefront/src/lib/apps/integrationLifecycle.ts +614 -0
  89. package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +125 -0
  90. package/apps/storefront/src/lib/apps/integrationReadModel.ts +563 -0
  91. package/apps/storefront/src/lib/apps/integrationsTab.ts +547 -0
  92. package/apps/storefront/src/lib/apps/integrationsView.ts +106 -0
  93. package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +599 -0
  94. package/apps/storefront/src/lib/apps/orders/orderConversionD1.ts +150 -0
  95. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +8 -0
  96. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +1 -1
  97. package/apps/storefront/src/lib/apps/orders/verifiedProviderMeasurement.ts +117 -0
  98. package/apps/storefront/src/lib/apps/registryService.ts +264 -10
  99. package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +170 -0
  100. package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +16 -0
  101. package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +105 -0
  102. package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +177 -0
  103. package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +251 -0
  104. package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +241 -0
  105. package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +211 -0
  106. package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +409 -0
  107. package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +368 -0
  108. package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +188 -0
  109. package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +291 -0
  110. package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +95 -0
  111. package/apps/storefront/src/lib/auth/adminApiGuard.ts +66 -0
  112. package/apps/storefront/src/lib/auth/adminEntry.ts +2 -2
  113. package/apps/storefront/src/lib/auth/adminLanding.ts +88 -0
  114. package/apps/storefront/src/lib/blog/collection.ts +114 -17
  115. package/apps/storefront/src/lib/blog/markdown.ts +1 -1
  116. package/apps/storefront/src/lib/blog/provider.ts +76 -9
  117. package/apps/storefront/src/lib/blog/types.ts +9 -9
  118. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome-assignments.example.json +6 -0
  119. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +79 -0
  120. package/apps/storefront/src/lib/chrome/model.ts +48 -253
  121. package/apps/storefront/src/lib/chrome/navCurrent.ts +29 -0
  122. package/apps/storefront/src/lib/chrome/parseConfig.ts +18 -32
  123. package/apps/storefront/src/lib/chrome/variant.ts +39 -0
  124. package/apps/storefront/src/lib/commerce/checkoutHandoffCorrelation.ts +105 -0
  125. package/apps/storefront/src/lib/compliance/tamper-sentinel-boot.ts +152 -0
  126. package/apps/storefront/src/lib/compliance/tamper-sentinel.ts +327 -0
  127. package/apps/storefront/src/lib/content/index.ts +5 -5
  128. package/apps/storefront/src/lib/content-edit/applyDiff.ts +1 -1
  129. package/apps/storefront/src/lib/content-edit/route.ts +1 -1
  130. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  131. package/apps/storefront/src/lib/d1/schema-apps.ts +276 -0
  132. package/apps/storefront/src/lib/dev/cockpit/ask.ts +49 -0
  133. package/apps/storefront/src/lib/dev/cockpit/clickToSource.ts +71 -0
  134. package/apps/storefront/src/lib/dev/cockpit/copyCommand.ts +227 -0
  135. package/apps/storefront/src/lib/dev/cockpit/files.ts +249 -0
  136. package/apps/storefront/src/lib/dev/cockpit/hostedActivity.ts +500 -0
  137. package/apps/storefront/src/lib/dev/cockpit/infoPopover.ts +12 -0
  138. package/apps/storefront/src/lib/dev/cockpit/mcpCopy.ts +34 -0
  139. package/apps/storefront/src/lib/dev/cockpit/monitor.ts +107 -0
  140. package/apps/storefront/src/lib/dev/cockpit/stepper.ts +116 -0
  141. package/apps/storefront/src/lib/dev/cockpit/themeToggle.ts +23 -0
  142. package/apps/storefront/src/lib/dev/runtimeStore.ts +1 -1
  143. package/apps/storefront/src/lib/edgeCache.ts +18 -6
  144. package/apps/storefront/src/lib/email/candidateConflictEmail.ts +80 -0
  145. package/apps/storefront/src/lib/integration/browserEnvironment.ts +137 -0
  146. package/apps/storefront/src/lib/integration/managedInstallDeclarations.ts +72 -0
  147. package/apps/storefront/src/lib/integration/nonceStamp.ts +49 -0
  148. package/apps/storefront/src/lib/integration/tenantDeclarations.ts +116 -0
  149. package/apps/storefront/src/lib/membership/eligibility.ts +1 -1
  150. package/apps/storefront/src/lib/membership/model.ts +1 -2
  151. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +2 -2
  152. package/apps/storefront/src/lib/newsletter/consent.ts +2 -2
  153. package/apps/storefront/src/lib/newsletter/send.ts +3 -3
  154. package/apps/storefront/src/lib/previewReload.ts +57 -1
  155. package/apps/storefront/src/lib/publish/adminPublishTab.ts +1775 -669
  156. package/apps/storefront/src/lib/publish/buildStream.ts +269 -0
  157. package/apps/storefront/src/lib/publish/collapsePersistence.ts +72 -0
  158. package/apps/storefront/src/lib/publish/lifecycleState.ts +369 -0
  159. package/apps/storefront/src/lib/publish/liveReleaseSummary.ts +29 -0
  160. package/apps/storefront/src/lib/publish/mergeDoctorOnFailure.ts +78 -0
  161. package/apps/storefront/src/lib/publish/mergeHealthBanner.ts +62 -0
  162. package/apps/storefront/src/lib/publish/mergeRowFindings.ts +124 -0
  163. package/apps/storefront/src/lib/publish/mergeSupportEscalation.ts +75 -0
  164. package/apps/storefront/src/lib/publish/publish-guard.ts +88 -0
  165. package/apps/storefront/src/lib/publish/publishFormat.ts +165 -0
  166. package/apps/storefront/src/lib/publish/publishHero.ts +177 -0
  167. package/apps/storefront/src/lib/publish/reviewRelease/changeState.ts +66 -0
  168. package/apps/storefront/src/lib/publish/reviewRelease/copy.ts +141 -0
  169. package/apps/storefront/src/lib/publish/reviewRelease/index.ts +41 -0
  170. package/apps/storefront/src/lib/publish/reviewRelease/permissions.ts +47 -0
  171. package/apps/storefront/src/lib/publish/reviewRelease/releaseState.ts +42 -0
  172. package/apps/storefront/src/lib/publish/reviewRelease/types.ts +59 -0
  173. package/apps/storefront/src/lib/publish/reviewRelease/viewModel.ts +440 -0
  174. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/actions.ts +161 -0
  175. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/controller.ts +325 -0
  176. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/render.ts +241 -0
  177. package/apps/storefront/src/lib/publish/shipWorkspace.ts +22 -3
  178. package/apps/storefront/src/lib/publish/sseRoute.ts +119 -0
  179. package/apps/storefront/src/lib/publish/streamLogConsole.ts +142 -0
  180. package/apps/storefront/src/lib/publish/versionNumber.ts +52 -0
  181. package/apps/storefront/src/lib/rawChrome.ts +125 -47
  182. package/apps/storefront/src/lib/rawMarketingHtml.ts +74 -3
  183. package/apps/storefront/src/lib/seo/alternates.ts +1 -1
  184. package/apps/storefront/src/lib/social/notify.ts +4 -4
  185. package/apps/storefront/src/lib/storyblok/content-model.ts +7 -33
  186. package/apps/storefront/src/lib/storyblok/provider.ts +203 -69
  187. package/apps/storefront/src/lib/subscription/model.ts +4 -4
  188. package/apps/storefront/src/lib/the-build/provider.ts +9 -4
  189. package/apps/storefront/src/middleware/index.ts +273 -51
  190. package/apps/storefront/src/pages/[...slug].astro +9 -4
  191. package/apps/storefront/src/pages/admin/review-release.astro +265 -0
  192. package/apps/storefront/src/pages/admin/select.astro +96 -0
  193. package/apps/storefront/src/pages/admin.astro +16 -2
  194. package/apps/storefront/src/pages/api/activity.ts +8 -8
  195. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +5 -20
  196. package/apps/storefront/src/pages/api/admin/orders/[id].ts +7 -2
  197. package/apps/storefront/src/pages/api/admin/orders.ts +8 -2
  198. package/apps/storefront/src/pages/api/apps/admin/gtm-migration-install.ts +174 -0
  199. package/apps/storefront/src/pages/api/apps/admin/gtm-migration.ts +96 -0
  200. package/apps/storefront/src/pages/api/apps/admin/integration-lifecycle.ts +118 -0
  201. package/apps/storefront/src/pages/api/apps/admin/integrations.ts +89 -0
  202. package/apps/storefront/src/pages/api/apps/admin/platform-adapters.ts +85 -0
  203. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +74 -2
  204. package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +77 -0
  205. package/apps/storefront/src/pages/api/compliance/tamper.ts +88 -0
  206. package/apps/storefront/src/pages/api/integrations/v1/[operation].ts +108 -0
  207. package/apps/storefront/src/pages/api/rum/vitals.ts +1 -2
  208. package/apps/storefront/src/pages/blog/[slug].astro +5 -5
  209. package/apps/storefront/src/pages/blog/author/[author].astro +3 -3
  210. package/apps/storefront/src/pages/blog/category/[category].astro +2 -2
  211. package/apps/storefront/src/pages/blog/index.astro +2 -2
  212. package/apps/storefront/src/pages/blog/tag/[tag].astro +2 -2
  213. package/apps/storefront/src/pages/cockpit.astro +61 -1362
  214. package/apps/storefront/src/pages/index.astro +10 -5
  215. package/apps/storefront/src/pages/llms.txt.ts +1 -1
  216. package/apps/storefront/src/pages/products/[handle].astro +7 -6
  217. package/apps/storefront/src/pages/sitemap.xml.ts +2 -2
  218. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +42 -31
  219. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +8 -3
  220. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +6 -2
  221. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +6 -2
  222. package/apps/storefront/src/pages/style-guide/index.astro +19 -5
  223. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +38 -2
  224. package/apps/storefront/src/pages/the-build/[slug].astro +4 -4
  225. package/apps/storefront/src/pages/the-build/index.astro +2 -2
  226. package/apps/storefront/src/pages/the-build/rss.xml.ts +2 -2
  227. package/apps/storefront/src/styles/admin.css +27 -1
  228. package/apps/storefront/src/themes/tenants/index.ts +19 -7
  229. package/apps/storefront/tsconfig.json +1 -1
  230. package/package.json +1 -1
  231. package/packages/public-runtime/package.json +2 -1
  232. package/packages/public-runtime/schemas/managed-analytics/v1/adapter-definition.schema.json +427 -0
  233. package/packages/public-runtime/schemas/managed-analytics/v1/create-integration-install-request.schema.json +161 -0
  234. package/packages/public-runtime/schemas/managed-analytics/v1/create-migration-draft-request.schema.json +157 -0
  235. package/packages/public-runtime/schemas/managed-analytics/v1/delete-integration-install-request.schema.json +84 -0
  236. package/packages/public-runtime/schemas/managed-analytics/v1/delete-migration-draft-request.schema.json +84 -0
  237. package/packages/public-runtime/schemas/managed-analytics/v1/error-envelope.schema.json +180 -0
  238. package/packages/public-runtime/schemas/managed-analytics/v1/get-adapter-definition-request.schema.json +52 -0
  239. package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-install-request.schema.json +52 -0
  240. package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-revision-request.schema.json +79 -0
  241. package/packages/public-runtime/schemas/managed-analytics/v1/get-migration-draft-request.schema.json +52 -0
  242. package/packages/public-runtime/schemas/managed-analytics/v1/integration-install.schema.json +167 -0
  243. package/packages/public-runtime/schemas/managed-analytics/v1/integration-revision.schema.json +208 -0
  244. package/packages/public-runtime/schemas/managed-analytics/v1/list-adapter-definitions-request.schema.json +53 -0
  245. package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-installs-request.schema.json +53 -0
  246. package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-revisions-request.schema.json +82 -0
  247. package/packages/public-runtime/schemas/managed-analytics/v1/migration-draft.schema.json +218 -0
  248. package/packages/public-runtime/schemas/managed-analytics/v1/mutation-request.schema.json +52 -0
  249. package/packages/public-runtime/schemas/managed-analytics/v1/mutation-success-envelope.schema.json +49 -0
  250. package/packages/public-runtime/schemas/managed-analytics/v1/read-request.schema.json +20 -0
  251. package/packages/public-runtime/schemas/managed-analytics/v1/read-success-envelope.schema.json +45 -0
  252. package/packages/public-runtime/schemas/managed-analytics/v1/revise-integration-install-request.schema.json +156 -0
  253. package/packages/public-runtime/schemas/managed-analytics/v1/set-integration-install-status-request.schema.json +92 -0
  254. package/packages/public-runtime/schemas/managed-analytics/v1/update-migration-draft-request.schema.json +184 -0
  255. package/packages/public-runtime/scripts/generate-managed-analytics-api-v1-schemas.mjs +13 -0
  256. package/packages/public-runtime/src/activity/catalog.ts +41 -37
  257. package/packages/public-runtime/src/activity/event.ts +6 -6
  258. package/packages/public-runtime/src/activity/index.ts +2 -2
  259. package/packages/public-runtime/src/activity/redaction.ts +4 -4
  260. package/packages/public-runtime/src/candidate-index.ts +42 -8
  261. package/packages/public-runtime/src/checkout-orders.ts +2 -2
  262. package/packages/public-runtime/src/checkout-style-publish.ts +2 -2
  263. package/packages/public-runtime/src/checkout-style.ts +7 -7
  264. package/packages/public-runtime/src/checkout.ts +3 -3
  265. package/packages/public-runtime/src/chrome.ts +777 -0
  266. package/packages/public-runtime/src/compliance-obligations.ts +310 -0
  267. package/packages/public-runtime/src/csp.ts +99 -0
  268. package/packages/public-runtime/src/customization-runtime.ts +32 -1
  269. package/packages/public-runtime/src/destination-webhook/contract.ts +353 -0
  270. package/packages/public-runtime/src/embed-catalog.d.mts +9 -0
  271. package/packages/public-runtime/src/embed-catalog.mjs +40 -0
  272. package/packages/public-runtime/src/evidence.ts +288 -0
  273. package/packages/public-runtime/src/gtm-migration/bounds.ts +138 -0
  274. package/packages/public-runtime/src/gtm-migration/disposition-rules.ts +361 -0
  275. package/packages/public-runtime/src/gtm-migration/dispositions.ts +181 -0
  276. package/packages/public-runtime/src/gtm-migration/draft-generator.ts +400 -0
  277. package/packages/public-runtime/src/gtm-migration/graph.ts +468 -0
  278. package/packages/public-runtime/src/gtm-migration/index.ts +95 -0
  279. package/packages/public-runtime/src/gtm-migration/parse.ts +410 -0
  280. package/packages/public-runtime/src/gtm-migration/references.ts +266 -0
  281. package/packages/public-runtime/src/gtm-migration/types.ts +86 -0
  282. package/packages/public-runtime/src/index.ts +16 -1
  283. package/packages/public-runtime/src/integration/assurance-decision.ts +259 -0
  284. package/packages/public-runtime/src/integration/assurance.ts +228 -0
  285. package/packages/public-runtime/src/integration/candidate-evidence.ts +377 -0
  286. package/packages/public-runtime/src/integration/commerce-broker.ts +497 -0
  287. package/packages/public-runtime/src/integration/consent-broker.ts +337 -0
  288. package/packages/public-runtime/src/integration/consent-cmp-demand.ts +115 -0
  289. package/packages/public-runtime/src/integration/consent-config.ts +260 -0
  290. package/packages/public-runtime/src/integration/consent-preview.ts +125 -0
  291. package/packages/public-runtime/src/integration/correlation-token.ts +444 -0
  292. package/packages/public-runtime/src/integration/csp-compiler.ts +267 -0
  293. package/packages/public-runtime/src/integration/csp.ts +105 -0
  294. package/packages/public-runtime/src/integration/digest.ts +43 -0
  295. package/packages/public-runtime/src/integration/environment.ts +228 -0
  296. package/packages/public-runtime/src/integration/events-catalog.ts +186 -0
  297. package/packages/public-runtime/src/integration/events.ts +701 -0
  298. package/packages/public-runtime/src/integration/g2-action-boundary.ts +106 -0
  299. package/packages/public-runtime/src/integration/index.ts +90 -0
  300. package/packages/public-runtime/src/integration/interim-bridge.ts +138 -0
  301. package/packages/public-runtime/src/integration/lifecycle-boot.ts +317 -0
  302. package/packages/public-runtime/src/integration/lifecycle-runtime.ts +393 -0
  303. package/packages/public-runtime/src/integration/lifecycle.ts +94 -0
  304. package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.d.mts +2 -0
  305. package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.mjs +10 -0
  306. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +382 -0
  307. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +684 -0
  308. package/packages/public-runtime/src/integration/managed-analytics-bridge.ts +383 -0
  309. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +14 -0
  310. package/packages/public-runtime/src/integration/managed-analytics.ts +277 -0
  311. package/packages/public-runtime/src/integration/manifest.ts +470 -0
  312. package/packages/public-runtime/src/integration/native-amplitude.ts +362 -0
  313. package/packages/public-runtime/src/integration/native-ga4.ts +352 -0
  314. package/packages/public-runtime/src/integration/observability.ts +451 -0
  315. package/packages/public-runtime/src/integration/order-conversion.ts +0 -0
  316. package/packages/public-runtime/src/integration/phases.ts +182 -0
  317. package/packages/public-runtime/src/integration/plan.ts +112 -0
  318. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +146 -0
  319. package/packages/public-runtime/src/integration/platform-adapters.ts +671 -0
  320. package/packages/public-runtime/src/integration/promotion-gate.ts +451 -0
  321. package/packages/public-runtime/src/integration/provider-checkout-extension.ts +425 -0
  322. package/packages/public-runtime/src/integration/receipt-measurement.ts +370 -0
  323. package/packages/public-runtime/src/integration/regulated-parent-cert.ts +311 -0
  324. package/packages/public-runtime/src/integration/render-phases.ts +267 -0
  325. package/packages/public-runtime/src/integration/resolve.ts +465 -0
  326. package/packages/public-runtime/src/integration/result.ts +239 -0
  327. package/packages/public-runtime/src/integration/secrets.ts +92 -0
  328. package/packages/public-runtime/src/integration/tamper-evidence.ts +205 -0
  329. package/packages/public-runtime/src/integration/tamper-recovery.ts +127 -0
  330. package/packages/public-runtime/src/integration/version.ts +92 -0
  331. package/packages/public-runtime/src/integration-capabilities.ts +520 -0
  332. package/packages/public-runtime/src/membership.ts +2 -2
  333. package/packages/public-runtime/src/raw-html-policy.ts +2 -4
  334. package/packages/public-runtime/src/regulated-commerce/broker/consent-dedupe.ts +144 -0
  335. package/packages/public-runtime/src/regulated-commerce/correlation/contract.ts +177 -0
  336. package/packages/public-runtime/src/regulated-commerce/diagnostics/local-dispatch.ts +24 -0
  337. package/packages/public-runtime/src/regulated-commerce/diagnostics/read-model.ts +69 -0
  338. package/packages/public-runtime/src/regulated-commerce/dispatch/core.ts +134 -0
  339. package/packages/public-runtime/src/regulated-commerce/events/contract.ts +113 -0
  340. package/packages/public-runtime/src/regulated-commerce/ledger/core.ts +81 -0
  341. package/packages/public-runtime/src/regulated-commerce/mappings/ga4-ads.ts +87 -0
  342. package/packages/public-runtime/src/regulated-commerce/provider-order/bridge.ts +52 -0
  343. package/packages/public-runtime/src/regulated-commerce/purchase-truth/contract.ts +252 -0
  344. package/packages/public-runtime/src/regulated-commerce/sources/accepted-browser-operation.ts +67 -0
  345. package/packages/public-runtime/src/regulated-commerce/testing/fake-destination.ts +93 -0
  346. package/packages/public-runtime/src/static-bundle.ts +5 -5
  347. package/packages/public-runtime/src/tenant-assets.ts +143 -4
  348. package/packages/public-runtime/src/tenant.ts +136 -9
  349. package/packages/public-runtime/tests/fixtures/integration/browser-environment.example.json +8 -0
  350. package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +44 -0
  351. package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +44 -0
  352. package/packages/public-runtime/tests/fixtures/integration/compliance-assurance-record.example.json +50 -0
  353. package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +120 -0
  354. package/packages/public-runtime/tests/fixtures/integration/managed-analytics-v1/fake-adapter-catalog.json +35 -0
  355. package/packages/public-runtime/tests/fixtures/integration/tenant-action-registry.example.json +11 -0
  356. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/README.md +22 -0
  357. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/entry.js +30 -0
  358. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +75 -0
  359. package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/public-config.json +3 -0
  360. package/packages/public-runtime/tests/fixtures/regulated-commerce/fake-destinations.ts +38 -0
  361. package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.provenance.json +29 -0
  362. package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.redacted.json +3397 -0
  363. package/scripts/build/copy-tenant-assets.mjs +12 -5
  364. package/scripts/dev/ai-edit.mjs +2 -1
  365. package/scripts/dev/file-browser.mjs +2 -2
  366. package/scripts/dev/file-writer.mjs +2 -2
  367. package/scripts/dev/integration-guardrails.mjs +498 -0
  368. package/scripts/dev/safe-path.mjs +2 -2
  369. package/scripts/dev/tenant-source-root.mjs +3 -2
  370. package/scripts/tot-dev.mjs +5 -4
@@ -9,8 +9,65 @@
9
9
  * duplicated (see docs/architecture/decisions/0013-client-island-pattern-and-inline-drift-guard.md).
10
10
  */
11
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";
12
22
  import { candidatePrUrl } from "@/lib/preview/candidateDashboard";
13
- import { classifyResolveResponse } from "@/lib/change/resolveConflict";
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";
14
71
 
15
72
  export function initAdminPublishTab(): void {
16
73
  const root: any = document.getElementById("publish");
@@ -19,25 +76,85 @@ export function initAdminPublishTab(): void {
19
76
  const canShip = ds.canShip === "true";
20
77
  const repo = ds.repo || "";
21
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
+ };
22
124
 
23
125
  const statusEl = root.querySelector("[data-publish-status]");
24
126
  const queueBody = root.querySelector("[data-publish-queue-body]");
25
127
  const queueChip = root.querySelector("[data-publish-queue-chip]");
26
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]");
27
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]");
28
145
 
29
146
  // rux7 — in-flight UI state the auto-refresh poll must preserve across a
30
147
  // re-render. These are the AUTHORITATIVE source of truth (never scraped
31
148
  // back out of the DOM): an action's async closure holds a reference to the
32
149
  // OLD button, so after a poll swaps the row the completion signal lands on a
33
150
  // detached node — reading `disabled`/`data-building` off the fresh DOM would
34
- // wedge forever. buildPreview/integrate/reject/retire add on start and
151
+ // wedge forever. buildPreview/integrate/reject add on start and
35
152
  // delete in `finally` (before their own loadQueue), so the next render
36
153
  // reflects the true busy set. Open diff panes are the exception: the DOM IS
37
154
  // their source of truth (toggleDiff owns open/close), so renderQueue detaches
38
155
  // and re-attaches the actual pane node — see renderQueue.
39
156
  const inflightBuilds = new Set(); // prNumber currently building
40
- const busyChanges = new Set(); // changeId with an in-flight integrate/reject/retire
157
+ const busyChanges = new Set(); // changeId with an in-flight integrate/reject
41
158
  let aggregatePublishing = false; // the aggregate Publish POST is in flight
42
159
  let aggregateRetrying = false; // u15: the aggregate Retry-build POST is in flight
43
160
 
@@ -60,122 +177,153 @@ export function initAdminPublishTab(): void {
60
177
  else delete el.dataset.state;
61
178
  };
62
179
 
63
- const shortSha = (sha?: any) => (sha ? String(sha).slice(0, 8) : "—");
64
-
65
- // Build a "+N −M" lines-changed node (added green, removed red) as ONE element,
66
- // so a meta "·" separator sits before the pair rather than between the numbers.
67
- // Either count may be absent.
68
- const linesNode = (added?: any, removed?: any) => {
69
- const wrap = document.createElement("span");
70
- wrap.className = "agg-lines";
71
- if (typeof added === "number") {
72
- const a = document.createElement("span");
73
- a.className = "agg-add";
74
- a.textContent = "+" + added;
75
- wrap.append(a);
76
- }
77
- if (typeof removed === "number") {
78
- const d = document.createElement("span");
79
- d.className = "agg-del";
80
- d.textContent = (typeof added === "number" ? " " : "") + "−" + removed;
81
- wrap.append(d);
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);
82
205
  }
83
- return wrap;
206
+ el.append(document.createTextNode(text ?? ""));
84
207
  };
85
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
+ };
86
236
 
87
- const age = (iso?: any) => {
88
- if (!iso) return "—";
89
- const ms = Date.now() - new Date(iso).getTime();
90
- if (!Number.isFinite(ms) || ms < 0) return "—";
91
- const mins = Math.floor(ms / 60000);
92
- if (mins < 1) return "just now";
93
- if (mins < 60) return mins + "m ago";
94
- const hours = Math.floor(mins / 60);
95
- if (hours < 48) return hours + "h ago";
96
- return Math.floor(hours / 24) + "d ago";
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 };
97
310
  };
98
311
 
312
+
99
313
  const STATUS_CHIP: Record<string, string> = {
100
314
  queued: "",
101
315
  reconciling: "blue",
102
316
  ready: "green",
103
317
  blocked: "red",
104
318
  };
105
-
106
- const chip = (text?: any, tone?: any) => {
107
- const el = document.createElement("span");
108
- el.className = "chip" + (tone ? " " + tone : "");
109
- el.textContent = text;
110
- return el;
111
- };
112
-
113
- const cell = (...children: any[]) => {
114
- const td = document.createElement("td");
115
- for (const child of children) {
116
- if (child == null) continue;
117
- td.append(typeof child === "string" ? document.createTextNode(child) : child);
118
- }
119
- return td;
120
- };
121
-
122
- const subtle = (text?: any) => {
123
- const el = document.createElement("span");
124
- el.className = "subtle";
125
- el.textContent = text;
126
- return el;
127
- };
128
-
129
- // One included-change line item (PR # + human title, a one-line description of
130
- // what it does, and meta: sha / files / lines). Shared by the aggregate plan's
131
- // "Included changes" and the published-versions "what shipped" list, so both
132
- // read the same way. Every enrichment field is optional — a row degrades to
133
- // just "PR #N · <sha>" when candidate_summary didn't cover it.
134
- const changeSummaryItem = (ch?: any) => {
135
- const li = document.createElement("li");
136
- li.className = "agg-included-item";
137
-
138
- const top = document.createElement("div");
139
- top.className = "agg-included-top";
140
- const prEl = document.createElement("span");
141
- prEl.className = "agg-included-pr";
142
- prEl.textContent = ch.prNumber != null ? "PR #" + ch.prNumber : ch.changeId;
143
- top.append(prEl);
144
- if (ch.title) {
145
- const t = document.createElement("span");
146
- t.className = "agg-included-title";
147
- t.textContent = ch.title;
148
- top.append(t);
149
- }
150
- li.append(top);
151
-
152
- const description = ch.description || ch.summary;
153
- if (description) {
154
- const desc = document.createElement("p");
155
- desc.className = "agg-included-desc";
156
- desc.textContent = description;
157
- li.append(desc);
158
- }
159
-
160
- const meta = document.createElement("div");
161
- meta.className = "agg-included-meta";
162
- if (ch.headSha) {
163
- const sha = document.createElement("span");
164
- sha.className = "publish-mono sha-token";
165
- sha.textContent = shortSha(ch.headSha);
166
- meta.append(sha);
167
- }
168
- const fileCount = ch.fileCount != null ? ch.fileCount : ch.filesChanged;
169
- if (fileCount != null) {
170
- const f = document.createElement("span");
171
- f.textContent = fileCount + (fileCount === 1 ? " file" : " files");
172
- meta.append(f);
173
- }
174
- if (typeof ch.linesAdded === "number" || typeof ch.linesRemoved === "number") {
175
- meta.append(linesNode(ch.linesAdded, ch.linesRemoved));
176
- }
177
- if (meta.childNodes.length) li.append(meta);
178
- return li;
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",
179
327
  };
180
328
 
181
329
  // ---- ?pr=N deep link ---------------------------------------------------
@@ -225,23 +373,179 @@ export function initAdminPublishTab(): void {
225
373
 
226
374
  // ---- Open change queue -------------------------------------------------
227
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
+
228
502
  const renderQueue = (payload?: any) => {
229
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));
230
527
  // rux1: surface a forge-listing failure as a visible strip — never let a
231
528
  // degraded (short) list read as a genuine empty queue.
232
529
  if (degradedStrip) degradedStrip.hidden = payload.degraded !== true;
233
530
  queueBody.textContent = "";
234
531
  if (queueCount) queueCount.textContent = String(changes.length);
235
532
  if (queueChip) {
236
- queueChip.textContent = changes.length === 1 ? "1 open" : changes.length + " open";
237
- queueChip.className = "chip" + (changes.length ? " blue" : "");
533
+ queueChip.textContent = pending.length === 1 ? "1 open" : pending.length + " open";
534
+ queueChip.className = "chip" + (pending.length ? " blue" : "");
238
535
  }
239
536
 
240
- if (!changes.length) {
537
+ if (!pending.length) {
241
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.
242
542
  const td = cell(
243
543
  subtle(
244
- "No open changes. New store changes appear here when a developer submits them for review.",
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.",
245
549
  ),
246
550
  );
247
551
  td.colSpan = 6;
@@ -250,7 +554,7 @@ export function initAdminPublishTab(): void {
250
554
  return;
251
555
  }
252
556
 
253
- for (const change of changes) {
557
+ for (const change of pending) {
254
558
  const tr = document.createElement("tr");
255
559
  if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
256
560
 
@@ -263,13 +567,17 @@ export function initAdminPublishTab(): void {
263
567
  if (change.branch) meta.push(change.branch);
264
568
  if (change.prNumber != null) meta.push("PR #" + change.prNumber);
265
569
  if (meta.length) idCell.append(subtle(meta.join(" · ")));
266
- // Owner-facing "who did this" — accept takes priority over build (a
570
+ // Owner-facing "who did this" — accept takes priority over submit/build (a
267
571
  // candidate that's been accepted-to-preview is more meaningfully
268
- // "acted on" than merely built). Reject never shows here: a rejected
572
+ // "acted on" than merely submitted or built). Reject never shows here: a rejected
269
573
  // candidate is closed and has already left the open queue.
270
- const actorRecord = change.actors && (change.actors.accept || change.actors.build);
574
+ const actorRecord = change.actors && (change.actors.accept || change.actors.submit || change.actors.build);
271
575
  if (actorRecord) {
272
- const label = change.actors.accept ? "Accepted by " : "Built by ";
576
+ const label = change.actors.accept
577
+ ? "Added by "
578
+ : change.actors.submit
579
+ ? "Submitted by "
580
+ : "Built by ";
273
581
  idCell.append(subtle(label + (actorRecord.name || actorRecord.email)));
274
582
  }
275
583
  // Prefer the synthesized friendly per-PR route (handles every state);
@@ -287,6 +595,23 @@ export function initAdminPublishTab(): void {
287
595
  wrap.append(preview);
288
596
  idCell.append(wrap);
289
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
+ }
290
615
  tr.append(idCell);
291
616
 
292
617
  // Reconcile status. u13 DISPLAY HONESTY: when the server could not confirm
@@ -296,12 +621,16 @@ export function initAdminPublishTab(): void {
296
621
  // as current fact; keep the last-known label as a subtle, clearly-hedged note.
297
622
  let statusCell;
298
623
  if (change.liveStateConfirmed === false) {
299
- statusCell = cell(chip("State unknown", "amber"));
624
+ statusCell = cell(chip("State unknown", "amber", CHIP_GLYPH.amber));
300
625
  statusCell.append(
301
- subtle("Forge unreachable — last known: " + (change.status || "unknown")),
626
+ subtle("Status couldn't be confirmed — last known: " +
627
+ (STATUS_LABEL[change.status] || change.status || "unknown")),
302
628
  );
303
629
  } else {
304
- statusCell = cell(chip(change.status || "unknown", STATUS_CHIP[change.status] ?? "amber"));
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
+ );
305
634
  if (change.lastError) statusCell.append(subtle(change.lastError));
306
635
  }
307
636
  tr.append(statusCell);
@@ -311,7 +640,9 @@ export function initAdminPublishTab(): void {
311
640
  const failing = checks.filter((c: any) => c.status === "fail");
312
641
  const promotable = Boolean(change.evidence && change.evidence.promotable);
313
642
  const checksCell = cell(
314
- promotable ? chip("Checks passed", "green") : chip("Checks blocked", "amber"),
643
+ promotable
644
+ ? chip("Checks passed", "green", CHIP_GLYPH.green)
645
+ : chip("Checks blocked", "amber", CHIP_GLYPH.amber),
315
646
  );
316
647
  if (failing.length) {
317
648
  checksCell.append(subtle("Failing: " + failing.map((c: any) => c.label || c.id).join(", ")));
@@ -330,14 +661,12 @@ export function initAdminPublishTab(): void {
330
661
  const actions = document.createElement("td");
331
662
  actions.className = "action publish-actions";
332
663
 
333
- // rux5 — a synthetic not-built forge PR row (built:false / status
334
- // "not-built") has NO built candidate: changeId is null, so the built-only
335
- // actions below (Diff/Accept/Reject/Retire, all keyed on changeId) do not
336
- // apply. Its one action is Build preview — materialize the PR's hosted
337
- // preview on demand. Capability-gated: the button is SHOWN only to a
338
- // ship-capable operator, and the server (buildGate) re-authorizes — the UI
339
- // check is UX, not the real gate.
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.
340
668
  if (change.built === false || change.status === "not-built") {
669
+ let notBuiltBuild: any;
341
670
  if (canShip) {
342
671
  const build = document.createElement("button");
343
672
  build.type = "button";
@@ -356,24 +685,47 @@ export function initAdminPublishTab(): void {
356
685
  }
357
686
  build.addEventListener("click", () => buildPreview(change, build));
358
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
+ }
359
699
  } else {
360
- actions.append(subtle("Owner/admin sign-in required to build"));
700
+ actions.append(subtle("Owner/admin sign-in required to build or reject"));
361
701
  }
702
+ applyRowFinding(actions, change, rowFindings, { build: notBuiltBuild });
362
703
  tr.append(actions);
363
704
  queueBody.append(tr);
364
705
  continue;
365
706
  }
366
707
 
367
- // rux7: while an integrate/reject/retire for this change is in flight,
708
+ // rux7: while an integrate/reject for this change is in flight,
368
709
  // keep its mutating actions disabled across a poll re-render so the
369
710
  // operator can't double-submit against a freshly-minted button.
370
711
  const changeBusy = busyChanges.has(change.changeId);
371
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
+
372
724
  // rux6 Review: expand the row into a single "review moment" pane —
373
725
  // preview iframe + evidence chips + diff + Accept/Reject — so a reviewer
374
726
  // never leaves the queue. Supersedes the standalone Diff button (the dg7
375
727
  // diff now lives inside the pane, reusing renderDiff). The row's own
376
- // Accept/Reject/Retire buttons below are unchanged.
728
+ // Accept/Reject buttons below are unchanged.
377
729
  const reviewBtn = document.createElement("button");
378
730
  reviewBtn.type = "button";
379
731
  reviewBtn.className = "btn";
@@ -382,6 +734,26 @@ export function initAdminPublishTab(): void {
382
734
  reviewBtn.title = "Open one pane to preview, diff, and act on this change";
383
735
  reviewBtn.addEventListener("click", () => toggleReview(change, reviewBtn, tr));
384
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
+ }
385
757
  // b11 SPLIT: the candidate-queue primary action is now ACCEPT TO PREVIEW
386
758
  // (b08 integrate = advance the shared preview aggregate), NOT go-live.
387
759
  // Integrate flips NO live channel, so it has NO paywall (entitled) gate —
@@ -396,21 +768,37 @@ export function initAdminPublishTab(): void {
396
768
  // than presenting a generic, disabled "not ready" action. Other false
397
769
  // mergeability states remain disabled: a rebuild cannot fix an incomplete
398
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);
399
776
  const needsPreviewUpdate =
400
- change.status === "ready" && promotable && change.mergeable === false;
401
- accept.textContent = needsPreviewUpdate ? "Update change" : "Accept to preview";
402
- accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable) || changeBusy;
403
- accept.title = !canShip
404
- ? "Requires a ship-on-behalf grant on your account — ask the store owner"
405
- : needsPreviewUpdate
406
- ? "Preview changed while you worked — update this change, then integrate it"
407
- : !change.mergeable
408
- ? "This change is not ready to integrate"
409
- : "Integrate this change into the preview aggregate (does not go live)";
410
- accept.addEventListener("click", () =>
411
- needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
412
- );
413
- actions.append(accept);
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
+ }
414
802
  const reject = document.createElement("button");
415
803
  reject.type = "button";
416
804
  reject.className = "btn";
@@ -421,21 +809,13 @@ export function initAdminPublishTab(): void {
421
809
  : "Requires a ship-on-behalf grant on your account — ask the store owner";
422
810
  reject.addEventListener("click", () => rejectChange(change, reject));
423
811
  actions.append(reject);
424
-
425
- // U7 Retire/GC: evict this candidate's preview environment + version to
426
- // reclaim space. DISTINCT from Reject — retire is reversible-by-rebuild
427
- // (U1 build-on-demand + the U2 fallback page rematerialize it), so a
428
- // retired PR degrades to "not built yet", not a dead 404.
429
- const retire = document.createElement("button");
430
- retire.type = "button";
431
- retire.className = "btn";
432
- retire.textContent = "Retire";
433
- retire.disabled = !canShip || changeBusy;
434
- retire.title = canShip
435
- ? "Evict this preview to reclaim space — rebuildable on demand (not the same as Reject)"
436
- : "Requires an owner or admin sign-in";
437
- retire.addEventListener("click", () => retireChange(change, retire));
438
- actions.append(retire);
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
+ });
439
819
  tr.append(actions);
440
820
 
441
821
  queueBody.append(tr);
@@ -443,13 +823,86 @@ export function initAdminPublishTab(): void {
443
823
  }
444
824
  };
445
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
+
446
882
  const renderCurrent = (current?: any, shippedBy?: any) => {
447
- const versionEl = root.querySelector("[data-publish-live-version]");
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]");
448
892
  const sourceEl = root.querySelector("[data-publish-live-source]");
449
893
  const atEl = root.querySelector("[data-publish-live-at]");
450
894
  const shippedByEl = root.querySelector("[data-publish-live-shipped-by]");
451
895
  if (current && current.versionId) {
452
- if (versionEl) versionEl.textContent = shortSha(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;
453
906
  if (sourceEl) {
454
907
  sourceEl.textContent =
455
908
  (current.source === "commit" ? "Published from a change" : "Published version") +
@@ -463,7 +916,13 @@ export function initAdminPublishTab(): void {
463
916
  shippedByEl.textContent = shippedBy ? "Shipped by " + (shippedBy.name || shippedBy.email) : "";
464
917
  }
465
918
  } else {
466
- if (versionEl) versionEl.textContent = "Not live yet";
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 = "—";
467
926
  if (sourceEl) sourceEl.textContent = "Nothing has been published for this store";
468
927
  if (atEl) atEl.textContent = "—";
469
928
  if (shippedByEl) shippedByEl.textContent = "";
@@ -505,6 +964,11 @@ export function initAdminPublishTab(): void {
505
964
  c.mergeable,
506
965
  c.built,
507
966
  c.lastError || null,
967
+ c.title || null,
968
+ c.description || null,
969
+ c.intent || null,
970
+ c.verification || null,
971
+ c.riskRollback || null,
508
972
  !!(c.evidence && c.evidence.promotable),
509
973
  c.actors ? Object.keys(c.actors).sort().join(",") : "",
510
974
  ]);
@@ -569,8 +1033,10 @@ export function initAdminPublishTab(): void {
569
1033
  lastChangesSignature = sig;
570
1034
  renderCurrent(data.current, data.shippedBy);
571
1035
  renderQueue(data);
1036
+ renderClosed(data);
572
1037
  applyPrDeeplink();
573
1038
  renderAggregate(data);
1039
+ renderHero(data);
574
1040
  return "changed";
575
1041
  };
576
1042
 
@@ -611,6 +1077,7 @@ export function initAdminPublishTab(): void {
611
1077
  if (res.status === 401 || res.status === 403) {
612
1078
  if (queueChip) queueChip.textContent = "Sign-in required";
613
1079
  if (degradedStrip) degradedStrip.hidden = true;
1080
+ if (heroEl) (heroEl as any).hidden = true;
614
1081
  queueBody.textContent = "";
615
1082
  const tr = document.createElement("tr");
616
1083
  const td = cell(subtle("Sign in as a store owner or admin to view the publish queue."));
@@ -626,8 +1093,10 @@ export function initAdminPublishTab(): void {
626
1093
  }
627
1094
  renderCurrent(data.current, data.shippedBy);
628
1095
  renderQueue(data);
1096
+ renderClosed(data);
629
1097
  applyPrDeeplink();
630
1098
  renderAggregate(data);
1099
+ renderHero(data);
631
1100
  // rux7: this is fresh authoritative data — resync the poll clock so the
632
1101
  // next tick dedupes against it, and reset the cadence to responsive
633
1102
  // (a manual refresh / a just-completed action likely means more coming).
@@ -659,6 +1128,16 @@ export function initAdminPublishTab(): void {
659
1128
  red: "red",
660
1129
  shipped: "green",
661
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
+ };
662
1141
  // The plan we last rendered — the exact-digest guard the Publish POST sends
663
1142
  // back so a stale plan (queue moved / artifact changed) is refused, never
664
1143
  // shipped blind.
@@ -748,15 +1227,74 @@ export function initAdminPublishTab(): void {
748
1227
  if (retryRow) retryRow.hidden = state !== "red";
749
1228
  if (retryBtn) retryBtn.disabled = aggregateRetrying;
750
1229
  if (chipEl) {
751
- chipEl.textContent = state;
752
- chipEl.className = "chip " + (AGG_STATE_CHIP[state] || "");
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
+ }
753
1245
  }
754
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
+ });
755
1260
  if (runLinkEl) {
756
1261
  runLinkEl.textContent = "";
757
- const link = agg && runLink(agg.revHref, "Open build");
758
- if (link) runLinkEl.append(link);
759
- else runLinkEl.append(document.createTextNode("The current green preview build for this store."));
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
+ }
760
1298
  }
761
1299
 
762
1300
  // Combined evidence = the last (or in-flight) integration run's honest state.
@@ -785,10 +1323,18 @@ export function initAdminPublishTab(): void {
785
1323
  else if (lastRun.state === "failed") parts.push("Combined evidence did not pass.");
786
1324
  if (lastRun.finishedAt) parts.push(age(lastRun.finishedAt));
787
1325
  evDetailEl.append(document.createTextNode(parts.join(" · ")));
788
- const rl = runLink(lastRun.revHref, "Run build");
789
- if (rl) {
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) {
790
1336
  evDetailEl.append(document.createTextNode(" · "));
791
- evDetailEl.append(rl);
1337
+ evDetailEl.append(runLink(lastRun.revHref, "Run build")!);
792
1338
  }
793
1339
  } else {
794
1340
  evDetailEl.append(document.createTextNode("The last integration run's verdict."));
@@ -877,53 +1423,114 @@ export function initAdminPublishTab(): void {
877
1423
  }
878
1424
  }
879
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.
880
1432
  if (prsList) {
881
1433
  prsList.textContent = "";
882
1434
  for (const pr of prs) {
883
- const li = document.createElement("li");
884
- li.className = "agg-change";
885
-
886
- const main = document.createElement("div");
887
- main.className = "agg-change-main";
888
-
889
- // Line 1: PR label + optional human title (what the change actually is).
890
- const line1 = document.createElement("div");
891
- line1.className = "agg-change-line";
892
- const label = document.createElement("span");
893
- label.className = "agg-change-pr";
894
- label.textContent = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
895
- line1.append(label);
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);
896
1444
  if (pr.title) {
897
- const title = document.createElement("span");
898
- title.className = "agg-change-title";
899
- title.textContent = pr.title;
900
- line1.append(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);
901
1456
  }
902
- main.append(line1);
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);
903
1497
 
904
- // Line 2: sha token + provenance (server enrichment): WHEN it entered the
905
- // staged preview + WHO ran Integrate. Best-effort — absent when unattributed.
906
- const line2 = document.createElement("div");
907
- line2.className = "agg-change-line agg-change-sub";
1498
+ // Head sha.
1499
+ const shaCell = document.createElement("td");
908
1500
  const sha = document.createElement("span");
909
1501
  sha.className = "publish-mono sha-token";
910
1502
  sha.textContent = shortSha(pr.headSha);
911
- line2.append(sha);
912
- if (pr.integratedAt || (pr.integratedBy && pr.integratedBy.email)) {
913
- const meta = document.createElement("span");
914
- meta.className = "subtle aggregate-pr-meta";
915
- const bits = [];
916
- if (pr.integratedAt) bits.push("integrated " + age(pr.integratedAt));
917
- if (pr.integratedBy && pr.integratedBy.email) bits.push("by " + pr.integratedBy.email);
918
- meta.textContent = bits.join(" ");
919
- line2.append(meta);
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 = "—";
920
1518
  }
921
- main.append(line2);
922
- li.append(main);
923
-
924
- // u14 Undo — per-change revert: roll THIS change OUT of the staged preview
925
- // (a revert commit + rebuild); the live site is untouched. Only for a
926
- // PR-numbered, still-ahead-of-live change; gated on ship capability.
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";
927
1534
  if (canShip && pr.prNumber != null && !matchesLive) {
928
1535
  const undo = document.createElement("button");
929
1536
  undo.type = "button";
@@ -932,35 +1539,80 @@ export function initAdminPublishTab(): void {
932
1539
  undo.title =
933
1540
  "Revert PR #" + pr.prNumber + " out of the staged preview (your live site is untouched)";
934
1541
  undo.addEventListener("click", () => revertStaged(pr, undo));
935
- li.append(undo);
1542
+ actionsCell.append(undo);
936
1543
  }
937
- prsList.append(li);
1544
+ tr.append(actionsCell);
1545
+
1546
+ prsList.append(tr);
938
1547
  }
939
1548
  }
940
1549
 
941
1550
  renderPlan(data.plan);
942
1551
 
943
1552
  // The Publish button: enabled only for a shippable, paywall-cleared plan and
944
- // a viewer who can ship. A paywalled plan is SHOWN (upsell), never hidden.
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
+ });
945
1573
  if (publishBtn) {
946
- const shippable = Boolean(currentPlan);
947
- const paywalled = currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed;
948
1574
  // rux7: never re-enable the Publish button while its POST is in flight —
949
- // a poll refresh landing mid-publish must not open a double-submit.
950
- publishBtn.disabled =
951
- aggregatePublishing || !canShip || !shippable || paywalled || currentPlan.alreadyShipped;
952
- publishBtn.title = !canShip
953
- ? "Requires an owner or admin sign-in"
954
- : !shippable
955
- ? "The aggregate is not green / has no passed run to publish yet"
956
- : paywalled
957
- ? "Publishing to your live store is part of the paid plan"
958
- : currentPlan.alreadyShipped
959
- ? "This reviewed build is already live"
960
- : "Publish the exact reviewed digest to your live store";
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
+ }
961
1609
  }
962
1610
  if (publishDetail) {
963
- if (currentPlan && currentPlan.alreadyShipped) {
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) {
964
1616
  publishDetail.textContent =
965
1617
  "This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live. ";
966
1618
  const view = document.createElement("a");
@@ -970,12 +1622,13 @@ export function initAdminPublishTab(): void {
970
1622
  view.textContent = "View your live store →";
971
1623
  publishDetail.append(view);
972
1624
  } else if (currentPlan) {
973
- publishDetail.textContent =
974
- "Publishes the exact reviewed digest " + shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) +
975
- " (build " + shortSha(currentPlan.pinnedSha) + ") — no rebuild.";
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.";
976
1629
  } else {
977
1630
  publishDetail.textContent =
978
- "Publishes the exact reviewed digest — no rebuild. Available once the aggregate is green and its integration run passed.";
1631
+ "Publishes the exact build you reviewed — no rebuild. Available once your next release is ready and its checks pass.";
979
1632
  }
980
1633
  }
981
1634
 
@@ -996,133 +1649,9 @@ export function initAdminPublishTab(): void {
996
1649
  }
997
1650
  };
998
1651
 
999
- // Openable "Included changes" — each accepted PR as a line item with its
1000
- // human title, a one-line description of what it does, and meta (sha / files /
1001
- // who + when). title/description/fileCount are OPTIONAL server enrichment: a
1002
- // row degrades to just "PR #N · <sha>" when they're absent, and the <details>
1003
- // still lists every included change. The <details> element itself is static
1004
- // (in the markup) so its open/closed state survives a poll re-render.
1005
- const renderIncludedChanges = (planBox?: any, prs?: any) => {
1006
- const wrap = planBox.querySelector("[data-aggregate-plan-included]");
1007
- if (!wrap) return;
1008
- if (!prs.length) {
1009
- wrap.hidden = true;
1010
- return;
1011
- }
1012
- wrap.hidden = false;
1013
- const countEl = wrap.querySelector("[data-aggregate-plan-included-count]");
1014
- const hintEl = wrap.querySelector("[data-aggregate-plan-included-hint]");
1015
- const listEl = wrap.querySelector("[data-aggregate-plan-included-list]");
1016
- if (countEl) countEl.textContent = String(prs.length);
1017
- if (hintEl) {
1018
- hintEl.textContent = prs
1019
- .map((pr: any) => (pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId))
1020
- .filter(Boolean)
1021
- .join(", ");
1022
- }
1023
- if (!listEl) return;
1024
- listEl.textContent = "";
1025
- for (const pr of prs) {
1026
- const li = document.createElement("li");
1027
- li.className = "agg-included-item";
1028
-
1029
- const top = document.createElement("div");
1030
- top.className = "agg-included-top";
1031
- const prEl = document.createElement("span");
1032
- prEl.className = "agg-included-pr";
1033
- prEl.textContent = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
1034
- top.append(prEl);
1035
- if (pr.title) {
1036
- const t = document.createElement("span");
1037
- t.className = "agg-included-title";
1038
- t.textContent = pr.title;
1039
- top.append(t);
1040
- }
1041
- li.append(top);
1042
-
1043
- const description = pr.description || pr.summary;
1044
- if (description) {
1045
- const desc = document.createElement("p");
1046
- desc.className = "agg-included-desc";
1047
- desc.textContent = description;
1048
- li.append(desc);
1049
- }
1050
-
1051
- const meta = document.createElement("div");
1052
- meta.className = "agg-included-meta";
1053
- const sha = document.createElement("span");
1054
- sha.className = "publish-mono sha-token";
1055
- sha.textContent = shortSha(pr.headSha);
1056
- meta.append(sha);
1057
- const fileCount = pr.fileCount != null ? pr.fileCount : pr.filesChanged;
1058
- if (fileCount != null) {
1059
- const f = document.createElement("span");
1060
- f.textContent = fileCount + (fileCount === 1 ? " file" : " files");
1061
- meta.append(f);
1062
- }
1063
- // Lines added/removed (green/red) — one meta item so the "·" separator sits
1064
- // before the pair, not between +N and −M.
1065
- if (typeof pr.linesAdded === "number" || typeof pr.linesRemoved === "number") {
1066
- meta.append(linesNode(pr.linesAdded, pr.linesRemoved));
1067
- }
1068
- const bits = [];
1069
- if (pr.integratedAt) bits.push("integrated " + age(pr.integratedAt));
1070
- if (pr.integratedBy && pr.integratedBy.email) bits.push("by " + pr.integratedBy.email);
1071
- if (bits.length) {
1072
- const when = document.createElement("span");
1073
- when.textContent = bits.join(" ");
1074
- meta.append(when);
1075
- }
1076
- li.append(meta);
1077
-
1078
- // A trivial change carries its own bounded diff — offer it in a "View diff"
1079
- // disclosure (for a tiny change the diff IS the summary). Lines are colored
1080
- // by their leading +/- marker.
1081
- if (Array.isArray(pr.diff) && pr.diff.length) {
1082
- const details = document.createElement("details");
1083
- details.className = "agg-diff";
1084
- const summaryEl = document.createElement("summary");
1085
- summaryEl.textContent = "View diff";
1086
- details.append(summaryEl);
1087
- for (const file of pr.diff) {
1088
- const fileEl = document.createElement("div");
1089
- fileEl.className = "agg-diff-file";
1090
- const path = document.createElement("div");
1091
- path.className = "agg-diff-path publish-mono";
1092
- path.textContent = file.path;
1093
- fileEl.append(path);
1094
- if (file.patch) {
1095
- const pre = document.createElement("pre");
1096
- pre.className = "agg-diff-patch publish-mono";
1097
- for (const line of String(file.patch).split("\n")) {
1098
- const ln = document.createElement("span");
1099
- ln.className =
1100
- line.startsWith("+") && !line.startsWith("+++") ? "agg-diff-add"
1101
- : line.startsWith("-") && !line.startsWith("---") ? "agg-diff-del"
1102
- : line.startsWith("@@") ? "agg-diff-hunk" : "";
1103
- ln.textContent = line + "\n";
1104
- pre.append(ln);
1105
- }
1106
- fileEl.append(pre);
1107
- } else {
1108
- const binary = document.createElement("div");
1109
- binary.className = "subtle";
1110
- binary.textContent = "(no line-level diff available)";
1111
- fileEl.append(binary);
1112
- }
1113
- details.append(fileEl);
1114
- }
1115
- li.append(details);
1116
- }
1117
-
1118
- listEl.append(li);
1119
- }
1120
- };
1121
-
1122
1652
  const renderPlan = (plan?: any) => {
1123
1653
  const planBox = aggPanel && aggPanel.querySelector("[data-aggregate-plan]");
1124
1654
  if (!planBox) return;
1125
- const includedWrap = planBox.querySelector("[data-aggregate-plan-included]");
1126
1655
  const chipEl = planBox.querySelector("[data-aggregate-plan-chip]");
1127
1656
  const grid = planBox.querySelector("[data-aggregate-plan-grid]");
1128
1657
  const note = planBox.querySelector("[data-aggregate-plan-note]");
@@ -1131,26 +1660,22 @@ export function initAdminPublishTab(): void {
1131
1660
  // No plan at all (degenerate: no store bound) — hide the box.
1132
1661
  if (!plan) {
1133
1662
  planBox.hidden = true;
1134
- if (includedWrap) includedWrap.hidden = true;
1135
1663
  return;
1136
1664
  }
1137
1665
  planBox.hidden = false;
1138
1666
 
1139
1667
  if (!plan.ok) {
1140
- // An honest refusal — show WHY it isn't shippable, never a fake plan.
1141
- if (chipEl) {
1142
- chipEl.textContent = "Not shippable";
1143
- chipEl.className = "chip amber";
1144
- }
1145
- if (note) note.textContent = plan.message || ("Not shippable (" + (plan.reason || "refused") + ").");
1146
- if (includedWrap) includedWrap.hidden = true;
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") + ").");
1147
1671
  return;
1148
1672
  }
1149
1673
 
1150
1674
  const paywalled = plan.paywall && !plan.paywall.allowed;
1151
1675
  if (chipEl) {
1152
- chipEl.textContent = plan.alreadyShipped ? "Already live" : paywalled ? "Upgrade required" : "Ready to publish";
1153
- chipEl.className = "chip " + (plan.alreadyShipped ? "green" : paywalled ? "amber" : "green");
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]);
1154
1679
  }
1155
1680
 
1156
1681
  const row = (term?: any, value?: any, mono?: any) => {
@@ -1181,16 +1706,15 @@ export function initAdminPublishTab(): void {
1181
1706
  row("Rollback to", "None (first publish)");
1182
1707
  }
1183
1708
  row("Subscription", plan.paywall && plan.paywall.allowed ? "Enabled" : "Not enabled");
1184
-
1185
- // The openable per-PR line items (replaces the bare "Included changes: N" row).
1186
- renderIncludedChanges(planBox, plan.includedPrs || []);
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.
1187
1711
 
1188
1712
  if (note) {
1189
1713
  note.textContent = paywalled
1190
1714
  ? (plan.paywall.message || "Publishing to your live store requires the paid plan.")
1191
1715
  : plan.alreadyShipped
1192
1716
  ? "This reviewed build is already live — nothing to publish."
1193
- : "Publish sends the exact reviewed digest above to your live store. No rebuild.";
1717
+ : "Publish sends the exact build you reviewed to your live store. No rebuild.";
1194
1718
  }
1195
1719
  };
1196
1720
 
@@ -1211,24 +1735,69 @@ export function initAdminPublishTab(): void {
1211
1735
  plan: shipoff.querySelector("[data-shipoff-plan]"),
1212
1736
  flight: shipoff.querySelector("[data-shipoff-flight]"),
1213
1737
  outcome: shipoff.querySelector("[data-shipoff-outcome]"),
1738
+ log: shipoff.querySelector("[data-shipoff-log]"),
1214
1739
  grid: shipoff.querySelector("[data-shipoff-grid]"),
1215
1740
  proceed: shipoff.querySelector("[data-shipoff-proceed]"),
1216
1741
  cancel: shipoff.querySelector("[data-shipoff-cancel]"),
1217
1742
  live: shipoff.querySelector("[data-shipoff-live]"),
1218
1743
  elapsed: shipoff.querySelector("[data-shipoff-elapsed]"),
1744
+ hide: shipoff.querySelector("[data-shipoff-hide]"),
1219
1745
  seal: shipoff.querySelector("[data-shipoff-seal]"),
1220
1746
  outcomeTitle: shipoff.querySelector("[data-shipoff-outcome-title]"),
1221
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]"),
1222
1751
  view: shipoff.querySelector("[data-shipoff-view]"),
1752
+ undo: shipoff.querySelector("[data-shipoff-undo]"),
1223
1753
  done: shipoff.querySelector("[data-shipoff-done]"),
1224
1754
  confetti: shipoff.querySelector("[data-shipoff-confetti]"),
1225
1755
  }
1226
1756
  : null;
1227
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
+ }
1228
1795
  const closeShipoff = () => {
1229
1796
  if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1230
1797
  if (shipoff) shipoff.hidden = true;
1231
1798
  if (shipoffEls) shipoffEls.confetti.textContent = "";
1799
+ if (shipoffOpenedFrom && document.contains(shipoffOpenedFrom)) shipoffOpenedFrom.focus();
1800
+ shipoffOpenedFrom = null;
1232
1801
  };
1233
1802
  const showShipoffPhase = (phase?: any) => {
1234
1803
  if (!shipoffEls) return;
@@ -1249,14 +1818,10 @@ export function initAdminPublishTab(): void {
1249
1818
  return lines.join("\n");
1250
1819
  };
1251
1820
 
1252
- const shortDigest = (digest?: any) => {
1253
- const d = String(digest || "").replace(/^sha256:/, "");
1254
- return d ? "sha256:" + d.slice(0, 12) + "…" : "—";
1255
- };
1256
-
1257
1821
  const publishAggregate = (button?: any) => {
1258
1822
  if (!currentPlan) return;
1259
1823
  const plan = currentPlan;
1824
+ shipoffOpenedFrom = button || null; // r12: return focus here on close
1260
1825
  if (!shipoff || !shipoffEls) {
1261
1826
  // No-markup fallback only — the ceremony IS the confirm now.
1262
1827
  if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
@@ -1288,12 +1853,17 @@ export function initAdminPublishTab(): void {
1288
1853
  const startedAt = Date.now();
1289
1854
  if (shipoffEls) {
1290
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
1291
1861
  shipoffTimer = setInterval(() => {
1292
1862
  const s = Math.floor((Date.now() - startedAt) / 1000);
1293
1863
  shipoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
1294
1864
  }, 1000);
1295
1865
  }
1296
- const outcome = (state?: any, title?: any, detail?: any, showView?: any) => {
1866
+ const outcome = (state?: any, title?: any, detail?: any, showView?: any, failureDetail?: any) => {
1297
1867
  if (!shipoffEls) return;
1298
1868
  if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1299
1869
  shipoffEls.seal.dataset.state = state;
@@ -1301,6 +1871,20 @@ export function initAdminPublishTab(): void {
1301
1871
  shipoffEls.outcomeDetail.textContent = detail;
1302
1872
  shipoffEls.view.hidden = !showView;
1303
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
1304
1888
  showShipoffPhase("outcome");
1305
1889
  shipoffEls.done.focus();
1306
1890
  if (state === "ok") celebrate(shipoffEls.confetti);
@@ -1308,7 +1892,13 @@ export function initAdminPublishTab(): void {
1308
1892
  try {
1309
1893
  const res = await fetch(ds.shipEndpoint, {
1310
1894
  method: "POST",
1311
- headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
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
+ },
1312
1902
  body: JSON.stringify({
1313
1903
  aggregateId: plan.aggregateId,
1314
1904
  // The HUMAN go-live gate + the reviewed-digest guards (b09 ship input).
@@ -1317,7 +1907,7 @@ export function initAdminPublishTab(): void {
1317
1907
  expectedArtifactDigest: plan.artifactDigest,
1318
1908
  }),
1319
1909
  });
1320
- const data = await res.json().catch(() => ({}));
1910
+ const { data } = await streamWithLog(res, shipLog);
1321
1911
  // b09 AggregateShipResult: the ONLY live claim is state === "shipped".
1322
1912
  if (data.state === "shipped") {
1323
1913
  setStatus(
@@ -1325,13 +1915,26 @@ export function initAdminPublishTab(): void {
1325
1915
  "Published live · build " + shortSha(data.pinnedSha) + " (receipt " + (data.receiptId || "—") + ").",
1326
1916
  "ok",
1327
1917
  );
1918
+ void autoSweepBranches();
1328
1919
  outcome(
1329
1920
  "ok",
1330
1921
  "Your store is live",
1331
- "Build " + shortSha(data.pinnedSha) + " — the exact digest you reviewed — is now serving" +
1922
+ "Build " + shortSha(data.pinnedSha) + " — exactly what you reviewed — is now serving" +
1332
1923
  (data.receiptId ? " (receipt " + data.receiptId + ")." : "."),
1333
1924
  true,
1334
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
+ }
1335
1938
  } else {
1336
1939
  let message;
1337
1940
  if (data.state === "promote_failed" || data.state === "record_failed") {
@@ -1340,10 +1943,15 @@ export function initAdminPublishTab(): void {
1340
1943
  } else if (data.reason === "paywall") {
1341
1944
  message = data.message || "Publishing to your live store is part of the paid plan.";
1342
1945
  } else {
1343
- message = data.message || data.error || "Publish refused (" + (data.reason || res.status) + ")";
1946
+ message = data.message || data.error || "Publish refused (" + (data.reason || data.status || res.status) + ")";
1344
1947
  }
1345
1948
  setStatus(aggStatusEl, message, "err");
1346
- outcome("err", "Publish did not complete", message + " Your live store is unchanged.", false);
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]);
1347
1955
  }
1348
1956
  } catch {
1349
1957
  setStatus(aggStatusEl, "Publish failed — network error", "err");
@@ -1365,9 +1973,38 @@ export function initAdminPublishTab(): void {
1365
1973
  aggPanel
1366
1974
  ?.querySelector("[data-aggregate-publish]")
1367
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
+ });
1368
1993
  aggPanel
1369
1994
  ?.querySelector("[data-aggregate-retry-build]")
1370
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
+ }
1371
2008
 
1372
2009
  // ── Accept-to-preview LIFTOFF ─────────────────────────────────────────────
1373
2010
  // The plan confirm + live integration flight + outcome ceremony (replaces
@@ -1388,6 +2025,7 @@ export function initAdminPublishTab(): void {
1388
2025
  plan: liftoff.querySelector("[data-liftoff-plan]"),
1389
2026
  flight: liftoff.querySelector("[data-liftoff-flight]"),
1390
2027
  outcome: liftoff.querySelector("[data-liftoff-outcome]"),
2028
+ log: liftoff.querySelector("[data-liftoff-log]"),
1391
2029
  title: liftoff.querySelector("[data-liftoff-title]"),
1392
2030
  grid: liftoff.querySelector("[data-liftoff-grid]"),
1393
2031
  proceed: liftoff.querySelector("[data-liftoff-proceed]"),
@@ -1400,6 +2038,7 @@ export function initAdminPublishTab(): void {
1400
2038
  outcomeTitle: liftoff.querySelector("[data-liftoff-outcome-title]"),
1401
2039
  outcomeDetail: liftoff.querySelector("[data-liftoff-outcome-detail]"),
1402
2040
  review: liftoff.querySelector("[data-liftoff-review]"),
2041
+ rebuild: liftoff.querySelector("[data-liftoff-rebuild]"),
1403
2042
  done: liftoff.querySelector("[data-liftoff-done]"),
1404
2043
  confetti: liftoff.querySelector("[data-liftoff-confetti]"),
1405
2044
  conflict: liftoff.querySelector("[data-liftoff-conflict]"),
@@ -1435,7 +2074,7 @@ export function initAdminPublishTab(): void {
1435
2074
  // conflict markers was lurking and poisoned the first attempt), the
1436
2075
  // two-stage auth self-heal (tot login fixes the session, NOT the token
1437
2076
  // baked into the remote URL — u10 credential-helper removes this), the
1438
- // conflict-intent framing, and the never-`--new` rule.
2077
+ // conflict-intent framing, and the never-`--fork-candidate` rule.
1439
2078
  const conflictPromptFor = (change?: any, rawMessage?: any) => {
1440
2079
  const tenant = appDomain || repo;
1441
2080
  const pr = change && change.prNumber != null ? "PR #" + change.prNumber : "my open candidate PR";
@@ -1448,8 +2087,8 @@ export function initAdminPublishTab(): void {
1448
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.",
1449
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).",
1450
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.",
1451
- "5. Re-submit with `tot preview` (plain — never `--new`, which would fork a separate PR). " + pr + " updates in place.",
1452
- "6. Confirm the push succeeded and the submit output reports the preview building. Then end by reminding me how to FINISH the merge once the build is ready: back in the store admin's Publish tab (" + adminPublishUrl() + "), " + pr + " → Integrate into preview" +
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" +
1453
2092
  (change && change.prNumber != null
1454
2093
  ? " — or from this terminal: `tot accept --tenant " + tenant + " --pr " + change.prNumber + "`."
1455
2094
  : "."),
@@ -1524,6 +2163,13 @@ export function initAdminPublishTab(): void {
1524
2163
  setConflictStatus(statusEl2, "");
1525
2164
  // Auto-re-integrate ONCE with the refreshed change (new PR/head).
1526
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();
1527
2173
  } else if (action.kind === "cards") {
1528
2174
  if (source === "card") {
1529
2175
  // A merge card that STILL overlaps — no safe auto-combine exists; hand
@@ -1624,7 +2270,7 @@ export function initAdminPublishTab(): void {
1624
2270
  if (!liftoff || !liftoffEls) {
1625
2271
  setStatus(
1626
2272
  statusEl,
1627
- "This change needs updating against the latest preview before it can integrate.",
2273
+ "This change needs updating against the latest preview before it can be added.",
1628
2274
  "err",
1629
2275
  );
1630
2276
  return;
@@ -1635,7 +2281,7 @@ export function initAdminPublishTab(): void {
1635
2281
  liftoffEls.outcomeTitle.textContent = "Preview changed while you worked";
1636
2282
  liftoffEls.outcomeDetail.textContent =
1637
2283
  target + " passed its checks, but preview changed after it was created and the two now " +
1638
- "conflict. Nothing was integrated — update this change automatically, then review the result.";
2284
+ "conflict. Nothing was added — update this change automatically, then review the result.";
1639
2285
  liftoffEls.review.hidden = true;
1640
2286
  setConflictGuidance(true, "", change);
1641
2287
  showPhase("outcome");
@@ -1664,6 +2310,18 @@ export function initAdminPublishTab(): void {
1664
2310
  let liftoffTimer: any = null;
1665
2311
  let liftoffPoll: any = null;
1666
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
+ };
1667
2325
  const stopLiftoffLoops = () => {
1668
2326
  if (liftoffTimer) { clearInterval(liftoffTimer); liftoffTimer = null; }
1669
2327
  if (liftoffPoll) { clearInterval(liftoffPoll); liftoffPoll = null; }
@@ -1709,38 +2367,12 @@ export function initAdminPublishTab(): void {
1709
2367
  setTimeout(() => { container.textContent = ""; }, 2400);
1710
2368
  };
1711
2369
 
1712
- // The live poll during flight: reflect the server's REAL progress.
1713
- const startFlightPoll = (change?: any, preFlightSha?: any) => {
1714
- let inAggregate = false;
1715
- liftoffPoll = setInterval(async () => {
1716
- try {
1717
- const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1718
- if (!res.ok) return;
1719
- const data = await res.json();
1720
- const agg = data.aggregate || {};
1721
- const included = (agg.includedPrs || []).some((p: any) => p.changeId === change.changeId);
1722
- const shaMoved = agg.aggregateSha && agg.aggregateSha !== preFlightSha;
1723
- const run = agg.activeRun || null;
1724
- if (!inAggregate && (included || shaMoved)) {
1725
- inAggregate = true;
1726
- railTo("build");
1727
- if (liftoffEls) liftoffEls.live.textContent =
1728
- "Merged into preview — rebuilding the combined aggregate…";
1729
- }
1730
- if (run) {
1731
- railTo("evidence");
1732
- if (liftoffEls) liftoffEls.live.textContent =
1733
- run.statusMessage || "Combined evidence is running…";
1734
- }
1735
- } catch { /* polling is best-effort; the POST outcome is authoritative */ }
1736
- }, 2500);
1737
- };
1738
2370
 
1739
2371
  const openLiftoff = (change?: any, button?: any) => {
1740
2372
  if (!liftoff || !liftoffEls) return false;
1741
2373
  liftoffOpenedFrom = button || null;
1742
2374
  const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1743
- liftoffEls.title.textContent = "Integrate " + target + " into preview";
2375
+ liftoffEls.title.textContent = "Add " + target + " to your next release";
1744
2376
  liftoffEls.grid.textContent = "";
1745
2377
  liftoffEls.grid.append(
1746
2378
  ...planRow("Store", appDomain || repo),
@@ -1776,42 +2408,140 @@ export function initAdminPublishTab(): void {
1776
2408
  if (mode === "flight") shipoff.hidden = true;
1777
2409
  else closeShipoff();
1778
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
+ }
1779
2421
  });
1780
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
+
1781
2515
  // b08 integrate returns an IntegrateOutcome — { ok, queueState, runState,
1782
2516
  // aggregateSha, pointerMoved, statusMessage, reason? } — NEVER a bare
1783
- // merged:true. Render it honestly: green advances the aggregate; red left it
1784
- // exactly where it was and names the failing stage.
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.
1785
2520
  const runIntegration = async (change?: any, button?: any) => {
1786
2521
  const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1787
2522
  if (button) button.disabled = true;
1788
2523
  busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
1789
- setStatus(statusEl, "Integrating " + change.changeId + " into preview…");
2524
+ setStatus(statusEl, "Adding " + change.changeId + " to your next release…");
1790
2525
  const startedAt = Date.now();
1791
- let preFlightSha: any = null;
1792
2526
  if (liftoffEls) {
1793
2527
  showPhase("flight");
1794
- liftoffEls.flightTitle.textContent = "Integrating " + target + " into preview";
2528
+ liftoffLog.reset();
2529
+ liftoffEls.flightTitle.textContent = "Adding " + target + " to your next release";
1795
2530
  railTo("merge");
1796
- liftoffEls.live.textContent = "Squash-merging the candidate into preview…";
2531
+ liftoffEls.live.textContent = "Adding your change to the next release…";
1797
2532
  liftoffTimer = setInterval(() => {
1798
2533
  const s = Math.floor((Date.now() - startedAt) / 1000);
1799
2534
  liftoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
1800
2535
  }, 1000);
1801
2536
  }
1802
2537
  try {
1803
- // Snapshot the pre-flight aggregate sha so the poll can detect the merge
1804
- // landing (sha moved) even before includedPrs reflects it.
1805
- try {
1806
- const pre = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1807
- if (pre.ok) preFlightSha = ((await pre.json()).aggregate || {}).aggregateSha || null;
1808
- } catch { /* best-effort */ }
1809
- startFlightPoll(change, preFlightSha);
1810
-
1811
2538
  const res = await fetch(ds.integrateEndpoint, {
1812
2539
  method: "POST",
1813
2540
  headers: {
1814
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",
1815
2545
  // Mirror of the server-side capability model (UX): the server
1816
2546
  // re-derives capability and stays authoritative.
1817
2547
  "x-tot-capability": "ship-on-behalf",
@@ -1823,118 +2553,44 @@ export function initAdminPublishTab(): void {
1823
2553
  expectedHeadSha: change.headSha || undefined,
1824
2554
  }),
1825
2555
  });
1826
- const data = await res.json().catch(() => ({}));
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);
1827
2560
  stopLiftoffLoops();
1828
- if (res.ok && data.ok) {
1829
- setStatus(
1830
- statusEl,
1831
- "Integrated " + change.changeId + " — preview aggregate is green (" +
1832
- shortSha(data.aggregateSha) + "). Review the aggregate, then Publish to go live.",
1833
- "ok",
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],
1834
2584
  );
1835
- if (liftoffEls) {
1836
- LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
1837
- liftoffEls.seal.dataset.state = "ok";
1838
- liftoffEls.outcomeTitle.textContent = "The preview aggregate is green";
1839
- liftoffEls.outcomeDetail.textContent =
1840
- target + " is in. Aggregate build " + shortSha(data.aggregateSha) +
1841
- " passed its combined evidence — this exact build is what you review, and what " +
1842
- "Publish would take live. Nothing is live yet.";
1843
- if (data.aggregateSha) {
1844
- liftoffEls.review.href =
1845
- "/" + encodeURIComponent(appDomain) + "/preview/rev/" + data.aggregateSha + "/";
1846
- liftoffEls.review.hidden = false;
1847
- } else {
1848
- liftoffEls.review.hidden = true;
1849
- }
1850
- setConflictGuidance(false);
1851
- if (liftoff.hidden) liftoff.hidden = false; // surface the payoff even if backgrounded
1852
- showPhase("outcome");
1853
- liftoffEls.done.focus();
1854
- celebrate(liftoffEls.confetti);
1855
- }
1856
- } else if (data.reason === "candidate_not_open") {
1857
- // "No open candidate" = the PR already merged before this attempt —
1858
- // usually a second click on a just-integrated candidate whose row went
1859
- // stale (DZ, 2026-08-20). That is a SUCCESS the operator is re-hearing,
1860
- // not a failure: green seal, happy copy, no fail stage, never conflict
1861
- // guidance. The queue read now drops merged rows, so the row is gone on
1862
- // the next refresh; the durable KV closeout is u12.
1863
- const target = change.prNumber != null ? "PR #" + change.prNumber : "This change";
1864
- setStatus(statusEl, target + " was already integrated — it's in the preview build.", "ok");
1865
- if (liftoffEls) {
1866
- LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
1867
- liftoffEls.seal.dataset.state = "ok";
1868
- liftoffEls.outcomeTitle.textContent = "Already integrated";
1869
- liftoffEls.outcomeDetail.textContent =
1870
- target + " landed in the preview build earlier — this attempt found " +
1871
- "nothing left to do. It will drop off the queue on the next refresh; " +
1872
- "the integration is recorded in the history below.";
1873
- setConflictGuidance(false);
1874
- liftoffEls.review.hidden = true;
1875
- if (liftoff.hidden) liftoff.hidden = false;
1876
- showPhase("outcome");
1877
- liftoffEls.done.focus();
1878
- }
1879
- } else {
1880
- let message;
1881
- let failStage = "merge";
1882
- // The merge-refused conflict is a FORK, not a failure of the change:
1883
- // preview moved after the candidate branched and both edit the same
1884
- // content. Render it as directions (rebase + re-submit), never as the
1885
- // raw forge refusal — that dead-ended real operators (2026-08-20).
1886
- const isMergeConflict = data.reason === "merge_failed";
1887
- if (res.ok && data.queueState === "red") {
1888
- // The queue ran but the aggregate did not go green — the candidate did
1889
- // NOT advance the shippable set. Name the honest failing stage.
1890
- failStage = "evidence";
1891
- message =
1892
- "Integration of " + change.changeId + " failed (" + (data.reason || "red") + "): " +
1893
- (data.statusMessage || "the aggregate did not pass its combined evidence.") +
1894
- " The aggregate is unchanged.";
1895
- } else if (data.reason === "not_preview_base") {
1896
- message =
1897
- "Not integrated: " + change.changeId + " does not target the preview branch " +
1898
- "(base is not \"preview\"). " + (data.statusMessage || "");
1899
- } else {
1900
- message = data.statusMessage || data.error || "Integrate failed (" + res.status + ")";
1901
- }
1902
- setStatus(statusEl, message, "err");
1903
- if (liftoffEls) {
1904
- setStage(failStage, "fail");
1905
- liftoffEls.seal.dataset.state = "err";
1906
- if (isMergeConflict) {
1907
- const target = change.prNumber != null ? "PR #" + change.prNumber : "This change";
1908
- liftoffEls.outcomeTitle.textContent = "Preview changed while you worked";
1909
- liftoffEls.outcomeDetail.textContent =
1910
- target + " edits content that also changed on preview after it was " +
1911
- "created, so it couldn't merge as-is. Nothing was integrated — preview " +
1912
- "is exactly as it was. You can update this change automatically below.";
1913
- setConflictGuidance(true, data.statusMessage || "", change);
1914
- } else {
1915
- liftoffEls.outcomeTitle.textContent = "Integration did not land";
1916
- liftoffEls.outcomeDetail.textContent = message;
1917
- setConflictGuidance(false);
1918
- }
1919
- liftoffEls.review.hidden = true;
1920
- if (liftoff.hidden) liftoff.hidden = false;
1921
- showPhase("outcome");
1922
- liftoffEls.done.focus();
1923
- }
1924
2585
  }
2586
+ applyIntegrateOutcome(view, change);
1925
2587
  } catch {
1926
2588
  stopLiftoffLoops();
1927
- setStatus(statusEl, "Integrate failednetwork error", "err");
1928
- if (liftoffEls) {
1929
- liftoffEls.seal.dataset.state = "err";
1930
- liftoffEls.outcomeTitle.textContent = "Integration did not land";
1931
- liftoffEls.outcomeDetail.textContent =
1932
- "Network error mid-flight — refresh the queue to see the aggregate's honest state.";
1933
- setConflictGuidance(false);
1934
- liftoffEls.review.hidden = true;
1935
- if (liftoff.hidden) liftoff.hidden = false;
1936
- showPhase("outcome");
1937
- }
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);
1938
2594
  } finally {
1939
2595
  busyChanges.delete(change.changeId);
1940
2596
  if (button) button.disabled = false;
@@ -1948,19 +2604,21 @@ export function initAdminPublishTab(): void {
1948
2604
  if (openLiftoff(change, button)) return;
1949
2605
  const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1950
2606
  const plan =
1951
- "Accept-to-preview plan:\n tenant: " + (appDomain || repo) +
2607
+ "Add to release plan:\n store: " + (appDomain || repo) +
1952
2608
  (change.prNumber != null ? "\n PR: #" + change.prNumber : "") +
1953
2609
  "\n change id: " + change.changeId +
1954
2610
  (change.headSha ? "\n head sha: " + shortSha(change.headSha) : "") +
1955
- "\n effect: integrate " + target + " into the preview aggregate, then rebuild + " +
1956
- "re-evidence it. Does NOT go live — publish the reviewed build separately.";
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.";
1957
2613
  if (!window.confirm(plan + "\n\nProceed?")) return;
1958
2614
  runIntegration(change, button);
1959
2615
  };
1960
2616
 
1961
2617
  const rejectChange = async (change?: any, button?: any) => {
1962
2618
  const reason = window.prompt(
1963
- "Reject " + change.changeId + " — add a short note for the submitter:",
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:",
1964
2622
  );
1965
2623
  if (reason == null) return;
1966
2624
  if (!reason.trim()) {
@@ -1984,7 +2642,16 @@ export function initAdminPublishTab(): void {
1984
2642
  );
1985
2643
  const data = await res.json().catch(() => ({}));
1986
2644
  if (res.ok) {
1987
- setStatus(statusEl, "Change " + change.changeId + " rejected.", "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();
1988
2655
  } else {
1989
2656
  setStatus(statusEl, data.error || "Reject failed (" + res.status + ")", "err");
1990
2657
  }
@@ -2050,109 +2717,162 @@ export function initAdminPublishTab(): void {
2050
2717
  // PR already merged into preview, but the build/evidence step that follows
2051
2718
  // died (e.g. a transient MCP error). `POST /api/changes/rebuild` re-runs
2052
2719
  // ONLY that step against the sha the forge already carries — never a
2053
- // second merge attempt, never a membership change.
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
+
2054
2778
  const retryBuild = async (button?: any) => {
2055
2779
  if (!window.confirm(
2056
2780
  "Retry the build for " + (appDomain || repo) + "?\n\n" +
2057
- " effect: re-run the build + evidence check against the last merged\n" +
2058
- " commit — no new merge, no membership change.\n\n" +
2781
+ " effect: rebuild your next release and re-run its checks —\n" +
2782
+ " nothing new is added.\n\n" +
2059
2783
  "Proceed?",
2060
2784
  )) return;
2061
2785
  button.disabled = true;
2786
+ rebuildoffOpenedFrom = button || null; // r12: return focus here on close
2062
2787
  aggregateRetrying = true; // rux7-style: hold disabled across a poll landing mid-retry
2063
2788
  setStatus(aggStatusEl, "Retrying the build…");
2064
- try {
2065
- const res = await fetch(ds.changesEndpoint + "/rebuild", {
2066
- method: "POST",
2067
- headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
2068
- body: JSON.stringify({ repo }),
2069
- });
2070
- const data = await res.json().catch(() => ({}));
2071
- if (res.ok && data.ok) {
2072
- setStatus(aggStatusEl, "Build retry passed — the aggregate is green.", "ok");
2073
- } else if (res.ok && data.reason === "nothing_to_rebuild") {
2074
- setStatus(aggStatusEl, "Nothing to rebuild no prior integration attempt exists yet.");
2075
- } else if (res.ok) {
2076
- setStatus(
2077
- aggStatusEl,
2078
- "Build retry did not pass" +
2079
- (data.statusMessage ? ": " + data.statusMessage : " (" + (data.reason || data.queueState) + ")"),
2080
- "err",
2081
- );
2082
- } else {
2083
- setStatus(aggStatusEl, data.error || "Retry failed (" + res.status + ")", "err");
2084
- }
2085
- } catch {
2086
- setStatus(aggStatusEl, "Retry failed — network error", "err");
2087
- } finally {
2088
- aggregateRetrying = false;
2089
- button.disabled = false;
2090
- loadQueue();
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);
2091
2802
  }
2092
- };
2093
-
2094
- // U7 — retire/GC. Mirror of the accept plan affordance (decision
2095
- // operator-verb-and-hosting-model): state EXACTLY what retire does — evict the
2096
- // candidate's preview environment + immutable version — and that it is
2097
- // REBUILDABLE (degrades to "not built yet", not a 404), so an operator can't
2098
- // confuse it with Reject. Same shape as `tot retire`'s CLI plan for parity.
2099
- const buildRetirePlan = (change?: any) => {
2100
- const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
2101
- const lines = ["Retire plan:", " tenant: " + (appDomain || repo)];
2102
- if (change.prNumber != null) lines.push(" PR: #" + change.prNumber);
2103
- lines.push(" change id: " + change.changeId);
2104
- if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
2105
- lines.push(
2106
- " effect: evict " + target + "'s preview environment + version to reclaim space.",
2107
- );
2108
- lines.push(
2109
- " rebuild: REVERSIBLE — rebuild it any time (this is NOT Reject; the change stays open).",
2110
- );
2111
- return lines.join("\n");
2112
- };
2113
-
2114
- const retireChange = async (change?: any, button?: any) => {
2115
- if (!window.confirm(buildRetirePlan(change) + "\n\nProceed?")) return;
2116
- button.disabled = true;
2117
- busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
2118
- setStatus(statusEl, "Retiring " + change.changeId + "…");
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
+ };
2119
2821
  try {
2120
- const res = await fetch(ds.retireEndpoint, {
2822
+ const res = await fetch(ds.changesEndpoint + "/rebuild", {
2121
2823
  method: "POST",
2122
2824
  headers: {
2123
2825
  "content-type": "application/json",
2124
- // Mirror of the server-side capability model (UX); the server
2125
- // re-derives capability and stays authoritative.
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",
2126
2829
  "x-tot-capability": "ship-on-behalf",
2127
2830
  },
2128
- body: JSON.stringify({
2129
- changeId: change.changeId,
2130
- pr: change.prNumber ?? undefined,
2131
- }),
2831
+ body: JSON.stringify({ repo }),
2132
2832
  });
2133
- const data = await res.json().catch(() => ({}));
2134
- if (res.ok && data.ok) {
2135
- setStatus(
2136
- statusEl,
2137
- data.evicted
2138
- ? "Retired " + change.changeId + " the preview was evicted (rebuildable on demand)."
2139
- : "Retire " + change.changeId + ": nothing to evict (already retired).",
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 rebuildthere's no earlier build to retry yet.");
2839
+ outcome(
2140
2840
  "ok",
2841
+ "Nothing to rebuild",
2842
+ "There's no earlier preview build for this store to retry yet.",
2141
2843
  );
2142
2844
  } else {
2143
- setStatus(statusEl, data.error || "Retire failed (" + res.status + ")", "err");
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]);
2144
2859
  }
2145
2860
  } catch {
2146
- setStatus(statusEl, "Retire failed — network error", "err");
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
+ );
2147
2867
  } finally {
2148
- busyChanges.delete(change.changeId);
2868
+ aggregateRetrying = false;
2149
2869
  button.disabled = false;
2150
2870
  loadQueue();
2151
2871
  }
2152
2872
  };
2153
2873
 
2154
2874
  // ---- rux5: build-on-demand for a synthetic not-built PR ------------------
2155
- // Mirror of the integrate/retire plan affordances: state EXACTLY what build
2875
+ // Mirror of the integrate plan affordance: state EXACTLY what build
2156
2876
  // does — materialize this PR's hosted preview at its forge head — and that it
2157
2877
  // is INERT (flips no live channel). POSTs { pr, headSha?, baseSha? }; the row
2158
2878
  // already carries the head sha GET /api/changes resolved via forge PR-read, and
@@ -2171,63 +2891,269 @@ export function initAdminPublishTab(): void {
2171
2891
  return lines.join("\n");
2172
2892
  };
2173
2893
 
2174
- const buildPreview = async (change?: any, button?: any) => {
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) => {
2175
2965
  const label = "PR #" + change.prNumber;
2176
- if (!window.confirm(buildPreviewPlan(change) + "\n\nBuild the preview now?")) return;
2177
- button.disabled = true;
2178
- const originalText = button.textContent;
2179
- button.textContent = "Building…";
2180
- button.dataset.building = "true";
2181
- // rux7: record the in-flight build so a poll re-render preserves the
2182
- // spinner/disabled state on the freshly-rendered button (cleared in finally).
2183
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
+ }
2184
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
+
2185
3069
  try {
2186
3070
  const res = await fetch(ds.buildEndpoint, {
2187
3071
  method: "POST",
2188
3072
  headers: {
2189
3073
  "content-type": "application/json",
3074
+ accept: "text/event-stream",
2190
3075
  // Mirror of the server-side capability model (UX); the server
2191
3076
  // re-derives capability (buildGate) and stays authoritative.
2192
3077
  "x-tot-capability": "ship-on-behalf",
2193
3078
  },
2194
3079
  body: JSON.stringify({
2195
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,
2196
3087
  headSha: change.headSha || undefined,
2197
3088
  baseSha: change.baseSha || undefined,
2198
3089
  }),
2199
3090
  });
2200
- const data = await res.json().catch(() => ({}));
2201
- if (res.ok && data.ok) {
2202
- setStatus(
2203
- statusEl,
2204
- "Built preview for " + label +
2205
- " it now appears as a built change in the queue.",
2206
- "ok",
2207
- );
2208
- } else if (data && data.ok === false) {
2209
- // reconcileCandidate returned a blocked/failed candidate (e.g. a safety
2210
- // finding with no override) — the honest reason, never a false success.
2211
- setStatus(
2212
- statusEl,
2213
- "Build did not complete for " + label + ": " +
2214
- ((data.errors && data.errors.join("; ")) || data.error || "blocked"),
2215
- "err",
2216
- );
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);
2217
3113
  } else {
2218
- setStatus(statusEl, data.error || "Build failed (" + res.status + ")", "err");
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
+ });
2219
3128
  }
2220
3129
  } catch {
2221
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
+ }
2222
3142
  } finally {
2223
3143
  if (change.prNumber != null) inflightBuilds.delete(change.prNumber);
2224
- button.disabled = false;
2225
- button.textContent = originalText;
2226
- delete button.dataset.building;
3144
+ if (button) {
3145
+ button.disabled = false;
3146
+ delete button.dataset.building;
3147
+ }
2227
3148
  loadQueue();
2228
3149
  }
2229
3150
  };
2230
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
+
2231
3157
  // ---- rux6: unified review pane -----------------------------------------
2232
3158
  // "One review moment": expanding a candidate row reveals a single pane with
2233
3159
  // the live preview (iframed SAME-ORIGIN — the /<tenant>/preview/pr/<N> route
@@ -2264,12 +3190,14 @@ export function initAdminPublishTab(): void {
2264
3190
  const checks = (change.evidence && change.evidence.checks) || [];
2265
3191
  const promotable = Boolean(change.evidence && change.evidence.promotable);
2266
3192
  container.append(
2267
- promotable ? chip("Checks passed", "green") : chip("Checks blocked", "amber"),
3193
+ promotable
3194
+ ? chip("Checks passed", "green", CHIP_GLYPH.green)
3195
+ : chip("Checks blocked", "amber", CHIP_GLYPH.amber),
2268
3196
  );
2269
3197
  for (const c of checks) {
2270
3198
  const tone = c.status === "pass" ? "green" : c.status === "fail" ? "red" : "amber";
2271
3199
  const label = (c.label || c.id || "check") + (c.status && c.status !== "pass" ? " · " + c.status : "");
2272
- const el = chip(label, tone);
3200
+ const el = chip(label, tone, CHIP_GLYPH[tone]);
2273
3201
  if (c.detail) el.title = c.detail;
2274
3202
  container.append(el);
2275
3203
  }
@@ -2320,6 +3248,30 @@ export function initAdminPublishTab(): void {
2320
3248
  const detailCol = document.createElement("div");
2321
3249
  detailCol.className = "review-detail";
2322
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
+
2323
3275
  const evSection = document.createElement("div");
2324
3276
  evSection.className = "review-section";
2325
3277
  const evTitle = document.createElement("b");
@@ -2352,18 +3304,33 @@ export function initAdminPublishTab(): void {
2352
3304
  change.status === "ready" &&
2353
3305
  Boolean(change.evidence && change.evidence.promotable) &&
2354
3306
  change.mergeable === false;
2355
- accept.textContent = needsPreviewUpdate ? "Update change" : "Accept to preview";
2356
- accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable);
2357
- accept.title = !canShip
2358
- ? "Requires a ship-on-behalf grant on your account — ask the store owner"
2359
- : needsPreviewUpdate
2360
- ? "Preview changed while you worked — update this change, then integrate it"
2361
- : !change.mergeable
2362
- ? "This change is not ready to integrate"
2363
- : "Integrate this change into the preview aggregate (does not go live)";
2364
- accept.addEventListener("click", () =>
2365
- needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
2366
- );
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
+ }
2367
3334
  const reject = document.createElement("button");
2368
3335
  reject.type = "button";
2369
3336
  reject.className = "btn";
@@ -2373,7 +3340,7 @@ export function initAdminPublishTab(): void {
2373
3340
  ? "Close this change without publishing"
2374
3341
  : "Requires an owner or admin sign-in";
2375
3342
  reject.addEventListener("click", () => rejectChange(change, reject));
2376
- actionRow.append(accept, reject);
3343
+ actionRow.append(...(needsChecks ? [] : [accept]), reject);
2377
3344
  detailCol.append(actionRow);
2378
3345
 
2379
3346
  pane.append(previewCol, detailCol);
@@ -2439,6 +3406,7 @@ export function initAdminPublishTab(): void {
2439
3406
 
2440
3407
  // Authority: honest label of whether the diff describes the exact head.
2441
3408
  const authority = diff.authority || {};
3409
+ const authTone = authority.authoritative ? "green" : authority.stale ? "amber" : "";
2442
3410
  const authChip = chip(
2443
3411
  authority.authoritative
2444
3412
  ? "Reviewed at head"
@@ -2447,7 +3415,8 @@ export function initAdminPublishTab(): void {
2447
3415
  : authority.missing
2448
3416
  ? "No evidence yet"
2449
3417
  : "Head",
2450
- authority.authoritative ? "green" : authority.stale ? "amber" : "",
3418
+ authTone,
3419
+ CHIP_GLYPH[authTone],
2451
3420
  );
2452
3421
  head.append(authChip);
2453
3422
 
@@ -2472,7 +3441,7 @@ export function initAdminPublishTab(): void {
2472
3441
  const reason = document.createElement("p");
2473
3442
  reason.className = "note publish-diff-blocked";
2474
3443
  reason.textContent =
2475
- diff.blockedReason || "This candidate has no renderable version to diff yet.";
3444
+ diff.blockedReason || "This change has no preview to compare yet.";
2476
3445
  td.append(reason);
2477
3446
  return;
2478
3447
  }
@@ -2512,6 +3481,43 @@ export function initAdminPublishTab(): void {
2512
3481
  // Hosted and static-publish stores both render the same instant "Roll back" UX
2513
3482
  // below — every rollback is an instant KV pointer flip.
2514
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
+
2515
3521
  const renderVersions = (view?: any) => {
2516
3522
  if (!versionsBody) return;
2517
3523
  const versions = Array.isArray(view.versions) ? view.versions : [];
@@ -2535,7 +3541,12 @@ export function initAdminPublishTab(): void {
2535
3541
 
2536
3542
  if (!versions.length) {
2537
3543
  const tr = document.createElement("tr");
2538
- const td = cell(subtle("No versions have been promoted to live yet."));
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
+ );
2539
3550
  td.colSpan = 6;
2540
3551
  tr.append(td);
2541
3552
  versionsBody.append(tr);
@@ -2544,14 +3555,33 @@ export function initAdminPublishTab(): void {
2544
3555
 
2545
3556
  for (const v of versions) {
2546
3557
  const tr = document.createElement("tr");
2547
-
2548
- // Version identity: the tenant Gitea SHA (content authority) + source.
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.
2549
3566
  const idCell = document.createElement("td");
2550
3567
  const strong = document.createElement("strong");
2551
- strong.className = "publish-mono";
2552
- strong.textContent = shortSha(v.versionId);
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));
2553
3572
  idCell.append(strong);
3573
+ if (releaseName && v.number != null) idCell.append(subtle("v" + v.number));
2554
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);
2555
3585
 
2556
3586
  // "What shipped" — the SAME human summary the staged Publish console shows,
2557
3587
  // now describing the RELEASE: a plain-language lead + an openable list of the
@@ -2621,7 +3651,13 @@ export function initAdminPublishTab(): void {
2621
3651
  tr.append(revCell);
2622
3652
 
2623
3653
  // State: current live vs prior.
2624
- tr.append(cell(v.isCurrent ? chip("Current live", "green") : chip("Prior", "")));
3654
+ tr.append(
3655
+ cell(
3656
+ v.isCurrent
3657
+ ? chip("Current live", "green", CHIP_GLYPH.green)
3658
+ : chip("Prior", "", CHIP_GLYPH[""]),
3659
+ ),
3660
+ );
2625
3661
 
2626
3662
  // Action: roll back to a PRIOR version (never the current one).
2627
3663
  const act = document.createElement("td");
@@ -2642,6 +3678,10 @@ export function initAdminPublishTab(): void {
2642
3678
 
2643
3679
  versionsBody.append(tr);
2644
3680
  }
3681
+
3682
+ // r10: land a pending post-publish "Undo" on the replaced version's row now
3683
+ // that the rows exist.
3684
+ applyRollbackFocus();
2645
3685
  };
2646
3686
 
2647
3687
  // Published versions only change when something is published, which moves the
@@ -2776,8 +3816,8 @@ export function initAdminPublishTab(): void {
2776
3816
  : "Previous host (pantheon)";
2777
3817
  }
2778
3818
  if (stateChip) {
2779
- stateChip.textContent = connected ? "Connected" : "Not connected";
2780
- stateChip.className = "chip " + (connected ? "green" : "amber");
3819
+ const tone = connected ? "green" : "amber";
3820
+ paintChip(stateChip, connected ? "Connected" : "Not connected", tone, CHIP_GLYPH[tone]);
2781
3821
  }
2782
3822
  if (sinceEl) {
2783
3823
  sinceEl.textContent = domain.since
@@ -2875,8 +3915,8 @@ export function initAdminPublishTab(): void {
2875
3915
 
2876
3916
  const chip = readinessBox.querySelector("[data-domain-readiness-chip]");
2877
3917
  if (chip) {
2878
- chip.textContent = domainReady ? "Ready" : "Not ready";
2879
- chip.className = "chip " + (domainReady ? "green" : "amber");
3918
+ const tone = domainReady ? "green" : "amber";
3919
+ paintChip(chip, domainReady ? "Ready" : "Not ready", tone, CHIP_GLYPH[tone]);
2880
3920
  }
2881
3921
  const list = readinessBox.querySelector("[data-domain-readiness-list]");
2882
3922
  if (list) {
@@ -3040,8 +4080,8 @@ export function initAdminPublishTab(): void {
3040
4080
  " classification: " + row.classificationLabel,
3041
4081
  " reason: " + row.reason,
3042
4082
  "",
3043
- "This deletes the Git BRANCH only it never evicts a hosted preview build",
3044
- "(that is Retire, a separate action above). The server re-classifies this",
4083
+ "This deletes the Git BRANCH only. Reject already closes the PR and removes",
4084
+ "its hosted candidate artifacts. The server re-classifies this",
3045
4085
  "branch fresh before deleting; a moved tip or a branch that is no longer",
3046
4086
  "integrated/closed-or-rejected is refused, not silently skipped.",
3047
4087
  ].join("\n");
@@ -3065,6 +4105,33 @@ export function initAdminPublishTab(): void {
3065
4105
  return { ok: res.ok && data.ok, status: res.status, data };
3066
4106
  };
3067
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
+
3068
4135
  const cleanupBranch = async (row?: any, button?: any) => {
3069
4136
  if (!window.confirm(buildCleanupPlan(row) + "\n\nDelete this branch?")) return;
3070
4137
  button.disabled = true;
@@ -3433,6 +4500,44 @@ export function initAdminPublishTab(): void {
3433
4500
  // IntersectionObserver is the single visibility signal — a panel inside a
3434
4501
  // hidden admin-tab (display:none) never intersects, so nothing fetches until
3435
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
+
3436
4541
  const revealDomainOnce = (() => {
3437
4542
  let done = false;
3438
4543
  return () => {
@@ -3515,5 +4620,6 @@ export function initAdminPublishTab(): void {
3515
4620
  }
3516
4621
  });
3517
4622
 
4623
+ setupCollapsePersistence();
3518
4624
  observeVisibility();
3519
4625
  }