@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.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 (246) hide show
  1. package/apps/storefront/astro.config.mjs +15 -0
  2. package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
  3. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
  4. package/apps/storefront/drizzle.config.apps.ts +13 -0
  5. package/apps/storefront/env.d.ts +10 -0
  6. package/apps/storefront/migrations/README.md +13 -7
  7. package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
  8. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  9. package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
  10. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  11. package/apps/storefront/migrations-apps/meta/_journal.json +20 -0
  12. package/apps/storefront/package.json +12 -1
  13. package/apps/storefront/perf/README.md +64 -0
  14. package/apps/storefront/perf/assert-budgets.ts +159 -0
  15. package/apps/storefront/playwright.config.ts +23 -0
  16. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  17. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  18. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  19. package/apps/storefront/src/components/ProductCard.astro +1 -0
  20. package/apps/storefront/src/components/admin/AdminPublishTab.astro +3562 -0
  21. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  22. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  23. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  24. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  25. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  26. package/apps/storefront/src/components/content/Callout.astro +75 -0
  27. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  28. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  29. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  30. package/apps/storefront/src/components/content/RichText.astro +44 -0
  31. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  32. package/apps/storefront/src/components/home/Hero.astro +14 -0
  33. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  34. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  35. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  36. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  37. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  38. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  39. package/apps/storefront/src/config/storeName.ts +34 -0
  40. package/apps/storefront/src/layouts/Layout.astro +97 -23
  41. package/apps/storefront/src/lib/activity/alerts.ts +428 -0
  42. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  43. package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
  44. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  45. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  46. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  47. package/apps/storefront/src/lib/activity/query.ts +403 -0
  48. package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
  49. package/apps/storefront/src/lib/activity/store.ts +122 -0
  50. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  51. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  52. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  53. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  54. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  55. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  56. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  57. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  58. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  59. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  60. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  61. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  62. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  63. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  64. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  65. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  66. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  67. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  68. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  69. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  70. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  71. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  72. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  73. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  74. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  75. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  76. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  77. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  78. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  79. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  80. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  81. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  82. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  83. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  84. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +219 -0
  85. package/apps/storefront/src/lib/auth/route.ts +60 -5
  86. package/apps/storefront/src/lib/auth/session.ts +8 -0
  87. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  88. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  89. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  90. package/apps/storefront/src/lib/blog/types.ts +26 -0
  91. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  92. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  93. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  94. package/apps/storefront/src/lib/content/callout.ts +78 -0
  95. package/apps/storefront/src/lib/content/index.ts +27 -0
  96. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  97. package/apps/storefront/src/lib/content/prose.ts +186 -0
  98. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  99. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  100. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  101. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  102. package/apps/storefront/src/lib/d1/schema-apps.ts +267 -0
  103. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  104. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  105. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  106. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  107. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  108. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  109. package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
  110. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  111. package/apps/storefront/src/lib/env.ts +63 -0
  112. package/apps/storefront/src/lib/jsonld.ts +12 -17
  113. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  114. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  115. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  116. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  117. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  118. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  119. package/apps/storefront/src/lib/publish/shipWorkspace.ts +369 -0
  120. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  121. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  122. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  123. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  124. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  125. package/apps/storefront/src/lib/tot/query.ts +32 -0
  126. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  127. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  128. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  129. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  130. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  131. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  132. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  133. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  134. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  135. package/apps/storefront/src/middleware/index.ts +45 -13
  136. package/apps/storefront/src/pages/404.astro +21 -9
  137. package/apps/storefront/src/pages/[...slug].astro +36 -2
  138. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  139. package/apps/storefront/src/pages/admin.astro +183 -9
  140. package/apps/storefront/src/pages/api/activity.ts +137 -0
  141. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  142. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +86 -0
  143. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  144. package/apps/storefront/src/pages/api/apps/admin/install.ts +125 -0
  145. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  146. package/apps/storefront/src/pages/api/apps/admin/resume.ts +79 -0
  147. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +80 -0
  148. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +98 -0
  149. package/apps/storefront/src/pages/api/apps/admin/update.ts +126 -0
  150. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +70 -0
  151. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  152. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +183 -0
  153. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  154. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +162 -0
  155. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  156. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  157. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  158. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  159. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  160. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  161. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  162. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +74 -0
  163. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  164. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  165. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  166. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  167. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  168. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  169. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  170. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  171. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  172. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  173. package/apps/storefront/src/pages/auth/login.astro +202 -45
  174. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  175. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  176. package/apps/storefront/src/pages/blog/index.astro +98 -0
  177. package/apps/storefront/src/pages/capabilities.astro +8 -0
  178. package/apps/storefront/src/pages/cockpit.astro +470 -69
  179. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  180. package/apps/storefront/src/pages/collections/index.astro +10 -2
  181. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  182. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  183. package/apps/storefront/src/pages/index.astro +57 -0
  184. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  185. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  186. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  187. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  188. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  189. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  190. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  191. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  192. package/apps/storefront/src/styles/fonts.css +54 -0
  193. package/apps/storefront/src/styles/global.css +22 -2
  194. package/apps/storefront/src/themes/schema.ts +3 -25
  195. package/apps/storefront/tsconfig.json +1 -1
  196. package/apps/storefront/vitest.config.ts +4 -1
  197. package/package.json +1 -1
  198. package/packages/public-runtime/src/activity/README.md +146 -0
  199. package/packages/public-runtime/src/activity/catalog.ts +501 -0
  200. package/packages/public-runtime/src/activity/event.ts +168 -0
  201. package/packages/public-runtime/src/activity/index.ts +16 -0
  202. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  203. package/packages/public-runtime/src/candidate-index.ts +324 -0
  204. package/packages/public-runtime/src/checkout.ts +94 -2
  205. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  206. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  207. package/packages/public-runtime/src/compliance/index.ts +22 -0
  208. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  209. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  210. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  211. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  212. package/packages/public-runtime/src/csp.ts +27 -3
  213. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  214. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  215. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  216. package/packages/public-runtime/src/extension-contract.ts +2 -1
  217. package/packages/public-runtime/src/hash.ts +25 -0
  218. package/packages/public-runtime/src/index.ts +12 -0
  219. package/packages/public-runtime/src/membership.ts +353 -0
  220. package/packages/public-runtime/src/product.ts +24 -2
  221. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  222. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  223. package/packages/public-runtime/src/tenant.ts +236 -0
  224. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  225. package/scripts/dev/publish.mjs +158 -0
  226. package/scripts/dev/transient-files.mjs +2 -1
  227. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  228. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  229. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  230. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  231. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  232. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  233. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  234. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  235. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  236. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  237. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  238. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  239. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  240. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  241. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  242. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  243. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  244. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  245. package/tenants/home/public/pages/storefront.css +23 -0
  246. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -0,0 +1,3562 @@
