@desplega.ai/agent-swarm 2.7.6 → 2.7.7

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 (244) hide show
  1. package/dist/{acp-adapter-bp91d37f.js → acp-adapter-7b4vk3h5.js} +5 -4
  2. package/dist/{actions-yj0eeaqh.js → actions-3tt153af.js} +18 -18
  3. package/dist/{amp-adapter-4pg9h34q.js → amp-adapter-njpts0yr.js} +1 -1
  4. package/dist/{anthropic-messages-x4q6rfq5.js → anthropic-messages-xey2skwx.js} +7 -7
  5. package/dist/{app-909e97cr.js → app-nky4378f.js} +3 -3
  6. package/dist/{assistant-qdyb5fyy.js → assistant-jnb669pa.js} +19 -19
  7. package/dist/{azure-openai-responses-s1w5n70z.js → azure-openai-responses-26t7j9ra.js} +5 -5
  8. package/dist/{boot-reembed-fk0s9z18.js → boot-reembed-9mgvaztt.js} +1 -1
  9. package/dist/{boot-reembed-gs1rzrnh.js → boot-reembed-qs2djdej.js} +1 -1
  10. package/dist/{boot-scrub-sweep-4qfd4kfx.js → boot-scrub-sweep-yjpdj7s1.js} +3 -3
  11. package/dist/{boot-seeds-yr00k13d.js → boot-seeds-5cm6ambd.js} +4 -4
  12. package/dist/{boxd-vdmfssd9.js → boxd-nv507va8.js} +1 -1
  13. package/dist/{claude-adapter-qta1f1xn.js → claude-adapter-fn287zmm.js} +5 -4
  14. package/dist/{claude-managed-setup-sc6j4dm0.js → claude-managed-setup-wtdz2mj2.js} +1 -1
  15. package/dist/{claude-sdk-session-zgrz6yvc.js → claude-sdk-session-x91kqyhm.js} +8288 -8224
  16. package/dist/{cli-wp9sp1qb.js → cli-08bmz9z9.js} +1 -1
  17. package/dist/{cli-yy3rhsdw.js → cli-1c6tzt8g.js} +7 -7
  18. package/dist/{cli-q0k1w5zd.js → cli-35rsp8ye.js} +1 -1
  19. package/dist/{cli-xnrwazav.js → cli-3fg0bdcv.js} +1 -1
  20. package/dist/{cli-89knwags.js → cli-5b456bpf.js} +2 -2
  21. package/dist/{cli-drr5v6av.js → cli-5jnhv72d.js} +1 -1
  22. package/dist/{cli-dxae2yg7.js → cli-5zx699j9.js} +2 -2
  23. package/dist/{cli-0at8w34w.js → cli-6g0hb4cp.js} +8 -8
  24. package/dist/{cli-e3r2neck.js → cli-6pfd54vx.js} +5 -5
  25. package/dist/{cli-bsn8nhrq.js → cli-6vcp76qk.js} +3 -3
  26. package/dist/{cli-ekwks2vs.js → cli-72v799xn.js} +1 -1
  27. package/dist/cli-82j36f2m.js +45 -0
  28. package/dist/{cli-yx4kfngt.js → cli-8305gbq0.js} +29 -16
  29. package/dist/{cli-4necwtsv.js → cli-8sckt74b.js} +8 -8
  30. package/dist/{cli-vvpx3gsx.js → cli-95jzxcp3.js} +1 -1
  31. package/dist/{cli-hr2e2fft.js → cli-adeegh7d.js} +7 -7
  32. package/dist/{cli-m48g6p95.js → cli-afkqkrtw.js} +3 -3
  33. package/dist/{cli-0qatpy8q.js → cli-ayayvfg4.js} +24 -4
  34. package/dist/{cli-bqt1mptt.js → cli-az48n0nr.js} +4 -4
  35. package/dist/{cli-zm72anhg.js → cli-bfv7vdg6.js} +9 -9
  36. package/dist/{cli-hgaazk69.js → cli-caj33vcq.js} +5 -5
  37. package/dist/{cli-3ypk9vk1.js → cli-cb2a8pna.js} +1 -1
  38. package/dist/{cli-waqbxg08.js → cli-cppkf404.js} +17 -17
  39. package/dist/{cli-yn4kxs06.js → cli-ddaanqkd.js} +4 -4
  40. package/dist/{cli-386tyxxg.js → cli-e0kmphd6.js} +2 -2
  41. package/dist/{cli-54t00gt8.js → cli-e46hg0xw.js} +1 -1
  42. package/dist/{cli-jm2vfpfd.js → cli-e5bzrn01.js} +1 -1
  43. package/dist/{cli-436532ck.js → cli-excnnjg2.js} +165 -153
  44. package/dist/{cli-71ptav7x.js → cli-ferkkrg9.js} +1 -1
  45. package/dist/{cli-vpf89f34.js → cli-fmjd0t1y.js} +3 -3
  46. package/dist/{cli-pd7vagab.js → cli-fyt2g4wd.js} +3 -3
  47. package/dist/{cli-gdhpcbhf.js → cli-g08zdaec.js} +1 -1
  48. package/dist/{cli-ehq736cn.js → cli-g7s47cc5.js} +4 -4
  49. package/dist/{cli-svj5090z.js → cli-gz6q4eve.js} +3 -3
  50. package/dist/{cli-8snvgs5r.js → cli-je518txm.js} +10 -10
  51. package/dist/{cli-rmmmrjkd.js → cli-jtjtabxe.js} +1 -1
  52. package/dist/{cli-611a1294.js → cli-k0wwfqbg.js} +18 -1
  53. package/dist/{cli-es6va8jk.js → cli-k1nje3v3.js} +38 -43
  54. package/dist/{cli-p5gp8pcx.js → cli-k8196sh2.js} +6 -6
  55. package/dist/{cli-nwj0ee2w.js → cli-k899ehvh.js} +2 -2
  56. package/dist/{cli-decfb6v8.js → cli-m0da8j35.js} +23 -9
  57. package/dist/{cli-he9wchme.js → cli-mkk9rnzz.js} +4 -4
  58. package/dist/{cli-nhrvpaj7.js → cli-n6bv12jn.js} +510 -4
  59. package/dist/{cli-8bc1w0tw.js → cli-nmzhp1b5.js} +14 -12
  60. package/dist/{cli-0yv5z2pg.js → cli-p82cnnjq.js} +1 -1
  61. package/dist/{cli-c60vetmm.js → cli-pc6n7fre.js} +1 -1
  62. package/dist/{cli-xds5f77d.js → cli-pjctyea3.js} +2 -2
  63. package/dist/{cli-xbbndvjy.js → cli-pq73h5mz.js} +3 -3
  64. package/dist/{cli-aa6yxc7m.js → cli-pz4rk021.js} +1 -1
  65. package/dist/{cli-xnqgpshy.js → cli-q3tkegby.js} +1 -1
  66. package/dist/cli-qghe0h66.js +67 -0
  67. package/dist/{cli-zbbwez0r.js → cli-qh4d4tvk.js} +4 -4
  68. package/dist/{cli-w6dt9ged.js → cli-rtt8g4v9.js} +1 -1
  69. package/dist/{cli-1h3wcpbw.js → cli-snpj5zbj.js} +2 -2
  70. package/dist/{cli-a7v854rf.js → cli-tgrv9cjx.js} +263 -12
  71. package/dist/{cli-faxe6thg.js → cli-vz22fyqv.js} +186 -54
  72. package/dist/{cli-k2gzwsj2.js → cli-wk5yrfwd.js} +160 -26
  73. package/dist/{cli-shpbx5ee.js → cli-ws19768n.js} +1641 -49
  74. package/dist/{cli-70r77n04.js → cli-xae4g51r.js} +1 -1
  75. package/dist/{cli-kqdg1bsb.js → cli-yndmfxb3.js} +2 -2
  76. package/dist/{cli-bjb3z51z.js → cli-yyj3j5m4.js} +2 -2
  77. package/dist/cli.js +21 -21
  78. package/dist/{cloudflare-workers-ai-system-one-bm60fjm0.js → cloudflare-workers-ai-system-one-ef2a980q.js} +2 -2
  79. package/dist/{codex-adapter-ayd1fb6h.js → codex-adapter-cf2yxe9m.js} +3 -2
  80. package/dist/{codex-hook-9efm2x1m.js → codex-hook-a0c6g6t6.js} +1 -1
  81. package/dist/{codex-session-runner-jesa32xc.js → codex-session-runner-6vfzdgbd.js} +3 -2
  82. package/dist/{commands-hanewgf2.js → commands-t3mfy7vn.js} +3 -3
  83. package/dist/{controller-dt1n3c1q.js → controller-q7xrbaz0.js} +3 -3
  84. package/dist/{controllers-connect-xx8x64n5.js → controllers-connect-c6nm70kg.js} +19 -19
  85. package/dist/{cursor-adapter-9s4dwwdw.js → cursor-adapter-ftd81ga8.js} +1 -1
  86. package/dist/{db-3vttcbv4.js → db-dk572x25.js} +3 -3
  87. package/dist/{db-9dv0g1ab.js → db-vemtpmcc.js} +4 -2
  88. package/dist/{dispatcher-tx9hd1sv.js → dispatcher-kh7ecxh9.js} +17 -17
  89. package/dist/{dsh-adapter-007y4ejs.js → dsh-adapter-v8628r89.js} +1 -1
  90. package/dist/{e2b-we6hs6q5.js → e2b-fr12yccx.js} +1 -1
  91. package/dist/{e2b-a19d28rz.js → e2b-x6ywzak3.js} +1 -1
  92. package/dist/{easter-egg-3d-rgbrqb8s.js → easter-egg-3d-nenw4x63.js} +1 -1
  93. package/dist/{embedding-space-ny1e2940.js → embedding-space-0jepb239.js} +6 -6
  94. package/dist/{embeddings-2t386t13.js → embeddings-xxt49yxg.js} +1 -1
  95. package/dist/{execute-pksh0ceh.js → execute-1mhw6e4x.js} +1 -1
  96. package/dist/{external-channel-phgmhmd7.js → external-channel-55v12e60.js} +2 -2
  97. package/dist/{fx-adapter-fy8rr921.js → fx-adapter-bs8wqrk5.js} +6 -5
  98. package/dist/{google-generative-ai-8y3m3k4b.js → google-generative-ai-v94hr7h0.js} +5 -5
  99. package/dist/{google-vertex-w3v98n7b.js → google-vertex-g7vr6cf8.js} +5 -5
  100. package/dist/{grok-adapter-q0abvpaj.js → grok-adapter-gpjqmkcq.js} +6 -5
  101. package/dist/handlers-zxmp1ta4.js +32 -0
  102. package/dist/{heartbeat-kadyek8p.js → heartbeat-k42svnr0.js} +20 -20
  103. package/dist/{hook-9k551xnj.js → hook-114m7hap.js} +4 -3
  104. package/dist/{hook-5zs3msf6.js → hook-6nphweh0.js} +24 -9
  105. package/dist/{http-mhz9vw1f.js → http-r8cc4wz7.js} +1025 -173
  106. package/dist/index-a8phxqnk.js +37 -0
  107. package/dist/{index-vdygbq10.js → index-e3s8wmxy.js} +1 -1
  108. package/dist/{index-q361cg2f.js → index-fpk4bcd9.js} +1 -1
  109. package/dist/index-k6h00t4c.js +30 -0
  110. package/dist/{index-rvr00sg7.js → index-nh3wyqdt.js} +1017 -574
  111. package/dist/index-xrz7te8h.js +33 -0
  112. package/dist/{keepalive-w56f32cg.js → keepalive-x060nt9p.js} +3 -3
  113. package/dist/lead-956sd2yc.js +71 -0
  114. package/dist/{maintenance-7mdgxcea.js → maintenance-2xxfexhf.js} +1 -1
  115. package/dist/{mistral-conversations-4pmwj1c0.js → mistral-conversations-3gjpdb4b.js} +4 -4
  116. package/dist/{oauth-refresh-sweep-29txy9vk.js → oauth-refresh-sweep-fckc4ar5.js} +2 -2
  117. package/dist/{onboard-kpsrkarj.js → onboard-zc48j237.js} +3 -3
  118. package/dist/{openai-codex-responses-44j573nm.js → openai-codex-responses-8pbxq9h5.js} +5 -5
  119. package/dist/{openai-completions-a4gee2a8.js → openai-completions-rm00s912.js} +4 -4
  120. package/dist/{openai-decisions-w4qkrcar.js → openai-decisions-8v957r2r.js} +1 -1
  121. package/dist/{openai-responses-rbp6h5kr.js → openai-responses-agxpf96k.js} +5 -5
  122. package/dist/{opencode-adapter-j25vm3rr.js → opencode-adapter-r690h1cr.js} +4 -3
  123. package/dist/{otel-impl-6kkct7a8.js → otel-impl-r5xbh81q.js} +4 -4
  124. package/dist/{pi-mono-adapter-exhkzhn2.js → pi-mono-adapter-c7v8sdec.js} +7 -6
  125. package/dist/{pricing-refresh-n2x7jh6k.js → pricing-refresh-3pmre11v.js} +3 -3
  126. package/dist/{rbac-roles-81ypx6es.js → rbac-roles-3vbshwsb.js} +1 -1
  127. package/dist/{rbac-roles-b5htkjby.js → rbac-roles-910mmkgz.js} +2 -2
  128. package/dist/{render-v2-qn6jmpk4.js → render-v2-pxykkbab.js} +3 -3
  129. package/dist/{resume-wd30wx23.js → resume-adda0bwy.js} +6 -6
  130. package/dist/{runtime-4hsg7h60.js → runtime-fnbm7stk.js} +1 -1
  131. package/dist/{session-access-5qvfevhk.js → session-access-2fvamrdc.js} +4 -4
  132. package/dist/{session-source-backfill-n1czavbr.js → session-source-backfill-08xvv0sm.js} +2 -2
  133. package/dist/{setup-dnb56qy5.js → setup-1aqmvmh1.js} +1 -1
  134. package/dist/{typesafe-system-one-5mavm259.js → typesafe-system-one-0bmqcxfg.js} +2 -2
  135. package/dist/{virtual-modules-76xxv9fs.js → virtual-modules-d2pjk9j1.js} +5 -5
  136. package/dist/{work-objects-4n21tfkm.js → work-objects-9s0yrm5j.js} +20 -20
  137. package/dist/{worker-follow-up-77hvzxww.js → worker-follow-up-3q78fqmr.js} +17 -17
  138. package/dist/worker-y15hy08h.js +71 -0
  139. package/openapi.json +197 -4
  140. package/package.json +5 -5
  141. package/src/apps/definition.ts +6 -5
  142. package/src/be/db/page-theme-tokens.ts +12 -0
  143. package/src/be/db/tasks/read.ts +9 -1
  144. package/src/be/db/tasks/write.ts +19 -0
  145. package/src/be/db-client.ts +107 -7
  146. package/src/be/db.ts +34 -2
  147. package/src/be/migrations/269_pages_design_system.sql +23 -0
  148. package/src/be/seed/schedules-seeder.ts +0 -10
  149. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +2 -0
  150. package/src/be/seed-scripts/catalog/catalog-report.ts +2 -0
  151. package/src/be/seed-scripts/catalog/memory-eval.ts +2 -0
  152. package/src/be/seed-scripts/catalog/swarm-update-check.ts +150 -0
  153. package/src/be/seed-scripts/index.ts +9 -0
  154. package/src/be/seed-skills/bundled-files.generated.json +3 -3
  155. package/src/be/swarm-config-guard.ts +32 -0
  156. package/src/be/task-auto-title-listener.ts +127 -0
  157. package/src/commands/onboard/agent-fs-version.ts +1 -1
  158. package/src/commands/profile-sync.ts +151 -16
  159. package/src/commands/runner.ts +15 -7
  160. package/src/hooks/hook.ts +20 -9
  161. package/src/http/index.ts +3 -0
  162. package/src/http/pages-public.ts +93 -129
  163. package/src/http/pages.ts +94 -21
  164. package/src/pages/browser-sdk.ts +48 -21
  165. package/src/pages/color-literals.ts +115 -0
  166. package/src/pages/page-head.ts +766 -0
  167. package/src/pages/version.ts +3 -0
  168. package/src/providers/amp-adapter.ts +1 -1
  169. package/src/providers/codex-adapter.ts +1 -0
  170. package/src/providers/codex-app-server.ts +27 -1
  171. package/src/providers/pi-mono-extension.ts +30 -15
  172. package/src/tasks/follow-up-gate.ts +318 -0
  173. package/src/tasks/worker-follow-up.ts +21 -1
  174. package/src/tests/codex-app-server.test.ts +59 -0
  175. package/src/tests/codex-gateway-route.test.ts +50 -1
  176. package/src/tests/create-page-tool.test.ts +501 -0
  177. package/src/tests/db-client-busy-retry.test.ts +81 -0
  178. package/src/tests/db-task-read-contract.test.ts +1 -0
  179. package/src/tests/db-task-write-contract.test.ts +2 -0
  180. package/src/tests/db-tasks-read.test.ts +21 -0
  181. package/src/tests/fixtures/pages/head-v1.html +56 -0
  182. package/src/tests/follow-up-gate-shadow.test.ts +513 -0
  183. package/src/tests/identity-field-budget.test.ts +1 -1
  184. package/src/tests/internal-ai/credentials.test.ts +62 -0
  185. package/src/tests/internal-ai/task-title.test.ts +283 -0
  186. package/src/tests/merge-gate-detect-changes.test.ts +145 -0
  187. package/src/tests/page-proxy.test.ts +1 -1
  188. package/src/tests/page-session-dev-cookie.test.ts +188 -0
  189. package/src/tests/pages-frame-ancestors.test.ts +28 -1
  190. package/src/tests/pages-head-v2.test.ts +534 -0
  191. package/src/tests/pages-http.test.ts +330 -0
  192. package/src/tests/pages-public-html.test.ts +43 -0
  193. package/src/tests/pages-storage.test.ts +182 -0
  194. package/src/tests/pages-versioning.test.ts +72 -0
  195. package/src/tests/profile-sync-stale-writeback.test.ts +334 -0
  196. package/src/tests/profile-sync.test.ts +11 -15
  197. package/src/tests/scheduled-task-required-params-migration.test.ts +3 -1
  198. package/src/tests/script-connections.test.ts +2 -0
  199. package/src/tests/seed-registry.test.ts +28 -6
  200. package/src/tests/seed-schedules.test.ts +61 -8
  201. package/src/tests/seed-scripts.test.ts +142 -2
  202. package/src/tests/swarm-diff.test.ts +121 -2
  203. package/src/tests/task-auto-title-config-guard.test.ts +37 -0
  204. package/src/tests/task-auto-title-listener.test.ts +165 -0
  205. package/src/tests/theme-import.test.ts +758 -0
  206. package/src/tests/themes-page-theme.test.ts +155 -0
  207. package/src/tests/themes-presets.test.ts +172 -0
  208. package/src/tests/workflow-script-principal.test.ts +2 -1
  209. package/src/themes/classics.ts +949 -0
  210. package/src/themes/import.ts +420 -0
  211. package/src/themes/page-fields.ts +40 -0
  212. package/src/themes/page-theme.ts +102 -0
  213. package/src/themes/presets.ts +561 -0
  214. package/src/themes/tokens.ts +218 -0
  215. package/src/tools/app-upsert.ts +1 -1
  216. package/src/tools/create-page.ts +118 -12
  217. package/src/tools/utils.ts +33 -0
  218. package/src/types.ts +45 -3
  219. package/src/utils/constants.ts +19 -0
  220. package/src/utils/follow-up-gate-schedule-ids.ts +13 -0
  221. package/src/utils/internal-ai/credentials.ts +146 -93
  222. package/src/utils/internal-ai/index.ts +10 -0
  223. package/src/utils/internal-ai/models.ts +18 -0
  224. package/src/utils/internal-ai/task-title.ts +282 -0
  225. package/src/utils/page-session.ts +37 -0
  226. package/templates/schedules/daily-status-report/content.md +88 -2
  227. package/templates/schedules/daily-swarm-update-check/content.md +16 -40
  228. package/templates/skills/apps/SKILL.md +1 -1
  229. package/templates/skills/apps/content.md +1 -1
  230. package/templates/skills/heartbeat-runbook/content.md +1 -1
  231. package/templates/skills/pages/config.json +1 -1
  232. package/templates/skills/pages/content.md +539 -86
  233. package/templates/skills/pages/files/examples/annotated-pr.html +21 -12
  234. package/templates/skills/pages/files/examples/multiplayer-board.html +41 -41
  235. package/templates/skills/pages/files/examples/report-page.html +125 -124
  236. package/dist/cli-2pnswgh8.js +0 -189
  237. package/dist/handlers-by3sfxyx.js +0 -32
  238. package/dist/index-4f2pvxae.js +0 -37
  239. package/dist/index-98gz7tmp.js +0 -30
  240. package/dist/index-k08z2cak.js +0 -33
  241. package/dist/lead-enq35nqp.js +0 -70
  242. package/dist/worker-gg1576j3.js +0 -70
  243. package/templates/schedules/daily-workflow-health-audit/config.json +0 -19
  244. package/templates/schedules/daily-workflow-health-audit/content.md +0 -191
