@panphora/clayjs 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/dist/all.js +1 -1
  2. package/dist/clay-dom.js +1 -1
  3. package/dist/clay-events.js +1 -1
  4. package/dist/clay-internals.js +1 -1
  5. package/dist/clay-options.js +1 -1
  6. package/dist/clay-router.js +1 -1
  7. package/dist/clay-ui.js +1 -1
  8. package/dist/clay-utils.js +1 -1
  9. package/dist/clay.js +1 -1
  10. package/dist/clay.standalone.js +72474 -62090
  11. package/dist/m/attrs/{save-freeze-MYNVXREN.js → save-freeze-LWGF6XUN.js} +1 -1
  12. package/dist/m/c-2GRUM77Q.js +30 -0
  13. package/dist/m/c-2KIEGNC4.js +1 -0
  14. package/dist/m/{c-AMSNPOCX.js → c-2KL3BD52.js} +1 -1
  15. package/dist/m/c-2ZZBC45O.js +1 -0
  16. package/dist/m/c-372N6H3B.js +1 -0
  17. package/dist/m/{c-4WKFKEKF.js → c-3G2DGZUP.js} +1 -1
  18. package/dist/m/c-3LDGIBMN.js +1 -0
  19. package/dist/m/c-3O7OBDCM.js +1 -0
  20. package/dist/m/c-4ASQPRQL.js +1 -0
  21. package/dist/m/c-4TXNLX4K.js +6 -0
  22. package/dist/m/c-4YU3BR4D.js +6 -0
  23. package/dist/m/c-5734MF2P.js +1 -0
  24. package/dist/m/c-57XDY6HC.js +1 -0
  25. package/dist/m/{c-KAU6E43D.js → c-5B4YBZXC.js} +1 -1
  26. package/dist/m/c-5ONTYIUI.js +1 -0
  27. package/dist/m/c-5OOVFCJM.js +1 -0
  28. package/dist/m/{c-3XIRD4NU.js → c-5T6NZHNM.js} +1 -1
  29. package/dist/m/{c-74MGYWZF.js → c-5ZQV7KC4.js} +1 -1
  30. package/dist/m/{c-7OLLJPWP.js → c-6DB2OLRJ.js} +1 -1
  31. package/dist/m/c-6DNRCPNM.js +1 -0
  32. package/dist/m/c-6UGWFMQC.js +2 -0
  33. package/dist/m/c-7K4SCQB7.js +1 -0
  34. package/dist/m/c-7MG5RU63.js +1 -0
  35. package/dist/m/c-7PRJCA3E.js +1 -0
  36. package/dist/m/c-A6L3FMUS.js +1 -0
  37. package/dist/m/c-AMU3BPPR.js +4 -0
  38. package/dist/m/{c-Y5HBBIQ4.js → c-AMYEFA2E.js} +1 -1
  39. package/dist/m/c-AQYCG7QW.js +1 -0
  40. package/dist/m/c-ATKAOH23.js +1 -0
  41. package/dist/m/c-B5CS55WR.js +1 -0
  42. package/dist/m/c-B5UJQT7W.js +1 -0
  43. package/dist/m/c-B7J4K2AE.js +3 -0
  44. package/dist/m/c-BHZYSHW4.js +1 -0
  45. package/dist/m/{c-VHM5XQHK.js → c-BIJALSRD.js} +1 -1
  46. package/dist/m/c-BJ7YQ5QZ.js +1 -0
  47. package/dist/m/{c-KIPQJX4B.js → c-BRLCTNMC.js} +1 -1
  48. package/dist/m/{c-VE6ZMYMC.js → c-BUAYFEV5.js} +1 -1
  49. package/dist/m/c-C6IPRG34.js +1 -0
  50. package/dist/m/{c-KU3ZFADW.js → c-CAS3NND4.js} +1 -1
  51. package/dist/m/{c-PS5ARVWR.js → c-CCQ25GFY.js} +1 -1
  52. package/dist/m/c-CJFDLD43.js +1 -0
  53. package/dist/m/c-COPTL5WB.js +1 -0
  54. package/dist/m/c-D3E4WJ4M.js +166 -0
  55. package/dist/m/c-D5IKEKXW.js +7 -0
  56. package/dist/m/c-DHBRA6DL.js +1 -0
  57. package/dist/m/c-DKLS74XB.js +72 -0
  58. package/dist/m/c-DVYGFTSK.js +3 -0
  59. package/dist/m/c-E3ZXARWA.js +1 -0
  60. package/dist/m/c-EK63G3CG.js +1 -0
  61. package/dist/m/c-ERQSNR6K.js +13 -0
  62. package/dist/m/c-EVYAISP3.js +1 -0
  63. package/dist/m/c-EXKHPOVC.js +3 -0
  64. package/dist/m/{c-QJ4OM3G6.js → c-FWKHM4E7.js} +1 -1
  65. package/dist/m/{c-4Q2XTQ2N.js → c-GBKB5WDL.js} +1 -1
  66. package/dist/m/c-GNDL3TKB.js +1 -0
  67. package/dist/m/c-GRWMD6O5.js +1 -0
  68. package/dist/m/c-GRXHHFPO.js +1 -0
  69. package/dist/m/c-HG2C77BM.js +4 -0
  70. package/dist/m/c-HHGJBU4D.js +1 -0
  71. package/dist/m/c-HMV6P4RX.js +1 -0
  72. package/dist/m/c-HMYWIHFG.js +1 -0
  73. package/dist/m/c-HT2DKBV2.js +1 -0
  74. package/dist/m/c-I2SCCUEW.js +1 -0
  75. package/dist/m/{c-TJ6WOJL3.js → c-ICJYHABF.js} +2 -2
  76. package/dist/m/c-IMZCWYHR.js +7 -0
  77. package/dist/m/c-IVMD3FEM.js +1 -0
  78. package/dist/m/{c-WDC5GVKU.js → c-IVTRVENZ.js} +1 -1
  79. package/dist/m/c-JKZJIUIP.js +1 -0
  80. package/dist/m/{c-UDUUJPFZ.js → c-JNLX3RJB.js} +1 -1
  81. package/dist/m/c-K3ADYNLU.js +1 -0
  82. package/dist/m/c-KGHVFRR3.js +1 -0
  83. package/dist/m/{c-5QWQ7A5L.js → c-KJJPV77R.js} +1 -1
  84. package/dist/m/{c-CZUSOB7V.js → c-KNPDOMDY.js} +1 -1
  85. package/dist/m/{c-EZU56NQV.js → c-KP64LT2A.js} +2 -2
  86. package/dist/m/c-KQ7E3PYY.js +1 -0
  87. package/dist/m/c-L3F5ZLIV.js +1 -0
  88. package/dist/m/{c-3RUU4TPX.js → c-L42OLXWM.js} +3 -2
  89. package/dist/m/c-L5VFATT6.js +1 -0
  90. package/dist/m/c-L7JL5TL3.js +3 -0
  91. package/dist/m/c-LKSL4AUJ.js +1 -0
  92. package/dist/m/c-M6FNQCTQ.js +1 -0
  93. package/dist/m/c-MCOOHCSG.js +1 -0
  94. package/dist/m/c-MHI3GYKF.js +1 -0
  95. package/dist/m/c-MWXFLW4J.js +1 -0
  96. package/dist/m/{c-5RJFTLSF.js → c-NEL7HQW3.js} +1 -1
  97. package/dist/m/{c-DCXOXBF3.js → c-NQJRVJ56.js} +1 -1
  98. package/dist/m/c-NWX3MFT4.js +1 -0
  99. package/dist/m/{c-HPURYVZS.js → c-OJYM642Q.js} +1 -1
  100. package/dist/m/c-OKXI52N7.js +1 -0
  101. package/dist/m/{c-R3L7ZJGB.js → c-OPLUZR3A.js} +1 -1
  102. package/dist/m/{c-GSFIGKOP.js → c-OTMKN2OO.js} +1 -1
  103. package/dist/m/{c-6NC5I2TV.js → c-OYRZ7XG2.js} +1 -1
  104. package/dist/m/{c-EVCQ3BKA.js → c-P3OFYUMA.js} +1 -1
  105. package/dist/m/c-PLN76GF3.js +6 -0
  106. package/dist/m/c-PLP4JKFC.js +1 -0
  107. package/dist/m/c-Q26MME6N.js +1 -0
  108. package/dist/m/{c-S7H2DRCB.js → c-Q3XOWZEV.js} +1 -1
  109. package/dist/m/{c-FRLHSY7E.js → c-QIKT6SKM.js} +1 -1
  110. package/dist/m/c-QIXT3Q7C.js +28 -0
  111. package/dist/m/c-QO4HNGCQ.js +1 -0
  112. package/dist/m/{c-JU3NK25H.js → c-R27DWQKX.js} +1 -1
  113. package/dist/m/{c-RSMOODDU.js → c-R2XUODVY.js} +1 -1
  114. package/dist/m/{c-QNKREOL6.js → c-R7LBFMF5.js} +1 -1
  115. package/dist/m/c-RND3G2S2.js +1 -0
  116. package/dist/m/{c-Q622NS2Y.js → c-S2U6NJU4.js} +1 -1
  117. package/dist/m/c-SPZPGD3R.js +1 -0
  118. package/dist/m/c-SW4YEG2D.js +8 -0
  119. package/dist/m/c-T637T7SN.js +1 -0
  120. package/dist/m/c-TJAXGIRV.js +1 -0
  121. package/dist/m/{c-RYUXUUBX.js → c-TNEM3RNM.js} +1 -1
  122. package/dist/m/c-TTB5EK4S.js +32 -0
  123. package/dist/m/c-U7SHNWDH.js +1 -0
  124. package/dist/m/c-UDHIVLYH.js +20 -0
  125. package/dist/m/{c-BBVGTQG7.js → c-UGNV5MFB.js} +1 -1
  126. package/dist/m/{c-QICP2YR5.js → c-UORXLSD3.js} +1 -1
  127. package/dist/m/c-UPD33ZPE.js +1 -0
  128. package/dist/m/c-URSMSFCB.js +1 -0
  129. package/dist/m/c-UY4QX42B.js +1 -0
  130. package/dist/m/c-V44KWI2L.js +4 -0
  131. package/dist/m/c-VILENZTD.js +1 -0
  132. package/dist/m/{c-HDDEEL77.js → c-VJYEVRYK.js} +1 -1
  133. package/dist/m/{c-SMBZG5X4.js → c-VR2BJ4AN.js} +1 -1
  134. package/dist/m/c-W4JCVLAB.js +1 -0
  135. package/dist/m/c-W6DQSHXI.js +135 -0
  136. package/dist/m/c-WBGWGOM7.js +1 -0
  137. package/dist/m/{c-4L3BTFRK.js → c-WCXLG3MV.js} +1 -1
  138. package/dist/m/c-WGW764WX.js +1 -0
  139. package/dist/m/c-WMRJKQ2V.js +5 -0
  140. package/dist/m/c-WNTYKCDG.js +1 -0
  141. package/dist/m/c-WQWIRWO3.js +72 -0
  142. package/dist/m/c-X5LNHET5.js +1 -0
  143. package/dist/m/{c-I2ZATQCD.js → c-X5TSXZYJ.js} +2 -2
  144. package/dist/m/c-XGH4GS2T.js +1 -0
  145. package/dist/m/c-Y6F2FORL.js +1 -0
  146. package/dist/m/c-YBKHUAK7.js +10 -0
  147. package/dist/m/c-YBSS55KU.js +20 -0
  148. package/dist/m/c-YCLU4UNT.js +2 -0
  149. package/dist/m/c-YVZ2L7CE.js +1 -0
  150. package/dist/m/c-ZK4GIZH2.js +1 -0
  151. package/dist/m/c-ZRXJD4G6.js +1 -0
  152. package/dist/m/c-ZWBI4HYK.js +1 -0
  153. package/dist/m/core/{admin-attrs-QHOKPJ7W.js → admin-attrs-YY7T5FGU.js} +1 -1
  154. package/dist/m/core/autosave-S6OMNDCK.js +1 -0
  155. package/dist/m/core/conflict-notice-IWHMHH43.js +1 -0
  156. package/dist/m/core/{host-permission-FNXVEFNE.js → host-permission-6EVJGNPL.js} +1 -1
  157. package/dist/m/core/is-edit-mode-GCX6767E.js +1 -0
  158. package/dist/m/core/{persist-JFYRHMRJ.js → persist-UKXV5KLA.js} +1 -1
  159. package/dist/m/core/recovery-DRFZRJED.js +1 -0
  160. package/dist/m/core/save-LYY7K6U7.js +1 -0
  161. package/dist/m/core/save-core-MKRPSEEO.js +1 -0
  162. package/dist/m/core/snapshot-NU3YM6GR.js +1 -0
  163. package/dist/m/core/unsaved-warning-7QTKEN7Z.js +1 -0
  164. package/dist/m/internals/{index-PVXGPUDA.js → index-TGLQINN3.js} +1 -1
  165. package/dist/m/loader-3BBNZZGM.js +1 -0
  166. package/dist/m/manifest-F449FF98.js +1 -0
  167. package/dist/m/manifest-edit-4396A009.js +1 -0
  168. package/dist/m/manifest.json +2111 -1573
  169. package/dist/m/page/{editor-NNJA4OKD.js → editor-6LRB3GE4.js} +123 -21
  170. package/dist/m/plugins/ai-edit-UTV7YO2I.js +6 -0
  171. package/dist/m/plugins/{cms-HP3LSTPN.js → cms-LRAOZHT7.js} +9 -9
  172. package/dist/m/plugins/{demo-RM5IR6AO.js → demo-OYO5GCAO.js} +1 -1
  173. package/dist/m/plugins/{derive-UAAA4V4S.js → derive-FRE5JJQE.js} +1 -1
  174. package/dist/m/plugins/indicator-4VDXWYTD.js +1 -0
  175. package/dist/m/plugins/live-AD4H6XSL.js +1 -0
  176. package/dist/m/plugins/menu-M5WW7DBF.js +1 -0
  177. package/dist/m/plugins/page-MD3T2ZX3.js +2 -0
  178. package/dist/m/plugins/page-view-PAWVZNFY.js +1 -0
  179. package/dist/m/plugins/{people-SGPJKANK.js → people-JCHVPF7P.js} +1 -1
  180. package/dist/m/plugins/quickcrop-BQ5YYFCH.js +26 -0
  181. package/dist/m/plugins/{richclay-NRQDSWX6.js → richclay-KLGETK77.js} +2 -2
  182. package/dist/m/plugins/sortable-4S6SQC3D.js +1 -0
  183. package/dist/m/plugins/source-TJXOIOYY.js +1 -0
  184. package/dist/m/plugins/undo-NLPGCJQY.js +1 -0
  185. package/dist/m/plugins/{unsent-HJFLQJ2O.js → unsent-OA4RGBUT.js} +1 -1
  186. package/dist/m/plugins/upgrade-HSUNDNJD.js +1 -0
  187. package/dist/m/plugins/{upload-WLYKK6TL.js → upload-NHGJXZK2.js} +1 -1
  188. package/dist/m/plugins/visitor-drafts-BW43DGY3.js +20 -0
  189. package/dist/m/plugins/visitor-drafts-owner-7ODQG7JX.js +1 -0
  190. package/dist/m/plugins/{wire-4N6GYAF6.js → wire-VBWXEXU2.js} +1 -1
  191. package/dist/m/reader-core-4W2KYEWQ.js +77 -0
  192. package/dist/m/route/route-62CHXL5B.js +1 -0
  193. package/dist/m/sync/live-sync-MO5DH33W.js +1 -0
  194. package/dist/m/ui/index-IVWN2QC4.js +1 -0
  195. package/dist/m/vendor/sap-core.vendor-RN4ZVY4X.js +6 -0
  196. package/dist/offline-manifest.json +1367 -930
  197. package/dist/page/themes/light-1.css +8 -7
  198. package/dist/server/core.cjs +1440 -288
  199. package/dist/server/offline-runtime.cjs +63 -63
  200. package/dist/server/offline-runtime.mjs +63 -63
  201. package/dist/server/page-compaction-worker.cjs +3710 -0
  202. package/dist/server/rooms.mjs +5 -0
  203. package/docs/guide.md +653 -101
  204. package/entries/sap.js +5 -5
  205. package/package.json +1 -1
  206. package/src/core/admin-contenteditable.js +5 -2
  207. package/src/core/admin-inputs.js +1 -0
  208. package/src/core/admin-onclick.js +2 -1
  209. package/src/core/conflict-notice-ui.js +4 -7
  210. package/src/core/download.js +2 -2
  211. package/src/core/host-meta.js +38 -0
  212. package/src/core/is-edit-mode.js +17 -1
  213. package/src/core/mode-key.js +125 -19
  214. package/src/core/mode-support.js +5 -0
  215. package/src/core/mode-transition.js +20 -6
  216. package/src/core/preserve-authored.js +4 -4
  217. package/src/core/recovery.js +121 -33
  218. package/src/core/save-accepted.js +33 -0
  219. package/src/core/save-core.js +106 -31
  220. package/src/core/save-job.js +108 -0
  221. package/src/core/save-prepare.js +53 -0
  222. package/src/core/save-size-notice.js +3 -1
  223. package/src/core/save-slices.js +15 -0
  224. package/src/core/save.js +557 -127
  225. package/src/core/serialize-steps.js +109 -0
  226. package/src/core/snapshot.js +170 -39
  227. package/src/core/source-map.js +83 -38
  228. package/src/core/stale-host-notice.js +1 -0
  229. package/src/core/upgrade-run.js +59 -8
  230. package/src/dom/active-element.js +5 -0
  231. package/src/lib/assets.js +1 -0
  232. package/src/lib/slice.js +65 -0
  233. package/src/lib/uimotion.js +774 -0
  234. package/src/lib/unsaved-state.js +2 -2
  235. package/src/live/fast-lane.js +10 -3
  236. package/src/live/local-lane.js +202 -11
  237. package/src/live/ops.js +16 -0
  238. package/src/live/page-compaction.js +162 -0
  239. package/src/live/server-lane.js +1 -0
  240. package/src/live/store.js +11 -3
  241. package/src/loader-logic.js +3 -1
  242. package/src/loader.js +14 -4
  243. package/src/page/adopt-boot.js +2 -1
  244. package/src/page/awareness.js +33 -2
  245. package/src/page/blocks.js +89 -27
  246. package/src/page/boot-pack.js +1 -1
  247. package/src/page/chart-tables.js +21 -3
  248. package/src/page/chart.js +406 -63
  249. package/src/page/checklist.js +10 -135
  250. package/src/page/checkpoint-proof.js +7 -1
  251. package/src/page/chrome.js +250 -51
  252. package/src/page/clipboard-shape.js +126 -0
  253. package/src/page/drag.js +33 -3
  254. package/src/page/editor.js +136 -25
  255. package/src/page/export-assets.js +357 -0
  256. package/src/page/export-html.js +209 -0
  257. package/src/page/export-markdown.js +361 -0
  258. package/src/page/features/checklist.js +428 -0
  259. package/src/page/features/export-html.js +189 -0
  260. package/src/page/features/export-markdown.js +169 -0
  261. package/src/page/features/import-markdown.js +349 -0
  262. package/src/page/features/media.js +61 -5
  263. package/src/page/features/registry.js +18 -0
  264. package/src/page/features/table.js +373 -83
  265. package/src/page/features/title-cover.js +331 -0
  266. package/src/page/features/title-icon.js +213 -0
  267. package/src/page/features/toc.js +87 -0
  268. package/src/page/format-command.js +25 -1
  269. package/src/page/format-gate.js +68 -18
  270. package/src/page/format-host.js +17 -0
  271. package/src/page/format-native-commands.js +21 -5
  272. package/src/page/image-actions.js +64 -0
  273. package/src/page/import-markdown.js +262 -0
  274. package/src/page/items.js +135 -17
  275. package/src/page/markdown.js +6 -2
  276. package/src/page/media.js +35 -119
  277. package/src/page/placeholder.js +14 -1
  278. package/src/page/projection.js +98 -10
  279. package/src/page/recover.js +10 -12
  280. package/src/page/recovery-ui.js +464 -0
  281. package/src/page/rich/chart.js +63 -7
  282. package/src/page/save-projection.js +28 -0
  283. package/src/page/save-states.js +13 -3
  284. package/src/page/schema.js +19 -3
  285. package/src/page/table-paste.js +3 -1
  286. package/src/page/table-style.js +4 -4
  287. package/src/page/table.js +44 -2
  288. package/src/page/themes/light-1.css +8 -7
  289. package/src/page/themes/light-1.source.css +8 -7
  290. package/src/page/toc-link.js +31 -0
  291. package/src/page/toc.js +5 -0
  292. package/src/page/view-hooks.js +38 -12
  293. package/src/page/view.js +13 -0
  294. package/src/page/ybind/binding.js +88 -5
  295. package/src/page/ybind/bridge.js +187 -20
  296. package/src/page/ybind/containment.js +162 -0
  297. package/src/page/ybind/doc.js +17 -9
  298. package/src/page/ybind/file-debt.js +63 -0
  299. package/src/page/ybind/layout.js +7 -2
  300. package/src/page/ybind/marks.js +1 -1
  301. package/src/page/ybind/moves.js +5 -4
  302. package/src/page/ybind/origins.js +106 -4
  303. package/src/page/ybind/parts.js +8 -1
  304. package/src/page/ybind/provider.js +113 -17
  305. package/src/page/ybind/recovery-proof.js +32 -9
  306. package/src/page/ybind/recovery-replay.js +558 -15
  307. package/src/page/ybind/recovery.js +174 -45
  308. package/src/page/ybind/table.js +67 -11
  309. package/src/page/ybind/walk-delta.js +258 -0
  310. package/src/page/ybind/walk.js +54 -29
  311. package/src/page/ybind/writer.js +1 -1
  312. package/src/plugins/ai-edit.js +13 -9
  313. package/src/plugins/indicator-chip.js +91 -0
  314. package/src/plugins/indicator.js +13 -80
  315. package/src/plugins/live.js +89 -14
  316. package/src/plugins/menu/actions.js +196 -0
  317. package/src/plugins/menu/people.js +279 -0
  318. package/src/plugins/menu/placement.js +121 -0
  319. package/src/plugins/menu/preferences.js +9 -0
  320. package/src/plugins/menu/seo.js +316 -397
  321. package/src/plugins/menu/style.js +171 -0
  322. package/src/plugins/menu/variants.js +296 -0
  323. package/src/plugins/menu.js +186 -183
  324. package/src/plugins/page-view.js +3 -2
  325. package/src/plugins/page.js +249 -44
  326. package/src/plugins/sortable.js +8 -1
  327. package/src/plugins/source.js +6 -0
  328. package/src/plugins/upgrade.js +51 -17
  329. package/src/plugins/upload-ui.js +12 -17
  330. package/src/plugins/visitor-drafts/bottom-ui.js +12 -4
  331. package/src/plugins/visitor-drafts/intro-style.js +7 -2
  332. package/src/plugins/visitor-drafts/intro.js +5 -3
  333. package/src/plugins/visitor-drafts/owner-chip.js +8 -12
  334. package/src/plugins/visitor-drafts/prompt.js +11 -9
  335. package/src/plugins/visitor-drafts/source.js +4 -3
  336. package/src/plugins/visitor-drafts/store.js +138 -9
  337. package/src/plugins/visitor-drafts/ui-memory.js +2 -2
  338. package/src/plugins/visitor-drafts-owner.js +4 -6
  339. package/src/plugins/visitor-drafts.js +5 -3
  340. package/src/recovery/store.js +1 -1
  341. package/src/route/route.js +4 -0
  342. package/src/server/rooms/index.js +5 -1
  343. package/src/server/rooms/live-data-rooms.js +302 -50
  344. package/src/server/rooms/page-compaction-worker.js +12 -0
  345. package/src/server/rooms/page-compactor.js +92 -0
  346. package/src/server/rooms/page-room-conformance.js +461 -0
  347. package/src/source/parse-steps.js +94 -0
  348. package/src/source/session.js +233 -57
  349. package/src/sync/editing-sender.js +12 -0
  350. package/src/sync/live-sync-edit.js +22 -2
  351. package/src/sync/live-sync.js +150 -23
  352. package/src/sync/page-relay.js +52 -0
  353. package/src/sync/presence-store.js +44 -4
  354. package/src/sync/presence.js +6 -3
  355. package/src/sync/saved-stream.js +11 -6
  356. package/src/ui/bevel-controls.js +1 -0
  357. package/src/ui/bevel-dialog.js +5 -2
  358. package/src/ui/editor-ui.js +77 -0
  359. package/src/ui/page-colour.js +281 -0
  360. package/src/ui/toast.js +16 -7
  361. package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
  362. package/src/vendor/hyper-undo.vendor.js +1 -1
  363. package/src/vendor/hypercms.vendor.js +9 -9
  364. package/src/vendor/parse5.vendor.js +2 -2
  365. package/src/vendor/sap-core.vendor.js +5 -5
  366. package/dist/m/c-2SPVQSHC.js +0 -1
  367. package/dist/m/c-336SGU5W.js +0 -1
  368. package/dist/m/c-3H6BZDNJ.js +0 -6
  369. package/dist/m/c-4DFEJ2PO.js +0 -3
  370. package/dist/m/c-56QRZTZH.js +0 -1
  371. package/dist/m/c-5J4E7O4Y.js +0 -4
  372. package/dist/m/c-5LKDWQTL.js +0 -19
  373. package/dist/m/c-5V3MOVJT.js +0 -1
  374. package/dist/m/c-6AZOGAPE.js +0 -1
  375. package/dist/m/c-6CRUUXOX.js +0 -5
  376. package/dist/m/c-6HMB24EV.js +0 -1
  377. package/dist/m/c-6KEVEQ7L.js +0 -1
  378. package/dist/m/c-A3E3NIMC.js +0 -1
  379. package/dist/m/c-BQ6LOBEE.js +0 -1
  380. package/dist/m/c-BTDI4Y23.js +0 -1
  381. package/dist/m/c-CJ3A7ZKE.js +0 -1
  382. package/dist/m/c-CQHPGKX2.js +0 -1
  383. package/dist/m/c-DFS4XZJV.js +0 -2
  384. package/dist/m/c-DNENWY4O.js +0 -1
  385. package/dist/m/c-E4LIR5X5.js +0 -1
  386. package/dist/m/c-FDNHHZY5.js +0 -1
  387. package/dist/m/c-FROJUZMV.js +0 -1
  388. package/dist/m/c-G2JPKCTP.js +0 -1
  389. package/dist/m/c-GB6TU7WV.js +0 -1
  390. package/dist/m/c-GFJJEBTU.js +0 -1
  391. package/dist/m/c-GJAEGGXY.js +0 -1
  392. package/dist/m/c-GOWBDM6J.js +0 -1
  393. package/dist/m/c-HFKUVACH.js +0 -1
  394. package/dist/m/c-HHDFCNHV.js +0 -1
  395. package/dist/m/c-IEM4NTCS.js +0 -1
  396. package/dist/m/c-JDCX26UP.js +0 -25
  397. package/dist/m/c-KGVGROZT.js +0 -1
  398. package/dist/m/c-KQ6TYJAP.js +0 -135
  399. package/dist/m/c-LFPHCXY3.js +0 -1
  400. package/dist/m/c-LRPUO2MX.js +0 -1
  401. package/dist/m/c-LWLBHLVC.js +0 -3
  402. package/dist/m/c-M2L47S2X.js +0 -7
  403. package/dist/m/c-MFP2ZWD4.js +0 -1
  404. package/dist/m/c-MKSQ4Y57.js +0 -1
  405. package/dist/m/c-NUFFJVVJ.js +0 -1
  406. package/dist/m/c-NUHSPT5P.js +0 -1
  407. package/dist/m/c-O72HVYSA.js +0 -1
  408. package/dist/m/c-OIBUTDXI.js +0 -1
  409. package/dist/m/c-OW5QIGG3.js +0 -1
  410. package/dist/m/c-PGRCK73F.js +0 -1
  411. package/dist/m/c-Q7NQD65V.js +0 -1
  412. package/dist/m/c-QH63LQOS.js +0 -1
  413. package/dist/m/c-R7IWVINQ.js +0 -1
  414. package/dist/m/c-RM7J7BV2.js +0 -1
  415. package/dist/m/c-S4ZEH46B.js +0 -9
  416. package/dist/m/c-SDAXDM2D.js +0 -1
  417. package/dist/m/c-SMGU7RT3.js +0 -235
  418. package/dist/m/c-SPIORRYS.js +0 -1
  419. package/dist/m/c-SWLRT66R.js +0 -1
  420. package/dist/m/c-TCJTVAFN.js +0 -1
  421. package/dist/m/c-TQEMVJCS.js +0 -1
  422. package/dist/m/c-TYLXX4NF.js +0 -21
  423. package/dist/m/c-U62OB4BT.js +0 -1
  424. package/dist/m/c-U6YWUAR3.js +0 -8
  425. package/dist/m/c-UDCSU5G5.js +0 -2
  426. package/dist/m/c-VI5RMSVY.js +0 -1
  427. package/dist/m/c-VRZN56XY.js +0 -1
  428. package/dist/m/c-W5MCHEGA.js +0 -1
  429. package/dist/m/c-WNYBLIOZ.js +0 -1
  430. package/dist/m/c-WWXIDH4O.js +0 -28
  431. package/dist/m/c-XCNTK76U.js +0 -1
  432. package/dist/m/c-XMS6DPBR.js +0 -1
  433. package/dist/m/c-ZUCOKOHT.js +0 -1
  434. package/dist/m/c-ZVPVXTJF.js +0 -4
  435. package/dist/m/c-ZVRKXKM7.js +0 -1
  436. package/dist/m/core/autosave-HRYGUWV6.js +0 -1
  437. package/dist/m/core/conflict-notice-6MROJN2L.js +0 -1
  438. package/dist/m/core/is-edit-mode-KTWA3O5N.js +0 -1
  439. package/dist/m/core/recovery-FLTCC2OC.js +0 -1
  440. package/dist/m/core/save-HQZ4S667.js +0 -1
  441. package/dist/m/core/save-core-KGQWFUNH.js +0 -1
  442. package/dist/m/core/snapshot-EGQZNT45.js +0 -1
  443. package/dist/m/core/unsaved-warning-IDJLD66H.js +0 -1
  444. package/dist/m/loader-S4I7GKXR.js +0 -1
  445. package/dist/m/manifest-4C419D67.js +0 -1
  446. package/dist/m/manifest-edit-B077F288.js +0 -1
  447. package/dist/m/plugins/ai-edit-QN4PMMNL.js +0 -6
  448. package/dist/m/plugins/indicator-URDBBOQO.js +0 -1
  449. package/dist/m/plugins/live-HFWD4OIH.js +0 -1
  450. package/dist/m/plugins/menu-3HIEQSI6.js +0 -138
  451. package/dist/m/plugins/page-JOKB6XPR.js +0 -2
  452. package/dist/m/plugins/page-view-NXOJHNW6.js +0 -1
  453. package/dist/m/plugins/quickcrop-QNY3PKLD.js +0 -26
  454. package/dist/m/plugins/sortable-GWT4PWDV.js +0 -1
  455. package/dist/m/plugins/source-4ZRSAIYM.js +0 -1
  456. package/dist/m/plugins/undo-VDA5P7FH.js +0 -1
  457. package/dist/m/plugins/upgrade-W7FG5VFG.js +0 -1
  458. package/dist/m/plugins/visitor-drafts-LIDY7NH6.js +0 -21
  459. package/dist/m/plugins/visitor-drafts-owner-ZXCKBZE7.js +0 -1
  460. package/dist/m/reader-core-DXYCURFF.js +0 -68
  461. package/dist/m/route/route-KOCZSEGN.js +0 -1
  462. package/dist/m/sync/live-sync-J7VMYTKS.js +0 -1
  463. package/dist/m/ui/index-3JLVI27O.js +0 -1
  464. package/dist/m/vendor/sap-core.vendor-A7BMUYZT.js +0 -6
  465. /package/dist/m/{c-FILLTJA7.js → c-QTTJYJMA.js} +0 -0
  466. /package/dist/m/plugins/{quickcrop-QNY3PKLD.js.LEGAL.txt → quickcrop-BQ5YYFCH.js.LEGAL.txt} +0 -0
  467. /package/dist/m/plugins/{richclay-NRQDSWX6.js.LEGAL.txt → richclay-KLGETK77.js.LEGAL.txt} +0 -0