1
+ ---
2
+ /**
3
+ * Publish tab — the go-live surface (handoffs 2026-08-05-admin-golive-surface
4
+ * + 2026-08-05-domain-connect-ux). Replaces the terminal runbook for a
5
+ * permissioned operator:
6
+ *
7
+ * - OPEN CHANGE QUEUE from GET /api/changes ({ current, changes }) with
8
+ * per-change reconcile/compliance state, preview link, head sha, and age.
9
+ * - ACCEPT TO PREVIEW (POST /api/changes/integrate — b11's accept-to-preview
10
+ * split; advances the shared `preview` aggregate, does NOT go live) and
11
+ * REJECT (with a note) per change. Going live is the aggregate card's
12
+ * exact-digest PUBLISH (POST /api/changes/ship), never a per-change accept.
13
+ * - LIVE STATE: the current live version (live-channel pointer) + the
14
+ * static-republish dispatch status for static-publish tenants (fired /
15
+ * skipped / sink-unbound notice).
16
+ * - DOMAIN section (static-publish tenants): apex connect + rollback via the
17
+ * www-domain dispatch endpoints — typed-confirmation connect (owner-only),
18
+ * always-visible rollback with the captured-records timestamp, in-flight
19
+ * run + legible failures.
20
+ *
21
+ * PERMISSIONS (server-authoritative; mirrored here for UX only):
22
+ * - Ship actions require an owner/admin session (`resolveOwnerSession`'s
23
+ * OWNER_CAPABILITIES) — others see the queue read-only with a sign-in note.
24
+ * - Non-premium tenants see the go-live control as a PAYWALL UPSELL — never
25
+ * hidden, never "coming soon" (recorded product direction). The
26
+ * entitlement signal is the existing premium model (D4): a custom-domain
27
+ * tenant is premium/pilot; a `<label>.tokenoftrust.store` self-serve store
28
+ * is not. TODO(paywall workstream): replace with the real billing
29
+ * entitlement when one exists.
30
+ */
31
+ import { readViewerSession } from "@/lib/auth/route";
32
+ import { withBase } from "@/lib/basePath";
33
+ import { readEnv } from "@/lib/env";
34
+ import { resolveViewerShipCapability } from "@/lib/change";
35
+ import { isHostedStoreDomain, isInternalTestTenant } from "@tot/public-runtime";
36
+
37
+ const { envelope } = Astro.props;
38
+ const { tenant, basePath, cspNonce } = Astro.locals;
39
+
40
+ // Viewer capability, mirrored from the same session model the API routes gate
41
+ // on (resolveOwnerSession admits capability owner|admin). UX-only: the server
42
+ // re-authorizes every action.
43
+ const viewerSession = await readViewerSession(Astro);
44
+ // Session capability counts only when the session's resource IS this tenant.
45
+ const sessionCapability =
46
+ viewerSession?.resource === tenant.appDomain ? viewerSession.capability : undefined;
47
+ const viewerCapability =
48
+ Astro.locals.viewer?.capability ?? sessionCapability ?? "none";
49
+ // A signed-in DEVELOPER (not owner/admin) may hold an explicit, owner-revocable
50
+ // ship-on-behalf grant. Resolve it LIVE from the MCP subject-capability store —
51
+ // NEVER the coarse role, NEVER a client header — so a ship-granted developer sees
52
+ // the queue + Go-live/Reject/Retire. This MIRRORS the server (resolveShipSession /
53
+ // effectiveShipCapability); the server re-authorizes every action. UX-only.
54
+ let viewerCanShip = viewerCapability === "owner" || viewerCapability === "admin";
55
+ if (!viewerCanShip && Astro.locals.viewer?.email) {
56
+ const shipGrant = await resolveViewerShipCapability(
57
+ Astro.locals.viewer.email,
58
+ tenant.appDomain,
59
+ );
60
+ if (shipGrant === "ship-on-behalf") viewerCanShip = true;
61
+ }
62
+ const viewerIsOwner = viewerCapability === "owner";
63
+ // Apex/domain cutover is granted to owner OR admin (decision: apex-cutover =
64
+ // owner+admin). This MIRRORS the server, whose `apexCutover` = `isOwner` over
65
+ // OWNER_CAPABILITIES {owner, admin} (see change/route.ts decideIsOwner +
66
+ // change/shipCapabilities.ts) — this UI gate was wrongly owner-only, stricter
67
+ // than the gate the server actually enforces. UX-only: the server re-authorizes.
68
+ const viewerCanApexCutover = viewerCapability === "owner" || viewerCapability === "admin";
69
+
70
+ // Entitlement: the existing premium signal (custom-domain tenant = premium).
71
+ // TODO(paywall workstream): swap for the real billing entitlement.
72
+ const goLiveEntitled = !isHostedStoreDomain(tenant.appDomain);
73
+ const internalTestTenant = isInternalTestTenant(tenant);
74
+
75
+ const staticPublishEnabled = Boolean(
76
+ tenant.staticPublish && tenant.staticPublish.enabled !== false,
77
+ );
78
+ // Presence-only signal for the T5 dispatch sink (never the value).
79
+ const dispatchConfigured = Boolean((await readEnv("WWW_PUBLISH_DISPATCH_TOKEN"))?.trim());
80
+
81
+ const changesEndpoint = withBase(basePath || "", "/api/changes");
82
+ // b11 — the branch-integration lifecycle SPLIT of accept from publish:
83
+ // - integrateEndpoint = b08's "accept = integrate into the protected preview
84
+ // aggregate" (POST /api/changes/integrate → TenantIntegrationQueue.enqueue).
85
+ // This is the candidate-queue "Accept to preview" action — it advances the
86
+ // shared aggregate, it does NOT go live. Returns an IntegrateOutcome
87
+ // (queueState/runState/statusMessage), never a bare merged:true.
88
+ // - shipEndpoint = b10's exact-digest go-live (POST /api/changes/ship →
89
+ // b09 AggregateShipOrchestrator.plan/ship). The aggregate card's "Publish"
90
+ // action: it ships the reviewed pinned SHA + digest, double-gated
91
+ // (paywall + human confirm). The read-only plan is served on GET /api/changes
92
+ // (`plan`), so the confirm surface never has to POST to preview it.
93
+ // If b08/b10's exact paths differ at fold, the integrator reconciles here —
94
+ // these are the agreed contract paths (see the unit brief).
95
+ const integrateEndpoint = withBase(basePath || "", "/api/changes/integrate");
96
+ const shipEndpoint = withBase(basePath || "", "/api/changes/ship");
97
+ // U7 — retire/GC: evict a candidate's ReviewEnvironment + version (rebuildable),
98
+ // DISTINCT from reject (which closes the change). Gated server-side by retireGate.
99
+ const retireEndpoint = withBase(basePath || "", "/api/preview/retire");
100
+ // rux5 — build-on-demand for a synthetic not-built PR row: materialize the PR's
101
+ // hosted preview (INERT — flips no live channel). Gated server-side by buildGate
102
+ // (the ship-on-behalf floor); the endpoint resolves the head sha via forge PR-read
103
+ // when the row supplies only the PR number.
104
+ const buildEndpoint = withBase(basePath || "", "/api/preview/build");
105
+ // dg7 — the ship-operations workspace woven into this tab: versions/promotion
106
+ // history, per-candidate diff, and rollback (through the ONE rollback
107
+ // orchestrator). Read endpoints mirror the change-queue's auth/scope.
108
+ const versionsEndpoint = withBase(basePath || "", "/api/changes/versions");
109
+ const rollbackEndpoint = withBase(basePath || "", "/api/changes/rollback");
110
+ // b17 — branch retention: GET dry-run classification / POST guarded delete.
111
+ // DISTINCT endpoint from retireEndpoint above (branch delete vs artifact evict).
112
+ const branchesEndpoint = withBase(basePath || "", "/api/changes/branches");
113
+ // The client builds `${diffEndpointBase}/<changeId>/diff` per candidate.
114
+ const diffEndpointBase = changesEndpoint;
115
+ // The live store's root on THIS surface (basePath-aware): where the published
116
+ // digest actually serves. Rendered into "already live" copy + the publish
117
+ // ceremony's "View your live store" CTA.
118
+ const liveHref = withBase(basePath || "", "/");
119
+ const domainStatusEndpoint = withBase(basePath || "", "/api/domain/status");
120
+ const domainDispatchEndpoint = withBase(basePath || "", "/api/domain/dispatch");
121
+ // u9: the machine-verifiable apex-cutover readiness gate — the SAME server gate the
122
+ // `tot go-live` CLI reads, so the admin display and the CLI never diverge.
123
+ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness");
124
+ ---
125
+
126
+ <!-- agent-hook:publish-tab -->
127
+ <section
128
+ id="publish"
129
+ class="band publish-tab"
130
+ aria-labelledby="admin-tab-publish publish-title"
131
+ data-admin-panel
132
+ data-agent-scope="publish"
133
+ role="tabpanel"
134
+ data-changes-endpoint={changesEndpoint}
135
+ data-integrate-endpoint={integrateEndpoint}
136
+ data-ship-endpoint={shipEndpoint}
137
+ data-retire-endpoint={retireEndpoint}
138
+ data-build-endpoint={buildEndpoint}
139
+ data-versions-endpoint={versionsEndpoint}
140
+ data-rollback-endpoint={rollbackEndpoint}
141
+ data-branches-endpoint={branchesEndpoint}
142
+ data-diff-endpoint-base={diffEndpointBase}
143
+ data-domain-status-endpoint={domainStatusEndpoint}
144
+ data-domain-dispatch-endpoint={domainDispatchEndpoint}
145
+ data-domain-readiness-endpoint={domainReadinessEndpoint}
146
+ data-repo={tenant.appDomain}
147
+ data-app-domain={tenant.appDomain}
148
+ data-live-href={liveHref}
149
+ data-can-ship={viewerCanShip ? "true" : "false"}
150
+ data-is-owner={viewerIsOwner ? "true" : "false"}
151
+ data-entitled={goLiveEntitled ? "true" : "false"}
152
+ data-static-publish={staticPublishEnabled ? "true" : "false"}
153
+ data-dispatch-configured={dispatchConfigured ? "true" : "false"}
154
+ >
155
+ <div class="section-title">
156
+ <div>
157
+ <h2 id="publish-title">Publish</h2>
158
+ <p>Accept changes into the preview aggregate, then publish the reviewed build live.</p>
159
+ </div>
160
+ <div class="actions">
161
+ <button class="btn" type="button" data-publish-refresh>
162
+ <svg><use href="#i-refresh"></use></svg>Refresh
163
+ </button>
164
+ </div>
165
+ </div>
166
+
167
+ {internalTestTenant && (
168
+ <div class="ship-gate amber">
169
+ <div class="ship-gate-main">
170
+ <span class="seal"><svg><use href="#i-alert"></use></svg></span>
171
+ <div>
172
+ <b>Internal test store</b>
173
+ <span>This store exists for testing only and is blocked from going live.</span>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ )}
178
+
179
+ {!goLiveEntitled && (
180
+ <div class="ship-gate amber" data-publish-paywall>
181
+ <div class="ship-gate-main">
182
+ <span class="seal"><svg><use href="#i-card"></use></svg></span>
183
+ <div>
184
+ <b>Publishing to your live store is part of the paid plan</b>
185
+ <span>
186
+ Previewing and reviewing changes stays free. Upgrade to take changes live
187
+ and publish your public site.
188
+ </span>
189
+ </div>
190
+ </div>
191
+ <a class="btn primary" href="mailto:success@tokenoftrust.com?subject=Upgrade%20store%20publishing">
192
+ Upgrade to publish
193
+ </a>
194
+ </div>
195
+ )}
196
+
197
+ {goLiveEntitled && !viewerCanShip && (
198
+ <div class="ship-gate amber">
199
+ <div class="ship-gate-main">
200
+ <span class="seal"><svg><use href="#i-shield"></use></svg></span>
201
+ <div>
202
+ <b>Read-only view</b>
203
+ <span>Going live requires an owner or admin sign-in for this store.</span>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ )}
208
+
209
+ <div class="grid cols-3">
210
+ <div class="metric">
211
+ <span>Live version</span>
212
+ <strong data-publish-live-version>—</strong>
213
+ <small data-publish-live-source>What shoppers see right now</small>
214
+ </div>
215
+ <div class="metric">
216
+ <span>Published</span>
217
+ <strong data-publish-live-at>—</strong>
218
+ <small>When the live version last changed</small>
219
+ <small data-publish-live-shipped-by></small>
220
+ </div>
221
+ <div class="metric">
222
+ <span>Open changes</span>
223
+ <strong data-publish-queue-count>—</strong>
224
+ <small>Waiting for review or go-live</small>
225
+ </div>
226
+ </div>
227
+
228
+ {staticPublishEnabled && !dispatchConfigured && (
229
+ <div class="risk-alert" data-publish-sink-unbound>
230
+ <div class="risk-alert-main">
231
+ <svg><use href="#i-alert"></use></svg>
232
+ <div>
233
+ <b>Automatic public-site republish is not yet enabled on this environment</b>
234
+ <span>
235
+ Going live updates the hosted store immediately; the public-site republish
236
+ job is dispatched once publishing is enabled here.
237
+ </span>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ )}
242
+
243
+ <div class="panel">
244
+ <div class="panel-head">
245
+ <h3><svg><use href="#i-export"></use></svg>Candidate queue</h3>
246
+ <span class="chip" data-publish-queue-chip>Loading</span>
247
+ </div>
248
+ <div class="panel-body grid">
249
+ <p class="note publish-status" data-publish-status role="status" aria-live="polite"></p>
250
+ <!-- rux1: forge-listing degraded strip. Shown ONLY when GET /api/changes
251
+ reports `degraded` (the open-PR listing failed) — distinct from the
252
+ genuine empty queue, so a forge blip never reads as "No open changes". -->
253
+ <div class="risk-alert" data-publish-degraded hidden role="alert">
254
+ <div class="risk-alert-main">
255
+ <svg><use href="#i-alert"></use></svg>
256
+ <div>
257
+ <b>Some open changes may not be shown</b>
258
+ <span>
259
+ We couldn't reach the pull-request list, so open changes that haven't been
260
+ built yet may be missing from the queue below. Built candidates are
261
+ unaffected. Refresh to try again.
262
+ </span>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ <p class="note publish-pr-callout" data-publish-pr-callout hidden role="status" aria-live="polite"></p>
267
+ <div class="table-wrap">
268
+ <table class="publish-table">
269
+ <thead>
270
+ <tr>
271
+ <th>Change</th>
272
+ <th>Status</th>
273
+ <th>Checks</th>
274
+ <th>Head</th>
275
+ <th>Updated</th>
276
+ <th class="action">Actions</th>
277
+ </tr>
278
+ </thead>
279
+ <tbody data-publish-queue-body>
280
+ <tr data-publish-placeholder>
281
+ <td colspan="6"><span class="subtle">Loading the change queue…</span></td>
282
+ </tr>
283
+ </tbody>
284
+ </table>
285
+ </div>
286
+ {staticPublishEnabled && (
287
+ <p class="note" data-publish-dispatch-status>
288
+ {dispatchConfigured
289
+ ? "Public-site republish: dispatched automatically when a change goes live."
290
+ : "Public-site republish: not yet enabled on this environment."}
291
+ </p>
292
+ )}
293
+ </div>
294
+ </div>
295
+
296
+ <!-- b11: the AGGREGATE card — the protected `preview` aggregate the accepted
297
+ candidates integrate into, its combined evidence (the last integration
298
+ run's honest state), and the exact-digest Publish (b09/b10 ship the
299
+ reviewed pinned SHA + digest, never a rebuild). -->
300
+ <div class="panel" data-publish-aggregate>
301
+ <div class="panel-head">
302
+ <h3><svg><use href="#i-box"></use></svg>Preview aggregate</h3>
303
+ <span class="chip" data-aggregate-chip>Loading</span>
304
+ </div>
305
+ <div class="panel-body grid">
306
+ <p class="note publish-status" data-aggregate-status role="status" aria-live="polite"></p>
307
+
308
+ <div class="grid cols-3">
309
+ <div class="field">
310
+ <label>Aggregate build</label>
311
+ <strong class="publish-mono" data-aggregate-sha>—</strong>
312
+ <p data-aggregate-run-link>The current green preview build for this store.</p>
313
+ </div>
314
+ <div class="field">
315
+ <label>Combined evidence</label>
316
+ <strong data-aggregate-evidence>—</strong>
317
+ <p data-aggregate-evidence-detail>The last integration run's verdict.</p>
318
+ </div>
319
+ <div class="field">
320
+ <label>Reviewed digest</label>
321
+ <strong class="publish-mono" data-aggregate-digest>—</strong>
322
+ <p>The content digest that will be published — exactly what you reviewed.</p>
323
+ </div>
324
+ </div>
325
+
326
+ <!-- Included PRs — the batch this aggregate composes (b01 includedPrs). -->
327
+ <div class="aggregate-prs" data-aggregate-prs-wrap hidden>
328
+ <b>Included changes</b>
329
+ <ul class="aggregate-prs-list" data-aggregate-prs></ul>
330
+ </div>
331
+
332
+ <!-- The exact-digest Publish plan (b09 plan()): pinned SHA + digest +
333
+ included PRs + rollback target + paywall verdict + run link. Shown
334
+ inline (from GET /api/changes `plan`) so the confirm never POSTs to
335
+ preview. Publish is refused until the plan is `ok`. -->
336
+ <div class="aggregate-plan" data-aggregate-plan hidden>
337
+ <div class="aggregate-plan-head">
338
+ <b>Publish plan</b>
339
+ <span class="chip" data-aggregate-plan-chip></span>
340
+ </div>
341
+ <dl class="aggregate-plan-grid" data-aggregate-plan-grid></dl>
342
+ <p class="note" data-aggregate-plan-note></p>
343
+ </div>
344
+
345
+ <div class="check-row">
346
+ <div>
347
+ <b>Publish the reviewed build to your live store</b>
348
+ <p data-aggregate-publish-detail>
349
+ Publishes the exact reviewed digest — no rebuild. Available once the
350
+ aggregate is green and its integration run passed.
351
+ </p>
352
+ </div>
353
+ <button class="btn primary" type="button" data-aggregate-publish disabled>
354
+ <svg><use href="#i-export"></use></svg>Publish live
355
+ </button>
356
+ </div>
357
+ </div>
358
+ </div>
359
+
360
+ <!-- dg7: published versions + promotion history + rollback (u2/u3 promotion_status,
361
+ u5 immutable /rev links, u8 rollback through the one orchestrator). -->
362
+ <div class="panel" data-publish-versions>
363
+ <div class="panel-head">
364
+ <h3><svg><use href="#i-refresh"></use></svg>Published versions</h3>
365
+ <span class="chip" data-versions-chip>Loading</span>
366
+ </div>
367
+ <div class="panel-body grid">
368
+ <p class="note publish-status" data-versions-status role="status" aria-live="polite"></p>
369
+ <p class="note" data-versions-rollback-note hidden></p>
370
+ <div class="table-wrap">
371
+ <table class="publish-table versions-table">
372
+ <thead>
373
+ <tr>
374
+ <th>Version</th>
375
+ <th>Digest</th>
376
+ <th>Promoted</th>
377
+ <th>Preview</th>
378
+ <th>State</th>
379
+ <th class="action">Actions</th>
380
+ </tr>
381
+ </thead>
382
+ <tbody data-versions-body>
383
+ <tr data-versions-placeholder>
384
+ <td colspan="6"><span class="subtle">Loading published versions…</span></td>
385
+ </tr>
386
+ </tbody>
387
+ </table>
388
+ </div>
389
+ </div>
390
+ </div>
391
+
392
+ <!-- b17: branch retention — Admin visibility into the branch-lifecycle
393
+ contract's "Candidate branch retirement" table (P1 items 8/9/11). Reads
394
+ the guarded `candidate_list` classification (protected/active-open/
395
+ stale-open/integrated/closed-or-rejected/orphan) and, for a branch that
396
+ classifies as ready, runs the guarded `candidate_delete` — which
397
+ re-classifies fresh server-side and refuses anything not integrated or
398
+ closed-or-rejected, no matter what this panel last rendered. DISTINCT
399
+ from the "Retire" action in the candidate queue above: retire evicts a
400
+ hosted PREVIEW ARTIFACT (rebuildable); this panel deletes a Git BRANCH
401
+ (transport only, never the durable record — see the note below). An
402
+ orphan is always shown quarantined for review, never one-click deletable. -->
403
+ <div class="panel" data-publish-branches>
404
+ <div class="panel-head">
405
+ <h3><svg><use href="#i-workflows"></use></svg>Branch retention</h3>
406
+ <span class="chip" data-branches-chip>Loading</span>
407
+ </div>
408
+ <div class="panel-body grid">
409
+ <p class="note">
410
+ Cleans up candidate <b>branches</b> once their pull request is merged or
411
+ closed — a different action on a different object than <b>Retire</b> in
412
+ the queue above, which only evicts a hosted preview build (rebuildable
413
+ on demand, never a branch). Every classification below is a fresh
414
+ dry-run; deleting always re-checks eligibility on the server and asks
415
+ you to confirm before removing anything. Orphan branches (no PR record
416
+ at all) are quarantined for your review, never deleted automatically.
417
+ </p>
418
+ <p class="note publish-status" data-branches-status role="status" aria-live="polite"></p>
419
+ <div class="table-wrap">
420
+ <table class="publish-table">
421
+ <thead>
422
+ <tr>
423
+ <th>Branch</th>
424
+ <th>Classification</th>
425
+ <th>PR</th>
426
+ <th>Tip commit</th>
427
+ <th>Age</th>
428
+ <th>Evidence</th>
429
+ <th class="action">Actions</th>
430
+ </tr>
431
+ </thead>
432
+ <tbody data-branches-body>
433
+ <tr data-branches-placeholder>
434
+ <td colspan="7"><span class="subtle">Loading branch classification…</span></td>
435
+ </tr>
436
+ </tbody>
437
+ </table>
438
+ </div>
439
+ </div>
440
+ </div>
441
+
442
+ {staticPublishEnabled && (
443
+ <div class="panel" data-publish-domain>
444
+ <div class="panel-head">
445
+ <h3><svg><use href="#i-shield"></use></svg>Public domain</h3>
446
+ <span class="chip" data-domain-state-chip>Loading</span>
447
+ </div>
448
+ <div class="panel-body grid">
449
+ <div class="grid cols-3">
450
+ <div class="field">
451
+ <label>Serving from</label>
452
+ <strong data-domain-state>—</strong>
453
+ <p data-domain-since>Current host for {tenant.appDomain}</p>
454
+ </div>
455
+ <div class="field">
456
+ <label>Rollback records</label>
457
+ <strong data-domain-capture>None captured</strong>
458
+ <p>DNS records captured before connect — rollback restores these.</p>
459
+ </div>
460
+ <div class="field">
461
+ <label>Last run</label>
462
+ <strong data-domain-run>None</strong>
463
+ <p data-domain-run-detail>Connect and rollback runs appear here.</p>
464
+ </div>
465
+ </div>
466
+
467
+ <p class="note publish-status" data-domain-status role="status" aria-live="polite"></p>
468
+
469
+ <!-- u9: apex-cutover readiness — the itemized preconditions the server gate
470
+ requires before a connect is permitted. Populated from
471
+ /api/domain/readiness (the SAME gate `tot go-live` reads). -->
472
+ <div class="domain-readiness" data-domain-readiness hidden>
473
+ <div class="domain-readiness-head">
474
+ <b>Cutover readiness</b>
475
+ <span class="chip" data-domain-readiness-chip>Checking…</span>
476
+ </div>
477
+ <ul class="domain-readiness-list" data-domain-readiness-list></ul>
478
+ <p class="note" data-domain-readiness-note></p>
479
+ </div>
480
+
481
+ <div class="check-row">
482
+ <div>
483
+ <b>Connect {tenant.appDomain} to the storefront</b>
484
+ <p>
485
+ Updates two DNS names — <code>{tenant.appDomain}</code> and <code>www.{tenant.appDomain}</code> —
486
+ to serve the published site. Takes effect in about a minute. Type the
487
+ domain to confirm.
488
+ </p>
489
+ {!viewerCanApexCutover && (
490
+ <p><span class="chip amber">Owner/admin only</span> Connecting the domain requires an owner or admin sign-in.</p>
491
+ )}
492
+ </div>
493
+ <div class="domain-connect-controls">
494
+ <input
495
+ type="text"
496
+ placeholder={tenant.appDomain}
497
+ autocomplete="off"
498
+ spellcheck="false"
499
+ aria-label="Type the domain to confirm"
500
+ data-domain-confirm
501
+ disabled={!viewerCanApexCutover}
502
+ />
503
+ <label class="domain-rehearsal">
504
+ <input type="checkbox" data-domain-rehearsal disabled={!viewerCanApexCutover} />
505
+ Rehearsal (dry run, no DNS change)
506
+ </label>
507
+ <button
508
+ class="btn primary"
509
+ type="button"
510
+ data-domain-connect
511
+ data-owner-locked={!viewerCanApexCutover ? "true" : undefined}
512
+ disabled={!viewerCanApexCutover}
513
+ >
514
+ <svg><use href="#i-check"></use></svg>Connect domain
515
+ </button>
516
+ </div>
517
+ </div>
518
+
519
+ <div class="check-row">
520
+ <div>
521
+ <b>Roll back to the previous host</b>
522
+ <p data-domain-rollback-detail>
523
+ Restores the DNS records captured before connect. Available once records
524
+ have been captured.
525
+ </p>
526
+ </div>
527
+ <button class="btn" type="button" data-domain-rollback disabled={!viewerCanShip}>
528
+ <svg><use href="#i-refresh"></use></svg>Roll back
529
+ </button>
530
+ </div>
531
+ </div>
532
+ </div>
533
+ )}
534
+
535
+ <!-- Accept-to-preview LIFTOFF — the plan confirm + live integration flight +
536
+ outcome ceremony. One dialog, three phases (plan → flight → outcome).
537
+ The flight's stage rail advances ONLY on real signals (the in-flight POST
538
+ plus GET /api/changes polling — includedPrs / activeRun / aggregateSha);
539
+ nothing is simulated. Replaces the old window.confirm() plan popup. -->
540
+ <div class="liftoff-backdrop" data-liftoff hidden>
541
+ <div class="liftoff-card" role="dialog" aria-modal="true" aria-labelledby="liftoff-title" tabindex="-1">
542
+ <div class="liftoff-phase" data-liftoff-plan>
543
+ <p class="liftoff-kicker">Accept to preview</p>
544
+ <h3 class="liftoff-title" id="liftoff-title" data-liftoff-title>Integrate into preview</h3>
545
+ <dl class="liftoff-grid" data-liftoff-grid></dl>
546
+ <p class="note">
547
+ Integrates this candidate into the protected <code>preview</code> aggregate, then
548
+ rebuilds it and re-runs its combined evidence. <b>Nothing goes live</b> —
549
+ publishing the reviewed build stays a separate, explicit step.
550
+ </p>
551
+ <div class="liftoff-actions">
552
+ <button class="btn" type="button" data-liftoff-cancel>Cancel</button>
553
+ <button class="btn primary" type="button" data-liftoff-proceed>
554
+ <svg><use href="#i-check"></use></svg>Integrate into preview
555
+ </button>
556
+ </div>
557
+ </div>
558
+
559
+ <div class="liftoff-phase" data-liftoff-flight hidden>
560
+ <p class="liftoff-kicker">Integration in flight</p>
561
+ <h3 class="liftoff-title" data-liftoff-flight-title>Integrating…</h3>
562
+ <ol class="liftoff-stages">
563
+ <li data-liftoff-stage="merge">
564
+ <span class="liftoff-dot"></span>
565
+ <div><b>Merge</b><span>squash-merge the candidate into <code>preview</code></span></div>
566
+ </li>
567
+ <li data-liftoff-stage="build">
568
+ <span class="liftoff-dot"></span>
569
+ <div><b>Rebuild</b><span>build the combined preview aggregate</span></div>
570
+ </li>
571
+ <li data-liftoff-stage="evidence">
572
+ <span class="liftoff-dot"></span>
573
+ <div><b>Evidence</b><span>re-run the aggregate's combined checks</span></div>
574
+ </li>
575
+ <li data-liftoff-stage="green">
576
+ <span class="liftoff-dot"></span>
577
+ <div><b>Green</b><span>the aggregate becomes the reviewable, shippable build</span></div>
578
+ </li>
579
+ </ol>
580
+ <p class="liftoff-live" data-liftoff-live role="status" aria-live="polite">Working…</p>
581
+ <div class="liftoff-meta">
582
+ <span class="publish-mono" data-liftoff-elapsed>0:00</span>
583
+ <button class="btn" type="button" data-liftoff-hide>Continue in background</button>
584
+ </div>
585
+ </div>
586
+
587
+ <div class="liftoff-phase liftoff-outcome-phase" data-liftoff-outcome hidden>
588
+ <div class="liftoff-seal" data-liftoff-seal aria-hidden="true"></div>
589
+ <h3 class="liftoff-title" data-liftoff-outcome-title></h3>
590
+ <p class="note" data-liftoff-outcome-detail></p>
591
+ <div class="liftoff-actions">
592
+ <a class="btn" data-liftoff-review target="_blank" rel="noopener" hidden>Review the preview build</a>
593
+ <button class="btn primary" type="button" data-liftoff-done>Done</button>
594
+ </div>
595
+ </div>
596
+
597
+ <div class="liftoff-confetti" data-liftoff-confetti aria-hidden="true"></div>
598
+ </div>
599
+ </div>
600
+
601
+ <!-- Publish-live ceremony — the go-live counterpart of the accept liftoff
602
+ (same shell/styles). Plan states the exact digest + no-rebuild contract;
603
+ flight is a single honest in-flight state (one POST, no staged theater);
604
+ outcome celebrates with a real "View your live store" link. Replaces the
605
+ raw window.confirm() publish popup. -->
606
+ <div class="liftoff-backdrop" data-shipoff hidden>
607
+ <div class="liftoff-card" role="dialog" aria-modal="true" aria-labelledby="shipoff-title" tabindex="-1">
608
+ <div class="liftoff-phase" data-shipoff-plan>
609
+ <p class="liftoff-kicker">Publish live</p>
610
+ <h3 class="liftoff-title" id="shipoff-title">Publish the reviewed build to your live store</h3>
611
+ <dl class="liftoff-grid" data-shipoff-grid></dl>
612
+ <p class="note">
613
+ Publishes this <b>exact reviewed digest</b> — no rebuild, nothing rides along.
614
+ Your live store updates in about a second; rolling back re-publishes the prior
615
+ version from the archive.
616
+ </p>
617
+ <div class="liftoff-actions">
618
+ <button class="btn" type="button" data-shipoff-cancel>Cancel</button>
619
+ <button class="btn primary" type="button" data-shipoff-proceed>
620
+ <svg><use href="#i-export"></use></svg>Publish live
621
+ </button>
622
+ </div>
623
+ </div>
624
+
625
+ <div class="liftoff-phase" data-shipoff-flight hidden>
626
+ <p class="liftoff-kicker">Publishing</p>
627
+ <h3 class="liftoff-title">Promoting the reviewed digest…</h3>
628
+ <p class="liftoff-live" data-shipoff-live role="status" aria-live="polite">
629
+ Pinning the exact reviewed build to your live channel…
630
+ </p>
631
+ <div class="liftoff-meta">
632
+ <span class="publish-mono" data-shipoff-elapsed>0:00</span>
633
+ </div>
634
+ </div>
635
+
636
+ <div class="liftoff-phase liftoff-outcome-phase" data-shipoff-outcome hidden>
637
+ <div class="liftoff-seal" data-shipoff-seal aria-hidden="true"></div>
638
+ <h3 class="liftoff-title" data-shipoff-outcome-title></h3>
639
+ <p class="note" data-shipoff-outcome-detail></p>
640
+ <div class="liftoff-actions">
641
+ <a class="btn primary" data-shipoff-view target="_blank" rel="noopener" hidden>View your live store</a>
642
+ <button class="btn" type="button" data-shipoff-done>Done</button>
643
+ </div>
644
+ </div>
645
+
646
+ <div class="liftoff-confetti" data-shipoff-confetti aria-hidden="true"></div>
647
+ </div>
648
+ </div>
649
+ </section>
650
+
651
+ <script is:inline nonce={cspNonce}>
652
+ (() => {
653
+ const root = document.getElementById("publish");
654
+ if (!root) return;
655
+ const ds = root.dataset;
656
+ const canShip = ds.canShip === "true";
657
+ const repo = ds.repo || "";
658
+ const appDomain = ds.appDomain || "";
659
+
660
+ const statusEl = root.querySelector("[data-publish-status]");
661
+ const queueBody = root.querySelector("[data-publish-queue-body]");
662
+ const queueChip = root.querySelector("[data-publish-queue-chip]");
663
+ const queueCount = root.querySelector("[data-publish-queue-count]");
664
+ const degradedStrip = root.querySelector("[data-publish-degraded]");
665
+
666
+ // rux7 — in-flight UI state the auto-refresh poll must preserve across a
667
+ // re-render. These are the AUTHORITATIVE source of truth (never scraped
668
+ // back out of the DOM): an action's async closure holds a reference to the
669
+ // OLD button, so after a poll swaps the row the completion signal lands on a
670
+ // detached node — reading `disabled`/`data-building` off the fresh DOM would
671
+ // wedge forever. buildPreview/integrate/reject/retire add on start and
672
+ // delete in `finally` (before their own loadQueue), so the next render
673
+ // reflects the true busy set. Open diff panes are the exception: the DOM IS
674
+ // their source of truth (toggleDiff owns open/close), so renderQueue detaches
675
+ // and re-attaches the actual pane node — see renderQueue.
676
+ const inflightBuilds = new Set(); // prNumber currently building
677
+ const busyChanges = new Set(); // changeId with an in-flight integrate/reject/retire
678
+ let aggregatePublishing = false; // the aggregate Publish POST is in flight
679
+
680
+ const setStatus = (el, message, state) => {
681
+ if (!el) return;
682
+ el.textContent = message || "";
683
+ if (state) el.dataset.state = state;
684
+ else delete el.dataset.state;
685
+ };
686
+
687
+ const shortSha = (sha) => (sha ? String(sha).slice(0, 8) : "—");
688
+
689
+ // Inline mirror of `candidatePrUrl` (lib/preview/candidateDashboard.ts) — an
690
+ // is:inline script can't import a module, so the pure URL shape is mirrored
691
+ // here (same pattern as `shortSha`). The friendly `/preview/<tenant>/pr/<N>`
692
+ // route handles every state (ready, not-built, …), so a row with a PR number
693
+ // always has a working preview link even when `previewUrl` is still null.
694
+ const candidatePrUrl = (prNumber) =>
695
+ prNumber != null ? "/preview/" + encodeURIComponent(appDomain) + "/pr/" + prNumber : null;
696
+
697
+ const age = (iso) => {
698
+ if (!iso) return "—";
699
+ const ms = Date.now() - new Date(iso).getTime();
700
+ if (!Number.isFinite(ms) || ms < 0) return "—";
701
+ const mins = Math.floor(ms / 60000);
702
+ if (mins < 1) return "just now";
703
+ if (mins < 60) return mins + "m ago";
704
+ const hours = Math.floor(mins / 60);
705
+ if (hours < 48) return hours + "h ago";
706
+ return Math.floor(hours / 24) + "d ago";
707
+ };
708
+
709
+ const STATUS_CHIP = {
710
+ queued: "",
711
+ reconciling: "blue",
712
+ ready: "green",
713
+ blocked: "red",
714
+ };
715
+
716
+ const chip = (text, tone) => {
717
+ const el = document.createElement("span");
718
+ el.className = "chip" + (tone ? " " + tone : "");
719
+ el.textContent = text;
720
+ return el;
721
+ };
722
+
723
+ const cell = (...children) => {
724
+ const td = document.createElement("td");
725
+ for (const child of children) {
726
+ if (child == null) continue;
727
+ td.append(typeof child === "string" ? document.createTextNode(child) : child);
728
+ }
729
+ return td;
730
+ };
731
+
732
+ const subtle = (text) => {
733
+ const el = document.createElement("span");
734
+ el.className = "subtle";
735
+ el.textContent = text;
736
+ return el;
737
+ };
738
+
739
+ // ---- ?pr=N deep link ---------------------------------------------------
740
+ // A deep link like /admin#publish?pr=42 asks us to surface a specific PR's
741
+ // row once the queue has loaded. The param rides in the hash fragment
742
+ // (after the tab anchor) because that's how the admin shell routes tabs;
743
+ // fall back to a real query string too. Resolve exactly once — so the
744
+ // scroll/highlight never re-fires on the reloads that follow every ship
745
+ // action — and until it resolves, keep an honest "not in the queue yet"
746
+ // callout up rather than silently doing nothing.
747
+
748
+ const readRequestedPr = () => {
749
+ const hash = window.location.hash || "";
750
+ const q = hash.indexOf("?");
751
+ let raw = new URLSearchParams(q >= 0 ? hash.slice(q + 1) : "").get("pr");
752
+ if (raw == null) raw = new URLSearchParams(window.location.search).get("pr");
753
+ if (raw == null) return null;
754
+ const n = Number.parseInt(raw, 10);
755
+ return Number.isInteger(n) && n > 0 ? n : null;
756
+ };
757
+
758
+ const requestedPr = readRequestedPr();
759
+ let prDeeplinkResolved = false;
760
+
761
+ const applyPrDeeplink = () => {
762
+ if (requestedPr == null || prDeeplinkResolved) return;
763
+ const callout = root.querySelector("[data-publish-pr-callout]");
764
+ const row = queueBody.querySelector('tr[data-pr-number="' + requestedPr + '"]');
765
+ if (!row) {
766
+ if (callout) {
767
+ callout.hidden = false;
768
+ callout.textContent = "PR #" + requestedPr + " isn't in the queue yet.";
769
+ }
770
+ return;
771
+ }
772
+ prDeeplinkResolved = true;
773
+ if (callout) {
774
+ callout.hidden = true;
775
+ callout.textContent = "";
776
+ }
777
+ row.scrollIntoView({ block: "center", behavior: "smooth" });
778
+ row.classList.remove("publish-row-highlight");
779
+ void row.offsetWidth; // restart the flash animation if re-applied
780
+ row.classList.add("publish-row-highlight");
781
+ window.setTimeout(() => row.classList.remove("publish-row-highlight"), 2600);
782
+ };
783
+
784
+ // ---- Open change queue -------------------------------------------------
785
+
786
+ const renderQueue = (payload) => {
787
+ const changes = Array.isArray(payload.changes) ? payload.changes : [];
788
+ // rux1: surface a forge-listing failure as a visible strip — never let a
789
+ // degraded (short) list read as a genuine empty queue.
790
+ if (degradedStrip) degradedStrip.hidden = payload.degraded !== true;
791
+ // rux7: capture any open diff panes BEFORE wiping so a poll re-render
792
+ // re-attaches the exact node (with its already-fetched content) under the
793
+ // matching new row, instead of blowing the expanded pane away. Keyed by
794
+ // changeId (== the diff row's data-publish-diff-row). Wiping detaches them
795
+ // from the DOM but the Map keeps them alive for re-attachment.
796
+ const openDiffNodes = new Map();
797
+ queueBody.querySelectorAll("tr[data-publish-diff-row]").forEach((node) => {
798
+ openDiffNodes.set(node.dataset.publishDiffRow, node);
799
+ });
800
+ queueBody.textContent = "";
801
+ if (queueCount) queueCount.textContent = String(changes.length);
802
+ if (queueChip) {
803
+ queueChip.textContent = changes.length === 1 ? "1 open" : changes.length + " open";
804
+ queueChip.className = "chip" + (changes.length ? " blue" : "");
805
+ }
806
+
807
+ if (!changes.length) {
808
+ const tr = document.createElement("tr");
809
+ const td = cell(
810
+ subtle(
811
+ "No open changes. New store changes appear here when a developer submits them for review.",
812
+ ),
813
+ );
814
+ td.colSpan = 6;
815
+ tr.append(td);
816
+ queueBody.append(tr);
817
+ return;
818
+ }
819
+
820
+ for (const change of changes) {
821
+ const tr = document.createElement("tr");
822
+ if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
823
+
824
+ // Change id + branch / PR + preview link.
825
+ const idCell = document.createElement("td");
826
+ const idStrong = document.createElement("strong");
827
+ idStrong.textContent = change.changeId || "—";
828
+ idCell.append(idStrong);
829
+ const meta = [];
830
+ if (change.branch) meta.push(change.branch);
831
+ if (change.prNumber != null) meta.push("PR #" + change.prNumber);
832
+ if (meta.length) idCell.append(subtle(meta.join(" · ")));
833
+ // Owner-facing "who did this" — accept takes priority over build (a
834
+ // candidate that's been accepted-to-preview is more meaningfully
835
+ // "acted on" than merely built). Reject never shows here: a rejected
836
+ // candidate is closed and has already left the open queue.
837
+ const actorRecord = change.actors && (change.actors.accept || change.actors.build);
838
+ if (actorRecord) {
839
+ const label = change.actors.accept ? "Accepted by " : "Built by ";
840
+ idCell.append(subtle(label + (actorRecord.name || actorRecord.email)));
841
+ }
842
+ // Prefer the synthesized friendly per-PR route (handles every state);
843
+ // fall back to any server-provided previewUrl for change-first rows
844
+ // that have no PR number yet.
845
+ const previewUrl = candidatePrUrl(change.prNumber) || change.previewUrl;
846
+ if (previewUrl) {
847
+ const preview = document.createElement("a");
848
+ preview.href = previewUrl;
849
+ preview.target = "_blank";
850
+ preview.rel = "noopener";
851
+ preview.textContent = "Open preview";
852
+ const wrap = document.createElement("span");
853
+ wrap.className = "subtle";
854
+ wrap.append(preview);
855
+ idCell.append(wrap);
856
+ }
857
+ tr.append(idCell);
858
+
859
+ // Reconcile status.
860
+ const statusCell = cell(
861
+ chip(change.status || "unknown", STATUS_CHIP[change.status] ?? "amber"),
862
+ );
863
+ if (change.lastError) statusCell.append(subtle(change.lastError));
864
+ tr.append(statusCell);
865
+
866
+ // Evidence / compliance verdict.
867
+ const checks = (change.evidence && change.evidence.checks) || [];
868
+ const failing = checks.filter((c) => c.status === "fail");
869
+ const promotable = Boolean(change.evidence && change.evidence.promotable);
870
+ const checksCell = cell(
871
+ promotable ? chip("Checks passed", "green") : chip("Checks blocked", "amber"),
872
+ );
873
+ if (failing.length) {
874
+ checksCell.append(subtle("Failing: " + failing.map((c) => c.label || c.id).join(", ")));
875
+ } else if (!checks.length) {
876
+ checksCell.append(subtle("No checks reported yet"));
877
+ }
878
+ tr.append(checksCell);
879
+
880
+ // Head sha + age.
881
+ const headCell = cell(shortSha(change.headSha));
882
+ headCell.classList.add("publish-mono");
883
+ tr.append(headCell);
884
+ tr.append(cell(age(change.updatedAt)));
885
+
886
+ // Actions.
887
+ const actions = document.createElement("td");
888
+ actions.className = "action publish-actions";
889
+
890
+ // rux5 — a synthetic not-built forge PR row (built:false / status
891
+ // "not-built") has NO built candidate: changeId is null, so the built-only
892
+ // actions below (Diff/Accept/Reject/Retire, all keyed on changeId) do not
893
+ // apply. Its one action is Build preview — materialize the PR's hosted
894
+ // preview on demand. Capability-gated: the button is SHOWN only to a
895
+ // ship-capable operator, and the server (buildGate) re-authorizes — the UI
896
+ // check is UX, not the real gate.
897
+ if (change.built === false || change.status === "not-built") {
898
+ if (canShip) {
899
+ const build = document.createElement("button");
900
+ build.type = "button";
901
+ build.className = "btn primary";
902
+ build.title = "Materialize this PR's hosted preview on demand (does not go live)";
903
+ // rux7: if a build for this PR is mid-flight, a poll re-render must
904
+ // reflect the spinner/disabled state (not a fresh clickable button
905
+ // that would let the operator double-submit). The in-flight closure
906
+ // re-renders on completion, clearing this.
907
+ if (change.prNumber != null && inflightBuilds.has(change.prNumber)) {
908
+ build.textContent = "Building…";
909
+ build.disabled = true;
910
+ build.dataset.building = "true";
911
+ } else {
912
+ build.textContent = "Build preview";
913
+ }
914
+ build.addEventListener("click", () => buildPreview(change, build));
915
+ actions.append(build);
916
+ } else {
917
+ actions.append(subtle("Owner/admin sign-in required to build"));
918
+ }
919
+ tr.append(actions);
920
+ queueBody.append(tr);
921
+ continue;
922
+ }
923
+
924
+ // rux7: while an integrate/reject/retire for this change is in flight,
925
+ // keep its mutating actions disabled across a poll re-render so the
926
+ // operator can't double-submit against a freshly-minted button.
927
+ const changeBusy = busyChanges.has(change.changeId);
928
+
929
+ // rux6 Review: expand the row into a single "review moment" pane —
930
+ // preview iframe + evidence chips + diff + Accept/Reject — so a reviewer
931
+ // never leaves the queue. Supersedes the standalone Diff button (the dg7
932
+ // diff now lives inside the pane, reusing renderDiff). The row's own
933
+ // Accept/Reject/Retire buttons below are unchanged.
934
+ const reviewBtn = document.createElement("button");
935
+ reviewBtn.type = "button";
936
+ reviewBtn.className = "btn";
937
+ reviewBtn.textContent = "Review";
938
+ reviewBtn.setAttribute("aria-expanded", "false");
939
+ reviewBtn.title = "Open one pane to preview, diff, and act on this change";
940
+ reviewBtn.addEventListener("click", () => toggleReview(change, reviewBtn, tr));
941
+ actions.append(reviewBtn);
942
+ // b11 SPLIT: the candidate-queue primary action is now ACCEPT TO PREVIEW
943
+ // (b08 integrate = advance the shared preview aggregate), NOT go-live.
944
+ // Integrate flips NO live channel, so it has NO paywall (entitled) gate —
945
+ // it holds only the ship-on-behalf capability floor. Going live is the
946
+ // aggregate card's Publish action, once the aggregate is green.
947
+ const accept = document.createElement("button");
948
+ accept.type = "button";
949
+ accept.className = "btn primary";
950
+ accept.textContent = "Accept to preview";
951
+ accept.disabled = !canShip || !change.mergeable || changeBusy;
952
+ accept.title = !canShip
953
+ ? "Requires an owner or admin sign-in"
954
+ : !change.mergeable
955
+ ? "This change is not ready to integrate"
956
+ : "Integrate this change into the preview aggregate (does not go live)";
957
+ accept.addEventListener("click", () => integrateCandidate(change, accept));
958
+ actions.append(accept);
959
+ const reject = document.createElement("button");
960
+ reject.type = "button";
961
+ reject.className = "btn";
962
+ reject.textContent = "Reject";
963
+ reject.disabled = !canShip || changeBusy;
964
+ reject.title = canShip ? "Close this change without publishing" : "Requires an owner or admin sign-in";
965
+ reject.addEventListener("click", () => rejectChange(change, reject));
966
+ actions.append(reject);
967
+
968
+ // U7 Retire/GC: evict this candidate's preview environment + version to
969
+ // reclaim space. DISTINCT from Reject — retire is reversible-by-rebuild
970
+ // (U1 build-on-demand + the U2 fallback page rematerialize it), so a
971
+ // retired PR degrades to "not built yet", not a dead 404.
972
+ const retire = document.createElement("button");
973
+ retire.type = "button";
974
+ retire.className = "btn";
975
+ retire.textContent = "Retire";
976
+ retire.disabled = !canShip || changeBusy;
977
+ retire.title = canShip
978
+ ? "Evict this preview to reclaim space — rebuildable on demand (not the same as Reject)"
979
+ : "Requires an owner or admin sign-in";
980
+ retire.addEventListener("click", () => retireChange(change, retire));
981
+ actions.append(retire);
982
+ tr.append(actions);
983
+
984
+ queueBody.append(tr);
985
+
986
+ // rux7: re-attach a diff pane that was open before this re-render. The
987
+ // pane node carries its already-fetched content; restore the expanded
988
+ // state + the "Hide diff" toggle affordance so a poll never collapses it.
989
+ const openDiff = openDiffNodes.get(change.changeId);
990
+ if (openDiff) {
991
+ tr.dataset.changeRowId = change.changeId;
992
+ diffBtn.textContent = "Hide diff";
993
+ tr.after(openDiff);
994
+ }
995
+ }
996
+ };
997
+
998
+ const renderCurrent = (current, shippedBy) => {
999
+ const versionEl = root.querySelector("[data-publish-live-version]");
1000
+ const sourceEl = root.querySelector("[data-publish-live-source]");
1001
+ const atEl = root.querySelector("[data-publish-live-at]");
1002
+ const shippedByEl = root.querySelector("[data-publish-live-shipped-by]");
1003
+ if (current && current.versionId) {
1004
+ if (versionEl) versionEl.textContent = shortSha(current.versionId);
1005
+ if (sourceEl) {
1006
+ sourceEl.textContent =
1007
+ (current.source === "commit" ? "Published from a change" : "Published version") +
1008
+ (current.artifactCount != null ? " · " + current.artifactCount + " files" : "");
1009
+ }
1010
+ if (atEl) atEl.textContent = current.createdAt ? age(current.createdAt) : "—";
1011
+ // Owner-facing "who did this" — see changeActorAttribution.ts. Absent for
1012
+ // any version published before this attribution existed, which is honest,
1013
+ // not a bug.
1014
+ if (shippedByEl) {
1015
+ shippedByEl.textContent = shippedBy ? "Shipped by " + (shippedBy.name || shippedBy.email) : "";
1016
+ }
1017
+ } else {
1018
+ if (versionEl) versionEl.textContent = "Not live yet";
1019
+ if (sourceEl) sourceEl.textContent = "Nothing has been published for this store";
1020
+ if (atEl) atEl.textContent = "—";
1021
+ if (shippedByEl) shippedByEl.textContent = "";
1022
+ }
1023
+ };
1024
+
1025
+ // ---- rux7: auto-refresh (poll GET /api/changes) ------------------------
1026
+ // Keep the queue + aggregate live without a manual reload: poll on an
1027
+ // interval that BACKS OFF while nothing changes (grow the delay up to a cap)
1028
+ // and SNAPS BACK to the base the moment something does. Pause entirely while
1029
+ // the tab is hidden (document.hidden) — a backgrounded admin tab must not
1030
+ // hammer the endpoint — and refresh promptly on return. A poll re-render
1031
+ // reuses renderQueue/renderAggregate, which already preserve open diff panes
1032
+ // and in-flight actions (inflightBuilds / busyChanges / aggregatePublishing),
1033
+ // and applyPrDeeplink stays a one-time effect (prDeeplinkResolved), so the
1034
+ // ?pr scroll never re-fires on a tick.
1035
+ const POLL_BASE_MS = 8000;
1036
+ const POLL_MAX_MS = 60000;
1037
+ const POLL_GROWTH = 1.6;
1038
+ let pollDelay = POLL_BASE_MS;
1039
+ let pollTimer = null;
1040
+ let pollBusy = false;
1041
+ let lastChangesSignature = null;
1042
+
1043
+ // A compact projection of everything the queue/aggregate render VISIBLY off,
1044
+ // so a no-op poll (identical data) neither re-renders nor interrupts a text
1045
+ // selection — it just backs off. Relative ages are intentionally excluded;
1046
+ // they drift at most one backoff interval and refresh on any real change.
1047
+ const changesSignature = (data) => {
1048
+ const cur = data.current || {};
1049
+ const agg = data.aggregate || {};
1050
+ const run = agg.activeRun || agg.lastRun || {};
1051
+ const plan = data.plan || {};
1052
+ const rows = (Array.isArray(data.changes) ? data.changes : []).map((c) => [
1053
+ c.changeId,
1054
+ c.prNumber,
1055
+ c.status,
1056
+ c.headSha,
1057
+ c.mergeable,
1058
+ c.built,
1059
+ c.lastError || null,
1060
+ !!(c.evidence && c.evidence.promotable),
1061
+ c.actors ? Object.keys(c.actors).sort().join(",") : "",
1062
+ ]);
1063
+ return JSON.stringify({
1064
+ degraded: data.degraded === true,
1065
+ current: [cur.versionId || null, cur.createdAt || null, cur.artifactCount ?? null],
1066
+ shippedBy: (data.shippedBy && data.shippedBy.email) || null,
1067
+ aggregate: [
1068
+ agg.queueState || null,
1069
+ agg.aggregateSha || null,
1070
+ run.state || null,
1071
+ run.finishedAt || null,
1072
+ (agg.includedPrs || []).length,
1073
+ ],
1074
+ plan: [plan.ok === true, plan.pinnedSha || null, plan.artifactDigest || null, plan.alreadyShipped === true],
1075
+ rows,
1076
+ });
1077
+ };
1078
+
1079
+ const clearPoll = () => {
1080
+ if (pollTimer) {
1081
+ clearTimeout(pollTimer);
1082
+ pollTimer = null;
1083
+ }
1084
+ };
1085
+
1086
+ const schedulePoll = () => {
1087
+ clearPoll();
1088
+ if (document.hidden) return; // paused while hidden; visibilitychange resumes
1089
+ pollTimer = setTimeout(runPoll, pollDelay);
1090
+ };
1091
+
1092
+ // One poll tick: fetch, and only re-render when the projection actually
1093
+ // changed. Returns an outcome the caller uses to size the next interval.
1094
+ const pollChanges = async () => {
1095
+ let res;
1096
+ try {
1097
+ res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1098
+ } catch {
1099
+ return "error"; // network blip — keep the last good view, just back off
1100
+ }
1101
+ if (res.status === 401 || res.status === 403) return "auth"; // session gone
1102
+ const data = await res.json().catch(() => null);
1103
+ if (!res.ok || !data) return "error";
1104
+ const sig = changesSignature(data);
1105
+ if (sig === lastChangesSignature) return "unchanged";
1106
+ lastChangesSignature = sig;
1107
+ renderCurrent(data.current, data.shippedBy);
1108
+ renderQueue(data);
1109
+ applyPrDeeplink();
1110
+ renderAggregate(data);
1111
+ return "changed";
1112
+ };
1113
+
1114
+ const runPoll = async () => {
1115
+ pollTimer = null;
1116
+ if (document.hidden || pollBusy) {
1117
+ schedulePoll();
1118
+ return;
1119
+ }
1120
+ pollBusy = true;
1121
+ let outcome;
1122
+ try {
1123
+ outcome = await pollChanges();
1124
+ } finally {
1125
+ pollBusy = false;
1126
+ }
1127
+ if (outcome === "auth") {
1128
+ clearPoll(); // stop polling — a sign-in is required; a manual refresh retries
1129
+ return;
1130
+ }
1131
+ pollDelay =
1132
+ outcome === "changed"
1133
+ ? POLL_BASE_MS
1134
+ : Math.min(POLL_MAX_MS, Math.round(pollDelay * POLL_GROWTH));
1135
+ schedulePoll();
1136
+ };
1137
+
1138
+ const loadQueue = async () => {
1139
+ try {
1140
+ const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1141
+ if (res.status === 401 || res.status === 403) {
1142
+ if (queueChip) queueChip.textContent = "Sign-in required";
1143
+ if (degradedStrip) degradedStrip.hidden = true;
1144
+ queueBody.textContent = "";
1145
+ const tr = document.createElement("tr");
1146
+ const td = cell(subtle("Sign in as a store owner or admin to view the publish queue."));
1147
+ td.colSpan = 6;
1148
+ tr.append(td);
1149
+ queueBody.append(tr);
1150
+ return;
1151
+ }
1152
+ const data = await res.json();
1153
+ if (!res.ok) {
1154
+ setStatus(statusEl, data.error || "Could not load the change queue (" + res.status + ")", "err");
1155
+ return;
1156
+ }
1157
+ renderCurrent(data.current, data.shippedBy);
1158
+ renderQueue(data);
1159
+ applyPrDeeplink();
1160
+ renderAggregate(data);
1161
+ // rux7: this is fresh authoritative data — resync the poll clock so the
1162
+ // next tick dedupes against it, and reset the cadence to responsive
1163
+ // (a manual refresh / a just-completed action likely means more coming).
1164
+ lastChangesSignature = changesSignature(data);
1165
+ pollDelay = POLL_BASE_MS;
1166
+ schedulePoll();
1167
+ } catch {
1168
+ setStatus(statusEl, "Could not load the change queue — network error", "err");
1169
+ }
1170
+ };
1171
+
1172
+ // ---- b11: the aggregate card + exact-digest Publish ---------------------
1173
+ // Reads the aggregate/runs/plan projection GET /api/changes now returns
1174
+ // (AggregateCardView + AggregateShipPlanView, built by aggregateView.ts):
1175
+ // - the aggregate's queueState + build sha + reviewed digest,
1176
+ // - the COMBINED EVIDENCE = the last integration run's honest state +
1177
+ // statusMessage (never re-derived),
1178
+ // - the included-PR batch,
1179
+ // - the b09 ship PLAN (pinned SHA/digest/PRs/rollback/paywall + run link),
1180
+ // shown inline so the confirm never has to POST to preview it.
1181
+
1182
+ const aggPanel = root.querySelector("[data-publish-aggregate]");
1183
+ const AGG_STATE_CHIP = {
1184
+ empty: "",
1185
+ queued: "amber",
1186
+ integrating: "blue",
1187
+ green: "green",
1188
+ red: "red",
1189
+ shipped: "green",
1190
+ };
1191
+ // The plan we last rendered — the exact-digest guard the Publish POST sends
1192
+ // back so a stale plan (queue moved / artifact changed) is refused, never
1193
+ // shipped blind.
1194
+ let currentPlan = null;
1195
+
1196
+ const runLink = (revHref, label) => {
1197
+ if (!revHref) return null;
1198
+ const a = document.createElement("a");
1199
+ a.href = revHref;
1200
+ a.target = "_blank";
1201
+ a.rel = "noopener";
1202
+ a.textContent = label || "Open build";
1203
+ return a;
1204
+ };
1205
+
1206
+ const renderAggregate = (data) => {
1207
+ if (!aggPanel) return;
1208
+ const agg = data.aggregate || null;
1209
+ const plan = data.plan || null;
1210
+ currentPlan = plan && plan.ok ? plan : null;
1211
+
1212
+ const chipEl = aggPanel.querySelector("[data-aggregate-chip]");
1213
+ const shaEl = aggPanel.querySelector("[data-aggregate-sha]");
1214
+ const runLinkEl = aggPanel.querySelector("[data-aggregate-run-link]");
1215
+ const evEl = aggPanel.querySelector("[data-aggregate-evidence]");
1216
+ const evDetailEl = aggPanel.querySelector("[data-aggregate-evidence-detail]");
1217
+ const digestEl = aggPanel.querySelector("[data-aggregate-digest]");
1218
+ const prsWrap = aggPanel.querySelector("[data-aggregate-prs-wrap]");
1219
+ const prsList = aggPanel.querySelector("[data-aggregate-prs]");
1220
+ const publishBtn = aggPanel.querySelector("[data-aggregate-publish]");
1221
+ const publishDetail = aggPanel.querySelector("[data-aggregate-publish-detail]");
1222
+
1223
+ const state = (agg && agg.queueState) || "empty";
1224
+ if (chipEl) {
1225
+ chipEl.textContent = state;
1226
+ chipEl.className = "chip " + (AGG_STATE_CHIP[state] || "");
1227
+ }
1228
+ if (shaEl) shaEl.textContent = agg && agg.aggregateSha ? shortSha(agg.aggregateSha) : "—";
1229
+ if (runLinkEl) {
1230
+ runLinkEl.textContent = "";
1231
+ const link = agg && runLink(agg.revHref, "Open build");
1232
+ if (link) runLinkEl.append(link);
1233
+ else runLinkEl.append(document.createTextNode("The current green preview build for this store."));
1234
+ }
1235
+
1236
+ // Combined evidence = the last (or in-flight) integration run's honest state.
1237
+ const lastRun = agg && (agg.activeRun || agg.lastRun);
1238
+ if (evEl) {
1239
+ if (!lastRun) {
1240
+ evEl.textContent = "No runs yet";
1241
+ } else if (lastRun.state === "passed") {
1242
+ evEl.textContent = "Passed";
1243
+ } else if (lastRun.state === "failed") {
1244
+ evEl.textContent = "Failed";
1245
+ } else {
1246
+ evEl.textContent = "Integrating…";
1247
+ }
1248
+ }
1249
+ if (evDetailEl) {
1250
+ evDetailEl.textContent = "";
1251
+ if (lastRun) {
1252
+ const parts = [];
1253
+ if (lastRun.statusMessage) parts.push(lastRun.statusMessage);
1254
+ else if (lastRun.state === "passed") parts.push("Combined evidence passed.");
1255
+ else if (lastRun.state === "failed") parts.push("Combined evidence did not pass.");
1256
+ if (lastRun.finishedAt) parts.push(age(lastRun.finishedAt));
1257
+ evDetailEl.append(document.createTextNode(parts.join(" · ")));
1258
+ const rl = runLink(lastRun.revHref, "Run build");
1259
+ if (rl) {
1260
+ evDetailEl.append(document.createTextNode(" · "));
1261
+ evDetailEl.append(rl);
1262
+ }
1263
+ } else {
1264
+ evDetailEl.append(document.createTextNode("The last integration run's verdict."));
1265
+ }
1266
+ }
1267
+
1268
+ // Reviewed digest: only known once the plan pins it (b09). Never fabricated.
1269
+ if (digestEl) {
1270
+ digestEl.textContent = currentPlan ? shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) : "—";
1271
+ }
1272
+
1273
+ // Included PRs.
1274
+ const prs = (agg && agg.includedPrs) || [];
1275
+ if (prsWrap) prsWrap.hidden = prs.length === 0;
1276
+ if (prsList) {
1277
+ prsList.textContent = "";
1278
+ for (const pr of prs) {
1279
+ const li = document.createElement("li");
1280
+ const label = document.createElement("span");
1281
+ label.textContent = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
1282
+ const sha = document.createElement("span");
1283
+ sha.className = "publish-mono subtle";
1284
+ sha.textContent = shortSha(pr.headSha);
1285
+ li.append(label, sha);
1286
+ prsList.append(li);
1287
+ }
1288
+ }
1289
+
1290
+ renderPlan(data.plan);
1291
+
1292
+ // The Publish button: enabled only for a shippable, paywall-cleared plan and
1293
+ // a viewer who can ship. A paywalled plan is SHOWN (upsell), never hidden.
1294
+ if (publishBtn) {
1295
+ const shippable = Boolean(currentPlan);
1296
+ const paywalled = currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed;
1297
+ // rux7: never re-enable the Publish button while its POST is in flight —
1298
+ // a poll refresh landing mid-publish must not open a double-submit.
1299
+ publishBtn.disabled =
1300
+ aggregatePublishing || !canShip || !shippable || paywalled || currentPlan.alreadyShipped;
1301
+ publishBtn.title = !canShip
1302
+ ? "Requires an owner or admin sign-in"
1303
+ : !shippable
1304
+ ? "The aggregate is not green / has no passed run to publish yet"
1305
+ : paywalled
1306
+ ? "Publishing to your live store is part of the paid plan"
1307
+ : currentPlan.alreadyShipped
1308
+ ? "This reviewed build is already live"
1309
+ : "Publish the exact reviewed digest to your live store";
1310
+ }
1311
+ if (publishDetail) {
1312
+ if (currentPlan && currentPlan.alreadyShipped) {
1313
+ publishDetail.textContent =
1314
+ "This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live. ";
1315
+ const view = document.createElement("a");
1316
+ view.href = ds.liveHref || "/";
1317
+ view.target = "_blank";
1318
+ view.rel = "noopener";
1319
+ view.textContent = "View your live store →";
1320
+ publishDetail.append(view);
1321
+ } else if (currentPlan) {
1322
+ publishDetail.textContent =
1323
+ "Publishes the exact reviewed digest " + shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) +
1324
+ " (build " + shortSha(currentPlan.pinnedSha) + ") — no rebuild.";
1325
+ } else {
1326
+ publishDetail.textContent =
1327
+ "Publishes the exact reviewed digest — no rebuild. Available once the aggregate is green and its integration run passed.";
1328
+ }
1329
+ }
1330
+ };
1331
+
1332
+ const renderPlan = (plan) => {
1333
+ const planBox = aggPanel && aggPanel.querySelector("[data-aggregate-plan]");
1334
+ if (!planBox) return;
1335
+ const chipEl = planBox.querySelector("[data-aggregate-plan-chip]");
1336
+ const grid = planBox.querySelector("[data-aggregate-plan-grid]");
1337
+ const note = planBox.querySelector("[data-aggregate-plan-note]");
1338
+ if (grid) grid.textContent = "";
1339
+
1340
+ // No plan at all (degenerate: no store bound) — hide the box.
1341
+ if (!plan) {
1342
+ planBox.hidden = true;
1343
+ return;
1344
+ }
1345
+ planBox.hidden = false;
1346
+
1347
+ if (!plan.ok) {
1348
+ // An honest refusal — show WHY it isn't shippable, never a fake plan.
1349
+ if (chipEl) {
1350
+ chipEl.textContent = "Not shippable";
1351
+ chipEl.className = "chip amber";
1352
+ }
1353
+ if (note) note.textContent = plan.message || ("Not shippable (" + (plan.reason || "refused") + ").");
1354
+ return;
1355
+ }
1356
+
1357
+ const paywalled = plan.paywall && !plan.paywall.allowed;
1358
+ if (chipEl) {
1359
+ chipEl.textContent = plan.alreadyShipped ? "Already live" : paywalled ? "Upgrade required" : "Ready to publish";
1360
+ chipEl.className = "chip " + (plan.alreadyShipped ? "green" : paywalled ? "amber" : "green");
1361
+ }
1362
+
1363
+ const row = (term, value, mono) => {
1364
+ const dt = document.createElement("dt");
1365
+ dt.textContent = term;
1366
+ const dd = document.createElement("dd");
1367
+ if (value instanceof Node) dd.append(value);
1368
+ else {
1369
+ dd.textContent = value;
1370
+ if (mono) dd.className = "publish-mono";
1371
+ }
1372
+ grid.append(dt, dd);
1373
+ };
1374
+
1375
+ row("Reviewed build", shortSha(plan.pinnedSha), true);
1376
+ row("Reviewed digest", shortSha(plan.artifactDigest.replace(/^sha256:/, "")), true);
1377
+ row("Included changes", String((plan.includedPrs || []).length) + " change" + ((plan.includedPrs || []).length === 1 ? "" : "s"));
1378
+ const runCell = document.createElement("span");
1379
+ runCell.append(document.createTextNode(plan.integrationRunId));
1380
+ const planRunLink = runLink(plan.pinnedRevHref, "Open build");
1381
+ if (planRunLink) {
1382
+ runCell.append(document.createTextNode(" · "));
1383
+ runCell.append(planRunLink);
1384
+ }
1385
+ row("Integration run", runCell);
1386
+ if (plan.rollbackTarget) {
1387
+ row("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true);
1388
+ } else {
1389
+ row("Rollback to", "None (first publish)");
1390
+ }
1391
+ row("Subscription", plan.paywall && plan.paywall.allowed ? "Enabled" : "Not enabled");
1392
+
1393
+ if (note) {
1394
+ note.textContent = paywalled
1395
+ ? (plan.paywall.message || "Publishing to your live store requires the paid plan.")
1396
+ : plan.alreadyShipped
1397
+ ? "This reviewed build is already live — nothing to publish."
1398
+ : "Publish sends the exact reviewed digest above to your live store. No rebuild.";
1399
+ }
1400
+ };
1401
+
1402
+ // The exact-digest Publish (b10 ship = b09 orchestrator.ship). Confirms the
1403
+ // pinned plan, then POSTs { confirmGoLive, expectedAggregateSha,
1404
+ // expectedArtifactDigest } — the reviewed-digest guard: a stale plan (queue
1405
+ // moved / artifact changed) is refused by the server, never shipped blind.
1406
+ // ── Publish-live ceremony (shipoff) ──────────────────────────────────────
1407
+ // The go-live counterpart of the accept liftoff below (shares its shell,
1408
+ // styles, planRow + celebrate helpers — referenced at runtime, defined a few
1409
+ // sections down). One honest in-flight state: ship is a single POST that
1410
+ // promotes the pinned digest, so no staged theater — just plan → publishing →
1411
+ // outcome with a real "View your live store" link.
1412
+ const shipoff = root.querySelector("[data-shipoff]");
1413
+ const shipoffCard = shipoff && shipoff.querySelector(".liftoff-card");
1414
+ const shipoffEls = shipoff
1415
+ ? {
1416
+ plan: shipoff.querySelector("[data-shipoff-plan]"),
1417
+ flight: shipoff.querySelector("[data-shipoff-flight]"),
1418
+ outcome: shipoff.querySelector("[data-shipoff-outcome]"),
1419
+ grid: shipoff.querySelector("[data-shipoff-grid]"),
1420
+ proceed: shipoff.querySelector("[data-shipoff-proceed]"),
1421
+ cancel: shipoff.querySelector("[data-shipoff-cancel]"),
1422
+ live: shipoff.querySelector("[data-shipoff-live]"),
1423
+ elapsed: shipoff.querySelector("[data-shipoff-elapsed]"),
1424
+ seal: shipoff.querySelector("[data-shipoff-seal]"),
1425
+ outcomeTitle: shipoff.querySelector("[data-shipoff-outcome-title]"),
1426
+ outcomeDetail: shipoff.querySelector("[data-shipoff-outcome-detail]"),
1427
+ view: shipoff.querySelector("[data-shipoff-view]"),
1428
+ done: shipoff.querySelector("[data-shipoff-done]"),
1429
+ confetti: shipoff.querySelector("[data-shipoff-confetti]"),
1430
+ }
1431
+ : null;
1432
+ let shipoffTimer = null;
1433
+ const closeShipoff = () => {
1434
+ if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1435
+ if (shipoff) shipoff.hidden = true;
1436
+ if (shipoffEls) shipoffEls.confetti.textContent = "";
1437
+ };
1438
+ const showShipoffPhase = (phase) => {
1439
+ if (!shipoffEls) return;
1440
+ shipoffEls.plan.hidden = phase !== "plan";
1441
+ shipoffEls.flight.hidden = phase !== "flight";
1442
+ shipoffEls.outcome.hidden = phase !== "outcome";
1443
+ if (shipoffCard) shipoffCard.dataset.mode = phase;
1444
+ };
1445
+
1446
+ const buildPublishPlanText = (plan) => {
1447
+ const lines = ["Publish plan (exact reviewed digest — no rebuild):"];
1448
+ lines.push(" tenant: " + (appDomain || repo));
1449
+ lines.push(" build: " + shortSha(plan.pinnedSha));
1450
+ lines.push(" digest: " + plan.artifactDigest);
1451
+ lines.push(" changes: " + (plan.includedPrs || []).length + " included");
1452
+ if (plan.rollbackTarget) lines.push(" rollback: " + shortSha(plan.rollbackTarget.aggregateSha));
1453
+ lines.push(" effect: publish this exact digest to your LIVE store.");
1454
+ return lines.join("\n");
1455
+ };
1456
+
1457
+ const shortDigest = (digest) => {
1458
+ const d = String(digest || "").replace(/^sha256:/, "");
1459
+ return d ? "sha256:" + d.slice(0, 12) + "…" : "—";
1460
+ };
1461
+
1462
+ const publishAggregate = (button) => {
1463
+ if (!currentPlan) return;
1464
+ const plan = currentPlan;
1465
+ if (!shipoff || !shipoffEls) {
1466
+ // No-markup fallback only — the ceremony IS the confirm now.
1467
+ if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
1468
+ runShip(plan, button);
1469
+ return;
1470
+ }
1471
+ shipoffEls.grid.textContent = "";
1472
+ shipoffEls.grid.append(
1473
+ ...planRow("Store", appDomain || repo),
1474
+ ...planRow("Build", shortSha(plan.pinnedSha), true),
1475
+ ...planRow("Digest", shortDigest(plan.artifactDigest), true),
1476
+ ...planRow("Changes", (plan.includedPrs || []).length + " included"),
1477
+ ...(plan.rollbackTarget
1478
+ ? planRow("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true)
1479
+ : []),
1480
+ );
1481
+ showShipoffPhase("plan");
1482
+ shipoff.hidden = false;
1483
+ shipoffEls.proceed.focus();
1484
+ shipoffEls.cancel.onclick = () => closeShipoff();
1485
+ shipoffEls.done.onclick = () => closeShipoff();
1486
+ shipoffEls.proceed.onclick = () => runShip(plan, button);
1487
+ };
1488
+
1489
+ const runShip = async (plan, button) => {
1490
+ if (button) button.disabled = true;
1491
+ aggregatePublishing = true; // rux7: hold the button disabled across polls
1492
+ setStatus(aggStatusEl, "Publishing the reviewed build (" + shortSha(plan.pinnedSha) + ") live…");
1493
+ const startedAt = Date.now();
1494
+ if (shipoffEls) {
1495
+ showShipoffPhase("flight");
1496
+ shipoffTimer = setInterval(() => {
1497
+ const s = Math.floor((Date.now() - startedAt) / 1000);
1498
+ shipoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
1499
+ }, 1000);
1500
+ }
1501
+ const outcome = (state, title, detail, showView) => {
1502
+ if (!shipoffEls) return;
1503
+ if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1504
+ shipoffEls.seal.dataset.state = state;
1505
+ shipoffEls.outcomeTitle.textContent = title;
1506
+ shipoffEls.outcomeDetail.textContent = detail;
1507
+ shipoffEls.view.hidden = !showView;
1508
+ if (showView) shipoffEls.view.href = ds.liveHref || "/";
1509
+ showShipoffPhase("outcome");
1510
+ shipoffEls.done.focus();
1511
+ if (state === "ok") celebrate(shipoffEls.confetti);
1512
+ };
1513
+ try {
1514
+ const res = await fetch(ds.shipEndpoint, {
1515
+ method: "POST",
1516
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
1517
+ body: JSON.stringify({
1518
+ aggregateId: plan.aggregateId,
1519
+ // The HUMAN go-live gate + the reviewed-digest guards (b09 ship input).
1520
+ confirmGoLive: true,
1521
+ expectedAggregateSha: plan.pinnedSha,
1522
+ expectedArtifactDigest: plan.artifactDigest,
1523
+ }),
1524
+ });
1525
+ const data = await res.json().catch(() => ({}));
1526
+ // b09 AggregateShipResult: the ONLY live claim is state === "shipped".
1527
+ if (data.state === "shipped") {
1528
+ setStatus(
1529
+ aggStatusEl,
1530
+ "Published live · build " + shortSha(data.pinnedSha) + " (receipt " + (data.receiptId || "—") + ").",
1531
+ "ok",
1532
+ );
1533
+ outcome(
1534
+ "ok",
1535
+ "Your store is live",
1536
+ "Build " + shortSha(data.pinnedSha) + " — the exact digest you reviewed — is now serving" +
1537
+ (data.receiptId ? " (receipt " + data.receiptId + ")." : "."),
1538
+ true,
1539
+ );
1540
+ } else {
1541
+ let message;
1542
+ if (data.state === "promote_failed" || data.state === "record_failed") {
1543
+ // Honest partial-failure truth — recoverable, never a false "shipped".
1544
+ message = data.message || "Publish did not complete (" + data.state + "). Retry.";
1545
+ } else if (data.reason === "paywall") {
1546
+ message = data.message || "Publishing to your live store is part of the paid plan.";
1547
+ } else {
1548
+ message = data.message || data.error || "Publish refused (" + (data.reason || res.status) + ")";
1549
+ }
1550
+ setStatus(aggStatusEl, message, "err");
1551
+ outcome("err", "Publish did not complete", message + " Your live store is unchanged.", false);
1552
+ }
1553
+ } catch {
1554
+ setStatus(aggStatusEl, "Publish failed — network error", "err");
1555
+ outcome(
1556
+ "err",
1557
+ "Publish did not complete",
1558
+ "Network error mid-publish — refresh to see the honest state; your live store serves the last published version.",
1559
+ false,
1560
+ );
1561
+ } finally {
1562
+ aggregatePublishing = false;
1563
+ if (button) button.disabled = false;
1564
+ loadQueue();
1565
+ loadVersions();
1566
+ }
1567
+ };
1568
+
1569
+ const aggStatusEl = aggPanel && aggPanel.querySelector("[data-aggregate-status]");
1570
+ aggPanel
1571
+ ?.querySelector("[data-aggregate-publish]")
1572
+ ?.addEventListener("click", (event) => publishAggregate(event.currentTarget));
1573
+
1574
+ // ── Accept-to-preview LIFTOFF ─────────────────────────────────────────────
1575
+ // The plan confirm + live integration flight + outcome ceremony (replaces
1576
+ // the old window.confirm plan popup). Same b11 contract as before: accept =
1577
+ // `POST /api/changes/integrate` (b08) → integrate into the protected
1578
+ // `preview` aggregate, rebuild + combined-evidence it (b05). Flips NO live
1579
+ // channel — going live is the aggregate card's Publish.
1580
+ //
1581
+ // HONESTY RULE for the stage rail: a stage only advances on a REAL signal —
1582
+ // the outcome POST resolving, or GET /api/changes polling showing the
1583
+ // candidate inside `aggregate.includedPrs` (merge landed) / an `activeRun`
1584
+ // (build+evidence running, statusMessage relayed verbatim). No timers ever
1585
+ // fake progress.
1586
+ const liftoff = root.querySelector("[data-liftoff]");
1587
+ const liftoffCard = liftoff && liftoff.querySelector(".liftoff-card");
1588
+ const liftoffEls = liftoff
1589
+ ? {
1590
+ plan: liftoff.querySelector("[data-liftoff-plan]"),
1591
+ flight: liftoff.querySelector("[data-liftoff-flight]"),
1592
+ outcome: liftoff.querySelector("[data-liftoff-outcome]"),
1593
+ title: liftoff.querySelector("[data-liftoff-title]"),
1594
+ grid: liftoff.querySelector("[data-liftoff-grid]"),
1595
+ proceed: liftoff.querySelector("[data-liftoff-proceed]"),
1596
+ cancel: liftoff.querySelector("[data-liftoff-cancel]"),
1597
+ flightTitle: liftoff.querySelector("[data-liftoff-flight-title]"),
1598
+ live: liftoff.querySelector("[data-liftoff-live]"),
1599
+ elapsed: liftoff.querySelector("[data-liftoff-elapsed]"),
1600
+ hide: liftoff.querySelector("[data-liftoff-hide]"),
1601
+ seal: liftoff.querySelector("[data-liftoff-seal]"),
1602
+ outcomeTitle: liftoff.querySelector("[data-liftoff-outcome-title]"),
1603
+ outcomeDetail: liftoff.querySelector("[data-liftoff-outcome-detail]"),
1604
+ review: liftoff.querySelector("[data-liftoff-review]"),
1605
+ done: liftoff.querySelector("[data-liftoff-done]"),
1606
+ confetti: liftoff.querySelector("[data-liftoff-confetti]"),
1607
+ }
1608
+ : null;
1609
+
1610
+ const liftoffStage = (name) =>
1611
+ liftoff && liftoff.querySelector('[data-liftoff-stage="' + name + '"]');
1612
+ const LIFTOFF_STAGES = ["merge", "build", "evidence", "green"];
1613
+ const setStage = (name, state) => {
1614
+ const el = liftoffStage(name);
1615
+ if (el) el.dataset.state = state; // pending | active | done | fail
1616
+ };
1617
+ // Advance the rail so everything before `name` is done and `name` is active.
1618
+ const railTo = (name) => {
1619
+ const idx = LIFTOFF_STAGES.indexOf(name);
1620
+ LIFTOFF_STAGES.forEach((s, i) => {
1621
+ const el = liftoffStage(s);
1622
+ const cur = el && el.dataset.state;
1623
+ if (cur === "fail") return; // never un-fail
1624
+ setStage(s, i < idx ? "done" : i === idx ? "active" : "pending");
1625
+ });
1626
+ };
1627
+
1628
+ let liftoffTimer = null;
1629
+ let liftoffPoll = null;
1630
+ let liftoffOpenedFrom = null;
1631
+ const stopLiftoffLoops = () => {
1632
+ if (liftoffTimer) { clearInterval(liftoffTimer); liftoffTimer = null; }
1633
+ if (liftoffPoll) { clearInterval(liftoffPoll); liftoffPoll = null; }
1634
+ };
1635
+ const closeLiftoff = () => {
1636
+ stopLiftoffLoops();
1637
+ if (liftoff) liftoff.hidden = true;
1638
+ if (liftoffEls) liftoffEls.confetti.textContent = "";
1639
+ if (liftoffOpenedFrom && document.contains(liftoffOpenedFrom)) liftoffOpenedFrom.focus();
1640
+ liftoffOpenedFrom = null;
1641
+ };
1642
+ const showPhase = (phase) => {
1643
+ if (!liftoffEls) return;
1644
+ liftoffEls.plan.hidden = phase !== "plan";
1645
+ liftoffEls.flight.hidden = phase !== "flight";
1646
+ liftoffEls.outcome.hidden = phase !== "outcome";
1647
+ if (liftoffCard) liftoffCard.dataset.mode = phase;
1648
+ };
1649
+
1650
+ const planRow = (dt, dd, mono) => {
1651
+ const t = document.createElement("dt");
1652
+ t.textContent = dt;
1653
+ const d = document.createElement("dd");
1654
+ d.textContent = dd;
1655
+ if (mono) d.className = "publish-mono";
1656
+ return [t, d];
1657
+ };
1658
+
1659
+ const celebrate = (container) => {
1660
+ if (!container) return;
1661
+ if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
1662
+ const colors = ["#0f9d8f", "#25b8a8", "#f4b942", "#5b8def", "#e2634f"];
1663
+ for (let i = 0; i < 42; i++) {
1664
+ const p = document.createElement("i");
1665
+ p.style.left = Math.random() * 100 + "%";
1666
+ p.style.background = colors[i % colors.length];
1667
+ p.style.animationDelay = Math.random() * 0.35 + "s";
1668
+ p.style.animationDuration = 0.9 + Math.random() * 0.9 + "s";
1669
+ p.style.setProperty("--liftoff-drift", (Math.random() * 160 - 80).toFixed(0) + "px");
1670
+ p.style.setProperty("--liftoff-spin", (Math.random() * 640 - 320).toFixed(0) + "deg");
1671
+ container.append(p);
1672
+ }
1673
+ setTimeout(() => { container.textContent = ""; }, 2400);
1674
+ };
1675
+
1676
+ // The live poll during flight: reflect the server's REAL progress.
1677
+ const startFlightPoll = (change, preFlightSha) => {
1678
+ let inAggregate = false;
1679
+ liftoffPoll = setInterval(async () => {
1680
+ try {
1681
+ const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1682
+ if (!res.ok) return;
1683
+ const data = await res.json();
1684
+ const agg = data.aggregate || {};
1685
+ const included = (agg.includedPrs || []).some((p) => p.changeId === change.changeId);
1686
+ const shaMoved = agg.aggregateSha && agg.aggregateSha !== preFlightSha;
1687
+ const run = agg.activeRun || null;
1688
+ if (!inAggregate && (included || shaMoved)) {
1689
+ inAggregate = true;
1690
+ railTo("build");
1691
+ if (liftoffEls) liftoffEls.live.textContent =
1692
+ "Merged into preview — rebuilding the combined aggregate…";
1693
+ }
1694
+ if (run) {
1695
+ railTo("evidence");
1696
+ if (liftoffEls) liftoffEls.live.textContent =
1697
+ run.statusMessage || "Combined evidence is running…";
1698
+ }
1699
+ } catch { /* polling is best-effort; the POST outcome is authoritative */ }
1700
+ }, 2500);
1701
+ };
1702
+
1703
+ const openLiftoff = (change, button) => {
1704
+ if (!liftoff || !liftoffEls) return false;
1705
+ liftoffOpenedFrom = button || null;
1706
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1707
+ liftoffEls.title.textContent = "Integrate " + target + " into preview";
1708
+ liftoffEls.grid.textContent = "";
1709
+ liftoffEls.grid.append(
1710
+ ...planRow("Store", appDomain || repo),
1711
+ ...(change.prNumber != null ? planRow("Pull request", "#" + change.prNumber) : []),
1712
+ ...planRow("Change", change.changeId, true),
1713
+ ...(change.headSha ? planRow("Head", shortSha(change.headSha), true) : []),
1714
+ );
1715
+ LIFTOFF_STAGES.forEach((s) => setStage(s, "pending"));
1716
+ liftoffEls.live.textContent = "Working…";
1717
+ liftoffEls.elapsed.textContent = "0:00";
1718
+ showPhase("plan");
1719
+ liftoff.hidden = false;
1720
+ liftoffEls.proceed.focus();
1721
+
1722
+ liftoffEls.cancel.onclick = () => closeLiftoff();
1723
+ liftoffEls.done.onclick = () => closeLiftoff();
1724
+ liftoffEls.hide.onclick = () => { liftoff.hidden = true; }; // flight keeps running
1725
+ liftoffEls.proceed.onclick = () => runIntegration(change, button);
1726
+ return true;
1727
+ };
1728
+
1729
+ // Esc: close in plan/outcome; during flight just hide (the POST keeps going).
1730
+ // Handles BOTH ceremonies (accept liftoff + publish shipoff).
1731
+ document.addEventListener("keydown", (ev) => {
1732
+ if (ev.key !== "Escape") return;
1733
+ if (liftoff && !liftoff.hidden) {
1734
+ const mode = liftoffCard && liftoffCard.dataset.mode;
1735
+ if (mode === "flight") liftoff.hidden = true;
1736
+ else closeLiftoff();
1737
+ }
1738
+ if (shipoff && !shipoff.hidden) {
1739
+ const mode = shipoffCard && shipoffCard.dataset.mode;
1740
+ if (mode === "flight") shipoff.hidden = true;
1741
+ else closeShipoff();
1742
+ }
1743
+ });
1744
+
1745
+ // b08 integrate returns an IntegrateOutcome — { ok, queueState, runState,
1746
+ // aggregateSha, pointerMoved, statusMessage, reason? } — NEVER a bare
1747
+ // merged:true. Render it honestly: green advances the aggregate; red left it
1748
+ // exactly where it was and names the failing stage.
1749
+ const runIntegration = async (change, button) => {
1750
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1751
+ if (button) button.disabled = true;
1752
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
1753
+ setStatus(statusEl, "Integrating " + change.changeId + " into preview…");
1754
+ const startedAt = Date.now();
1755
+ let preFlightSha = null;
1756
+ if (liftoffEls) {
1757
+ showPhase("flight");
1758
+ liftoffEls.flightTitle.textContent = "Integrating " + target + " into preview";
1759
+ railTo("merge");
1760
+ liftoffEls.live.textContent = "Squash-merging the candidate into preview…";
1761
+ liftoffTimer = setInterval(() => {
1762
+ const s = Math.floor((Date.now() - startedAt) / 1000);
1763
+ liftoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
1764
+ }, 1000);
1765
+ }
1766
+ try {
1767
+ // Snapshot the pre-flight aggregate sha so the poll can detect the merge
1768
+ // landing (sha moved) even before includedPrs reflects it.
1769
+ try {
1770
+ const pre = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1771
+ if (pre.ok) preFlightSha = ((await pre.json()).aggregate || {}).aggregateSha || null;
1772
+ } catch { /* best-effort */ }
1773
+ startFlightPoll(change, preFlightSha);
1774
+
1775
+ const res = await fetch(ds.integrateEndpoint, {
1776
+ method: "POST",
1777
+ headers: {
1778
+ "content-type": "application/json",
1779
+ // Mirror of the server-side capability model (UX): the server
1780
+ // re-derives capability and stays authoritative.
1781
+ "x-tot-capability": "ship-on-behalf",
1782
+ },
1783
+ body: JSON.stringify({
1784
+ repo,
1785
+ changeId: change.changeId,
1786
+ prNumber: change.prNumber ?? undefined,
1787
+ expectedHeadSha: change.headSha || undefined,
1788
+ }),
1789
+ });
1790
+ const data = await res.json().catch(() => ({}));
1791
+ stopLiftoffLoops();
1792
+ if (res.ok && data.ok) {
1793
+ setStatus(
1794
+ statusEl,
1795
+ "Integrated " + change.changeId + " — preview aggregate is green (" +
1796
+ shortSha(data.aggregateSha) + "). Review the aggregate, then Publish to go live.",
1797
+ "ok",
1798
+ );
1799
+ if (liftoffEls) {
1800
+ LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
1801
+ liftoffEls.seal.dataset.state = "ok";
1802
+ liftoffEls.outcomeTitle.textContent = "The preview aggregate is green";
1803
+ liftoffEls.outcomeDetail.textContent =
1804
+ target + " is in. Aggregate build " + shortSha(data.aggregateSha) +
1805
+ " passed its combined evidence — this exact build is what you review, and what " +
1806
+ "Publish would take live. Nothing is live yet.";
1807
+ if (data.aggregateSha) {
1808
+ liftoffEls.review.href =
1809
+ "/preview/" + encodeURIComponent(appDomain) + "/rev/" + data.aggregateSha + "/";
1810
+ liftoffEls.review.hidden = false;
1811
+ } else {
1812
+ liftoffEls.review.hidden = true;
1813
+ }
1814
+ if (liftoff.hidden) liftoff.hidden = false; // surface the payoff even if backgrounded
1815
+ showPhase("outcome");
1816
+ liftoffEls.done.focus();
1817
+ celebrate(liftoffEls.confetti);
1818
+ }
1819
+ } else {
1820
+ let message;
1821
+ let failStage = "merge";
1822
+ if (res.ok && data.queueState === "red") {
1823
+ // The queue ran but the aggregate did not go green — the candidate did
1824
+ // NOT advance the shippable set. Name the honest failing stage.
1825
+ failStage = "evidence";
1826
+ message =
1827
+ "Integration of " + change.changeId + " failed (" + (data.reason || "red") + "): " +
1828
+ (data.statusMessage || "the aggregate did not pass its combined evidence.") +
1829
+ " The aggregate is unchanged.";
1830
+ } else if (data.reason === "not_preview_base") {
1831
+ message =
1832
+ "Not integrated: " + change.changeId + " does not target the preview branch " +
1833
+ "(base is not \"preview\"). " + (data.statusMessage || "");
1834
+ } else {
1835
+ message = data.statusMessage || data.error || "Integrate failed (" + res.status + ")";
1836
+ }
1837
+ setStatus(statusEl, message, "err");
1838
+ if (liftoffEls) {
1839
+ setStage(failStage, "fail");
1840
+ liftoffEls.seal.dataset.state = "err";
1841
+ liftoffEls.outcomeTitle.textContent = "Integration did not land";
1842
+ liftoffEls.outcomeDetail.textContent = message;
1843
+ liftoffEls.review.hidden = true;
1844
+ if (liftoff.hidden) liftoff.hidden = false;
1845
+ showPhase("outcome");
1846
+ liftoffEls.done.focus();
1847
+ }
1848
+ }
1849
+ } catch {
1850
+ stopLiftoffLoops();
1851
+ setStatus(statusEl, "Integrate failed — network error", "err");
1852
+ if (liftoffEls) {
1853
+ liftoffEls.seal.dataset.state = "err";
1854
+ liftoffEls.outcomeTitle.textContent = "Integration did not land";
1855
+ liftoffEls.outcomeDetail.textContent =
1856
+ "Network error mid-flight — refresh the queue to see the aggregate's honest state.";
1857
+ liftoffEls.review.hidden = true;
1858
+ if (liftoff.hidden) liftoff.hidden = false;
1859
+ showPhase("outcome");
1860
+ }
1861
+ } finally {
1862
+ busyChanges.delete(change.changeId);
1863
+ if (button) button.disabled = false;
1864
+ loadQueue();
1865
+ }
1866
+ };
1867
+
1868
+ const integrateCandidate = (change, button) => {
1869
+ // The liftoff dialog IS the plan confirm now; window.confirm stays only as
1870
+ // the no-dialog fallback (markup missing = fail open to the old popup).
1871
+ if (openLiftoff(change, button)) return;
1872
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1873
+ const plan =
1874
+ "Accept-to-preview plan:\n tenant: " + (appDomain || repo) +
1875
+ (change.prNumber != null ? "\n PR: #" + change.prNumber : "") +
1876
+ "\n change id: " + change.changeId +
1877
+ (change.headSha ? "\n head sha: " + shortSha(change.headSha) : "") +
1878
+ "\n effect: integrate " + target + " into the preview aggregate, then rebuild + " +
1879
+ "re-evidence it. Does NOT go live — publish the reviewed build separately.";
1880
+ if (!window.confirm(plan + "\n\nProceed?")) return;
1881
+ runIntegration(change, button);
1882
+ };
1883
+
1884
+ const rejectChange = async (change, button) => {
1885
+ const reason = window.prompt(
1886
+ "Reject " + change.changeId + " — add a short note for the submitter:",
1887
+ );
1888
+ if (reason == null) return;
1889
+ if (!reason.trim()) {
1890
+ setStatus(statusEl, "A reject note is required.", "err");
1891
+ return;
1892
+ }
1893
+ button.disabled = true;
1894
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
1895
+ setStatus(statusEl, "Rejecting " + change.changeId + "…");
1896
+ try {
1897
+ const res = await fetch(
1898
+ ds.changesEndpoint + "/" + encodeURIComponent(change.changeId) + "/reject",
1899
+ {
1900
+ method: "POST",
1901
+ headers: {
1902
+ "content-type": "application/json",
1903
+ "x-tot-capability": "ship-on-behalf",
1904
+ },
1905
+ body: JSON.stringify({ repo, reason: reason.trim() }),
1906
+ },
1907
+ );
1908
+ const data = await res.json().catch(() => ({}));
1909
+ if (res.ok) {
1910
+ setStatus(statusEl, "Change " + change.changeId + " rejected.", "ok");
1911
+ } else {
1912
+ setStatus(statusEl, data.error || "Reject failed (" + res.status + ")", "err");
1913
+ }
1914
+ } catch {
1915
+ setStatus(statusEl, "Reject failed — network error", "err");
1916
+ } finally {
1917
+ busyChanges.delete(change.changeId);
1918
+ button.disabled = false;
1919
+ loadQueue();
1920
+ }
1921
+ };
1922
+
1923
+ // U7 — retire/GC. Mirror of the accept plan affordance (decision
1924
+ // operator-verb-and-hosting-model): state EXACTLY what retire does — evict the
1925
+ // candidate's preview environment + immutable version — and that it is
1926
+ // REBUILDABLE (degrades to "not built yet", not a 404), so an operator can't
1927
+ // confuse it with Reject. Same shape as `tot retire`'s CLI plan for parity.
1928
+ const buildRetirePlan = (change) => {
1929
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1930
+ const lines = ["Retire plan:", " tenant: " + (appDomain || repo)];
1931
+ if (change.prNumber != null) lines.push(" PR: #" + change.prNumber);
1932
+ lines.push(" change id: " + change.changeId);
1933
+ if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
1934
+ lines.push(
1935
+ " effect: evict " + target + "'s preview environment + version to reclaim space.",
1936
+ );
1937
+ lines.push(
1938
+ " rebuild: REVERSIBLE — rebuild it any time (this is NOT Reject; the change stays open).",
1939
+ );
1940
+ return lines.join("\n");
1941
+ };
1942
+
1943
+ const retireChange = async (change, button) => {
1944
+ if (!window.confirm(buildRetirePlan(change) + "\n\nProceed?")) return;
1945
+ button.disabled = true;
1946
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
1947
+ setStatus(statusEl, "Retiring " + change.changeId + "…");
1948
+ try {
1949
+ const res = await fetch(ds.retireEndpoint, {
1950
+ method: "POST",
1951
+ headers: {
1952
+ "content-type": "application/json",
1953
+ // Mirror of the server-side capability model (UX); the server
1954
+ // re-derives capability and stays authoritative.
1955
+ "x-tot-capability": "ship-on-behalf",
1956
+ },
1957
+ body: JSON.stringify({
1958
+ changeId: change.changeId,
1959
+ pr: change.prNumber ?? undefined,
1960
+ }),
1961
+ });
1962
+ const data = await res.json().catch(() => ({}));
1963
+ if (res.ok && data.ok) {
1964
+ setStatus(
1965
+ statusEl,
1966
+ data.evicted
1967
+ ? "Retired " + change.changeId + " — the preview was evicted (rebuildable on demand)."
1968
+ : "Retire " + change.changeId + ": nothing to evict (already retired).",
1969
+ "ok",
1970
+ );
1971
+ } else {
1972
+ setStatus(statusEl, data.error || "Retire failed (" + res.status + ")", "err");
1973
+ }
1974
+ } catch {
1975
+ setStatus(statusEl, "Retire failed — network error", "err");
1976
+ } finally {
1977
+ busyChanges.delete(change.changeId);
1978
+ button.disabled = false;
1979
+ loadQueue();
1980
+ }
1981
+ };
1982
+
1983
+ // ---- rux5: build-on-demand for a synthetic not-built PR ------------------
1984
+ // Mirror of the integrate/retire plan affordances: state EXACTLY what build
1985
+ // does — materialize this PR's hosted preview at its forge head — and that it
1986
+ // is INERT (flips no live channel). POSTs { pr, headSha?, baseSha? }; the row
1987
+ // already carries the head sha GET /api/changes resolved via forge PR-read, and
1988
+ // the server re-resolves it authoritatively when the body omits it. Shows an
1989
+ // in-progress state (disabled + "Building…") while the build runs, then reloads
1990
+ // the queue so the freshly-built PR reappears as a built candidate.
1991
+
1992
+ const buildPreviewPlan = (change) => {
1993
+ const lines = ["Build preview plan:", " tenant: " + (appDomain || repo)];
1994
+ lines.push(" PR: #" + change.prNumber);
1995
+ if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
1996
+ lines.push(
1997
+ " effect: materialize this PR's hosted preview at its head — INERT " +
1998
+ "(does NOT go live).",
1999
+ );
2000
+ return lines.join("\n");
2001
+ };
2002
+
2003
+ const buildPreview = async (change, button) => {
2004
+ const label = "PR #" + change.prNumber;
2005
+ if (!window.confirm(buildPreviewPlan(change) + "\n\nBuild the preview now?")) return;
2006
+ button.disabled = true;
2007
+ const originalText = button.textContent;
2008
+ button.textContent = "Building…";
2009
+ button.dataset.building = "true";
2010
+ // rux7: record the in-flight build so a poll re-render preserves the
2011
+ // spinner/disabled state on the freshly-rendered button (cleared in finally).
2012
+ if (change.prNumber != null) inflightBuilds.add(change.prNumber);
2013
+ setStatus(statusEl, "Building preview for " + label + "…");
2014
+ try {
2015
+ const res = await fetch(ds.buildEndpoint, {
2016
+ method: "POST",
2017
+ headers: {
2018
+ "content-type": "application/json",
2019
+ // Mirror of the server-side capability model (UX); the server
2020
+ // re-derives capability (buildGate) and stays authoritative.
2021
+ "x-tot-capability": "ship-on-behalf",
2022
+ },
2023
+ body: JSON.stringify({
2024
+ pr: change.prNumber,
2025
+ headSha: change.headSha || undefined,
2026
+ baseSha: change.baseSha || undefined,
2027
+ }),
2028
+ });
2029
+ const data = await res.json().catch(() => ({}));
2030
+ if (res.ok && data.ok) {
2031
+ setStatus(
2032
+ statusEl,
2033
+ "Built preview for " + label +
2034
+ " — it now appears as a built change in the queue.",
2035
+ "ok",
2036
+ );
2037
+ } else if (data && data.ok === false) {
2038
+ // reconcileCandidate returned a blocked/failed candidate (e.g. a safety
2039
+ // finding with no override) — the honest reason, never a false success.
2040
+ setStatus(
2041
+ statusEl,
2042
+ "Build did not complete for " + label + ": " +
2043
+ ((data.errors && data.errors.join("; ")) || data.error || "blocked"),
2044
+ "err",
2045
+ );
2046
+ } else {
2047
+ setStatus(statusEl, data.error || "Build failed (" + res.status + ")", "err");
2048
+ }
2049
+ } catch {
2050
+ setStatus(statusEl, "Build failed — network error", "err");
2051
+ } finally {
2052
+ if (change.prNumber != null) inflightBuilds.delete(change.prNumber);
2053
+ button.disabled = false;
2054
+ button.textContent = originalText;
2055
+ delete button.dataset.building;
2056
+ loadQueue();
2057
+ }
2058
+ };
2059
+
2060
+ // ---- rux6: unified review pane -----------------------------------------
2061
+ // "One review moment": expanding a candidate row reveals a single pane with
2062
+ // the live preview (iframed SAME-ORIGIN — the /preview/<tenant>/pr/<N> route
2063
+ // serves `frame-ancestors 'self'` + `X-Frame-Options: SAMEORIGIN`, so a
2064
+ // same-origin admin embed is permitted; a cross-origin embed is refused,
2065
+ // which is why the pane also always offers an "Open in new tab" link), the
2066
+ // evidence chips the row already carries (change.evidence), the dg7 diff
2067
+ // (reusing renderDiff below — GET /api/changes/<id>/diff, which never invents
2068
+ // a diff for a non-renderable candidate), and the SAME Accept-to-preview /
2069
+ // Reject actions. The reviewer never leaves the queue to review and act; the
2070
+ // server stays authoritative for every action (this is UI consolidation only).
2071
+
2072
+ const diffBase = ds.diffEndpointBase;
2073
+
2074
+ const reviewRowFor = (tr) => {
2075
+ const next = tr.nextElementSibling;
2076
+ return next && next.dataset && next.dataset.publishReviewRow === tr.dataset.changeRowId
2077
+ ? next
2078
+ : null;
2079
+ };
2080
+
2081
+ const closeReview = (tr, button) => {
2082
+ const existing = reviewRowFor(tr);
2083
+ if (existing) existing.remove();
2084
+ if (button) {
2085
+ button.textContent = "Review";
2086
+ button.setAttribute("aria-expanded", "false");
2087
+ }
2088
+ };
2089
+
2090
+ // Evidence chips from the data the row already carries (change.evidence):
2091
+ // an overall promotable verdict + one chip per reported check.
2092
+ const renderEvidenceChips = (container, change) => {
2093
+ const checks = (change.evidence && change.evidence.checks) || [];
2094
+ const promotable = Boolean(change.evidence && change.evidence.promotable);
2095
+ container.append(
2096
+ promotable ? chip("Checks passed", "green") : chip("Checks blocked", "amber"),
2097
+ );
2098
+ for (const c of checks) {
2099
+ const tone = c.status === "pass" ? "green" : c.status === "fail" ? "red" : "amber";
2100
+ const label = (c.label || c.id || "check") + (c.status && c.status !== "pass" ? " · " + c.status : "");
2101
+ const el = chip(label, tone);
2102
+ if (c.detail) el.title = c.detail;
2103
+ container.append(el);
2104
+ }
2105
+ if (!checks.length) container.append(subtle("No checks reported yet"));
2106
+ };
2107
+
2108
+ // Build the split pane. Returns the pane node + the diff container so the
2109
+ // caller can populate the diff asynchronously (reusing renderDiff).
2110
+ const buildReviewPane = (change) => {
2111
+ const pane = document.createElement("div");
2112
+ pane.className = "review-pane";
2113
+
2114
+ // Preview column — same-origin iframe of the friendly per-PR route (it
2115
+ // handles every state: ready render, or the not-built page). Falls back to
2116
+ // any server-provided previewUrl for a change-first row with no PR number.
2117
+ const previewUrl = candidatePrUrl(change.prNumber) || change.previewUrl;
2118
+ const previewCol = document.createElement("div");
2119
+ previewCol.className = "review-preview";
2120
+ const pHead = document.createElement("div");
2121
+ pHead.className = "review-pane-head";
2122
+ const pTitle = document.createElement("b");
2123
+ pTitle.textContent = "Preview";
2124
+ pHead.append(pTitle);
2125
+ if (previewUrl) {
2126
+ const openLink = document.createElement("a");
2127
+ openLink.href = previewUrl;
2128
+ openLink.target = "_blank";
2129
+ openLink.rel = "noopener";
2130
+ openLink.textContent = "Open in new tab ↗";
2131
+ pHead.append(openLink);
2132
+ }
2133
+ previewCol.append(pHead);
2134
+ if (previewUrl) {
2135
+ const frame = document.createElement("iframe");
2136
+ frame.className = "review-frame";
2137
+ frame.src = previewUrl;
2138
+ frame.loading = "lazy";
2139
+ frame.title =
2140
+ change.prNumber != null
2141
+ ? "Preview of PR #" + change.prNumber
2142
+ : "Preview of " + change.changeId;
2143
+ previewCol.append(frame);
2144
+ } else {
2145
+ previewCol.append(subtle("No preview is available for this change yet."));
2146
+ }
2147
+
2148
+ // Detail column — evidence chips + diff + the same Accept/Reject actions.
2149
+ const detailCol = document.createElement("div");
2150
+ detailCol.className = "review-detail";
2151
+
2152
+ const evSection = document.createElement("div");
2153
+ evSection.className = "review-section";
2154
+ const evTitle = document.createElement("b");
2155
+ evTitle.textContent = "Checks";
2156
+ const evChips = document.createElement("div");
2157
+ evChips.className = "review-chips";
2158
+ renderEvidenceChips(evChips, change);
2159
+ evSection.append(evTitle, evChips);
2160
+ detailCol.append(evSection);
2161
+
2162
+ const diffSection = document.createElement("div");
2163
+ diffSection.className = "review-section";
2164
+ const diffTitle = document.createElement("b");
2165
+ diffTitle.textContent = "Diff";
2166
+ const diffTarget = document.createElement("div");
2167
+ diffTarget.className = "review-diff-target";
2168
+ diffTarget.append(subtle("Loading the diff…"));
2169
+ diffSection.append(diffTitle, diffTarget);
2170
+ detailCol.append(diffSection);
2171
+
2172
+ // The SAME actions as the row — same handlers, same gates. Not a new authz
2173
+ // path: integrateCandidate/rejectChange POST to the same endpoints, and the
2174
+ // server re-authorizes.
2175
+ const actionRow = document.createElement("div");
2176
+ actionRow.className = "review-actions";
2177
+ const accept = document.createElement("button");
2178
+ accept.type = "button";
2179
+ accept.className = "btn primary";
2180
+ accept.textContent = "Accept to preview";
2181
+ accept.disabled = !canShip || !change.mergeable;
2182
+ accept.title = !canShip
2183
+ ? "Requires an owner or admin sign-in"
2184
+ : !change.mergeable
2185
+ ? "This change is not ready to integrate"
2186
+ : "Integrate this change into the preview aggregate (does not go live)";
2187
+ accept.addEventListener("click", () => integrateCandidate(change, accept));
2188
+ const reject = document.createElement("button");
2189
+ reject.type = "button";
2190
+ reject.className = "btn";
2191
+ reject.textContent = "Reject";
2192
+ reject.disabled = !canShip;
2193
+ reject.title = canShip
2194
+ ? "Close this change without publishing"
2195
+ : "Requires an owner or admin sign-in";
2196
+ reject.addEventListener("click", () => rejectChange(change, reject));
2197
+ actionRow.append(accept, reject);
2198
+ detailCol.append(actionRow);
2199
+
2200
+ pane.append(previewCol, detailCol);
2201
+ return { pane, diffTarget };
2202
+ };
2203
+
2204
+ const loadReviewDiff = async (change, diffTarget) => {
2205
+ try {
2206
+ const res = await fetch(diffBase + "/" + encodeURIComponent(change.changeId) + "/diff", {
2207
+ headers: { accept: "application/json" },
2208
+ });
2209
+ const data = await res.json().catch(() => ({}));
2210
+ diffTarget.textContent = "";
2211
+ if (!res.ok) {
2212
+ diffTarget.append(subtle(data.error || "Could not load the diff (" + res.status + ")"));
2213
+ } else {
2214
+ renderDiff(diffTarget, data.diff || {});
2215
+ }
2216
+ } catch {
2217
+ diffTarget.textContent = "";
2218
+ diffTarget.append(subtle("Could not load the diff — network error"));
2219
+ }
2220
+ };
2221
+
2222
+ const toggleReview = (change, button, tr) => {
2223
+ if (!tr.dataset.changeRowId) tr.dataset.changeRowId = change.changeId;
2224
+ if (reviewRowFor(tr)) {
2225
+ closeReview(tr, button);
2226
+ return;
2227
+ }
2228
+ const detailTr = document.createElement("tr");
2229
+ detailTr.dataset.publishReviewRow = tr.dataset.changeRowId;
2230
+ const td = document.createElement("td");
2231
+ td.colSpan = 6;
2232
+ td.className = "publish-review-cell";
2233
+ const { pane, diffTarget } = buildReviewPane(change);
2234
+ td.append(pane);
2235
+ detailTr.append(td);
2236
+ tr.after(detailTr);
2237
+ button.textContent = "Hide review";
2238
+ button.setAttribute("aria-expanded", "true");
2239
+ loadReviewDiff(change, diffTarget);
2240
+ };
2241
+
2242
+ const CHANGE_MARK = { added: "+", changed: "~", removed: "−" };
2243
+
2244
+ const renderDiff = (td, diff) => {
2245
+ td.textContent = "";
2246
+ const head = document.createElement("div");
2247
+ head.className = "publish-diff-head";
2248
+
2249
+ // Authority: honest label of whether the diff describes the exact head.
2250
+ const authority = diff.authority || {};
2251
+ const authChip = chip(
2252
+ authority.authoritative
2253
+ ? "Reviewed at head"
2254
+ : authority.stale
2255
+ ? "Last-good preview (stale vs head)"
2256
+ : authority.missing
2257
+ ? "No evidence yet"
2258
+ : "Head",
2259
+ authority.authoritative ? "green" : authority.stale ? "amber" : "",
2260
+ );
2261
+ head.append(authChip);
2262
+
2263
+ if (diff.headSha) {
2264
+ const sha = document.createElement("span");
2265
+ sha.className = "publish-mono subtle";
2266
+ sha.textContent = "head " + shortSha(diff.headSha);
2267
+ head.append(sha);
2268
+ }
2269
+ if (diff.revHref) {
2270
+ const rev = document.createElement("a");
2271
+ rev.href = diff.revHref;
2272
+ rev.target = "_blank";
2273
+ rev.rel = "noopener";
2274
+ rev.textContent = "Open reviewed revision";
2275
+ head.append(rev);
2276
+ }
2277
+ td.append(head);
2278
+
2279
+ if (!diff.renderable) {
2280
+ // Honest: a candidate that can't render has NO diff — say why (dg3), no fake.
2281
+ const reason = document.createElement("p");
2282
+ reason.className = "note publish-diff-blocked";
2283
+ reason.textContent =
2284
+ diff.blockedReason || "This candidate has no renderable version to diff yet.";
2285
+ td.append(reason);
2286
+ return;
2287
+ }
2288
+
2289
+ const files = Array.isArray(diff.files) ? diff.files : [];
2290
+ if (!files.length) {
2291
+ td.append(subtle("No file changes vs the current live version."));
2292
+ return;
2293
+ }
2294
+ const list = document.createElement("ul");
2295
+ list.className = "publish-diff-list";
2296
+ for (const f of files) {
2297
+ const li = document.createElement("li");
2298
+ li.className = "publish-diff-" + f.change;
2299
+ const mark = document.createElement("span");
2300
+ mark.className = "publish-diff-mark";
2301
+ mark.textContent = CHANGE_MARK[f.change] || "•";
2302
+ const path = document.createElement("code");
2303
+ path.textContent = f.path;
2304
+ li.append(mark, path);
2305
+ list.append(li);
2306
+ }
2307
+ td.append(list);
2308
+ };
2309
+
2310
+ // ---- dg7: versions / promotion history + rollback ----------------------
2311
+ // The promotion history (u2/u3 promotion_status) with the tenant Gitea SHA,
2312
+ // its /rev link (u5), and the current-live marker; a prior version rolls back
2313
+ // THROUGH the one orchestrator (/api/changes/rollback → u8), rendering only the
2314
+ // HONEST terminal state (rolled_back only when verified).
2315
+
2316
+ const versionsPanel = root.querySelector("[data-publish-versions]");
2317
+ const versionsBody = versionsPanel?.querySelector("[data-versions-body]");
2318
+ const versionsChip = versionsPanel?.querySelector("[data-versions-chip]");
2319
+ const versionsStatus = versionsPanel?.querySelector("[data-versions-status]");
2320
+ const versionsRollbackNote = versionsPanel?.querySelector("[data-versions-rollback-note]");
2321
+ let rollbackMode = "control-plane";
2322
+
2323
+ const renderVersions = (view) => {
2324
+ if (!versionsBody) return;
2325
+ const versions = Array.isArray(view.versions) ? view.versions : [];
2326
+ rollbackMode = view.rollbackMode || "control-plane";
2327
+ versionsBody.textContent = "";
2328
+
2329
+ if (versionsChip) {
2330
+ versionsChip.textContent = versions.length === 1 ? "1 version" : versions.length + " versions";
2331
+ versionsChip.className = "chip" + (versions.length ? " blue" : "");
2332
+ }
2333
+
2334
+ // Static tenants (rux13): per-version rollback is an HONEST ONE-DISPATCH.
2335
+ // The console can't enumerate the S3 versions archive digest, so it dispatches
2336
+ // by the tenant version id and CI resolves the digest + re-publishes — it
2337
+ // completes via CI, not instantly (never implied one-click).
2338
+ if (versionsRollbackNote) {
2339
+ if (rollbackMode === "static-archive") {
2340
+ versionsRollbackNote.hidden = false;
2341
+ versionsRollbackNote.textContent =
2342
+ "This store publishes to a static public site. Rolling back DISPATCHES a CI job that " +
2343
+ "re-publishes the prior version from the S3 versions archive — it completes via CI, not " +
2344
+ "instantly. The store keeps serving the current version until the re-publish is verified live.";
2345
+ } else {
2346
+ versionsRollbackNote.hidden = true;
2347
+ }
2348
+ }
2349
+
2350
+ if (!versions.length) {
2351
+ const tr = document.createElement("tr");
2352
+ const td = cell(subtle("No versions have been promoted to live yet."));
2353
+ td.colSpan = 6;
2354
+ tr.append(td);
2355
+ versionsBody.append(tr);
2356
+ return;
2357
+ }
2358
+
2359
+ for (const v of versions) {
2360
+ const tr = document.createElement("tr");
2361
+
2362
+ // Version identity: the tenant Gitea SHA (content authority) + source.
2363
+ const idCell = document.createElement("td");
2364
+ const strong = document.createElement("strong");
2365
+ strong.className = "publish-mono";
2366
+ strong.textContent = shortSha(v.versionId);
2367
+ idCell.append(strong);
2368
+ if (v.source) idCell.append(subtle(v.source === "commit" ? "From a commit" : "Content hash"));
2369
+ tr.append(idCell);
2370
+
2371
+ // Digest (static current only — never fabricated for priors).
2372
+ const digestCell = cell(v.digest ? shortSha(v.digest) : "—");
2373
+ digestCell.classList.add("publish-mono");
2374
+ if (!v.digest) digestCell.classList.add("subtle");
2375
+ tr.append(digestCell);
2376
+
2377
+ tr.append(cell(age(v.promotedAt)));
2378
+
2379
+ // Preview: the u5 immutable /rev/<sha> link.
2380
+ const revCell = document.createElement("td");
2381
+ if (v.revHref) {
2382
+ const a = document.createElement("a");
2383
+ a.href = v.revHref;
2384
+ a.target = "_blank";
2385
+ a.rel = "noopener";
2386
+ a.textContent = "Revision";
2387
+ revCell.append(a);
2388
+ } else {
2389
+ revCell.append(subtle("—"));
2390
+ }
2391
+ tr.append(revCell);
2392
+
2393
+ // State: current live vs prior.
2394
+ tr.append(cell(v.isCurrent ? chip("Current live", "green") : chip("Prior", "")));
2395
+
2396
+ // Action: roll back to a PRIOR version (never the current one).
2397
+ const act = document.createElement("td");
2398
+ act.className = "action publish-actions";
2399
+ if (!v.isCurrent) {
2400
+ const rollbackBtn = document.createElement("button");
2401
+ rollbackBtn.type = "button";
2402
+ rollbackBtn.className = "btn";
2403
+ const isStatic = rollbackMode === "static-archive";
2404
+ // Honest label: a static rollback completes via CI, not instantly (rux13).
2405
+ rollbackBtn.textContent = isStatic ? "Roll back via CI" : "Roll back";
2406
+ rollbackBtn.disabled = !canShip;
2407
+ rollbackBtn.title = !canShip
2408
+ ? "Requires an owner or ship-on-behalf sign-in"
2409
+ : isStatic
2410
+ ? "Dispatch a rollback to this version — completes via CI (not instant)"
2411
+ : "Roll the live site back to this version";
2412
+ rollbackBtn.addEventListener("click", () => rollbackVersion(v, rollbackBtn));
2413
+ act.append(rollbackBtn);
2414
+ }
2415
+ tr.append(act);
2416
+
2417
+ versionsBody.append(tr);
2418
+ }
2419
+ };
2420
+
2421
+ const loadVersions = async () => {
2422
+ if (!versionsBody) return;
2423
+ try {
2424
+ const res = await fetch(ds.versionsEndpoint, { headers: { accept: "application/json" } });
2425
+ if (res.status === 401 || res.status === 403) {
2426
+ if (versionsChip) versionsChip.textContent = "Sign-in required";
2427
+ versionsBody.textContent = "";
2428
+ const tr = document.createElement("tr");
2429
+ const td = cell(subtle("Sign in as a store owner or admin to view published versions."));
2430
+ td.colSpan = 6;
2431
+ tr.append(td);
2432
+ versionsBody.append(tr);
2433
+ return;
2434
+ }
2435
+ const data = await res.json().catch(() => ({}));
2436
+ if (!res.ok) {
2437
+ setStatus(versionsStatus, data.error || "Could not load versions (" + res.status + ")", "err");
2438
+ return;
2439
+ }
2440
+ renderVersions(data.view || {});
2441
+ } catch {
2442
+ setStatus(versionsStatus, "Could not load versions — network error", "err");
2443
+ }
2444
+ };
2445
+
2446
+ // Interpret the rollback response into ONE honest terminal state (mirrors the
2447
+ // pure lib/publish/shipWorkspace `interpretRollbackResponse`; duplicated in
2448
+ // plain JS because is:inline scripts can't import modules). `rolledBack` is
2449
+ // true ONLY for a verified `rolled_back` — a `publish_pending` is NEVER a
2450
+ // rolled-back claim.
2451
+ const interpretRollback = (httpOk, body) => {
2452
+ const toVersionId = typeof body.toVersionId === "string" ? body.toVersionId : null;
2453
+ if (typeof body.state === "string") {
2454
+ const state = ["rolled_back", "publish_pending", "publish_failed", "blocked"].includes(body.state)
2455
+ ? body.state
2456
+ : "error";
2457
+ return {
2458
+ state,
2459
+ rolledBack: state === "rolled_back",
2460
+ message: body.message || body.error || "Rollback returned state " + state + ".",
2461
+ };
2462
+ }
2463
+ if (httpOk && body.rolledBack === true) {
2464
+ return {
2465
+ state: "rolled_back",
2466
+ rolledBack: true,
2467
+ message: toVersionId ? "Rolled back live to " + shortSha(toVersionId) + "." : "Rolled back live.",
2468
+ };
2469
+ }
2470
+ return {
2471
+ state: "blocked",
2472
+ rolledBack: false,
2473
+ message: (body.error || "Rollback failed") + (body.reason ? " (" + body.reason + ")" : ""),
2474
+ };
2475
+ };
2476
+
2477
+ const rollbackVersion = async (version, button) => {
2478
+ // Static rollback (rux13) is an honest one-DISPATCH — completes via CI, not
2479
+ // instantly; the confirm/status copy must never imply an instant flip.
2480
+ const isStatic = rollbackMode === "static-archive";
2481
+ const confirmMsg = isStatic
2482
+ ? "Roll the static public site back to version " + shortSha(version.versionId) +
2483
+ "? This DISPATCHES a rollback that completes via CI (not instantly) — the store keeps " +
2484
+ "serving the current version until CI re-publishes the prior one."
2485
+ : "Roll the live store back to version " + shortSha(version.versionId) +
2486
+ "? This publishes that prior version to the live site.";
2487
+ if (!window.confirm(confirmMsg)) return;
2488
+ button.disabled = true;
2489
+ setStatus(
2490
+ versionsStatus,
2491
+ isStatic
2492
+ ? "Dispatching rollback to " + shortSha(version.versionId) + " (completes via CI)…"
2493
+ : "Rolling back to " + shortSha(version.versionId) + "…",
2494
+ );
2495
+ try {
2496
+ // The console dispatches by version id only; a static prior version's
2497
+ // archive digest lives only in S3 (CI resolves it). Pass a digest ONLY when
2498
+ // we honestly have one (never for a static prior — its digest is always null).
2499
+ const payload = { targetVersionId: version.versionId };
2500
+ if (version.digest) payload.targetDigest = version.digest;
2501
+ const res = await fetch(ds.rollbackEndpoint, {
2502
+ method: "POST",
2503
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
2504
+ body: JSON.stringify(payload),
2505
+ });
2506
+ const data = await res.json().catch(() => ({}));
2507
+ const outcome = interpretRollback(res.ok, data);
2508
+ setStatus(versionsStatus, outcome.message, outcome.rolledBack ? "ok" : outcome.state === "publish_pending" ? "ok" : "err");
2509
+ } catch {
2510
+ setStatus(versionsStatus, "Rollback failed — network error", "err");
2511
+ } finally {
2512
+ button.disabled = false;
2513
+ loadVersions();
2514
+ loadQueue();
2515
+ }
2516
+ };
2517
+
2518
+ // ---- Domain section ----------------------------------------------------
2519
+
2520
+ const domainPanel = root.querySelector("[data-publish-domain]");
2521
+ let domainPollTimer = null;
2522
+
2523
+ const renderDomain = (payload) => {
2524
+ if (!domainPanel) return;
2525
+ const domain = payload.domain || {};
2526
+ const stateEl = domainPanel.querySelector("[data-domain-state]");
2527
+ const stateChip = domainPanel.querySelector("[data-domain-state-chip]");
2528
+ const sinceEl = domainPanel.querySelector("[data-domain-since]");
2529
+ const captureEl = domainPanel.querySelector("[data-domain-capture]");
2530
+ const runEl = domainPanel.querySelector("[data-domain-run]");
2531
+ const runDetailEl = domainPanel.querySelector("[data-domain-run-detail]");
2532
+ const rollbackBtn = domainPanel.querySelector("[data-domain-rollback]");
2533
+ const rollbackDetail = domainPanel.querySelector("[data-domain-rollback-detail]");
2534
+ const connected = domain.state === "cloudfront";
2535
+
2536
+ if (stateEl) {
2537
+ stateEl.textContent = connected
2538
+ ? "Storefront CDN (cloudfront)"
2539
+ : "Previous host (pantheon)";
2540
+ }
2541
+ if (stateChip) {
2542
+ stateChip.textContent = connected ? "Connected" : "Not connected";
2543
+ stateChip.className = "chip " + (connected ? "green" : "amber");
2544
+ }
2545
+ if (sinceEl) {
2546
+ sinceEl.textContent = domain.since
2547
+ ? "Since " + new Date(domain.since).toLocaleString()
2548
+ : "Current host for " + (payload.appDomain || ds.appDomain);
2549
+ }
2550
+ if (captureEl) {
2551
+ captureEl.textContent = domain.priorCapturedAt
2552
+ ? "Captured " + new Date(domain.priorCapturedAt).toLocaleString()
2553
+ : "None captured";
2554
+ }
2555
+
2556
+ const run = domain.lastRun;
2557
+ // "dispatched" = awaiting the CI completion callback (terminal-only wire).
2558
+ const inFlight = run && run.status === "dispatched";
2559
+ if (runEl) {
2560
+ runEl.textContent = run
2561
+ ? run.action + (run.rehearsal ? " (rehearsal)" : "") + " · " + run.status
2562
+ : "None";
2563
+ }
2564
+ if (runDetailEl) {
2565
+ runDetailEl.textContent = "";
2566
+ if (run) {
2567
+ const parts = [];
2568
+ if (run.actor) parts.push("by " + run.actor);
2569
+ if (run.startedAt) parts.push(age(run.startedAt));
2570
+ runDetailEl.append(document.createTextNode(parts.join(" · ")));
2571
+ if (run.runUrl) {
2572
+ const link = document.createElement("a");
2573
+ link.href = run.runUrl;
2574
+ link.target = "_blank";
2575
+ link.rel = "noopener";
2576
+ link.textContent = "View run";
2577
+ runDetailEl.append(document.createTextNode(" · "));
2578
+ runDetailEl.append(link);
2579
+ }
2580
+ if (run.status === "failed" && run.error) {
2581
+ const err = document.createElement("span");
2582
+ err.className = "publish-error-detail";
2583
+ err.textContent = run.error;
2584
+ runDetailEl.append(document.createElement("br"));
2585
+ runDetailEl.append(err);
2586
+ }
2587
+ } else {
2588
+ runDetailEl.textContent = "Connect and rollback runs appear here.";
2589
+ }
2590
+ }
2591
+ if (rollbackBtn) {
2592
+ rollbackBtn.disabled =
2593
+ !canShip || inFlight || !domain.priorRecordsKey;
2594
+ }
2595
+ if (rollbackDetail && domain.priorCapturedAt) {
2596
+ rollbackDetail.textContent =
2597
+ "Restores the DNS records captured " +
2598
+ new Date(domain.priorCapturedAt).toLocaleString() + ".";
2599
+ }
2600
+
2601
+ // Poll while a run is in flight so completion lands without a manual refresh.
2602
+ if (domainPollTimer) {
2603
+ clearTimeout(domainPollTimer);
2604
+ domainPollTimer = null;
2605
+ }
2606
+ if (inFlight) domainPollTimer = setTimeout(loadDomain, 15000);
2607
+ };
2608
+
2609
+ const loadDomain = async () => {
2610
+ if (!domainPanel) return;
2611
+ try {
2612
+ const res = await fetch(ds.domainStatusEndpoint, {
2613
+ headers: { accept: "application/json" },
2614
+ });
2615
+ if (!res.ok) return;
2616
+ renderDomain(await res.json());
2617
+ } catch {
2618
+ /* leave the last rendered state */
2619
+ }
2620
+ // u9: refresh the readiness itemization alongside the status.
2621
+ loadReadiness();
2622
+ };
2623
+
2624
+ // ---- Cutover readiness (u9) --------------------------------------------
2625
+ // Reads the SAME server gate `tot go-live` reads (/api/domain/readiness) and
2626
+ // itemizes the preconditions. The Connect button additionally requires the
2627
+ // server to report ready — the display and the enforcement can't diverge.
2628
+ const readinessBox = domainPanel?.querySelector("[data-domain-readiness]");
2629
+ const connectBtn = domainPanel?.querySelector("[data-domain-connect]");
2630
+ let domainReady = false;
2631
+
2632
+ const renderReadiness = (payload) => {
2633
+ if (!readinessBox) return;
2634
+ const readiness = (payload && payload.readiness) || {};
2635
+ const checks = Array.isArray(readiness.checks) ? readiness.checks : [];
2636
+ domainReady = readiness.ready === true;
2637
+ readinessBox.hidden = false;
2638
+
2639
+ const chip = readinessBox.querySelector("[data-domain-readiness-chip]");
2640
+ if (chip) {
2641
+ chip.textContent = domainReady ? "Ready" : "Not ready";
2642
+ chip.className = "chip " + (domainReady ? "green" : "amber");
2643
+ }
2644
+ const list = readinessBox.querySelector("[data-domain-readiness-list]");
2645
+ if (list) {
2646
+ list.textContent = "";
2647
+ for (const c of checks) {
2648
+ const li = document.createElement("li");
2649
+ li.className = c.ok ? "ok" : "blocked";
2650
+ const mark = document.createElement("span");
2651
+ mark.className = "domain-readiness-mark";
2652
+ mark.textContent = c.ok ? "✓" : "✗";
2653
+ const label = document.createElement("b");
2654
+ label.textContent = c.label || c.id || "";
2655
+ li.append(mark, label);
2656
+ if (c.detail) {
2657
+ const detail = document.createElement("span");
2658
+ detail.className = "domain-readiness-detail";
2659
+ detail.textContent = c.detail;
2660
+ li.append(detail);
2661
+ }
2662
+ list.append(li);
2663
+ }
2664
+ }
2665
+ const note = readinessBox.querySelector("[data-domain-readiness-note]");
2666
+ if (note) {
2667
+ note.textContent = payload && payload.ownerCapable === false
2668
+ ? "Apex cutover is owner-only — sign in as the store owner to connect the domain."
2669
+ : domainReady
2670
+ ? "All preconditions are green — the cutover is permitted."
2671
+ : "Clear the failing preconditions above before connecting the apex.";
2672
+ }
2673
+ // Gate the Connect button on server readiness in ADDITION to owner (the
2674
+ // markup already disables it for non-owners); never enable it when blocked.
2675
+ if (connectBtn && !connectBtn.dataset.ownerLocked) {
2676
+ connectBtn.disabled = !domainReady;
2677
+ }
2678
+ };
2679
+
2680
+ const loadReadiness = async () => {
2681
+ if (!readinessBox || !ds.domainReadinessEndpoint) return;
2682
+ try {
2683
+ const res = await fetch(ds.domainReadinessEndpoint, {
2684
+ headers: { accept: "application/json", "x-tot-capability": "ship-on-behalf" },
2685
+ });
2686
+ if (!res.ok) return;
2687
+ renderReadiness(await res.json());
2688
+ } catch {
2689
+ /* leave the last rendered readiness */
2690
+ }
2691
+ };
2692
+
2693
+ const domainStatusEl = domainPanel?.querySelector("[data-domain-status]");
2694
+
2695
+ const domainAction = async (body, button) => {
2696
+ button.disabled = true;
2697
+ setStatus(domainStatusEl, (body.action === "connect" ? "Connecting" : "Rolling back") + "…");
2698
+ try {
2699
+ const res = await fetch(ds.domainDispatchEndpoint, {
2700
+ method: "POST",
2701
+ headers: {
2702
+ "content-type": "application/json",
2703
+ "x-tot-capability": "ship-on-behalf",
2704
+ },
2705
+ body: JSON.stringify(body),
2706
+ });
2707
+ const data = await res.json().catch(() => ({}));
2708
+ if (res.ok && data.dispatched) {
2709
+ setStatus(
2710
+ domainStatusEl,
2711
+ (body.action === "connect" ? "Connect" : "Rollback") +
2712
+ (body.rehearsal ? " rehearsal" : "") +
2713
+ " dispatched — the run status updates below as it progresses.",
2714
+ "ok",
2715
+ );
2716
+ } else {
2717
+ setStatus(domainStatusEl, data.error || "Request failed (" + res.status + ")", "err");
2718
+ }
2719
+ } catch {
2720
+ setStatus(domainStatusEl, "Request failed — network error", "err");
2721
+ } finally {
2722
+ button.disabled = false;
2723
+ loadDomain();
2724
+ }
2725
+ };
2726
+
2727
+ domainPanel?.querySelector("[data-domain-connect]")?.addEventListener("click", (event) => {
2728
+ const confirmInput = domainPanel.querySelector("[data-domain-confirm]");
2729
+ const rehearsal = Boolean(domainPanel.querySelector("[data-domain-rehearsal]")?.checked);
2730
+ domainAction(
2731
+ {
2732
+ action: "connect",
2733
+ confirmDomain: (confirmInput?.value || "").trim(),
2734
+ rehearsal,
2735
+ },
2736
+ event.currentTarget,
2737
+ );
2738
+ });
2739
+
2740
+ domainPanel?.querySelector("[data-domain-rollback]")?.addEventListener("click", (event) => {
2741
+ if (
2742
+ !window.confirm(
2743
+ "Roll back " + ds.appDomain + " to the previous host? DNS returns to the captured records in about a minute.",
2744
+ )
2745
+ ) {
2746
+ return;
2747
+ }
2748
+ domainAction({ action: "rollback" }, event.currentTarget);
2749
+ });
2750
+
2751
+ // ---- b17: branch retention ----------------------------------------------
2752
+ // Renders the GET /api/changes/branches dry-run classification (already
2753
+ // projected server-side by branchRetentionView.ts — this script only
2754
+ // paints it, same division of labor as renderQueue/renderVersions above)
2755
+ // and drives the guarded delete. The server (candidate_delete, b13)
2756
+ // re-classifies fresh on every delete, so the confirm below is the HUMAN
2757
+ // gate, not the safety guarantee — the safety guarantee is server-side.
2758
+
2759
+ const branchesPanel = root.querySelector("[data-publish-branches]");
2760
+ const branchesBody = branchesPanel?.querySelector("[data-branches-body]");
2761
+ const branchesChip = branchesPanel?.querySelector("[data-branches-chip]");
2762
+ const branchesStatus = branchesPanel?.querySelector("[data-branches-status]");
2763
+
2764
+ const BRANCH_TONE = {
2765
+ protected: "",
2766
+ "active-open": "green",
2767
+ "stale-open": "amber",
2768
+ integrated: "blue",
2769
+ "closed-or-rejected": "blue",
2770
+ orphan: "red",
2771
+ };
2772
+
2773
+ const buildCleanupPlan = (row) => {
2774
+ return [
2775
+ "Delete branch plan:",
2776
+ " repo: " + (appDomain || repo),
2777
+ " branch: " + row.ref,
2778
+ " tip sha: " + row.shortSha,
2779
+ " classification: " + row.classificationLabel,
2780
+ " reason: " + row.reason,
2781
+ "",
2782
+ "This deletes the Git BRANCH only — it never evicts a hosted preview build",
2783
+ "(that is Retire, a separate action above). The server re-classifies this",
2784
+ "branch fresh before deleting; a moved tip or a branch that is no longer",
2785
+ "integrated/closed-or-rejected is refused, not silently skipped.",
2786
+ ].join("\n");
2787
+ };
2788
+
2789
+ const cleanupBranch = async (row, button) => {
2790
+ if (!window.confirm(buildCleanupPlan(row) + "\n\nDelete this branch?")) return;
2791
+ button.disabled = true;
2792
+ setStatus(branchesStatus, "Deleting " + row.shortRef + "…");
2793
+ try {
2794
+ const res = await fetch(ds.branchesEndpoint, {
2795
+ method: "POST",
2796
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
2797
+ body: JSON.stringify({ ref: row.ref, expectedSha: row.sha }),
2798
+ });
2799
+ const data = await res.json().catch(() => ({}));
2800
+ if (res.ok && data.ok) {
2801
+ setStatus(
2802
+ branchesStatus,
2803
+ data.alreadyAbsent
2804
+ ? row.shortRef + " was already gone (nothing to delete)."
2805
+ : "Deleted " + row.shortRef + ".",
2806
+ "ok",
2807
+ );
2808
+ } else {
2809
+ setStatus(branchesStatus, data.error || "Delete refused (" + res.status + ")", "err");
2810
+ }
2811
+ } catch {
2812
+ setStatus(branchesStatus, "Delete failed — network error", "err");
2813
+ } finally {
2814
+ button.disabled = false;
2815
+ loadBranches();
2816
+ }
2817
+ };
2818
+
2819
+ const renderBranches = (view) => {
2820
+ if (!branchesBody) return;
2821
+ const rows = Array.isArray(view.rows) ? view.rows : [];
2822
+ if (branchesChip) {
2823
+ const needsReview = rows.filter((r) => r.quarantine || r.eligibleForDelete).length;
2824
+ branchesChip.textContent = needsReview
2825
+ ? needsReview + " to review"
2826
+ : rows.length
2827
+ ? rows.length + " branches"
2828
+ : "0 branches";
2829
+ branchesChip.className = "chip" + (needsReview ? " amber" : "");
2830
+ }
2831
+ branchesBody.textContent = "";
2832
+ if (!rows.length) {
2833
+ const tr = document.createElement("tr");
2834
+ const td = cell(subtle("No candidate branches found for this repo."));
2835
+ td.colSpan = 7;
2836
+ tr.append(td);
2837
+ branchesBody.append(tr);
2838
+ return;
2839
+ }
2840
+
2841
+ for (const row of rows) {
2842
+ const tr = document.createElement("tr");
2843
+
2844
+ const branchCell = document.createElement("td");
2845
+ const branchStrong = document.createElement("strong");
2846
+ branchStrong.textContent = row.shortRef;
2847
+ branchCell.append(branchStrong, subtle(row.ref + " @ " + row.shortSha));
2848
+ tr.append(branchCell);
2849
+
2850
+ tr.append(cell(chip(row.classificationLabel, BRANCH_TONE[row.classification] || "")));
2851
+
2852
+ const prCell = document.createElement("td");
2853
+ if (row.prNumber != null) {
2854
+ // Link to the INTERNAL friendly PR route (/preview/<tenant>/pr/<N>), never
2855
+ // the server-provided `row.prUrl` — that is the raw Gitea forge html_url the
2856
+ // MCP candidate_list projection emits (<forge-host>/<org>/<repo>/pulls/N),
2857
+ // and surfacing a raw forge link to an owner is exactly the boundary
2858
+ // noGiteaLinks.test.ts guards (the source scan can't catch a host that
2859
+ // arrives in data, so we refuse it here at render). Mirrors the candidate
2860
+ // queue, which already builds its preview link this way.
2861
+ const prHref = candidatePrUrl(row.prNumber);
2862
+ if (prHref) {
2863
+ const a = document.createElement("a");
2864
+ a.href = prHref;
2865
+ a.target = "_blank";
2866
+ a.rel = "noopener";
2867
+ a.textContent = "PR #" + row.prNumber;
2868
+ prCell.append(a);
2869
+ } else {
2870
+ prCell.append(document.createTextNode("PR #" + row.prNumber));
2871
+ }
2872
+ prCell.append(subtle((row.prState || "") + (row.integratedIntoAggregate ? " · in aggregate" : "")));
2873
+ } else {
2874
+ prCell.append(subtle("No PR record"));
2875
+ }
2876
+ tr.append(prCell);
2877
+
2878
+ tr.append(cell(row.commitTimestampDisplay));
2879
+ tr.append(cell(row.ageLabel));
2880
+ tr.append(cell(row.evidenceSummary));
2881
+
2882
+ const actionsCell = document.createElement("td");
2883
+ actionsCell.className = "action";
2884
+ if (row.quarantine) {
2885
+ actionsCell.append(chip("Needs owner review", "red"));
2886
+ } else if (row.eligibleForDelete) {
2887
+ const btn = document.createElement("button");
2888
+ btn.type = "button";
2889
+ btn.className = "btn";
2890
+ btn.disabled = !canShip;
2891
+ btn.title = canShip ? "" : "Requires an owner/admin sign-in.";
2892
+ btn.textContent = "Clean up";
2893
+ btn.addEventListener("click", () => cleanupBranch(row, btn));
2894
+ actionsCell.append(btn);
2895
+ } else {
2896
+ actionsCell.append(subtle("Keep"));
2897
+ }
2898
+ tr.append(actionsCell);
2899
+
2900
+ branchesBody.append(tr);
2901
+ }
2902
+ };
2903
+
2904
+ const loadBranches = async () => {
2905
+ if (!branchesBody) return;
2906
+ try {
2907
+ const res = await fetch(ds.branchesEndpoint, { headers: { accept: "application/json" } });
2908
+ if (res.status === 401 || res.status === 403) {
2909
+ if (branchesChip) branchesChip.textContent = "Sign-in required";
2910
+ branchesBody.textContent = "";
2911
+ const tr = document.createElement("tr");
2912
+ const td = cell(subtle("Sign in as a store owner or admin to view branch retention."));
2913
+ td.colSpan = 7;
2914
+ tr.append(td);
2915
+ branchesBody.append(tr);
2916
+ return;
2917
+ }
2918
+ const data = await res.json().catch(() => ({}));
2919
+ if (!res.ok) {
2920
+ setStatus(branchesStatus, data.error || "Could not load branch classification (" + res.status + ")", "err");
2921
+ return;
2922
+ }
2923
+ renderBranches(data.report || {});
2924
+ } catch {
2925
+ setStatus(branchesStatus, "Could not load branch classification — network error", "err");
2926
+ }
2927
+ };
2928
+
2929
+ // ---- Wiring ------------------------------------------------------------
2930
+
2931
+ root.querySelector("[data-publish-refresh]")?.addEventListener("click", () => {
2932
+ setStatus(statusEl, "");
2933
+ loadQueue();
2934
+ loadVersions();
2935
+ loadDomain();
2936
+ loadBranches();
2937
+ });
2938
+
2939
+ // rux7: pause polling while hidden; on return, refresh immediately (loadQueue
2940
+ // re-renders + resyncs the poll clock) rather than waiting out the interval.
2941
+ document.addEventListener("visibilitychange", () => {
2942
+ if (document.hidden) {
2943
+ clearPoll();
2944
+ } else {
2945
+ loadQueue();
2946
+ }
2947
+ });
2948
+
2949
+ // loadQueue starts the poll cadence via its tail (schedulePoll).
2950
+ loadQueue();
2951
+ loadVersions();
2952
+ loadDomain();
2953
+ loadBranches();
2954
+ })();
2955
+ </script>
2956
+
2957
+ <style>
2958
+ .publish-table {
2959
+ min-width: 900px;
2960
+ }
2961
+
2962
+ .publish-mono {
2963
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2964
+ font-size: 12px;
2965
+ }
2966
+
2967
+ .publish-actions {
2968
+ white-space: nowrap;
2969
+ }
2970
+
2971
+ .publish-actions .btn {
2972
+ margin-left: 6px;
2973
+ }
2974
+
2975
+ .publish-status {
2976
+ min-height: 0;
2977
+ }
2978
+
2979
+ .publish-status:empty {
2980
+ display: none;
2981
+ }
2982
+
2983
+ .publish-status[data-state="ok"] {
2984
+ color: var(--admin-green);
2985
+ }
2986
+
2987
+ .publish-status[data-state="err"] {
2988
+ color: var(--admin-red);
2989
+ }
2990
+
2991
+ /* ?pr=N deep link: "not in the queue" callout + row flash */
2992
+ .publish-pr-callout {
2993
+ border: 1px solid var(--admin-line-strong);
2994
+ border-left: 3px solid var(--admin-amber);
2995
+ background: var(--admin-surface-soft);
2996
+ padding: 8px 12px;
2997
+ border-radius: var(--admin-radius-sm);
2998
+ color: var(--admin-ink);
2999
+ }
3000
+
3001
+ @keyframes publish-row-flash {
3002
+ 0%,
3003
+ 25% {
3004
+ background-color: var(--admin-teal-soft);
3005
+ }
3006
+ 100% {
3007
+ background-color: transparent;
3008
+ }
3009
+ }
3010
+
3011
+ .publish-row-highlight > td {
3012
+ animation: publish-row-flash 2.4s ease-out;
3013
+ }
3014
+
3015
+ @media (prefers-reduced-motion: reduce) {
3016
+ .publish-row-highlight > td {
3017
+ animation: none;
3018
+ background-color: var(--admin-teal-soft);
3019
+ }
3020
+ }
3021
+
3022
+ .publish-error-detail {
3023
+ color: var(--admin-red);
3024
+ font-size: 12px;
3025
+ }
3026
+
3027
+ /* ── Accept-to-preview LIFTOFF ─────────────────────────────────────────── */
3028
+ .liftoff-backdrop {
3029
+ position: fixed;
3030
+ inset: 0;
3031
+ z-index: 60;
3032
+ display: grid;
3033
+ place-items: center;
3034
+ padding: 24px;
3035
+ background: color-mix(in srgb, var(--admin-ink) 32%, transparent);
3036
+ backdrop-filter: blur(6px);
3037
+ -webkit-backdrop-filter: blur(6px);
3038
+ }
3039
+
3040
+ /* `display: grid` above beats the UA's [hidden] rule — restate it, or the
3041
+ invisible backdrop click-blocks the entire page (caught by live test). */
3042
+ .liftoff-backdrop[hidden] {
3043
+ display: none;
3044
+ }
3045
+
3046
+ .liftoff-card {
3047
+ position: relative;
3048
+ width: min(520px, 100%);
3049
+ max-height: min(84vh, 640px);
3050
+ overflow: auto;
3051
+ border: 1px solid var(--admin-line-strong);
3052
+ border-radius: var(--admin-radius, 12px);
3053
+ background: var(--admin-surface);
3054
+ box-shadow: 0 24px 80px rgba(8, 32, 30, 0.35);
3055
+ padding: 24px 26px;
3056
+ animation: liftoff-in 0.22s ease-out;
3057
+ }
3058
+
3059
+ @keyframes liftoff-in {
3060
+ from { transform: translateY(10px) scale(0.985); opacity: 0; }
3061
+ to { transform: none; opacity: 1; }
3062
+ }
3063
+
3064
+ /* In-flight shimmer: a live gradient beam along the card's top edge. */
3065
+ .liftoff-card::before {
3066
+ content: "";
3067
+ position: absolute;
3068
+ inset: 0 0 auto 0;
3069
+ height: 3px;
3070
+ border-radius: inherit;
3071
+ opacity: 0;
3072
+ background: linear-gradient(90deg, transparent, var(--admin-teal, #0f9d8f), #f4b942, var(--admin-teal, #0f9d8f), transparent);
3073
+ background-size: 220% 100%;
3074
+ transition: opacity 0.3s ease;
3075
+ }
3076
+
3077
+ .liftoff-card[data-mode="flight"]::before {
3078
+ opacity: 1;
3079
+ animation: liftoff-beam 1.6s linear infinite;
3080
+ }
3081
+
3082
+ @keyframes liftoff-beam {
3083
+ from { background-position: 200% 0; }
3084
+ to { background-position: -20% 0; }
3085
+ }
3086
+
3087
+ .liftoff-kicker {
3088
+ margin: 0 0 2px;
3089
+ font-size: 11px;
3090
+ font-weight: 700;
3091
+ letter-spacing: 0.08em;
3092
+ text-transform: uppercase;
3093
+ color: var(--admin-teal, #0f9d8f);
3094
+ }
3095
+
3096
+ .liftoff-title {
3097
+ margin: 0 0 14px;
3098
+ font-size: 19px;
3099
+ line-height: 1.25;
3100
+ }
3101
+
3102
+ .liftoff-grid {
3103
+ display: grid;
3104
+ grid-template-columns: auto 1fr;
3105
+ gap: 6px 16px;
3106
+ margin: 0 0 12px;
3107
+ padding: 12px 14px;
3108
+ border: 1px solid var(--admin-line-strong);
3109
+ border-radius: var(--admin-radius-sm);
3110
+ background: var(--admin-surface-soft);
3111
+ }
3112
+
3113
+ .liftoff-grid dt {
3114
+ color: var(--admin-muted);
3115
+ font-size: 12px;
3116
+ }
3117
+
3118
+ .liftoff-grid dd {
3119
+ margin: 0;
3120
+ font-size: 13px;
3121
+ }
3122
+
3123
+ .liftoff-actions {
3124
+ display: flex;
3125
+ justify-content: flex-end;
3126
+ gap: 8px;
3127
+ margin-top: 16px;
3128
+ }
3129
+
3130
+ .liftoff-stages {
3131
+ list-style: none;
3132
+ margin: 6px 0 4px;
3133
+ padding: 0;
3134
+ display: grid;
3135
+ gap: 0;
3136
+ }
3137
+
3138
+ .liftoff-stages li {
3139
+ position: relative;
3140
+ display: flex;
3141
+ gap: 12px;
3142
+ align-items: flex-start;
3143
+ padding: 9px 0;
3144
+ }
3145
+
3146
+ /* Connecting rail between the dots. */
3147
+ .liftoff-stages li + li::before {
3148
+ content: "";
3149
+ position: absolute;
3150
+ left: 10px;
3151
+ top: -8px;
3152
+ height: 16px;
3153
+ width: 2px;
3154
+ background: var(--admin-line-strong);
3155
+ }
3156
+
3157
+ .liftoff-stages li > div {
3158
+ display: grid;
3159
+ gap: 1px;
3160
+ }
3161
+
3162
+ .liftoff-stages b {
3163
+ font-size: 13px;
3164
+ }
3165
+
3166
+ .liftoff-stages span:not(.liftoff-dot) {
3167
+ font-size: 12px;
3168
+ color: var(--admin-muted);
3169
+ }
3170
+
3171
+ .liftoff-dot {
3172
+ flex: none;
3173
+ width: 21px;
3174
+ height: 21px;
3175
+ margin-top: 1px;
3176
+ border-radius: 50%;
3177
+ border: 2px solid var(--admin-line-strong);
3178
+ display: grid;
3179
+ place-items: center;
3180
+ font-size: 12px;
3181
+ font-weight: 700;
3182
+ color: #fff;
3183
+ background: transparent;
3184
+ transition: all 0.25s ease;
3185
+ }
3186
+
3187
+ .liftoff-stages li[data-state="active"] .liftoff-dot {
3188
+ border-color: var(--admin-teal, #0f9d8f);
3189
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--admin-teal, #0f9d8f) 45%, transparent);
3190
+ animation: liftoff-pulse 1.3s ease-out infinite;
3191
+ }
3192
+
3193
+ .liftoff-stages li[data-state="active"] b {
3194
+ color: var(--admin-teal, #0f9d8f);
3195
+ }
3196
+
3197
+ @keyframes liftoff-pulse {
3198
+ from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--admin-teal, #0f9d8f) 45%, transparent); }
3199
+ to { box-shadow: 0 0 0 10px transparent; }
3200
+ }
3201
+
3202
+ .liftoff-stages li[data-state="done"] .liftoff-dot {
3203
+ border-color: var(--admin-green);
3204
+ background: var(--admin-green);
3205
+ }
3206
+
3207
+ .liftoff-stages li[data-state="done"] .liftoff-dot::after {
3208
+ content: "✓";
3209
+ }
3210
+
3211
+ .liftoff-stages li[data-state="fail"] .liftoff-dot {
3212
+ border-color: var(--admin-red);
3213
+ background: var(--admin-red);
3214
+ }
3215
+
3216
+ .liftoff-stages li[data-state="fail"] .liftoff-dot::after {
3217
+ content: "✕";
3218
+ }
3219
+
3220
+ .liftoff-live {
3221
+ margin: 8px 0 0;
3222
+ min-height: 18px;
3223
+ font-size: 13px;
3224
+ color: var(--admin-ink);
3225
+ }
3226
+
3227
+ .liftoff-meta {
3228
+ display: flex;
3229
+ justify-content: space-between;
3230
+ align-items: center;
3231
+ margin-top: 14px;
3232
+ color: var(--admin-muted);
3233
+ }
3234
+
3235
+ .liftoff-outcome-phase {
3236
+ text-align: center;
3237
+ padding-top: 6px;
3238
+ }
3239
+
3240
+ .liftoff-outcome-phase .liftoff-actions {
3241
+ justify-content: center;
3242
+ }
3243
+
3244
+ .liftoff-seal {
3245
+ width: 56px;
3246
+ height: 56px;
3247
+ margin: 4px auto 12px;
3248
+ border-radius: 50%;
3249
+ display: grid;
3250
+ place-items: center;
3251
+ font-size: 28px;
3252
+ font-weight: 700;
3253
+ color: #fff;
3254
+ }
3255
+
3256
+ .liftoff-seal[data-state="ok"] {
3257
+ background: var(--admin-green);
3258
+ animation: liftoff-seal-pop 0.45s cubic-bezier(0.2, 1.6, 0.4, 1);
3259
+ }
3260
+
3261
+ .liftoff-seal[data-state="ok"]::after {
3262
+ content: "✓";
3263
+ }
3264
+
3265
+ .liftoff-seal[data-state="err"] {
3266
+ background: var(--admin-red);
3267
+ }
3268
+
3269
+ .liftoff-seal[data-state="err"]::after {
3270
+ content: "✕";
3271
+ }
3272
+
3273
+ @keyframes liftoff-seal-pop {
3274
+ from { transform: scale(0.4); opacity: 0; }
3275
+ 60% { transform: scale(1.12); }
3276
+ to { transform: none; opacity: 1; }
3277
+ }
3278
+
3279
+ .liftoff-confetti {
3280
+ pointer-events: none;
3281
+ position: absolute;
3282
+ inset: 0;
3283
+ overflow: hidden;
3284
+ border-radius: inherit;
3285
+ }
3286
+
3287
+ .liftoff-confetti i {
3288
+ position: absolute;
3289
+ top: -12px;
3290
+ width: 7px;
3291
+ height: 11px;
3292
+ border-radius: 2px;
3293
+ opacity: 0;
3294
+ animation-name: liftoff-confetti-fall;
3295
+ animation-timing-function: ease-in;
3296
+ animation-fill-mode: forwards;
3297
+ }
3298
+
3299
+ @keyframes liftoff-confetti-fall {
3300
+ 0% { opacity: 1; transform: translate(0, 0) rotate(0deg); }
3301
+ 100% {
3302
+ opacity: 0.9;
3303
+ transform: translate(var(--liftoff-drift, 0px), 660px) rotate(var(--liftoff-spin, 240deg));
3304
+ }
3305
+ }
3306
+
3307
+ @media (prefers-reduced-motion: reduce) {
3308
+ .liftoff-card,
3309
+ .liftoff-card[data-mode="flight"]::before,
3310
+ .liftoff-stages li[data-state="active"] .liftoff-dot,
3311
+ .liftoff-seal[data-state="ok"] {
3312
+ animation: none;
3313
+ }
3314
+ }
3315
+
3316
+ .domain-connect-controls {
3317
+ display: grid;
3318
+ gap: 8px;
3319
+ justify-items: end;
3320
+ min-width: 220px;
3321
+ }
3322
+
3323
+ .domain-connect-controls input[type="text"] {
3324
+ width: 100%;
3325
+ min-height: 36px;
3326
+ padding: 0 10px;
3327
+ border: 1px solid var(--admin-line-strong);
3328
+ border-radius: var(--admin-radius-sm);
3329
+ background: var(--admin-surface);
3330
+ color: var(--admin-ink);
3331
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3332
+ font-size: 12px;
3333
+ }
3334
+
3335
+ .domain-rehearsal {
3336
+ display: inline-flex;
3337
+ align-items: center;
3338
+ gap: 6px;
3339
+ color: var(--admin-muted);
3340
+ font-size: 12px;
3341
+ }
3342
+
3343
+ @media (max-width: 720px) {
3344
+ .domain-connect-controls {
3345
+ justify-items: stretch;
3346
+ min-width: 0;
3347
+ width: 100%;
3348
+ }
3349
+ }
3350
+
3351
+ /* u9: apex-cutover readiness itemization */
3352
+ .domain-readiness {
3353
+ margin: 0.75rem 0;
3354
+ padding: 0.75rem 1rem;
3355
+ border: 1px solid var(--hairline, rgba(0, 0, 0, 0.12));
3356
+ border-radius: 0.5rem;
3357
+ }
3358
+ .domain-readiness-head {
3359
+ display: flex;
3360
+ align-items: center;
3361
+ gap: 0.5rem;
3362
+ margin-bottom: 0.5rem;
3363
+ }
3364
+ .domain-readiness-list {
3365
+ list-style: none;
3366
+ margin: 0;
3367
+ padding: 0;
3368
+ display: grid;
3369
+ gap: 0.25rem;
3370
+ }
3371
+ .domain-readiness-list li {
3372
+ display: flex;
3373
+ align-items: baseline;
3374
+ gap: 0.4rem;
3375
+ font-size: 0.9em;
3376
+ }
3377
+ .domain-readiness-mark {
3378
+ font-weight: 700;
3379
+ width: 1em;
3380
+ }
3381
+ .domain-readiness-list li.ok .domain-readiness-mark {
3382
+ color: var(--good, #1a7f37);
3383
+ }
3384
+ .domain-readiness-list li.blocked .domain-readiness-mark {
3385
+ color: var(--bad, #b42318);
3386
+ }
3387
+ .domain-readiness-detail {
3388
+ opacity: 0.75;
3389
+ }
3390
+
3391
+ /* b11: aggregate card + ship plan */
3392
+ .aggregate-prs {
3393
+ display: grid;
3394
+ gap: 6px;
3395
+ }
3396
+
3397
+ .aggregate-prs-list {
3398
+ list-style: none;
3399
+ margin: 0;
3400
+ padding: 0;
3401
+ display: grid;
3402
+ gap: 4px;
3403
+ }
3404
+
3405
+ .aggregate-prs-list li {
3406
+ display: flex;
3407
+ align-items: baseline;
3408
+ gap: 10px;
3409
+ font-size: 13px;
3410
+ }
3411
+
3412
+ .aggregate-plan {
3413
+ padding: 0.75rem 1rem;
3414
+ border: 1px solid var(--hairline, rgba(0, 0, 0, 0.12));
3415
+ border-radius: 0.5rem;
3416
+ display: grid;
3417
+ gap: 8px;
3418
+ }
3419
+
3420
+ .aggregate-plan-head {
3421
+ display: flex;
3422
+ align-items: center;
3423
+ gap: 0.5rem;
3424
+ }
3425
+
3426
+ .aggregate-plan-grid {
3427
+ display: grid;
3428
+ grid-template-columns: max-content 1fr;
3429
+ gap: 4px 16px;
3430
+ margin: 0;
3431
+ }
3432
+
3433
+ .aggregate-plan-grid dt {
3434
+ color: var(--admin-muted);
3435
+ font-size: 12px;
3436
+ }
3437
+
3438
+ .aggregate-plan-grid dd {
3439
+ margin: 0;
3440
+ font-size: 13px;
3441
+ overflow-wrap: anywhere;
3442
+ }
3443
+
3444
+ /* dg7: versions + diff */
3445
+ .versions-table {
3446
+ min-width: 780px;
3447
+ }
3448
+
3449
+ /* rux6: unified review pane */
3450
+ .publish-review-cell {
3451
+ padding: 12px 14px;
3452
+ background: var(--admin-surface-soft);
3453
+ }
3454
+
3455
+ .review-pane {
3456
+ display: grid;
3457
+ grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
3458
+ gap: 16px;
3459
+ align-items: start;
3460
+ }
3461
+
3462
+ @media (max-width: 900px) {
3463
+ .review-pane {
3464
+ grid-template-columns: 1fr;
3465
+ }
3466
+ }
3467
+
3468
+ .review-preview {
3469
+ display: grid;
3470
+ gap: 8px;
3471
+ min-width: 0;
3472
+ }
3473
+
3474
+ .review-pane-head {
3475
+ display: flex;
3476
+ align-items: center;
3477
+ justify-content: space-between;
3478
+ gap: 10px;
3479
+ }
3480
+
3481
+ .review-frame {
3482
+ width: 100%;
3483
+ height: 420px;
3484
+ border: 1px solid var(--admin-line-strong);
3485
+ border-radius: var(--admin-radius-sm);
3486
+ background: var(--admin-surface);
3487
+ }
3488
+
3489
+ .review-detail {
3490
+ display: grid;
3491
+ gap: 14px;
3492
+ min-width: 0;
3493
+ }
3494
+
3495
+ .review-section {
3496
+ display: grid;
3497
+ gap: 6px;
3498
+ }
3499
+
3500
+ .review-chips {
3501
+ display: flex;
3502
+ flex-wrap: wrap;
3503
+ gap: 6px;
3504
+ }
3505
+
3506
+ .review-actions {
3507
+ display: flex;
3508
+ flex-wrap: wrap;
3509
+ gap: 8px;
3510
+ }
3511
+
3512
+ .publish-diff-head {
3513
+ display: flex;
3514
+ flex-wrap: wrap;
3515
+ align-items: center;
3516
+ gap: 10px;
3517
+ margin-bottom: 8px;
3518
+ }
3519
+
3520
+ .publish-diff-blocked {
3521
+ color: var(--admin-amber);
3522
+ }
3523
+
3524
+ .publish-diff-list {
3525
+ list-style: none;
3526
+ margin: 0;
3527
+ padding: 0;
3528
+ display: grid;
3529
+ gap: 3px;
3530
+ }
3531
+
3532
+ .publish-diff-list li {
3533
+ display: flex;
3534
+ align-items: baseline;
3535
+ gap: 8px;
3536
+ font-size: 12px;
3537
+ }
3538
+
3539
+ .publish-diff-mark {
3540
+ width: 1em;
3541
+ font-weight: 700;
3542
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3543
+ }
3544
+
3545
+ .publish-diff-added .publish-diff-mark {
3546
+ color: var(--admin-green);
3547
+ }
3548
+
3549
+ .publish-diff-changed .publish-diff-mark {
3550
+ color: var(--admin-blue);
3551
+ }
3552
+
3553
+ .publish-diff-removed .publish-diff-mark {
3554
+ color: var(--admin-red);
3555
+ }
3556
+
3557
+ .publish-diff-list code {
3558
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3559
+ font-size: 12px;
3560
+ overflow-wrap: anywhere;
3561
+ }
3562
+ </style>