@@ -313,4 +313,334 @@ describe("Pages HTTP API", () => {
313
313
  expect(res.status).toBe(200);
314
314
  });
315
315
  });
316
+
317
+ describe("design fields (theme, themeTokens, designVersion)", () => {
318
+ const shadcnTokens = {
319
+ light: {
320
+ "--primary": "oklch(0.55 0.2 260)",
321
+ background: "oklch(1 0 0)",
322
+ radius: "0.5rem",
323
+ "--sidebar": "oklch(0.9 0 0)",
324
+ },
325
+ dark: { "--primary": "oklch(0.7 0.15 260)" },
326
+ };
327
+ const normalizedShadcnTokens = {
328
+ light: {
329
+ "--color-primary": "oklch(0.55 0.2 260)",
330
+ "--color-background": "oklch(1 0 0)",
331
+ "--radius-sm": "calc(0.5rem - 4px)",
332
+ "--radius-md": "calc(0.5rem - 2px)",
333
+ "--radius-lg": "0.5rem",
334
+ "--radius-xl": "calc(0.5rem + 4px)",
335
+ },
336
+ dark: { "--color-primary": "oklch(0.7 0.15 260)" },
337
+ };
338
+
339
+ function postDesignPage(extra: Record<string, unknown> = {}) {
340
+ return fetch(`${baseUrl}/api/pages`, {
341
+ method: "POST",
342
+ headers,
343
+ body: JSON.stringify({
344
+ slug: `design-${crypto.randomUUID().slice(0, 8)}`,
345
+ title: "Design page",
346
+ contentType: "text/html",
347
+ authMode: "public",
348
+ body: "<h1>hi</h1>",
349
+ ...extra,
350
+ }),
351
+ });
352
+ }
353
+
354
+ function putPage(id: string, body: Record<string, unknown>) {
355
+ return fetch(`${baseUrl}/api/pages/${id}`, {
356
+ method: "PUT",
357
+ headers,
358
+ body: JSON.stringify(body),
359
+ });
360
+ }
361
+
362
+ async function getDesignPage(id: string): Promise<Page> {
363
+ const res = await fetch(`${baseUrl}/api/pages/${id}`, { headers });
364
+ expect(res.status).toBe(200);
365
+ return (await res.json()) as Page;
366
+ }
367
+
368
+ async function versionCount(id: string): Promise<number> {
369
+ const res = await fetch(`${baseUrl}/api/pages/${id}/versions`, { headers });
370
+ return ((await res.json()) as { versions: unknown[] }).versions.length;
371
+ }
372
+
373
+ test("a new page gets design version 2 with no theme and no tokens", async () => {
374
+ const res = await postDesignPage();
375
+ expect(res.status).toBe(201);
376
+ const created = (await res.json()) as {
377
+ id: string;
378
+ ignoredTokens?: string[];
379
+ designVersion?: number;
380
+ };
381
+ expect(created.ignoredTokens).toBeUndefined();
382
+ expect(created.designVersion).toBe(2);
383
+
384
+ const page = await getDesignPage(created.id);
385
+ expect(page.designVersion).toBe(2);
386
+ expect(page.theme).toBeNull();
387
+ expect(page.themeTokens).toBeNull();
388
+ });
389
+
390
+ test("POST stores a preset theme and an explicit design version", async () => {
391
+ const res = await postDesignPage({ theme: "paper", designVersion: 1 });
392
+ expect(res.status).toBe(201);
393
+ const { id, designVersion } = (await res.json()) as { id: string; designVersion?: number };
394
+ expect(designVersion).toBe(1);
395
+ const page = await getDesignPage(id);
396
+ expect(page.theme).toBe("paper");
397
+ expect(page.designVersion).toBe(1);
398
+ });
399
+
400
+ test("POST with a design version other than 1 or 2 → 400", async () => {
401
+ for (const designVersion of [0, 3, "2", null]) {
402
+ const res = await postDesignPage({ designVersion });
403
+ expect(res.status).toBe(400);
404
+ }
405
+ });
406
+
407
+ test("POST with an unknown theme → 400 that lists the presets, and no page is created", async () => {
408
+ const slug = `unknown-theme-${crypto.randomUUID().slice(0, 8)}`;
409
+ const res = await postDesignPage({ slug, theme: "neon" });
410
+ expect(res.status).toBe(400);
411
+ const { error } = (await res.json()) as { error: string };
412
+ expect(error).toContain('Unknown theme "neon"');
413
+ for (const id of ["hive", "paper", "github"]) expect(error).toContain(id);
414
+
415
+ const resolved = await fetch(`${baseUrl}/api/pages/resolve?slug=${slug}&agentId=${agentId}`, {
416
+ headers,
417
+ });
418
+ expect(resolved.status).toBe(404);
419
+ });
420
+
421
+ test("POST stores shadcn-style tokens normalized and reports the ignored names", async () => {
422
+ const res = await postDesignPage({ themeTokens: shadcnTokens });
423
+ expect(res.status).toBe(201);
424
+ const created = (await res.json()) as { id: string; ignoredTokens?: string[] };
425
+ expect(created.ignoredTokens).toEqual(["--sidebar"]);
426
+
427
+ const page = await getDesignPage(created.id);
428
+ expect(page.themeTokens).toEqual(normalizedShadcnTokens);
429
+ });
430
+
431
+ test("POST accepts the css format", async () => {
432
+ const css = `
433
+ :root { --primary: 222.2 47.4% 11.2%; --radius: 0.625rem; --shadow-2xs: 0 1px 2px #0000; }
434
+ .dark { --primary: 210 40% 98%; --sidebar-primary: red; }
435
+ `;
436
+ const res = await postDesignPage({ themeTokens: { format: "css", css } });
437
+ expect(res.status).toBe(201);
438
+ const created = (await res.json()) as { id: string; ignoredTokens?: string[] };
439
+ expect([...(created.ignoredTokens ?? [])].sort()).toEqual([
440
+ "--shadow-2xs",
441
+ "--sidebar-primary",
442
+ ]);
443
+
444
+ const page = await getDesignPage(created.id);
445
+ expect(page.themeTokens).toEqual({
446
+ light: {
447
+ "--color-primary": "hsl(222.2 47.4% 11.2%)",
448
+ "--radius-sm": "calc(0.625rem - 4px)",
449
+ "--radius-md": "calc(0.625rem - 2px)",
450
+ "--radius-lg": "0.625rem",
451
+ "--radius-xl": "calc(0.625rem + 4px)",
452
+ },
453
+ dark: { "--color-primary": "hsl(210 40% 98%)" },
454
+ });
455
+ });
456
+
457
+ test("POST with bad themeTokens → 400 that names the problem", async () => {
458
+ const unsafe = await postDesignPage({
459
+ themeTokens: { light: { "--color-primary": "red; } body { display: none" } },
460
+ });
461
+ expect(unsafe.status).toBe(400);
462
+ const unsafeError = ((await unsafe.json()) as { error: string }).error;
463
+ expect(unsafeError).toContain("Invalid themeTokens");
464
+ expect(unsafeError).toContain("--color-primary");
465
+
466
+ const missingCss = await postDesignPage({ themeTokens: { format: "css" } });
467
+ expect(missingCss.status).toBe(400);
468
+ expect(((await missingCss.json()) as { error: string }).error).toContain(
469
+ "Invalid themeTokens",
470
+ );
471
+
472
+ const noBlocks = await postDesignPage({ themeTokens: { format: "css", css: "body { }" } });
473
+ expect(noBlocks.status).toBe(400);
474
+ expect(((await noBlocks.json()) as { error: string }).error).toContain(
475
+ "no :root, .dark, or @theme block",
476
+ );
477
+ });
478
+
479
+ test("POST with an unknown themeTokens format or field → 400", async () => {
480
+ const format = await postDesignPage({ themeTokens: { format: "dtcg", light: {} } });
481
+ expect(format.status).toBe(400);
482
+ expect(((await format.json()) as { error: string }).error).toContain("themeTokens");
483
+
484
+ const field = await postDesignPage({ themeTokens: { lite: { "--color-primary": "red" } } });
485
+ expect(field.status).toBe(400);
486
+ expect(((await field.json()) as { error: string }).error).toContain("themeTokens");
487
+ });
488
+
489
+ test("PUT keeps the design fields it does not mention", async () => {
490
+ const created = await postDesignPage({ theme: "paper", themeTokens: shadcnTokens });
491
+ const { id } = (await created.json()) as { id: string };
492
+
493
+ const res = await putPage(id, { body: "<h1>v2</h1>" });
494
+ expect(res.status).toBe(200);
495
+ expect(((await res.json()) as { designVersion?: number }).designVersion).toBe(2);
496
+ const page = await getDesignPage(id);
497
+ expect(page.body).toBe("<h1>v2</h1>");
498
+ expect(page.theme).toBe("paper");
499
+ expect(page.themeTokens).toEqual(normalizedShadcnTokens);
500
+ expect(page.designVersion).toBe(2);
501
+ });
502
+
503
+ test("PUT sets a new theme, replaces the tokens, and moves the design version", async () => {
504
+ const created = await postDesignPage({ designVersion: 1, themeTokens: shadcnTokens });
505
+ const { id } = (await created.json()) as { id: string };
506
+
507
+ const res = await putPage(id, {
508
+ theme: "github",
509
+ designVersion: 2,
510
+ themeTokens: { light: { "--color-ring": "oklch(0.6 0.2 30)", "--spacing": "4px" } },
511
+ });
512
+ expect(res.status).toBe(200);
513
+ const updated = (await res.json()) as { ignoredTokens?: string[]; designVersion?: number };
514
+ expect(updated.ignoredTokens).toEqual(["--spacing"]);
515
+ expect(updated.designVersion).toBe(2);
516
+
517
+ const page = await getDesignPage(id);
518
+ expect(page.theme).toBe("github");
519
+ expect(page.designVersion).toBe(2);
520
+ // The new tokens replace the old set, they do not merge into it.
521
+ expect(page.themeTokens).toEqual({
522
+ light: { "--color-ring": "oklch(0.6 0.2 30)" },
523
+ dark: {},
524
+ });
525
+ });
526
+
527
+ test("PUT reports the design version the page keeps", async () => {
528
+ const created = await postDesignPage({ designVersion: 1 });
529
+ const { id } = (await created.json()) as { id: string };
530
+ const res = await putPage(id, { body: '<main class="swarm-page">v2</main>' });
531
+ expect(res.status).toBe(200);
532
+ expect(((await res.json()) as { designVersion?: number }).designVersion).toBe(1);
533
+ });
534
+
535
+ test("POST reports a reference outside the contract with the reference", async () => {
536
+ const res = await postDesignPage({
537
+ themeTokens: {
538
+ format: "css",
539
+ css: ":root { --font-sans: var(--font-geist-sans); --primary: red; } body { --ring: blue; }",
540
+ },
541
+ });
542
+ expect(res.status).toBe(201);
543
+ const created = (await res.json()) as { ignoredTokens?: string[] };
544
+ expect(created.ignoredTokens).toEqual([
545
+ "--font-sans (references --font-geist-sans)",
546
+ "--ring (in body)",
547
+ ]);
548
+ });
549
+
550
+ test("PUT with null clears the theme and the tokens", async () => {
551
+ const created = await postDesignPage({ theme: "paper", themeTokens: shadcnTokens });
552
+ const { id } = (await created.json()) as { id: string };
553
+
554
+ const res = await putPage(id, { theme: null, themeTokens: null });
555
+ expect(res.status).toBe(200);
556
+ const page = await getDesignPage(id);
557
+ expect(page.theme).toBeNull();
558
+ expect(page.themeTokens).toBeNull();
559
+ expect(page.designVersion).toBe(2);
560
+ });
561
+
562
+ test("PUT with a bad theme or bad tokens → 400, and the page and its history stay as they were", async () => {
563
+ const created = await postDesignPage({ theme: "paper" });
564
+ const { id } = (await created.json()) as { id: string };
565
+
566
+ const badTheme = await putPage(id, { theme: "neon", body: "<h1>changed</h1>" });
567
+ expect(badTheme.status).toBe(400);
568
+ expect(((await badTheme.json()) as { error: string }).error).toContain(
569
+ 'Unknown theme "neon"',
570
+ );
571
+
572
+ const badTokens = await putPage(id, {
573
+ themeTokens: { light: { "--color-primary": "url(https://example.test/x)" } },
574
+ body: "<h1>changed</h1>",
575
+ });
576
+ expect(badTokens.status).toBe(400);
577
+ expect(((await badTokens.json()) as { error: string }).error).toContain(
578
+ "Invalid themeTokens",
579
+ );
580
+
581
+ const page = await getDesignPage(id);
582
+ expect(page.body).toBe("<h1>hi</h1>");
583
+ expect(page.theme).toBe("paper");
584
+ expect(await versionCount(id)).toBe(0);
585
+ });
586
+
587
+ test("PUT with a design version other than 1 or 2 → 400", async () => {
588
+ const created = await postDesignPage();
589
+ const { id } = (await created.json()) as { id: string };
590
+ const res = await putPage(id, { designVersion: 3 });
591
+ expect(res.status).toBe(400);
592
+ });
593
+
594
+ test("GET resolve returns the design fields", async () => {
595
+ const slug = `resolve-${crypto.randomUUID().slice(0, 8)}`;
596
+ const created = await postDesignPage({ slug, theme: "paper", designVersion: 1 });
597
+ expect(created.status).toBe(201);
598
+ const res = await fetch(`${baseUrl}/api/pages/resolve?slug=${slug}&agentId=${agentId}`, {
599
+ headers,
600
+ });
601
+ expect(res.status).toBe(200);
602
+ const page = (await res.json()) as Page;
603
+ expect(page.theme).toBe("paper");
604
+ expect(page.designVersion).toBe(1);
605
+ expect(page.themeTokens).toBeNull();
606
+ });
607
+
608
+ test("the list leaves the design fields out unless fields=full", async () => {
609
+ const ownerId = crypto.randomUUID();
610
+ const ownerHeaders = { "Content-Type": "application/json", "X-Agent-ID": ownerId };
611
+ const created = await fetch(`${baseUrl}/api/pages`, {
612
+ method: "POST",
613
+ headers: ownerHeaders,
614
+ body: JSON.stringify({
615
+ slug: "listed",
616
+ title: "Listed",
617
+ contentType: "text/html",
618
+ authMode: "public",
619
+ body: "<h1>hi</h1>",
620
+ theme: "paper",
621
+ themeTokens: shadcnTokens,
622
+ }),
623
+ });
624
+ expect(created.status).toBe(201);
625
+
626
+ const slimRes = await fetch(`${baseUrl}/api/pages?agentId=${ownerId}`, {
627
+ headers: ownerHeaders,
628
+ });
629
+ expect(slimRes.status).toBe(200);
630
+ const [slim] = ((await slimRes.json()) as { pages: Array<Record<string, unknown>> }).pages;
631
+ expect(slim?.slug).toBe("listed");
632
+ for (const field of ["body", "theme", "themeTokens", "designVersion"]) {
633
+ expect(field in slim!).toBe(false);
634
+ }
635
+
636
+ const fullRes = await fetch(`${baseUrl}/api/pages?agentId=${ownerId}&fields=full`, {
637
+ headers: ownerHeaders,
638
+ });
639
+ expect(fullRes.status).toBe(200);
640
+ const [full] = ((await fullRes.json()) as { pages: Page[] }).pages;
641
+ expect(full?.theme).toBe("paper");
642
+ expect(full?.themeTokens).toEqual(normalizedShadcnTokens);
643
+ expect(full?.designVersion).toBe(2);
644
+ });
645
+ });
316
646
  });