package/src/core/save.js CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import throttle from "../lib/throttle.js";
13
13
  import Mutation from "../lib/mutation.js";
14
- import { isDraftMode, isEditMode, isOwner } from "./is-edit-mode.js";
14
+ import { isDraftMode, isEditMode, isOwner, learnedHostWritable } from "./is-edit-mode.js";
15
15
  import {
16
16
  saveCapturedHtml,
17
17
  getPageContents,
@@ -19,10 +19,17 @@ import {
19
19
  addDocumentTransform,
20
20
  isSaveInProgress,
21
21
  saveFateIsUnknown,
22
- withKeepalive
22
+ withKeepalive,
23
+ setExitSave,
24
+ reserveSaveLane,
25
+ releaseSaveLane,
26
+ keepaliveHeld,
27
+ knownPageFormatFloor,
28
+ PAGE_FORMAT_META
23
29
  } from "./save-core.js";
24
30
  import { captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck, captureSaveClone, releaseCaptureArtifact } from "./snapshot.js";
25
31
  import { saveLimit, saveLimits, inspectSaveSize } from "./save-size.js";
32
+ import { SLICED_SAVE_MIN_BYTES } from "./save-slices.js";
26
33
  import { paintSignature } from "../lib/derived.js";
27
34
  import { NO_TRIGGER_AUTOSAVE_SELECTOR } from "../lib/region-policy.js";
28
35
  import { seedEtag, learnConditionalSaves, lastSeenEtag, recordEtag } from "./etag.js";
@@ -40,7 +47,7 @@ import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/use
40
47
  import { bytesUnsaved } from "./unsaved-warning.js";
41
48
  import { bootPhase } from "./boot-phase.js";
42
49
  import { createAutosaveScheduler } from "../lib/autosave-scheduler.js";
43
- import { noteRecoveryCapture, noteRecoverySettled } from "./recovery.js";
50
+ import { beginRecoveryCapture, finishRecoveryCapture, noteRecoveryCapture, noteRecoverySettled } from "./recovery.js";
44
51
 
45
52
  // Keep this library's own root state out of the saved bytes.
46
53
  //
@@ -544,7 +551,7 @@ function heldForPreview(callback, resolve) {
544
551
  const skipped = skipped_(saveHoldReason());
545
552
  callback(skipped);
546
553
  resolve(skipped);
547
- return true;
554
+ return skipped;
548
555
  }
549
556
 
550
557
  // ============================================
@@ -632,6 +639,29 @@ function sessionClosed(msg) {
632
639
  return skipped_(msg);
633
640
  }
634
641
 
642
+ // A save this client will not send: the host has already answered that this page may not be
643
+ // written. The shape is any refused save's, carrying the `forbidden` code a host's own 403
644
+ // comes back with and `sent: false` because no request was made, so the chip, the notice and
645
+ // the exit barrier read it exactly as they read that refusal. Sending it would only earn the
646
+ // same answer, later.
647
+ function forbidden_() {
648
+ return {
649
+ ok: false,
650
+ msg: 'You are not allowed to save this document.',
651
+ msgType: 'forbidden',
652
+ code: 'forbidden',
653
+ status: 403,
654
+ sent: false,
655
+ etag: null
656
+ };
657
+ }
658
+
659
+ // The host has said no, and nothing here overrides that: a page it will not store is not
660
+ // written by an autosave, a Cmd+S, or the exit's own flush.
661
+ function refusesSave() {
662
+ return learnedHostWritable() === false;
663
+ }
664
+
635
665
  // The exit's own flush is not an ordinary save: it is the barrier writing the bytes the
636
666
  // exit is about to account for, so it runs under a permit. Nothing else does — a Cmd+S or
637
667
  // a [trigger-save] press while admission is closed stays parked.
@@ -833,6 +863,346 @@ export function setLastSavedContents(val) {
833
863
 
834
864
  const monotonic = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
835
865
 
866
+ // The size at which a document's work hands the page back, shared with the source map's
867
+ // own install and repair. Re-exported so this module keeps answering for the save side.
868
+ export { SLICED_SAVE_MIN_BYTES };
869
+ // The main-thread time one preparation slice may spend before handing the page back.
870
+ const SLICED_SAVE_BUDGET_MS = 4;
871
+ // What a save answers when the page changed underneath its preparation. A skip, because
872
+ // nothing was sent: the bytes are still here, and the next save starts over.
873
+ const SAVE_PREPARE_CANCELLED = 'Save cancelled: the page changed while it prepared';
874
+
875
+ // The save preparing across tasks right now, for a flush to wait on: it has taken the lane
876
+ // and sent nothing, so its own events have not fired yet.
877
+ let preparing = null;
878
+ // The lazily loaded driver for the steps. Null until something asks, so a page whose
879
+ // documents are small never loads it, and false-keyed off for good if it cannot load.
880
+ let prepDriver = null;
881
+ let prepLoading = null;
882
+ let prepFailed = false;
883
+
884
+ function loadPreparer() {
885
+ if (!prepLoading) {
886
+ prepLoading = import('./save-prepare.js').then(
887
+ (mod) => { prepDriver = mod; return mod; },
888
+ (err) => {
889
+ prepFailed = true;
890
+ console.info('clayjs: sliced save off (' + (err && err.message ? err.message : err) + '); saves prepare in one task');
891
+ return null;
892
+ }
893
+ );
894
+ }
895
+ return prepLoading;
896
+ }
897
+
898
+ // The size of the bytes the last save wrote. A document that has never saved is judged by
899
+ // the document it was served as, which the browser has already measured.
900
+ function servedSize() {
901
+ try {
902
+ return performance.getEntriesByType('navigation')[0]?.decodedBodySize || 0;
903
+ } catch {
904
+ return 0;
905
+ }
906
+ }
907
+ const lastSavedSize = () => (typeof lastSavedBytes === 'string' ? lastSavedBytes.length : servedSize());
908
+
909
+ // Whether this save prepares across tasks instead of in the caller's.
910
+ //
911
+ // A hidden tab has no typing to protect and timers there are throttled: it keeps the
912
+ // one-task path. A keepalive save belongs to a page that is going away and cannot wait for
913
+ // a task boundary, so the unload flush keeps it too.
914
+ function preparesSliced() {
915
+ if (prepFailed) return false;
916
+ if (document.hidden) return false;
917
+ if (keepaliveHeld()) return false;
918
+ return lastSavedSize() >= SLICED_SAVE_MIN_BYTES;
919
+ }
920
+
921
+ // Why a preparing job must not be sent any more, or null when it may go out.
922
+ //
923
+ // Ordinary typing is deliberately NOT one of these: the capture is a checkpoint, and
924
+ // saving it later is the point. Only an invalidation cancels: the session, the mode, the
925
+ // host's permission, a newer write to this document, a morph, or a document format that
926
+ // rose while the job prepared. A rebuilt source map is not one: a render laid out against
927
+ // the old pairing falls back to today's bytes, and a stale artifact is never adopted.
928
+ function prepareInvalidation(ctx, epoch, morph) {
929
+ if (morph) return 'morph';
930
+ if (saveGenerationAt !== epoch.generation || !isEditMode) return 'mode';
931
+ if (!savesAdmitted(ctx.permit)) return 'session';
932
+ if (refusesSave()) return 'refused';
933
+ if (baselineRevision !== epoch.baselines) return 'newer save';
934
+ if (Math.max(knownPageFormatFloor(), ctx.pageFormatFloor) > epoch.floor) return 'floor';
935
+ return null;
936
+ }
937
+
938
+ // A merge arrives as a burst of applied frames: a retry waits for a quiet moment, never
939
+ // longer than two seconds, so it captures the page the burst left behind.
940
+ function mergeSettled(quietMs = 150, maxMs = 2000) {
941
+ return new Promise((resolve) => {
942
+ const started = monotonic();
943
+ let timer = null;
944
+ const done = () => { document.removeEventListener('clay:sync-applied', bump); resolve(); };
945
+ const bump = () => {
946
+ clearTimeout(timer);
947
+ timer = setTimeout(done, Math.max(0, Math.min(quietMs, maxMs - (monotonic() - started))));
948
+ };
949
+ document.addEventListener('clay:sync-applied', bump);
950
+ bump();
951
+ });
952
+ }
953
+
954
+ /**
955
+ * Everything one capture decides, for both save paths.
956
+ *
957
+ * The same bytes, the same refusals and the same receipts either way: a capture taken in
958
+ * one task and a capture prepared across tasks differ only in when their verification
959
+ * ran, never in what is asked of the result. `noteRecovery` is how the async path records
960
+ * coverage from its pinned candidates; `release` gives back whatever the capture holds
961
+ * when nothing is sent.
962
+ */
963
+ async function settleCaptured({
964
+ explicit,
965
+ forced = false,
966
+ forSave,
967
+ forComparison,
968
+ forDirty,
969
+ captureId,
970
+ gateToken,
971
+ costMs,
972
+ pageFormatFloor = 0,
973
+ quietConflict = false,
974
+ laneHeld = false,
975
+ lane = null,
976
+ capture,
977
+ noteRecovery,
978
+ release,
979
+ callback,
980
+ }) {
981
+ noteRecovery();
982
+
983
+ if (explicit && !forced) {
984
+ // An explicit save asks the DIRTY question: write anything the person would
985
+ // otherwise lose, including an edit inside a batching region that was never
986
+ // going to autosave itself.
987
+ unsavedChanges = (forDirty !== lastSavedDirty);
988
+ logSaveCheck('savePage dirty check', !unsavedChanges);
989
+
990
+ // Skip if content hasn't changed. Clearing the explicit intent here is the
991
+ // whole point of scoping it: pressing Save on a clean page sends nothing, and
992
+ // leaving "a human asked for this" armed would hand it to the next
993
+ // background write and hide exactly the clobber the guard watches for.
994
+ if (!unsavedChanges) {
995
+ clearExplicitSave();
996
+ gateClearIfUnchanged(gateToken);
997
+ release();
998
+ const skipped = skipped_('No changes to save');
999
+ settleRecoveryCapture(captureId, skipped);
1000
+ if (typeof callback === 'function') callback(skipped);
1001
+ return { result: skipped, dispatched: false };
1002
+ }
1003
+ } else if (!explicit) {
1004
+ // For autosave: while the page is still settling, content must differ from
1005
+ // BOTH the load-time baseline and the last save, so module setup churn cannot
1006
+ // trigger a save. Once settled, the baseline veto is disarmed and only the
1007
+ // last save matters — otherwise undoing back to the page's original state
1008
+ // can never be persisted, because it matches the baseline forever.
1009
+ const differsFromBaseline = !baselineActive || forComparison !== baselineContents;
1010
+ const differsFromLastSave = forComparison !== lastSavedContents;
1011
+
1012
+ logSaveCheck('throttled vs baseline', !differsFromBaseline);
1013
+ logSaveCheck('throttled vs lastSave', !differsFromLastSave);
1014
+
1015
+ if (!(differsFromBaseline && differsFromLastSave)) {
1016
+ // This capture's comparison leaves out batching (no-trigger-autosave)
1017
+ // regions, which the gate counts, so on a page that has one the dirty
1018
+ // domain must match too before the gate is cleared.
1019
+ let clear = false;
1020
+ try {
1021
+ clear = !differsFromLastSave && (!document.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
1022
+ || (pageMaybeDirty() && forDirty === lastSavedDirty));
1023
+ } catch (err) {
1024
+ console.error('savePageThrottled: dirty domain check failed', err);
1025
+ }
1026
+ if (clear) gateClearIfUnchanged(gateToken);
1027
+ autosaveScheduler.noteCost(costMs);
1028
+ release();
1029
+ const skipped = skipped_('No changes to save');
1030
+ settleRecoveryCapture(captureId, skipped);
1031
+ if (typeof callback === 'function') callback(skipped);
1032
+ return { result: skipped, dispatched: false };
1033
+ }
1034
+
1035
+ unsavedChanges = true;
1036
+
1037
+ // A save that is not this lane's own (a raw saveHtml) may still be on the
1038
+ // wire. The capture has already been taken, so answer like the explicit lane
1039
+ // and keep these newer bytes queued behind it. A prepared save holds the lane
1040
+ // itself, so it can never be looking at somebody else's request here.
1041
+ if (!laneHeld && isSaveInProgress()) {
1042
+ pendingSave = true;
1043
+ release();
1044
+ const skipped = skipped_('Save already in progress');
1045
+ settleRecoveryCapture(captureId, skipped);
1046
+ if (typeof callback === 'function') callback(skipped);
1047
+ return { result: skipped, dispatched: false };
1048
+ }
1049
+ }
1050
+
1051
+ // Start debounced 'saving' state (only shows if save takes >500ms)
1052
+ setSavingState();
1053
+
1054
+ // Use the captured save path with our pre-captured content (avoids double capture)
1055
+ const comparison = { forComparison, forDirty };
1056
+ inFlightComparisons.add(comparison);
1057
+ ownSaveOnWire = true;
1058
+ return new Promise((resolve) => {
1059
+ saveCapturedHtml(forSave, captureId, (result) => {
1060
+ ownSaveOnWire = false;
1061
+ inFlightComparisons.delete(comparison);
1062
+ const completedAt = monotonic();
1063
+ applySaveResult(result, comparison.forComparison, comparison.forDirty, forced ? 'updated after force save' : 'updated after save', gateToken, forSave, captureId, quietConflict);
1064
+ if (!explicit) autosaveScheduler.noteCost(costMs + (monotonic() - completedAt));
1065
+ if (typeof callback === 'function') callback(result);
1066
+ resolve({ result, dispatched: true });
1067
+ // The save that queued behind this one goes out from here, in this request's own
1068
+ // task, which is where it has always gone out: a flush's own call then finds the
1069
+ // lane busy and waits that request out, which is what its 'clean' answer describes.
1070
+ // A prepared save holds the lane itself, so its caller does the draining instead.
1071
+ if (!laneHeld) drainPendingSave();
1072
+ }, { capture, pageFormatFloor, lane });
1073
+ });
1074
+ }
1075
+
1076
+ /**
1077
+ * Prepare one capture across tasks and send it, or answer like a skip.
1078
+ *
1079
+ * The lane was reserved before this function's first await, so nothing else can start a
1080
+ * save on the page this capture read. Every outcome gives it back: a send hands it to
1081
+ * `runSave`, which settles it with the bookkeeping, and a cancellation gives it back here.
1082
+ */
1083
+ async function runSlicedSave(ctx, lane) {
1084
+ // A morph replaces the page under a job that is only half verified. What it applied may
1085
+ // be a disk frame or a peer's edit, so these bytes describe a page that is gone.
1086
+ const applied = { over: false };
1087
+ const onApplied = () => { applied.over = true; };
1088
+ document.addEventListener('clay:sync-applied', onApplied);
1089
+ try {
1090
+ for (let round = 0; round < 2; round++) {
1091
+ const gateToken = gateCaptureToken();
1092
+ const epoch = {
1093
+ generation: saveGenerationAt,
1094
+ baselines: baselineRevision,
1095
+ floor: Math.max(knownPageFormatFloor(), ctx.pageFormatFloor),
1096
+ };
1097
+ const job = prepDriver.beginSaveCapture({
1098
+ requestRefresh: true,
1099
+ forRequest: true,
1100
+ pageFormatMarker: epoch.floor ? { name: PAGE_FORMAT_META, content: String(epoch.floor) } : null,
1101
+ });
1102
+ // The copies this capture could cover are the ones held right now: reading them
1103
+ // after the preparation would both miss A's coverage and claim a newer copy's.
1104
+ beginRecoveryCapture(job.captureId);
1105
+ let why = null;
1106
+ const signal = {
1107
+ get aborted() {
1108
+ const reason = prepareInvalidation(ctx, epoch, applied.over);
1109
+ if (reason) why = reason;
1110
+ return !!reason;
1111
+ },
1112
+ };
1113
+ let prepared;
1114
+ try {
1115
+ prepared = await prepDriver.runPreparedSteps(job.steps(), { budgetMs: SLICED_SAVE_BUDGET_MS, signal });
1116
+ } catch (err) {
1117
+ console.error(`${ctx.explicit ? 'savePage' : 'savePageThrottled'}: preparing the save failed`, err);
1118
+ job.drop();
1119
+ const result = { msg: err && err.message ? err.message : 'Save failed', msgType: 'error', code: err?.code ?? null, etag: null };
1120
+ setSaveState('error', result.msg, '', { sent: false, code: result.code });
1121
+ settleRecoveryCapture(job.captureId, result);
1122
+ if (typeof ctx.callback === 'function') ctx.callback(result);
1123
+ return { result, dispatched: false };
1124
+ }
1125
+
1126
+ const late = prepared.aborted ? why : prepareInvalidation(ctx, epoch, applied.over);
1127
+ if (!late) {
1128
+ const captured = prepared.result;
1129
+ return await settleCaptured({
1130
+ explicit: ctx.explicit,
1131
+ forced: ctx.forced,
1132
+ forSave: captured.forSave,
1133
+ forComparison: captured.forComparison,
1134
+ forDirty: captured.forDirty,
1135
+ captureId: captured.captureId,
1136
+ gateToken,
1137
+ costMs: prepared.activeMs,
1138
+ pageFormatFloor: ctx.pageFormatFloor,
1139
+ quietConflict: ctx.quietConflict,
1140
+ laneHeld: true,
1141
+ lane,
1142
+ capture: captured,
1143
+ noteRecovery: () => finishRecoveryCapture(captured.captureId, captured.forSave),
1144
+ release: () => job.drop(),
1145
+ callback: ctx.callback,
1146
+ });
1147
+ }
1148
+
1149
+ // Nothing is sent from a capture that cannot be trusted: the clones and the
1150
+ // candidate go, and the waiters hear the skip contract's answer.
1151
+ job.drop();
1152
+ const skipped = skipped_(SAVE_PREPARE_CANCELLED);
1153
+ // A session's closed admission cancelled this preparation: the page is between
1154
+ // modes, and the attempt is remembered so `resume` replays it.
1155
+ if (late === 'session') parkedAutosave = true;
1156
+ settleRecoveryCapture(job.captureId, skipped);
1157
+ // The document format rose under a job already prepared: its marker and its
1158
+ // verification both predate the floor, so the capture is taken again at the new one.
1159
+ if (late === 'floor' && round === 0) continue;
1160
+ // The person asked for this save: a merge that landed under it does not get to
1161
+ // swallow it. Once the merge settles the page is captured again, once.
1162
+ if (late === 'morph' && ctx.explicit && round === 0) {
1163
+ await mergeSettled();
1164
+ applied.over = false;
1165
+ // The retry captures again rather than asking the dirty gate: an editor's content
1166
+ // lives where the gate does not look, and the capture itself skips a clean page.
1167
+ continue;
1168
+ }
1169
+ if (typeof ctx.callback === 'function') ctx.callback(skipped);
1170
+ return { result: skipped, dispatched: false };
1171
+ }
1172
+ const skipped = skipped_(SAVE_PREPARE_CANCELLED);
1173
+ if (typeof ctx.callback === 'function') ctx.callback(skipped);
1174
+ return { result: skipped, dispatched: false };
1175
+ } finally {
1176
+ document.removeEventListener('clay:sync-applied', onApplied);
1177
+ releaseSaveLane(lane);
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * Start a prepared save, or answer false so the caller takes the one-task path.
1183
+ *
1184
+ * The lane is reserved and the capture is begun in THIS task, before anything awaits.
1185
+ */
1186
+ function startSlicedSave(ctx) {
1187
+ const lane = reserveSaveLane();
1188
+ if (!lane) return false;
1189
+ const task = runSlicedSave(ctx, lane).catch((err) => {
1190
+ console.error('save: preparing the save failed', err);
1191
+ const result = { msg: err && err.message ? err.message : 'Save failed', msgType: 'error', code: err?.code ?? null, etag: null };
1192
+ setSaveState('error', result.msg, '', { sent: false, code: result.code });
1193
+ if (typeof ctx.callback === 'function') ctx.callback(result);
1194
+ return { result, dispatched: false };
1195
+ });
1196
+ preparing = task;
1197
+ task.then((outcome) => {
1198
+ if (preparing === task) preparing = null;
1199
+ ctx.resolve(outcome.result);
1200
+ ctx.settleSoonQueued(outcome.result);
1201
+ if (outcome.dispatched || pendingSave || pendingAutosave || soonQueuedWaiters.length) drainPendingSave();
1202
+ });
1203
+ return true;
1204
+ }
1205
+
836
1206
  /**
837
1207
  * One save attempt, explicit or automatic.
838
1208
  *
@@ -862,19 +1232,38 @@ function attemptSaveNow({ kind, permit = null }, callback = () => {}) {
862
1232
  if (!isEditMode && !window.clay?.testMode) {
863
1233
  if (explicit) clearExplicitSave();
864
1234
  const skipped = skipped_('Not in edit mode');
1235
+ // The page left edit mode, so no later save is coming to take these callers.
1236
+ takeSoonQueued().forEach((done) => done(skipped));
865
1237
  callback(skipped);
866
1238
  return resolve(skipped);
867
1239
  }
868
1240
 
869
1241
  // A session has admission closed: the page is between modes, and neither lane writes
870
- // until it decides. An automatic attempt is remembered so `resume` replays it.
1242
+ // until it decides. The attempt is remembered so `resume` replays it, explicit or not.
871
1243
  if (!savesAdmitted(permit)) {
872
- if (!explicit) parkedAutosave = true;
1244
+ parkedAutosave = true;
873
1245
  const skipped = sessionClosed('Save session is quiesced');
1246
+ // The soon() callers waiting on this save hear its refusal: a queued waiter that is
1247
+ // never answered is a promise the page cannot settle, and the refusal is the answer
1248
+ // to the save it was moved to.
1249
+ if (explicit) takeSoonQueued().forEach((done) => done(skipped));
874
1250
  callback(skipped);
875
1251
  return resolve(skipped);
876
1252
  }
877
1253
 
1254
+ // The host has told this tab this page is no longer writable. Nothing sends a request
1255
+ // the client already knows will be refused: the text stays here, and the answer is the
1256
+ // one a host's 403 gets, so the indicator says so and the exit refuses like a failed
1257
+ // save. The flush inside the exit barrier passes through here too.
1258
+ if (refusesSave()) {
1259
+ if (explicit) clearExplicitSave();
1260
+ const refused = forbidden_();
1261
+ setSaveState('error', refused.msg, '', { code: refused.code, status: refused.status, sent: false });
1262
+ takeSoonQueued().forEach((done) => done(refused));
1263
+ callback(refused);
1264
+ return resolve(refused);
1265
+ }
1266
+
878
1267
  // A save is already on the wire. Remember that a newer state is waiting rather
879
1268
  // than dropping it: the in-flight request carries the older bytes, and if no
880
1269
  // further mutation happens to retrigger autosave, the newer ones would never
@@ -882,7 +1271,13 @@ function attemptSaveNow({ kind, permit = null }, callback = () => {}) {
882
1271
  // Not cleared here: pendingSave means this request is deferred, not
883
1272
  // abandoned, and drainPendingSave is what eventually sends it.
884
1273
  if (explicit) {
885
- if (heldForPreview(callback, resolve)) return;
1274
+ const held = heldForPreview(callback, resolve);
1275
+ if (held) {
1276
+ // A hold's replay is an autosave, which takes nobody's queued waiters, and a hold
1277
+ // released without a replay drops the missed save: the hold is their answer.
1278
+ takeSoonQueued().forEach((done) => done(held));
1279
+ return;
1280
+ }
886
1281
  if (isSaveInProgress()) {
887
1282
  pendingSave = true;
888
1283
  const skipped = skipped_('Save already in progress');
@@ -902,6 +1297,15 @@ function attemptSaveNow({ kind, permit = null }, callback = () => {}) {
902
1297
  callback(skipped);
903
1298
  return resolve(skipped);
904
1299
  }
1300
+ // A save preparing across tasks holds the lane and has sent nothing yet. This
1301
+ // records intent and nothing more: capturing the page again for it would be the
1302
+ // second full snapshot this split exists to prevent.
1303
+ if (preparing) {
1304
+ pendingAutosave = true;
1305
+ const skipped = skipped_('Save already in progress');
1306
+ callback(skipped);
1307
+ return resolve(skipped);
1308
+ }
905
1309
  // The dirty gate counts every change no save has accounted for, and it is
906
1310
  // the cheap half of the old dirtyMatchesLastSave check. When it says clean
907
1311
  // there is nothing to compare, so answer like the old comparison did and
@@ -919,6 +1323,11 @@ function attemptSaveNow({ kind, permit = null }, callback = () => {}) {
919
1323
  const prepare = window.clay?.source?.ensureForSave?.();
920
1324
  if (prepare) return prepare.then(() => resolve(attemptSaveNow({ kind, permit }, callback)));
921
1325
 
1326
+ if (preparesSliced() && !prepDriver) {
1327
+ loadPreparer().then(() => resolve(attemptSaveNow({ kind, permit }, callback)));
1328
+ return;
1329
+ }
1330
+
922
1331
  // From here this call answers: it either writes, or reports why it did not,
923
1332
  // and the soon() callers waiting on the save queued behind the one that was
924
1333
  // on the wire when they asked get that answer.
@@ -932,6 +1341,21 @@ function attemptSaveNow({ kind, permit = null }, callback = () => {}) {
932
1341
  setOfflineStateQuiet();
933
1342
  }
934
1343
 
1344
+ // A document this big prepares its save across tasks, so typing is never blocked for
1345
+ // the whole verification. The claim and the capture are both taken in this task.
1346
+ if (preparesSliced()) {
1347
+ if (startSlicedSave({
1348
+ explicit,
1349
+ forced: false,
1350
+ permit,
1351
+ pageFormatFloor: 0,
1352
+ quietConflict: false,
1353
+ settleSoonQueued,
1354
+ callback,
1355
+ resolve,
1356
+ })) return;
1357
+ }
1358
+
935
1359
  // Single capture: clone once, get all three versions
936
1360
  // forSave strips non-persisted regions ([no-save]/[save-remove])
937
1361
  // forComparison additionally strips every autosave-off region
@@ -961,99 +1385,22 @@ function attemptSaveNow({ kind, permit = null }, callback = () => {}) {
961
1385
 
962
1386
  // The recovery hook sees the accepted capture's exact bytes, before anything is
963
1387
  // sent, so a copy can be proved to be in them rather than merely to be nearby.
964
- noteRecoveryCapture({ captureId, html: forSave });
965
-
966
- if (explicit) {
967
- // An explicit save asks the DIRTY question: write anything the person would
968
- // otherwise lose, including an edit inside a batching region that was never
969
- // going to autosave itself.
970
- unsavedChanges = (forDirty !== lastSavedDirty);
971
- logSaveCheck('savePage dirty check', !unsavedChanges);
972
-
973
- // Skip if content hasn't changed. Clearing the explicit intent here is the
974
- // whole point of scoping it: pressing Save on a clean page sends nothing, and
975
- // leaving "a human asked for this" armed would hand it to the next
976
- // background write and hide exactly the clobber the guard watches for.
977
- if (!unsavedChanges) {
978
- clearExplicitSave();
979
- gateClearIfUnchanged(gateToken);
980
- releaseCaptureArtifact(capture);
981
- const skipped = skipped_('No changes to save');
982
- settleRecoveryCapture(captureId, skipped);
983
- callback(skipped);
984
- resolve(skipped);
985
- settleSoonQueued(skipped);
986
- return;
987
- }
988
- } else {
989
- // For autosave: while the page is still settling, content must differ from
990
- // BOTH the load-time baseline and the last save, so module setup churn cannot
991
- // trigger a save. Once settled, the baseline veto is disarmed and only the
992
- // last save matters — otherwise undoing back to the page's original state
993
- // can never be persisted, because it matches the baseline forever.
994
- const differsFromBaseline = !baselineActive || forComparison !== baselineContents;
995
- const differsFromLastSave = forComparison !== lastSavedContents;
996
-
997
- logSaveCheck('throttled vs baseline', !differsFromBaseline);
998
- logSaveCheck('throttled vs lastSave', !differsFromLastSave);
999
-
1000
- if (!(differsFromBaseline && differsFromLastSave)) {
1001
- // This capture's comparison leaves out batching (no-trigger-autosave)
1002
- // regions, which the gate counts, so on a page that has one the dirty
1003
- // domain must match too before the gate is cleared.
1004
- let clear = false;
1005
- try {
1006
- clear = !differsFromLastSave && (!document.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
1007
- || (pageMaybeDirty() && forDirty === lastSavedDirty));
1008
- } catch (err) {
1009
- console.error('savePageThrottled: dirty domain check failed', err);
1010
- }
1011
- if (clear) gateClearIfUnchanged(gateToken);
1012
- autosaveScheduler.noteCost(preparedMs);
1013
- releaseCaptureArtifact(capture);
1014
- const skipped = skipped_('No changes to save');
1015
- settleRecoveryCapture(captureId, skipped);
1016
- callback(skipped);
1017
- resolve(skipped);
1018
- return;
1019
- }
1020
-
1021
- unsavedChanges = true;
1022
-
1023
- // A save that is not this lane's own (a raw saveHtml) may still be on the
1024
- // wire. The capture has already been taken, so answer like the explicit lane
1025
- // and keep these newer bytes queued behind it.
1026
- if (isSaveInProgress()) {
1027
- pendingSave = true;
1028
- releaseCaptureArtifact(capture);
1029
- const skipped = skipped_('Save already in progress');
1030
- settleRecoveryCapture(captureId, skipped);
1031
- callback(skipped);
1032
- resolve(skipped);
1033
- return;
1034
- }
1035
- }
1036
-
1037
- // Start debounced 'saving' state (only shows if save takes >500ms)
1038
- setSavingState();
1039
-
1040
- // Use the captured save path with our pre-captured content (avoids double capture)
1041
- const comparison = { forComparison, forDirty };
1042
- inFlightComparisons.add(comparison);
1043
- ownSaveOnWire = true;
1044
- saveCapturedHtml(forSave, captureId, (result) => {
1045
- ownSaveOnWire = false;
1046
- inFlightComparisons.delete(comparison);
1047
- const completedAt = monotonic();
1048
- applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after save', gateToken, forSave, captureId);
1049
- if (!explicit) autosaveScheduler.noteCost(preparedMs + (monotonic() - completedAt));
1050
- if (typeof callback === 'function') {
1051
- callback(result);
1052
- }
1053
- resolve(result);
1054
- settleSoonQueued(result);
1055
- drainPendingSave();
1056
- }, { capture });
1388
+ settleCaptured({
1389
+ explicit,
1390
+ forSave,
1391
+ forComparison,
1392
+ forDirty,
1393
+ captureId,
1394
+ gateToken,
1395
+ costMs: preparedMs,
1396
+ capture,
1397
+ noteRecovery: () => noteRecoveryCapture({ captureId, html: forSave }),
1398
+ release: () => releaseCaptureArtifact(capture),
1399
+ callback,
1400
+ }).then((outcome) => {
1401
+ resolve(outcome.result);
1402
+ settleSoonQueued(outcome.result);
1403
+ });
1057
1404
  });
1058
1405
  }
1059
1406
 
@@ -1080,43 +1427,99 @@ function savePagePermitted(permit, callback = () => {}) {
1080
1427
  /**
1081
1428
  * Force-save the current page (skips dirty check).
1082
1429
  *
1430
+ * `permit` is the exit's own save permit: the one write a closed session still admits.
1431
+ * Internal to the exit flush, which no page can reach.
1432
+ *
1083
1433
  * @param {Function} callback - Optional callback for custom handling
1084
1434
  * @returns {Promise<{msg: string, msgType: string}>}
1085
1435
  */
1086
- export function savePageForce(callback = () => {}, { quietConflict = false, pageFormatFloor = 0 } = {}) {
1087
- if (capabilityPending) return capabilityKnown.then(() => savePageForce(callback, { quietConflict, pageFormatFloor }));
1436
+ export function savePageForce(callback = () => {}, { quietConflict = false, pageFormatFloor = 0, permit = null } = {}) {
1437
+ if (capabilityPending) return capabilityKnown.then(() => savePageForce(callback, { quietConflict, pageFormatFloor, permit }));
1088
1438
  return new Promise((resolve) => {
1089
1439
  if (!isEditMode && !window.clay?.testMode) {
1090
1440
  const skipped = skipped_('Not in edit mode');
1441
+ // A soon() caller queued behind a save that ends here hears this answer: the page left
1442
+ // edit mode, and no later save is coming to take them.
1443
+ takeSoonQueued().forEach((done) => done(skipped));
1091
1444
  callback(skipped);
1092
1445
  return resolve(skipped);
1093
1446
  }
1094
1447
 
1095
1448
  // A forced save is still an ordinary one for admission: only the exit's own flush
1096
- // carries the permit.
1097
- if (!savesAdmitted()) {
1449
+ // carries the permit. A revoked permit is refused even where admission has reopened,
1450
+ // so a continuation the exit already gave up on cannot write later input.
1451
+ if (!savesAdmitted(permit) || (permit && !permitHolds(permit))) {
1098
1452
  const skipped = sessionClosed('Save session is quiesced');
1453
+ // A closed session parks its own attempt and sends nothing more without a permit, so
1454
+ // the refusal is the last word these callers get from this page.
1455
+ takeSoonQueued().forEach((done) => done(skipped));
1099
1456
  callback(skipped);
1100
1457
  return resolve(skipped);
1101
1458
  }
1102
1459
 
1103
- if (heldForPreview(callback, resolve)) return;
1460
+ // A force save is not a way around the host's answer: these bytes are the ones it has
1461
+ // already refused, and a request it will reject is not sent.
1462
+ if (refusesSave()) {
1463
+ const refused = forbidden_();
1464
+ setSaveState('error', refused.msg, '', { code: refused.code, status: refused.status, sent: false });
1465
+ // The host's refusal is the standing answer for this page, so nothing later will take
1466
+ // these callers: they hear it here.
1467
+ takeSoonQueued().forEach((done) => done(refused));
1468
+ callback(refused);
1469
+ return resolve(refused);
1470
+ }
1471
+
1472
+ // A held save's replay is not the waiters' answer: a hold released without a replay
1473
+ // drops the missed save, and these callers would wait for one that never comes.
1474
+ const held = heldForPreview(callback, resolve);
1475
+ if (held) {
1476
+ takeSoonQueued().forEach((done) => done(held));
1477
+ return;
1478
+ }
1104
1479
 
1105
1480
  if (isSaveInProgress()) {
1106
1481
  pendingSave = true;
1107
1482
  const skipped = skipped_('Save already in progress');
1483
+ // The parked attempt behind the request on the wire is not the save these callers
1484
+ // asked for, and its drain can be held for good: the skip is their answer.
1485
+ takeSoonQueued().forEach((done) => done(skipped));
1108
1486
  callback(skipped);
1109
1487
  return resolve(skipped);
1110
1488
  }
1111
1489
 
1112
1490
  const prepare = window.clay?.source?.ensureForSave?.();
1113
- if (prepare) return prepare.then(() => resolve(savePageForce(callback, { quietConflict, pageFormatFloor })));
1491
+ if (prepare) return prepare.then(() => resolve(savePageForce(callback, { quietConflict, pageFormatFloor, permit })));
1114
1492
 
1115
1493
  const wasOffline = !navigator.onLine;
1116
1494
  if (wasOffline) {
1117
1495
  setOfflineStateQuiet();
1118
1496
  }
1119
1497
 
1498
+ // A forced save prepares like an ordinary one when the document is this big; it only
1499
+ // skips the dirty question, which `forced` says here.
1500
+ if (preparesSliced() && !prepDriver) {
1501
+ loadPreparer().then(() => resolve(savePageForce(callback, { quietConflict, pageFormatFloor, permit })));
1502
+ return;
1503
+ }
1504
+
1505
+ // From here this call answers, and the soon() callers waiting now are waiting on its
1506
+ // capture: one that queues after it stays queued for the save the drain starts.
1507
+ const soonQueued = takeSoonQueued();
1508
+ const settleSoonQueued = (result) => soonQueued.forEach((done) => done(result));
1509
+
1510
+ if (preparesSliced()) {
1511
+ if (startSlicedSave({
1512
+ explicit: true,
1513
+ forced: true,
1514
+ permit,
1515
+ pageFormatFloor,
1516
+ quietConflict,
1517
+ settleSoonQueued,
1518
+ callback,
1519
+ resolve,
1520
+ })) return;
1521
+ }
1522
+
1120
1523
  let capture, forSave, forComparison, forDirty, captureId;
1121
1524
  const gateToken = gateCaptureToken();
1122
1525
  try {
@@ -1129,29 +1532,39 @@ export function savePageForce(callback = () => {}, { quietConflict = false, page
1129
1532
  if (typeof callback === 'function') {
1130
1533
  callback(result);
1131
1534
  }
1132
- return resolve(result);
1535
+ resolve(result);
1536
+ settleSoonQueued(result);
1537
+ return;
1133
1538
  }
1134
1539
 
1135
- noteRecoveryCapture({ captureId, html: forSave });
1136
-
1137
- setSavingState();
1138
-
1139
- const comparison = { forComparison, forDirty };
1140
- inFlightComparisons.add(comparison);
1141
- ownSaveOnWire = true;
1142
- saveCapturedHtml(forSave, captureId, (result) => {
1143
- ownSaveOnWire = false;
1144
- inFlightComparisons.delete(comparison);
1145
- applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after force save', gateToken, forSave, captureId, quietConflict);
1146
- if (typeof callback === 'function') {
1147
- callback(result);
1148
- }
1149
- resolve(result);
1150
- drainPendingSave();
1151
- }, { capture, pageFormatFloor });
1540
+ settleCaptured({
1541
+ explicit: true,
1542
+ forced: true,
1543
+ forSave,
1544
+ forComparison,
1545
+ forDirty,
1546
+ captureId,
1547
+ gateToken,
1548
+ costMs: 0,
1549
+ pageFormatFloor,
1550
+ quietConflict,
1551
+ capture,
1552
+ noteRecovery: () => noteRecoveryCapture({ captureId, html: forSave }),
1553
+ release: () => releaseCaptureArtifact(capture),
1554
+ callback,
1555
+ }).then((outcome) => {
1556
+ resolve(outcome.result);
1557
+ // The waiters asked for a save soon and this one landed: its result is their answer.
1558
+ settleSoonQueued(outcome.result);
1559
+ });
1152
1560
  });
1153
1561
  }
1154
1562
 
1563
+ // The exit's own checkpoint, through the slot save-core holds for it: a page exit has
1564
+ // to save this tab's own input while admission is closed, and only a save minted here
1565
+ // can do it. Set at load, read only by the live plugin's exit flush.
1566
+ setExitSave((permit) => savePageForce(() => {}, { permit }));
1567
+
1155
1568
  // Resolve on the next save that settles, or reject once the deadline has passed.
1156
1569
  // Every settled state is one, not just success: a save that failed is an answer about
1157
1570
  // the page's bytes too, and the caller decides what to do with it.
@@ -1211,37 +1624,54 @@ export async function flushSave({ keepalive = false, timeoutMs = 15000, permit =
1211
1624
  // the generation that owns the page now, so a flush during a transition is still a
1212
1625
  // write in this generation and never the next one's.
1213
1626
  const held = permit || openSavePermit();
1627
+ const run = { over: false };
1214
1628
  let timer = null;
1215
1629
  const timeout = new Promise((_, reject) => {
1216
1630
  timer = setTimeout(() => reject(flushError('Save timed out.', 'failed')), Math.max(0, timeoutMs));
1217
1631
  });
1218
1632
  try {
1219
- return await Promise.race([flushBytes(keepalive, deadline, held), timeout]);
1633
+ return await Promise.race([flushBytes(keepalive, deadline, held, run), timeout]);
1220
1634
  } catch (err) {
1221
1635
  if (err && typeof err.state === 'string') throw err;
1222
1636
  throw flushError(err?.message || 'Save failed', 'failed');
1223
1637
  } finally {
1638
+ run.over = true;
1224
1639
  clearTimeout(timer);
1225
1640
  if (!permit) closeSavePermit(held);
1226
1641
  }
1227
1642
  }
1228
1643
 
1229
- async function flushBytes(keepalive, deadline, permit) {
1644
+ async function flushBytes(keepalive, deadline, permit, run = { over: false }) {
1230
1645
  // Load-time module churn is not the person's work: autosave never saves it, and
1231
1646
  // neither does a flush. Wait for the baseline to settle (at most MAX_SETTLE_MS).
1232
1647
  if (!baselineSettled()) await nextEvent('clay:baseline-settled', deadline);
1648
+ if (run.over) return null;
1233
1649
 
1234
1650
  let saved = false;
1235
1651
  let etag = null;
1236
1652
  for (let attempt = 0; attempt < 4; attempt++) {
1237
- if (isSaveInProgress()) await settled(deadline);
1653
+ // A save still preparing holds the lane and has sent nothing, so it has fired none of
1654
+ // the events below: the flush waits for it rather than reading the page in the middle
1655
+ // of a capture it cannot see.
1656
+ if (preparing) {
1657
+ await preparing;
1658
+ if (run.over) return null;
1659
+ }
1660
+ if (isSaveInProgress()) {
1661
+ await settled(deadline);
1662
+ if (run.over) return null;
1663
+ }
1238
1664
  if (isSaveConflicted()) throw flushError('This document has a conflict to resolve.', 'conflict');
1239
1665
  const token = gateCaptureToken();
1240
1666
  if (!bytesUnsaved()) {
1241
1667
  gateClearIfUnchanged(token);
1242
1668
  if (hasUnsavedState()) throw flushError('This document has unsaved work a save cannot write.', 'blocked');
1669
+ // Nothing was written because there was nothing to write: the bytes on the page are
1670
+ // the ones the last accepted save left, so work a closed session parked is covered.
1671
+ parkedWork = false;
1243
1672
  return { state: saved ? 'saved' : 'clean', etag: etag ?? lastSeenEtag() };
1244
1673
  }
1674
+ if (run.over) return null;
1245
1675
  const result = await withKeepalive(keepalive, () => savePagePermitted(permit));
1246
1676
  // The lane was busy (a drained pending save is on the wire): wait it out next turn.
1247
1677
  if (result.msgType === 'skipped') continue;