@@ -5,6 +5,8 @@
5
5
  * `class SwarmSDK` which is unique to the constant)
6
6
  * - the response `Content-Type` is `text/html`
7
7
  * - a `Content-Security-Policy` header is set
8
+ * - a design version 1 page keeps the frozen original head, and a body
9
+ * without `<head>` still gets it prepended, byte for byte
8
10
  */
9
11
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
12
  import crypto from "node:crypto";
@@ -19,6 +21,8 @@ import { closeDb, initDb } from "../be/db";
19
21
  import { handlePages } from "../http/pages";
20
22
  import { handlePagesPublic } from "../http/pages-public";
21
23
  import { getPathSegments, parseQueryParams } from "../http/utils";
24
+ import { BROWSER_SDK_JS, SWARM_UI_JS } from "../pages/browser-sdk";
25
+ import { PAGE_HEAD_V1 } from "../pages/page-head";
22
26
  import { listenOnFreePort } from "./test-net";
23
27
 
24
28
  const TEST_DB_PATH = "./test-pages-public-html.sqlite";
@@ -82,6 +86,9 @@ describe("GET /p/:id — HTML public path", () => {
82
86
  contentType: "text/html",
83
87
  authMode: "public",
84
88
  body: html,
89
+ // Design version 1 keeps the original head. Version 2 (the default)
90
+ // is covered by pages-head-v2.test.ts.
91
+ designVersion: 1,
85
92
  }),
86
93
  });
87
94
  expect(post.status).toBe(201);
@@ -109,9 +116,45 @@ describe("GET /p/:id — HTML public path", () => {
109
116
  const text = await res.text();
110
117
  expect(text).toContain("<h1>Hello</h1>");
111
118
  expect(text).toContain("class SwarmSDK"); // BROWSER_SDK_JS sentinel
119
+ // The frozen design version 1 head, verbatim, right after <head>.
120
+ expect(text).toContain(`<head>${PAGE_HEAD_V1}<script>`);
112
121
  expect(text).toContain("color-scheme: light");
113
122
  expect(text).toContain("--swarm-bg: #fbfbfa");
114
123
  expect(text).toContain("--swarm-text: #111827");
124
+ expect(text).toContain('<script src="https://cdn.tailwindcss.com"></script>');
125
+ expect(text).not.toContain("@tailwindcss/browser");
126
+ expect(text).not.toContain('id="swarm-theme"');
127
+
128
+ // Version 1 ignores the viewer theme and mode params.
129
+ const themed = await fetch(`${BASE}/p/${id}?__swarm-theme=github&__swarm-mode=dark`);
130
+ expect(themed.status).toBe(200);
131
+ expect(await themed.text()).toBe(text);
132
+ });
133
+
134
+ test("a version 1 body without a <head> still gets the injection prepended, byte for byte", async () => {
135
+ const injection = `${PAGE_HEAD_V1}<script>${BROWSER_SDK_JS}</script><script>${SWARM_UI_JS}</script>`;
136
+ // Version 2 adds a doctype and moves the head (pages-head-v2.test.ts).
137
+ // Version 1 never does.
138
+ const bodies = ["<h1>Fragment</h1>", "<!doctype html><html><body>x</body></html>"];
139
+ for (const [index, body] of bodies.entries()) {
140
+ const post = await fetch(`${BASE}/api/pages`, {
141
+ method: "POST",
142
+ headers,
143
+ body: JSON.stringify({
144
+ slug: `v1-no-head-${index}`,
145
+ title: "No head",
146
+ contentType: "text/html",
147
+ authMode: "public",
148
+ body,
149
+ designVersion: 1,
150
+ }),
151
+ });
152
+ expect(post.status).toBe(201);
153
+ const { id } = (await post.json()) as { id: string };
154
+ const res = await fetch(`${BASE}/p/${id}`);
155
+ expect(res.status).toBe(200);
156
+ expect(await res.text()).toBe(injection + body);
157
+ }
115
158
  });
116
159
 
117
160
  for (const authMode of ["public", "authed", "password"]) {
@@ -219,3 +219,185 @@ describe("pages storage CRUD", () => {
219
219
  await expect(snapshotPage("0".repeat(32), "agent-x")).rejects.toThrow(/not found/);
220
220
  });
221
221
  });
222
+
223
+ describe("pages design fields", () => {
224
+ const tokens = {
225
+ light: { "--color-primary": "oklch(0.55 0.2 260)", "--radius-lg": "0.5rem" },
226
+ dark: { "--color-primary": "oklch(0.7 0.15 260)" },
227
+ };
228
+
229
+ test("createPage defaults to design version 2 with no theme and no tokens", async () => {
230
+ const created = await createPage({
231
+ agentId: makeAgentId(),
232
+ slug: "design-default",
233
+ title: "Design default",
234
+ contentType: "text/html",
235
+ body: "<h1>hi</h1>",
236
+ });
237
+ expect(created.designVersion).toBe(2);
238
+ expect(created.theme).toBeNull();
239
+ expect(created.themeTokens).toBeNull();
240
+
241
+ const fetched = await getPage(created.id);
242
+ expect(fetched?.designVersion).toBe(2);
243
+ expect(fetched?.theme).toBeNull();
244
+ expect(fetched?.themeTokens).toBeNull();
245
+ });
246
+
247
+ test("createPage stores the theme, the tokens, and an explicit design version", async () => {
248
+ const created = await createPage({
249
+ agentId: makeAgentId(),
250
+ slug: "design-explicit",
251
+ title: "Design explicit",
252
+ contentType: "text/html",
253
+ body: "<h1>hi</h1>",
254
+ theme: "paper",
255
+ themeTokens: tokens,
256
+ designVersion: 1,
257
+ });
258
+ const fetched = await getPage(created.id);
259
+ expect(fetched?.theme).toBe("paper");
260
+ expect(fetched?.themeTokens).toEqual(tokens);
261
+ expect(fetched?.designVersion).toBe(1);
262
+ });
263
+
264
+ test("a row inserted without the design columns reads design version 1", async () => {
265
+ const row = await getDbClient().get<{ id: string }>(
266
+ `INSERT INTO pages (agentId, slug, title, contentType, body)
267
+ VALUES (?, ?, ?, ?, ?) RETURNING id`,
268
+ [makeAgentId(), "sql-design-default", "SQL design default", "text/html", "<h1>old</h1>"],
269
+ );
270
+ const page = await getPage(row!.id);
271
+ expect(page?.designVersion).toBe(1);
272
+ expect(page?.theme).toBeNull();
273
+ expect(page?.themeTokens).toBeNull();
274
+ });
275
+
276
+ test("updatePage keeps omitted design fields, sets new ones, and clears with null", async () => {
277
+ const page = await createPage({
278
+ agentId: makeAgentId(),
279
+ slug: "design-update",
280
+ title: "Design update",
281
+ contentType: "text/html",
282
+ body: "<h1>v1</h1>",
283
+ theme: "paper",
284
+ themeTokens: tokens,
285
+ });
286
+
287
+ // Omitted fields stay as they were.
288
+ const kept = await updatePage(page.id, { body: "<h1>v2</h1>" });
289
+ expect(kept?.theme).toBe("paper");
290
+ expect(kept?.themeTokens).toEqual(tokens);
291
+ expect(kept?.designVersion).toBe(2);
292
+
293
+ // New values replace the stored ones.
294
+ const replaced = await updatePage(page.id, {
295
+ theme: "github",
296
+ themeTokens: { light: { "--radius-lg": "1rem" }, dark: {} },
297
+ designVersion: 1,
298
+ });
299
+ expect(replaced?.theme).toBe("github");
300
+ expect(replaced?.themeTokens).toEqual({ light: { "--radius-lg": "1rem" }, dark: {} });
301
+ expect(replaced?.designVersion).toBe(1);
302
+
303
+ // null clears the theme and the tokens.
304
+ const cleared = await updatePage(page.id, { theme: null, themeTokens: null });
305
+ expect(cleared?.theme).toBeNull();
306
+ expect(cleared?.themeTokens).toBeNull();
307
+ expect(cleared?.designVersion).toBe(1);
308
+ const reread = await getPage(page.id);
309
+ expect(reread?.theme).toBeNull();
310
+ expect(reread?.themeTokens).toBeNull();
311
+ });
312
+
313
+ test("theme_tokens that is not valid tokens JSON reads as null", async () => {
314
+ const page = await createPage({
315
+ agentId: makeAgentId(),
316
+ slug: "design-bad-json",
317
+ title: "Design bad JSON",
318
+ contentType: "text/html",
319
+ body: "<h1>hi</h1>",
320
+ themeTokens: tokens,
321
+ });
322
+ for (const stored of ["{not json", '{"light": 1}', '["light"]', "null", ""]) {
323
+ await getDbClient().run("UPDATE pages SET theme_tokens = ? WHERE id = ?", [stored, page.id]);
324
+ expect((await getPage(page.id))?.themeTokens).toBeNull();
325
+ }
326
+ });
327
+
328
+ test("snapshotPage carries the design fields", async () => {
329
+ const agentId = makeAgentId();
330
+ const page = await createPage({
331
+ agentId,
332
+ slug: "design-snapshot",
333
+ title: "Design snapshot",
334
+ contentType: "text/html",
335
+ body: "<h1>v1</h1>",
336
+ theme: "paper",
337
+ themeTokens: tokens,
338
+ designVersion: 1,
339
+ });
340
+ await snapshotPage(page.id, agentId);
341
+ await updatePage(page.id, { theme: null, themeTokens: null, designVersion: 2 });
342
+
343
+ const version = await getPageVersion(page.id, 1);
344
+ expect(version?.snapshot.theme).toBe("paper");
345
+ expect(version?.snapshot.themeTokens).toEqual(tokens);
346
+ expect(version?.snapshot.designVersion).toBe(1);
347
+
348
+ // A page without a theme snapshots the nulls, not missing keys.
349
+ await snapshotPage(page.id, agentId);
350
+ const second = await getPageVersion(page.id, 2);
351
+ expect(second?.snapshot.theme).toBeNull();
352
+ expect(second?.snapshot.themeTokens).toBeNull();
353
+ expect(second?.snapshot.designVersion).toBe(2);
354
+ });
355
+
356
+ test("a snapshot stored before design version 2 still reads, without the design fields", async () => {
357
+ const page = await createPage({
358
+ agentId: makeAgentId(),
359
+ slug: "design-old-snapshot",
360
+ title: "Design old snapshot",
361
+ contentType: "text/html",
362
+ body: "<h1>old</h1>",
363
+ });
364
+ const oldSnapshot = JSON.stringify({
365
+ title: "Old",
366
+ contentType: "text/html",
367
+ authMode: "public",
368
+ body: "<h1>old</h1>",
369
+ });
370
+ await getDbClient().run(
371
+ "INSERT INTO page_versions (pageId, version, snapshot) VALUES (?, 1, ?)",
372
+ [page.id, oldSnapshot],
373
+ );
374
+ const version = await getPageVersion(page.id, 1);
375
+ expect(version?.snapshot.title).toBe("Old");
376
+ expect(version?.snapshot.theme).toBeUndefined();
377
+ expect(version?.snapshot.themeTokens).toBeUndefined();
378
+ expect(version?.snapshot.designVersion).toBeUndefined();
379
+ });
380
+
381
+ test("slim list rows leave out the design fields and full rows carry them", async () => {
382
+ const agentId = makeAgentId();
383
+ await createPage({
384
+ agentId,
385
+ slug: "design-list",
386
+ title: "Design list",
387
+ contentType: "text/html",
388
+ body: "<h1>hi</h1>",
389
+ theme: "paper",
390
+ themeTokens: tokens,
391
+ });
392
+ const [slim] = await listPagesByAgent(agentId, 10, 0, { slim: true });
393
+ expect(slim).toBeDefined();
394
+ expect("theme" in slim!).toBe(false);
395
+ expect("themeTokens" in slim!).toBe(false);
396
+ expect("designVersion" in slim!).toBe(false);
397
+
398
+ const [full] = await listPagesByAgent(agentId, 10, 0, { slim: false });
399
+ expect(full?.theme).toBe("paper");
400
+ expect(full?.themeTokens).toEqual(tokens);
401
+ expect(full?.designVersion).toBe(2);
402
+ });
403
+ });
@@ -229,4 +229,76 @@ describe("Pages versioning (PUT /api/pages/:id)", () => {
229
229
  expect(p.app_url).toContain(`/pages/${p.slug}`);
230
230
  }
231
231
  });
232
+
233
+ test("PUT snapshots the design fields as they stood before the update", async () => {
234
+ const created = await fetch(`${BASE}/api/pages`, {
235
+ method: "POST",
236
+ headers,
237
+ body: JSON.stringify({
238
+ slug: "design-versions",
239
+ title: "Design versions",
240
+ contentType: "text/html",
241
+ authMode: "public",
242
+ body: "d0",
243
+ theme: "paper",
244
+ designVersion: 1,
245
+ themeTokens: { light: { "--primary": "oklch(0.55 0.2 260)" } },
246
+ }),
247
+ });
248
+ expect(created.status).toBe(201);
249
+ const { id } = (await created.json()) as { id: string };
250
+
251
+ // PUT #1 moves every design field. PUT #2 clears the theme and the tokens.
252
+ const first = await fetch(`${BASE}/api/pages/${id}`, {
253
+ method: "PUT",
254
+ headers,
255
+ body: JSON.stringify({
256
+ theme: "github",
257
+ designVersion: 2,
258
+ themeTokens: { light: { "--color-ring": "oklch(0.6 0.2 30)" } },
259
+ }),
260
+ });
261
+ expect(first.status).toBe(200);
262
+ const second = await fetch(`${BASE}/api/pages/${id}`, {
263
+ method: "PUT",
264
+ headers,
265
+ body: JSON.stringify({ theme: null, themeTokens: null }),
266
+ });
267
+ expect(second.status).toBe(200);
268
+
269
+ const versionsRes = await fetch(`${BASE}/api/pages/${id}/versions`, { headers });
270
+ expect(versionsRes.status).toBe(200);
271
+ const { versions } = (await versionsRes.json()) as { versions: PageVersion[] };
272
+ expect(versions.map((v) => v.version)).toEqual([2, 1]);
273
+
274
+ // v1 froze the state before PUT #1.
275
+ expect(versions[1]!.snapshot.theme).toBe("paper");
276
+ expect(versions[1]!.snapshot.designVersion).toBe(1);
277
+ expect(versions[1]!.snapshot.themeTokens).toEqual({
278
+ light: { "--color-primary": "oklch(0.55 0.2 260)" },
279
+ dark: {},
280
+ });
281
+ // v2 froze the state before PUT #2.
282
+ expect(versions[0]!.snapshot.theme).toBe("github");
283
+ expect(versions[0]!.snapshot.designVersion).toBe(2);
284
+ expect(versions[0]!.snapshot.themeTokens).toEqual({
285
+ light: { "--color-ring": "oklch(0.6 0.2 30)" },
286
+ dark: {},
287
+ });
288
+
289
+ // The head holds the cleared state.
290
+ const head = (await (await fetch(`${BASE}/api/pages/${id}`, { headers })).json()) as {
291
+ theme: string | null;
292
+ themeTokens: unknown;
293
+ designVersion: number;
294
+ };
295
+ expect(head.theme).toBeNull();
296
+ expect(head.themeTokens).toBeNull();
297
+ expect(head.designVersion).toBe(2);
298
+
299
+ // The single-version route serves the same snapshot.
300
+ const single = await fetch(`${BASE}/api/pages/${id}/versions/1`, { headers });
301
+ expect(single.status).toBe(200);
302
+ expect(((await single.json()) as PageVersion).snapshot.theme).toBe("paper");
303
+ });
232
304
  });