@panphora/clayjs 1.15.2 → 1.16.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 (506) hide show
  1. package/README.md +4 -5
  2. package/THIRD-PARTY-NOTICES.md +16 -0
  3. package/compat/highlight-11.9.0.min.js +1213 -0
  4. package/compat/highlight-LICENSE +29 -0
  5. package/compat/journal-assets.lock.json +22 -0
  6. package/compat/overtype-2.6.1.min.js +116 -0
  7. package/compat/overtype-LICENSE +21 -0
  8. package/dist/all.js +90 -0
  9. package/dist/clay-dom.js +84 -0
  10. package/dist/clay-events.js +103 -0
  11. package/dist/clay-internals.js +85 -0
  12. package/dist/clay-options.js +93 -0
  13. package/dist/clay-router.js +98 -0
  14. package/dist/clay-ui.js +95 -0
  15. package/dist/clay-utils.js +84 -0
  16. package/dist/clay.js +146 -0
  17. package/dist/clay.standalone.js +83090 -58706
  18. package/dist/m/attrs/add-from-T5LJXFFI.js +1 -0
  19. package/dist/m/attrs/onaftersave-OKSWEJQF.js +1 -0
  20. package/dist/m/attrs/refetch-on-save-NWPPXOCZ.js +1 -0
  21. package/dist/m/attrs/save-freeze-FUP4Q4X3.js +1 -0
  22. package/dist/m/c-2AZDZWCK.js +1 -0
  23. package/dist/m/c-2JL6E75N.js +1 -0
  24. package/dist/m/c-2M73252O.js +1 -0
  25. package/dist/m/c-2SPVQSHC.js +1 -0
  26. package/dist/m/c-33HUYEDI.js +1 -0
  27. package/dist/m/c-33JJ6CBX.js +2 -0
  28. package/dist/m/c-35NBGMZB.js +1 -0
  29. package/dist/m/c-3GJ4SDE2.js +1 -0
  30. package/dist/m/c-3IQRECYS.js +1 -0
  31. package/dist/m/c-3OWDMNJR.js +2 -0
  32. package/dist/m/c-46XYM55L.js +1 -0
  33. package/dist/m/c-4B6H4G5V.js +1 -0
  34. package/dist/m/c-4DRMS4QG.js +9 -0
  35. package/dist/m/c-4FFHZM32.js +1 -0
  36. package/dist/m/c-4JZDZFF6.js +1 -0
  37. package/dist/m/c-4L3BTFRK.js +1 -0
  38. package/dist/m/c-4Q2XTQ2N.js +2 -0
  39. package/dist/m/c-4WKFKEKF.js +1 -0
  40. package/dist/m/c-4YSCRICO.js +1 -0
  41. package/dist/m/c-54RCDCBU.js +1 -0
  42. package/dist/m/c-5M4YEKCX.js +4 -0
  43. package/dist/m/c-5R4PTRG2.js +1 -0
  44. package/dist/m/c-5V3MOVJT.js +1 -0
  45. package/dist/m/c-5VYOWDTI.js +1 -0
  46. package/dist/m/c-5Y6TNX5W.js +34 -0
  47. package/dist/m/c-5YIHJ3TJ.js +1 -0
  48. package/dist/m/c-66O32MV3.js +28 -0
  49. package/dist/m/c-6HMB24EV.js +1 -0
  50. package/dist/m/c-7DJDKPGL.js +1 -0
  51. package/dist/m/c-7O6AFR65.js +1 -0
  52. package/dist/m/c-7SWMYKAI.js +1 -0
  53. package/dist/m/c-7VGZ7RU5.js +1 -0
  54. package/dist/m/c-A7LS5UZW.js +1 -0
  55. package/dist/m/c-ANQTJ3B5.js +2 -0
  56. package/dist/m/c-AQLFEATB.js +1 -0
  57. package/dist/m/c-AYVOREBE.js +1 -0
  58. package/dist/m/c-B2ZXPOS3.js +3 -0
  59. package/dist/m/c-BBVGTQG7.js +1 -0
  60. package/dist/m/c-BMIZKMPV.js +1 -0
  61. package/dist/m/c-BTDI4Y23.js +1 -0
  62. package/dist/m/c-BWFVSBDF.js +1 -0
  63. package/dist/m/c-BYTKL6UX.js +1 -0
  64. package/dist/m/c-CFHW5AMY.js +6 -0
  65. package/dist/m/c-CJZSLMVQ.js +1 -0
  66. package/dist/m/c-CYWLPPUS.js +1 -0
  67. package/dist/m/c-CZUSOB7V.js +1 -0
  68. package/dist/m/c-DBE6ORSI.js +1 -0
  69. package/dist/m/c-DCFSEJZG.js +1 -0
  70. package/dist/m/c-DNZ5IFM6.js +1 -0
  71. package/dist/m/c-EDHUR4MX.js +1 -0
  72. package/dist/m/c-EJTGYSLN.js +1 -0
  73. package/dist/m/c-EY6UOM54.js +1 -0
  74. package/dist/m/c-F3CKJTJS.js +1 -0
  75. package/dist/m/c-F7Y6FLPY.js +1 -0
  76. package/dist/m/c-FA4DZVTC.js +1 -0
  77. package/dist/m/c-FILLTJA7.js +1 -0
  78. package/dist/m/c-G2BUZITR.js +1 -0
  79. package/dist/m/c-GB3JTV6E.js +1 -0
  80. package/dist/m/c-GBGWXTX4.js +1 -0
  81. package/dist/m/c-GHBCCVG4.js +1 -0
  82. package/dist/m/c-GJAEGGXY.js +1 -0
  83. package/dist/m/c-GOWBDM6J.js +1 -0
  84. package/dist/m/c-GQ6F6IDK.js +1 -0
  85. package/dist/m/c-H4LG442Q.js +1 -0
  86. package/dist/m/c-HA6INT6J.js +1 -0
  87. package/dist/m/c-HPURYVZS.js +2 -0
  88. package/dist/m/c-HRDCD3WN.js +67 -0
  89. package/dist/m/c-HT33EQRN.js +1 -0
  90. package/dist/m/c-HVYXIS72.js +1 -0
  91. package/dist/m/c-IB52R237.js +1 -0
  92. package/dist/m/c-IJUK2GPW.js +1 -0
  93. package/dist/m/c-IKWNE4R4.js +1 -0
  94. package/dist/m/c-IU3IYEU6.js +1 -0
  95. package/dist/m/c-IZ2EBRAJ.js +1 -0
  96. package/dist/m/c-J7CA54IP.js +1 -0
  97. package/dist/m/c-JAX2667S.js +6 -0
  98. package/dist/m/c-KI7M4AY3.js +1 -0
  99. package/dist/m/c-KIPQJX4B.js +1 -0
  100. package/dist/m/c-KKWLDOPM.js +1 -0
  101. package/dist/m/c-KR4UW4KW.js +1 -0
  102. package/dist/m/c-KY3VJGUA.js +8 -0
  103. package/dist/m/c-L4WQSC27.js +1 -0
  104. package/dist/m/c-LFPHCXY3.js +1 -0
  105. package/dist/m/c-LR4CGEPT.js +91 -0
  106. package/dist/m/c-LRPUO2MX.js +1 -0
  107. package/dist/m/c-LTGMXXKO.js +1 -0
  108. package/dist/m/c-LXENEBV7.js +1 -0
  109. package/dist/m/c-LZJPNR2W.js +1 -0
  110. package/dist/m/c-MMUIUUZG.js +2 -0
  111. package/dist/m/c-MTQHWQC3.js +1 -0
  112. package/dist/m/c-N326R2JD.js +1 -0
  113. package/dist/m/c-N7JPUPGC.js +1 -0
  114. package/dist/m/c-NMJRXKPC.js +8 -0
  115. package/dist/m/c-NRPJEVJS.js +1 -0
  116. package/dist/m/c-NUHP2XDB.js +1 -0
  117. package/dist/m/c-NZMNXVF6.js +1 -0
  118. package/dist/m/c-OCSZ4QGN.js +1 -0
  119. package/dist/m/c-OIBUTDXI.js +1 -0
  120. package/dist/m/c-OIDRZKAF.js +1 -0
  121. package/dist/m/c-OYTFP5QN.js +1 -0
  122. package/dist/m/c-P7KNDG34.js +1 -0
  123. package/dist/m/c-PPOSCAQD.js +1 -0
  124. package/dist/m/c-PQ5LK6QE.js +993 -0
  125. package/dist/m/c-PZ4XEPUP.js +2 -0
  126. package/dist/m/c-QHIWXHRO.js +1 -0
  127. package/dist/m/c-QHWJ5ZIJ.js +1 -0
  128. package/dist/m/c-QIX5BJYN.js +1 -0
  129. package/dist/m/c-QNKREOL6.js +1 -0
  130. package/dist/m/c-QQHEX6HA.js +1 -0
  131. package/dist/m/c-R7RMANJI.js +9 -0
  132. package/dist/m/c-RI4NT75Y.js +1 -0
  133. package/dist/m/c-RM7J7BV2.js +1 -0
  134. package/dist/m/c-RUJKSUQW.js +1 -0
  135. package/dist/m/c-RVMWGXEJ.js +1 -0
  136. package/dist/m/c-S7H2DRCB.js +1 -0
  137. package/dist/m/c-S7LK4KUE.js +1 -0
  138. package/dist/m/c-SGCCC4FW.js +25 -0
  139. package/dist/m/c-SGIA4IEQ.js +188 -0
  140. package/dist/m/c-SWRJFCNN.js +1 -0
  141. package/dist/m/c-T4VO4OCB.js +1 -0
  142. package/dist/m/c-TCCA5BZN.js +2 -0
  143. package/dist/m/c-TQEMVJCS.js +1 -0
  144. package/dist/m/c-TQZ2JYHD.js +1 -0
  145. package/dist/m/c-TR6RMDQ7.js +3 -0
  146. package/dist/m/c-TRS6TM6O.js +1 -0
  147. package/dist/m/c-UGHH4MB3.js +1 -0
  148. package/dist/m/c-UKHTLWUW.js +1 -0
  149. package/dist/m/c-UL2YUY52.js +1 -0
  150. package/dist/m/c-UNN7LWCT.js +1 -0
  151. package/dist/m/c-UXBMRDZB.js +235 -0
  152. package/dist/m/c-V7PZZRG7.js +1 -0
  153. package/dist/m/c-VAK7SG37.js +1 -0
  154. package/dist/m/c-VDSS4K3Z.js +1 -0
  155. package/dist/m/c-VFX45IZQ.js +1 -0
  156. package/dist/m/c-VHNSBD2M.js +1 -0
  157. package/dist/m/c-VKEDWYDF.js +1 -0
  158. package/dist/m/c-VL52ADDR.js +1 -0
  159. package/dist/m/c-WN5AY3OZ.js +1 -0
  160. package/dist/m/c-WNYBLIOZ.js +1 -0
  161. package/dist/m/c-WRBKEJD4.js +1 -0
  162. package/dist/m/c-WWDBYXLK.js +1 -0
  163. package/dist/m/c-WYFDLITL.js +1 -0
  164. package/dist/m/c-XN6MBKI6.js +67 -0
  165. package/dist/m/c-XNNW2CWK.js +1 -0
  166. package/dist/m/c-XPELB6E5.js +1 -0
  167. package/dist/m/c-XQXZAO7N.js +1 -0
  168. package/dist/m/c-Y6USGTS5.js +5 -0
  169. package/dist/m/c-YDH7Q3NY.js +1 -0
  170. package/dist/m/c-YG2NJYS6.js +135 -0
  171. package/dist/m/c-Z36J63PP.js +1 -0
  172. package/dist/m/c-Z4VRC7GU.js +7 -0
  173. package/dist/m/c-Z6K52OLP.js +19 -0
  174. package/dist/m/c-ZDY2MDAA.js +21 -0
  175. package/dist/m/c-ZE46QDIH.js +1 -0
  176. package/dist/m/c-ZUCOKOHT.js +1 -0
  177. package/dist/m/core/admin-attrs-VGNLAUDK.js +1 -0
  178. package/dist/m/core/autosave-E4PEFPZ6.js +1 -0
  179. package/dist/m/core/conflict-notice-VQ6Y64PH.js +1 -0
  180. package/dist/m/core/draft-mode-WSQAT67S.js +1 -0
  181. package/dist/m/core/host-permission-XN3K2IW3.js +1 -0
  182. package/dist/m/core/is-edit-mode-JXHDVGUL.js +1 -0
  183. package/dist/m/core/persist-HKHA66IY.js +3 -0
  184. package/dist/m/core/recovery-TBBSICE2.js +1 -0
  185. package/dist/m/core/save-WUCHTQBE.js +1 -0
  186. package/dist/m/core/save-core-FJVHT5M3.js +1 -0
  187. package/dist/m/core/snapshot-F3SWDZMM.js +1 -0
  188. package/dist/m/core/unsaved-warning-STKBHE6G.js +1 -0
  189. package/dist/m/dom/all-APS5ZSKP.js +1 -0
  190. package/dist/m/dom/dom-helpers-L7OSR6S4.js +1 -0
  191. package/dist/m/events/index-23DPTUT4.js +1 -0
  192. package/dist/m/internals/index-WOVAP3RU.js +1 -0
  193. package/dist/m/lib/cache-bust-CGWT6ZJI.js +1 -0
  194. package/dist/m/lib/region-policy-SB2AWVG5.js +1 -0
  195. package/dist/m/loader-HXX6YNH5.js +1 -0
  196. package/dist/m/manifest-F22DEDAC.js +1 -0
  197. package/dist/m/manifest-edit-C3C7376B.js +1 -0
  198. package/dist/m/manifest.json +3491 -0
  199. package/dist/m/options/options-2LH76JQ3.js +1 -0
  200. package/dist/m/page/editor-XUTB2HB2.js +189 -0
  201. package/dist/m/plugins/ai-edit-ZQ4CGWIP.js +6 -0
  202. package/dist/m/plugins/bind-text-NX46N5UV.js +1 -0
  203. package/dist/m/plugins/cms-HP3LSTPN.js +2293 -0
  204. package/dist/m/plugins/demo-ZIBFAA3R.js +1 -0
  205. package/dist/m/plugins/derive-UAAA4V4S.js +1 -0
  206. package/dist/m/plugins/indicator-PV4YW2ID.js +1 -0
  207. package/dist/m/plugins/live-NUTTXSBS.js +1 -0
  208. package/dist/m/plugins/menu-HCKO3DSP.js +138 -0
  209. package/dist/m/plugins/message-LQ6EDH4V.js +1 -0
  210. package/dist/m/plugins/page-KSUH7G6J.js +2 -0
  211. package/dist/m/plugins/page-view-W73OW7WI.js +1 -0
  212. package/dist/m/plugins/people-AJJ32YCG.js +1 -0
  213. package/dist/m/plugins/quickcrop-QNY3PKLD.js +26 -0
  214. package/dist/m/plugins/quickcrop-QNY3PKLD.js.LEGAL.txt +7 -0
  215. package/dist/m/plugins/richclay-RC3L2F6X.js +517 -0
  216. package/dist/m/plugins/richclay-RC3L2F6X.js.LEGAL.txt +1 -0
  217. package/dist/m/plugins/sortable-YRMQX3ZU.js +1 -0
  218. package/dist/m/plugins/source-KHK5UUSK.js +1 -0
  219. package/dist/m/plugins/undo-OF2L3CXU.js +1 -0
  220. package/dist/m/plugins/unsent-HJFLQJ2O.js +1 -0
  221. package/dist/m/plugins/upgrade-ZNYODP5Y.js +1 -0
  222. package/dist/m/plugins/upload-XSGNILUY.js +1 -0
  223. package/dist/m/plugins/visitor-drafts-XK5GW55Q.js +21 -0
  224. package/dist/m/plugins/visitor-drafts-owner-KSSH75EV.js +1 -0
  225. package/dist/m/plugins/wire-ZWVWKF76.js +1 -0
  226. package/dist/m/reader-core-ILWLXVMP.js +68 -0
  227. package/dist/m/route/route-KOCZSEGN.js +1 -0
  228. package/dist/m/sync/live-sync-4AUVXVJU.js +1 -0
  229. package/dist/m/ui/index-EVHKMKBL.js +1 -0
  230. package/dist/m/utils/index-4TJCVLT7.js +1 -0
  231. package/dist/m/vendor/Sortable.vendor-A755ITLN.js +3 -0
  232. package/dist/m/vendor/Sortable.vendor-A755ITLN.js.LEGAL.txt +1 -0
  233. package/dist/m/vendor/sap-core.vendor-A7BMUYZT.js +6 -0
  234. package/dist/offline-manifest.json +3800 -0
  235. package/dist/page/themes/light-1.css +406 -0
  236. package/dist/server/core.cjs +23662 -22860
  237. package/dist/server/offline-runtime.cjs +4954 -0
  238. package/dist/server/offline-runtime.mjs +4923 -0
  239. package/dist/server/rooms.mjs +9 -0
  240. package/docs/guide.md +1078 -669
  241. package/entries/all.js +2 -0
  242. package/entries/clay-data.js +1 -1
  243. package/entries/clay-dom.js +2 -0
  244. package/entries/clay-events.js +2 -0
  245. package/entries/clay-internals.js +2 -0
  246. package/entries/clay-options.js +2 -0
  247. package/entries/clay-router.js +2 -0
  248. package/entries/clay-ui.js +2 -0
  249. package/entries/clay-utils.js +2 -0
  250. package/entries/clay.js +2 -0
  251. package/entries/sap.js +6 -6
  252. package/fonts/ibm-plex-mono-LICENSE +93 -0
  253. package/fonts/ibm-plex-mono-latin-300-normal.woff2 +0 -0
  254. package/fonts/ibm-plex-mono-latin-400-italic.woff2 +0 -0
  255. package/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  256. package/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  257. package/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  258. package/fonts/ibm-plex-sans-LICENSE +93 -0
  259. package/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  260. package/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  261. package/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  262. package/fonts/ibm-plex.css +71 -0
  263. package/fonts/inter-LICENSE +93 -0
  264. package/fonts/inter-cyrillic-ext-wght-normal.woff2 +0 -0
  265. package/fonts/inter-cyrillic-wght-normal.woff2 +0 -0
  266. package/fonts/inter-greek-ext-wght-normal.woff2 +0 -0
  267. package/fonts/inter-greek-wght-normal.woff2 +0 -0
  268. package/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  269. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  270. package/fonts/inter-vietnamese-wght-normal.woff2 +0 -0
  271. package/fonts/inter.css +62 -0
  272. package/fonts/newsreader-LICENSE +93 -0
  273. package/fonts/newsreader-latin-ext-opsz-normal.woff2 +0 -0
  274. package/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  275. package/fonts/newsreader-vietnamese-opsz-normal.woff2 +0 -0
  276. package/fonts/newsreader.css +26 -0
  277. package/fonts/work-sans-LICENSE +93 -0
  278. package/fonts/work-sans-latin-wght-normal.woff2 +0 -0
  279. package/fonts/work-sans.css +8 -0
  280. package/package.json +20 -3
  281. package/packed-contract.json +1 -0
  282. package/src/attrs/add-from.js +27 -4
  283. package/src/attrs/save-freeze.js +7 -52
  284. package/src/core/admin-attrs.js +119 -12
  285. package/src/core/admin-contenteditable.js +12 -10
  286. package/src/core/admin-inputs.js +14 -15
  287. package/src/core/admin-onclick.js +2 -0
  288. package/src/core/autosave.js +74 -21
  289. package/src/core/conflict-download.js +22 -0
  290. package/src/core/conflict-notice-ui.js +1173 -0
  291. package/src/core/conflict-notice.js +140 -697
  292. package/src/core/edit-mode.js +147 -14
  293. package/src/core/etag.js +30 -12
  294. package/src/core/host-meta.js +72 -8
  295. package/src/core/host-permission.js +29 -0
  296. package/src/core/is-edit-mode.js +145 -12
  297. package/src/core/mode-key.js +125 -0
  298. package/src/core/mode-lifecycle.js +544 -0
  299. package/src/core/mode-receipt.js +30 -0
  300. package/src/core/mode-refusal.js +13 -0
  301. package/src/core/mode-resources.js +52 -0
  302. package/src/core/mode-support.js +82 -0
  303. package/src/core/mode-switch.js +259 -0
  304. package/src/core/mode-transition.js +112 -0
  305. package/src/core/page-data-write.js +305 -0
  306. package/src/core/page-data.js +26 -227
  307. package/src/core/page-format-wire.js +4 -0
  308. package/src/core/page-format.js +58 -0
  309. package/src/core/persist.js +37 -9
  310. package/src/core/preserve-authored.js +114 -0
  311. package/src/core/recovery.js +772 -0
  312. package/src/core/runtime-url.js +105 -0
  313. package/src/core/save-core.js +109 -12
  314. package/src/core/save-replace.js +72 -0
  315. package/src/core/save-size-notice.js +134 -0
  316. package/src/core/save-size.js +140 -0
  317. package/src/core/save.js +805 -118
  318. package/src/core/snapshot.js +121 -11
  319. package/src/core/source-map.js +157 -9
  320. package/src/core/unsaved-warning.js +14 -4
  321. package/src/core/upgrade-run.js +408 -0
  322. package/src/core/version.js +18 -0
  323. package/src/lib/assets.js +30 -0
  324. package/src/lib/autosave-debug-gate.js +50 -0
  325. package/src/lib/autosave-scheduler.js +141 -0
  326. package/src/lib/boot-imports.js +8 -0
  327. package/src/lib/derived.js +3 -1
  328. package/src/lib/dirty-gate.js +15 -0
  329. package/src/lib/doc-storage.js +33 -0
  330. package/src/lib/effect.js +9 -4
  331. package/src/lib/mutation.js +4 -2
  332. package/src/lib/offline-runtime.js +226 -0
  333. package/src/lib/preload.js +48 -0
  334. package/src/lib/root-attrs.js +1 -1
  335. package/src/lib/trash.js +18 -0
  336. package/src/lib/undo/capture-move.js +152 -0
  337. package/src/live/local-channel.js +61 -0
  338. package/src/live/local-lane.js +60 -9
  339. package/src/live/server-lane.js +6 -3
  340. package/src/loader-logic.js +76 -15
  341. package/src/loader.js +218 -30
  342. package/src/page/adopt-boot.js +76 -22
  343. package/src/page/adopt.js +25 -4
  344. package/src/page/awareness.js +230 -0
  345. package/src/page/blocks.js +79 -5
  346. package/src/page/blocksel.js +20 -4
  347. package/src/page/board-face.js +13 -0
  348. package/src/page/board-panel.js +5 -17
  349. package/src/page/board-style.js +2 -13
  350. package/src/page/board-view.js +1 -11
  351. package/src/page/board-views.js +17 -0
  352. package/src/page/board.js +2 -2
  353. package/src/page/boot-pack.js +26 -0
  354. package/src/page/boot.js +237 -0
  355. package/src/page/checklist.js +1 -1
  356. package/src/page/chrome.js +411 -61
  357. package/src/page/csv-import.js +17 -2
  358. package/src/page/document-title-dom.js +40 -0
  359. package/src/page/document-title-runtime.js +76 -0
  360. package/src/page/document-title.js +9 -0
  361. package/src/page/drag.js +48 -13
  362. package/src/page/drop.js +2 -1
  363. package/src/page/editor.js +330 -86
  364. package/src/page/feature-loader.js +72 -0
  365. package/src/page/features/board.js +12 -0
  366. package/src/page/features/code.js +13 -0
  367. package/src/page/features/media.js +290 -0
  368. package/src/page/features/registry.js +23 -0
  369. package/src/page/features/table.js +383 -0
  370. package/src/page/{tool-view.js → features/tool.js} +33 -2
  371. package/src/page/format-codec.js +215 -0
  372. package/src/page/format-gate.js +179 -0
  373. package/src/page/format-level.js +157 -0
  374. package/src/page/highlight-slot.js +23 -0
  375. package/src/page/highlight.js +30 -16
  376. package/src/page/history-intent.js +78 -0
  377. package/src/page/inline.js +18 -25
  378. package/src/page/items.js +52 -11
  379. package/src/page/link.js +1 -0
  380. package/src/page/markdown.js +86 -1
  381. package/src/page/media-views.js +3 -0
  382. package/src/page/media.js +33 -245
  383. package/src/page/outline-cache.js +11 -0
  384. package/src/page/placeholder.js +27 -7
  385. package/src/page/projection.js +196 -50
  386. package/src/page/reader-patch.js +306 -0
  387. package/src/page/recover.js +1 -1
  388. package/src/page/rich/page-tool.js +1 -4
  389. package/src/page/runtime-owner.js +27 -0
  390. package/src/page/save-projection.js +7 -4
  391. package/src/page/schema.js +14 -0
  392. package/src/page/table-rect-key.js +3 -0
  393. package/src/page/table-select.js +4 -3
  394. package/src/page/table-style.js +1 -1
  395. package/src/page/table-views.js +3 -0
  396. package/src/page/table.js +8 -293
  397. package/src/page/themes/light-1.css +139 -2
  398. package/src/page/themes/light-1.source.css +269 -0
  399. package/src/page/tool-capability.js +3 -0
  400. package/src/page/tool-views.js +3 -0
  401. package/src/page/view.js +390 -107
  402. package/src/page/ybind/awareness.js +172 -0
  403. package/src/page/ybind/binding.js +9 -1
  404. package/src/page/ybind/bridge.js +50 -47
  405. package/src/page/ybind/decode.js +52 -19
  406. package/src/page/ybind/doc.js +27 -2
  407. package/src/page/ybind/group.js +12 -3
  408. package/src/page/ybind/index.js +2 -0
  409. package/src/page/ybind/lineage.js +71 -0
  410. package/src/page/ybind/marks.js +25 -1
  411. package/src/page/ybind/migrate.js +48 -13
  412. package/src/page/ybind/moves.js +32 -21
  413. package/src/page/ybind/positions.js +50 -0
  414. package/src/page/ybind/provider.js +84 -22
  415. package/src/page/ybind/recovery-proof.js +191 -0
  416. package/src/page/ybind/recovery-replay.js +130 -0
  417. package/src/page/ybind/tokens.js +15 -6
  418. package/src/page/ybind/walk.js +10 -3
  419. package/src/page/ybind/writer.js +48 -4
  420. package/src/plugins/ai-edit.js +167 -32
  421. package/src/plugins/bind-text.js +218 -0
  422. package/src/plugins/cms.js +93 -0
  423. package/src/plugins/indicator.js +57 -5
  424. package/src/plugins/live.js +282 -34
  425. package/src/plugins/menu/claygoo.svg.js +1 -0
  426. package/src/plugins/menu/seo-head.js +323 -0
  427. package/src/plugins/menu/seo.js +849 -0
  428. package/src/plugins/menu.js +425 -0
  429. package/src/plugins/page-view.js +131 -0
  430. package/src/plugins/page.js +1022 -275
  431. package/src/plugins/people.js +158 -31
  432. package/src/plugins/richclay.js +119 -9
  433. package/src/plugins/sortable.js +370 -60
  434. package/src/plugins/source.js +189 -366
  435. package/src/plugins/undo.js +289 -19
  436. package/src/plugins/unsent.js +132 -0
  437. package/src/plugins/upgrade-run.js +1 -0
  438. package/src/plugins/upgrade.js +210 -0
  439. package/src/plugins/upload-task.js +571 -0
  440. package/src/plugins/upload-ui.js +265 -0
  441. package/src/plugins/upload.js +34 -153
  442. package/src/plugins/visitor-drafts/inert.js +32 -0
  443. package/src/plugins/visitor-drafts/owner-chip.js +3 -0
  444. package/src/plugins/visitor-drafts/prompt.js +2 -2
  445. package/src/plugins/visitor-drafts-owner.js +85 -0
  446. package/src/plugins/visitor-drafts.js +31 -42
  447. package/src/plugins/wire.js +82 -1
  448. package/src/reader-core.js +26 -0
  449. package/src/recovery/conflicts.js +287 -0
  450. package/src/recovery/identity.js +154 -0
  451. package/src/recovery/store.js +792 -0
  452. package/src/recovery/undo.js +257 -0
  453. package/src/server/checkpoint/index.js +68 -4
  454. package/src/server/offline-runtime/index.js +214 -0
  455. package/src/server/rooms/index.js +2 -1
  456. package/src/server/rooms/live-data-rooms.js +2 -2
  457. package/src/server/rooms/page-admission.js +33 -0
  458. package/src/server/rooms/page-lineage.js +56 -0
  459. package/src/server/rooms/page-rebase.js +11 -5
  460. package/src/server/rooms/page-state.js +2 -0
  461. package/src/server/rooms/page-write-admission.js +9 -3
  462. package/src/source/session.js +705 -0
  463. package/src/sync/conflict-revert.js +5 -4
  464. package/src/sync/conflicts-edit.js +119 -0
  465. package/src/sync/conflicts-facade.js +49 -0
  466. package/src/sync/conflicts.js +99 -1
  467. package/src/sync/editing-marker.js +37 -6
  468. package/src/sync/editing-sender.js +8 -1
  469. package/src/sync/live-sync-edit.js +63 -0
  470. package/src/sync/live-sync.js +364 -119
  471. package/src/sync/local-presence.js +187 -0
  472. package/src/sync/merge-declarations.js +10 -0
  473. package/src/sync/merge-engine.js +12 -0
  474. package/src/sync/presence-store.js +412 -0
  475. package/src/sync/presence-target.js +243 -0
  476. package/src/sync/presence.js +72 -41
  477. package/src/sync/saved-handoff.js +200 -0
  478. package/src/sync/saved-stream.js +535 -0
  479. package/src/sync/version-guard.js +92 -0
  480. package/src/sync/wire-profile.js +81 -0
  481. package/src/ui/bevel-controls.js +2 -8
  482. package/src/ui/page-scheme.js +7 -0
  483. package/src/ui/skins/cms.js +1 -1
  484. package/src/ui/theme-config.js +150 -0
  485. package/src/ui/theme.js +6 -138
  486. package/src/ui/toast-lazy.js +11 -0
  487. package/src/vendor/LICENSES-optional.md +253 -0
  488. package/src/vendor/highlight.vendor.js +3 -0
  489. package/src/vendor/hljs-bash.vendor.js +2 -0
  490. package/src/vendor/hljs-css.vendor.js +2 -0
  491. package/src/vendor/hljs-javascript.vendor.js +2 -0
  492. package/src/vendor/hljs-json.vendor.js +2 -0
  493. package/src/vendor/hljs-python.vendor.js +2 -0
  494. package/src/vendor/hljs-xml.vendor.js +2 -0
  495. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  496. package/src/vendor/hyper-html-api-upgrade.vendor.js +5 -0
  497. package/src/vendor/hyper-morph-identity.vendor.js +2 -0
  498. package/src/vendor/hyper-morph-json.vendor.js +3 -0
  499. package/src/vendor/hyper-morph.vendor.js +6 -6
  500. package/src/vendor/hyper-undo.vendor.js +1 -1
  501. package/src/vendor/hypercms.vendor.js +169 -84
  502. package/src/vendor/marked.vendor.js +68 -0
  503. package/src/vendor/overtype.vendor.js +994 -0
  504. package/src/vendor/richclay.vendor.js +17 -17
  505. package/src/vendor/sap-core.vendor.js +5 -5
  506. package/src/vendor/sap-ownership.vendor.js +11 -7
package/src/core/save.js CHANGED
@@ -21,17 +21,17 @@ import {
21
21
  saveFateIsUnknown,
22
22
  withKeepalive
23
23
  } from "./save-core.js";
24
- import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck, captureSaveClone } from "./snapshot.js";
24
+ import { captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck, captureSaveClone, releaseCaptureArtifact } from "./snapshot.js";
25
+ import { saveLimit, saveLimits, inspectSaveSize } from "./save-size.js";
25
26
  import { paintSignature } from "../lib/derived.js";
26
27
  import { NO_TRIGGER_AUTOSAVE_SELECTOR } from "../lib/region-policy.js";
27
- import { seedEtag, lastSeenEtag } from "./etag.js";
28
- import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
28
+ import { seedEtag, learnConditionalSaves, lastSeenEtag, recordEtag } from "./etag.js";
29
+ import { startDirtyGate, gateCaptureToken, gateClearIfUnchanged, gateRevision, pageMaybeDirty } from "../lib/dirty-gate.js";
29
30
  import { hasUnsavedState } from "../lib/unsaved-state.js";
30
31
  import { autosaveActive } from "../lib/autosave-state.js";
31
32
  import { registerStyleAdoption } from "../lib/style-adoption.js";
32
- import { styleComparisonStripper } from "./style-comparisons.js";
33
33
  import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS, HOST_RESPONSE_ATTRS } from "../lib/root-attrs.js";
34
- import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
34
+ import { logSaveCheck, logBaseline } from "../lib/autosave-debug-gate.js";
35
35
  import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
36
36
  // A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
37
37
  // flush asks the SAME bytes question the close warning asks, through `bytesUnsaved`.
@@ -39,6 +39,8 @@ import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/use
39
39
  // side sees a half-built module.
40
40
  import { bytesUnsaved } from "./unsaved-warning.js";
41
41
  import { bootPhase } from "./boot-phase.js";
42
+ import { createAutosaveScheduler } from "../lib/autosave-scheduler.js";
43
+ import { noteRecoveryCapture, noteRecoverySettled } from "./recovery.js";
42
44
 
43
45
  // Keep this library's own root state out of the saved bytes.
44
46
  //
@@ -229,7 +231,12 @@ export function suspendAutosave() {
229
231
  export function resumeAutosave() {
230
232
  if (autosaveSuspended === 0) return;
231
233
  autosaveSuspended--;
232
- if (autosaveSuspended > 0 || !autosaveMissed) return;
234
+ if (autosaveSuspended > 0) return;
235
+ if (soonDeferred) {
236
+ soonDeferred = false;
237
+ soonTimer = setTimeout(fireSoon, 0);
238
+ }
239
+ if (!autosaveMissed) return;
233
240
  autosaveMissed = false;
234
241
  savePageThrottled();
235
242
  }
@@ -241,6 +248,251 @@ export function autosaveIsSuspended() {
241
248
  return autosaveSuspended > 0;
242
249
  }
243
250
 
251
+ // ============================================
252
+ // THE SAVE SESSION
253
+ // ============================================
254
+ //
255
+ // One lifetime per admitted editing session: `start` when the page admits input,
256
+ // `quiesce` while an exit is being decided, `resume` when that exit is refused, `stop`
257
+ // once it is committed. What the session owns is ADMISSION: whether an ordinary save and
258
+ // the automatic lane may write right now.
259
+ //
260
+ // What it does not own is the page's saved bytes. A quiesce is not a reload, so the
261
+ // session never runs the boot settle again, never takes a capture for itself and never
262
+ // writes a baseline: the bytes an exit is reconciled against are the ones the last
263
+ // accepted save (or the served file) left behind, and the next real save carries the
264
+ // snapshot's own capture identity.
265
+ //
266
+ // Work that arrives while admission is closed stays pending and stays visible. A denied
267
+ // save writes nothing, clears no gate token and keeps the explicit-save intent armed, so
268
+ // the page keeps reporting the bytes it still holds; the session also records the dirty
269
+ // gate's revision, so an exit can prove nothing new arrived while it was closed instead
270
+ // of assuming quiet.
271
+ let quiescedSessions = 0;
272
+ let parkedWork = false;
273
+ let parkedAutosave = false;
274
+ // Whether the page still held bytes no save had written when its session stopped. A
275
+ // view-time write is not that: an entry takes the page as it stands. Work an editing
276
+ // session left unsaved is the person's, and absorbing it into a baseline would make it
277
+ // read as saved, so the next entry refuses instead.
278
+ let unsavedAtStop = false;
279
+
280
+ /**
281
+ * Whether a save may go out right now.
282
+ *
283
+ * An open session admits everything. A closed one admits only the caller that holds a
284
+ * permit minted for the generation currently owning the page — the exit's own barrier.
285
+ */
286
+ export function savesAdmitted(permit = null) {
287
+ return quiescedSessions === 0 || permitHolds(permit);
288
+ }
289
+
290
+ /**
291
+ * Edited bytes a closed session parked, that no accepted save has covered since. The DOM
292
+ * still holds them and the host does not, in whatever mode the page is in by then, so the
293
+ * close warning reads this rather than the committed mode.
294
+ */
295
+ export function sessionParked() {
296
+ return parkedWork;
297
+ }
298
+
299
+ // The bytes a stopping session leaves behind, that no save has written: parked work is
300
+ // work even when the page has been put back since, and a capture that throws counts as
301
+ // work, because "nothing is unsaved" is the one answer that can lose something.
302
+ function heldWork() {
303
+ if (parkedWork) return true;
304
+ try {
305
+ const current = captureForDirtyCheck({ flushUndo: false });
306
+ if (styleAdoptionPending(current)) return false;
307
+ return current !== lastSavedDirty;
308
+ } catch {
309
+ return true;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * One editing session's admission lifetime.
315
+ *
316
+ * `changedWhileClosed` answers the exit's own question — did the page gain work while
317
+ * admission was closed? — from the gate's revision, which is why a quiesce costs no
318
+ * capture. `stop` releases the hold without replaying anything: the exit that stops a
319
+ * session has already accounted for the bytes, and a refused one resumes instead.
320
+ */
321
+ export function saveSession() {
322
+ let phase = "idle";
323
+ let revisionAtQuiesce = 0;
324
+
325
+ return {
326
+ get phase() {
327
+ return phase;
328
+ },
329
+ get open() {
330
+ return phase === "open";
331
+ },
332
+ changedWhileClosed() {
333
+ return phase === "quiesced" && gateRevision() !== revisionAtQuiesce;
334
+ },
335
+ start() {
336
+ if (phase !== "idle") return;
337
+ phase = "open";
338
+ },
339
+ quiesce() {
340
+ if (phase !== "open") return;
341
+ phase = "quiesced";
342
+ revisionAtQuiesce = gateRevision();
343
+ quiescedSessions++;
344
+ },
345
+ resume() {
346
+ if (phase !== "quiesced") return;
347
+ phase = "open";
348
+ quiescedSessions--;
349
+ if (quiescedSessions > 0) return;
350
+ replayParkedAutosave();
351
+ releaseDeferredSoon();
352
+ },
353
+ stop() {
354
+ if (phase === "stopped") return;
355
+ const wasQuiesced = phase === "quiesced";
356
+ // A session that ran and is now over answers for the bytes it leaves behind, so an
357
+ // entry after it can tell the person's work from what a script did in view.
358
+ if ((wasQuiesced || phase === "open") && heldWork()) unsavedAtStop = true;
359
+ phase = "stopped";
360
+ if (!wasQuiesced) return;
361
+ quiescedSessions--;
362
+ if (quiescedSessions === 0) releaseDeferredSoon();
363
+ },
364
+ };
365
+ }
366
+
367
+ // ============================================
368
+ // THE MODE PARTICIPANT
369
+ // ============================================
370
+ //
371
+ // A live entry imports this module while the page is still in view, so the installs the
372
+ // boot road performs at evaluation are inert there. The participant is how an entry asks
373
+ // for them: `prepare` builds the session, `enable` performs the installs once per page,
374
+ // and `adoptEntryBaseline` takes the entry's baseline from the represented file.
375
+
376
+ // Page-lifetime: a second entry starts its session and installs nothing again.
377
+ let servicesInstalled = false;
378
+ // The last accepted save, as the receipt an exit hands the next reader.
379
+ let acceptedSave = null;
380
+ // The version the kept baselines were taken at. A second entry can then tell the person's
381
+ // work from what the file delivered.
382
+ let baselineEtag = null;
383
+ let baselineKept = false;
384
+
385
+ /**
386
+ * The bytes and stamp of this session's last accepted save, or null when it made none.
387
+ *
388
+ * A save belongs to the session it was made in: an exit hands the next reader its
389
+ * own save, never an older session's, or a clean session would hand back a file
390
+ * another tab has since replaced.
391
+ */
392
+ export function acceptedReceipt(generation = saveGenerationAt) {
393
+ if (!acceptedSave || acceptedSave.generation !== generation) return null;
394
+ const { bytes, etag } = acceptedSave;
395
+ return { bytes, etag };
396
+ }
397
+
398
+ /**
399
+ * Adopt the represented file as this session's baseline, after every document transform
400
+ * is installed. One capture, both baselines, the settled mark and its event: the boot
401
+ * settle's job without a second settle over the reader's DOM.
402
+ *
403
+ * A second entry keeps the first one's baseline, taken at a version of the file. A page
404
+ * that moved while it was in view is taken as it now stands: a clock, a counter or a
405
+ * reader's own repaint is the page's doing, not the person's, and refusing it would block
406
+ * every entry for as long as that write stays. What is refused is the one case that is
407
+ * the person's: the session it ran left work no save wrote, and nothing says the file
408
+ * moved — which a stamp the host never answered and a baseline this page never kept both
409
+ * leave open.
410
+ */
411
+ export function adoptEntryBaseline({ represented } = {}) {
412
+ const token = gateCaptureToken();
413
+ const { forComparison, forDirty } = captureForComparisonAndDirty();
414
+ const etag = typeof represented?.etag === "string" && represented.etag ? represented.etag : null;
415
+
416
+ // Work a session left unsaved is the person's. A version the file has moved past is
417
+ // the page's own doing, and refusing it would block every entry for as long as that
418
+ // write stays; a stamp that is missing, or a baseline this page never kept, cannot say
419
+ // the file did not move, so those are refused exactly as the stamped path refuses them.
420
+ const moved = baselineKept && etag !== null && etag !== baselineEtag;
421
+ if (!moved && unsavedAtStop && (forComparison !== lastSavedContents || forDirty !== lastSavedDirty)) {
422
+ return { ok: false, code: "pending-input" };
423
+ }
424
+
425
+ unsavedAtStop = false;
426
+ setLastSavedBaselines(forComparison, forDirty);
427
+ baselineContents = forComparison;
428
+ baselineActive = false;
429
+ baselineSettledAt = true;
430
+ baselineEtag = etag;
431
+ baselineKept = true;
432
+ gateClearIfUnchanged(token);
433
+ document.documentElement.setAttribute('savestatus', 'saved');
434
+ document.dispatchEvent(new CustomEvent('clay:baseline-settled'));
435
+ return { ok: true };
436
+ }
437
+
438
+ // The host's conditional-save capability is learned from discovery, which is a request
439
+ // of its own, and the commit that enters edit cannot wait for it. The session keeps the
440
+ // promise and the save path waits on it, so the first save after a live entry still
441
+ // carries If-Match instead of going out unconditional because the answer was in flight.
442
+ let capabilityKnown = Promise.resolve(false);
443
+ let capabilityPending = false;
444
+ const learnCapability = () => {
445
+ capabilityPending = true;
446
+ return learnConditionalSaves()
447
+ .catch(() => false)
448
+ .then((supported) => {
449
+ capabilityPending = false;
450
+ return supported;
451
+ });
452
+ };
453
+
454
+ /**
455
+ * The save system as a mode participant: a fresh session plus the page's installs on a
456
+ * live entry, and, on adoption, a session in phase `open` over what the import already
457
+ * installed and nothing new.
458
+ */
459
+ export function modeParticipant() {
460
+ return {
461
+ id: "clay:save",
462
+ order: 10,
463
+ prepare(context = {}) {
464
+ const session = saveSession();
465
+ if (context.initial === true) session.start();
466
+ return {
467
+ enable() {
468
+ // Every session learns the capability and records the stamp it represents:
469
+ // a live entry included, and a re-entry after the stamp advanced in view.
470
+ capabilityKnown = learnCapability();
471
+ const etag = context.represented?.etag;
472
+ if (typeof etag === "string" && etag) recordEtag(etag);
473
+ if (context.initial === true) return;
474
+ installOnce();
475
+ session.start();
476
+ },
477
+ quiesce: () => session.quiesce(),
478
+ resume: () => session.resume(),
479
+ dispose: () => session.stop(),
480
+ };
481
+ },
482
+ };
483
+ }
484
+
485
+ // What `init` installed for a boot in edit. The baseline is not among it: `adoptEntryBaseline`
486
+ // settles that from the represented file, and the boot settle would settle over the reader's DOM.
487
+ function installOnce() {
488
+ if (servicesInstalled) return;
489
+ servicesInstalled = true;
490
+ startDirtyGate();
491
+ initUserGesture();
492
+ initSaveKeyboardShortcut();
493
+ initHyperclaySaveButton();
494
+ }
495
+
244
496
  // ============================================
245
497
  // THE ALL-SAVE HOLD
246
498
  // ============================================
@@ -371,6 +623,69 @@ function skipped_(msg) {
371
623
  return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
372
624
  }
373
625
 
626
+ // A save asked for while a session has admission closed. It writes nothing, so nothing
627
+ // about the page's state may move: no capture, no gate token, no baseline. Only work that
628
+ // would be lost is recorded, so a save asked for on a clean page cannot make the close
629
+ // warning fire later for a page that lost nothing.
630
+ function sessionClosed(msg) {
631
+ if (pageMaybeDirty() || hasUnsavedState()) parkedWork = true;
632
+ return skipped_(msg);
633
+ }
634
+
635
+ // The exit's own flush is not an ordinary save: it is the barrier writing the bytes the
636
+ // exit is about to account for, so it runs under a permit. Nothing else does — a Cmd+S or
637
+ // a [trigger-save] press while admission is closed stays parked.
638
+ //
639
+ // A permit is an opaque identity bound to ONE mode generation: it is minted by the
640
+ // controller that owns that generation, it stops admitting the moment the next generation
641
+ // commits, and it is revoked when the barrier that owns it finishes. That is what keeps a
642
+ // delayed callback from writing across a switch, and it is why there is no public `force`
643
+ // flag here: nothing a page can call mints one of these.
644
+ const livePermits = new Set();
645
+ let saveGenerationAt = 0;
646
+
647
+ /** The mode generation ordinary saves are being admitted against. */
648
+ export function saveGeneration() {
649
+ return saveGenerationAt;
650
+ }
651
+
652
+ /**
653
+ * A new generation has committed. Permits minted for the old one stop admitting: an exit
654
+ * that was flushing generation N must not write once generation N+1 owns the page.
655
+ */
656
+ export function setSaveGeneration(generation) {
657
+ if (typeof generation === 'number') saveGenerationAt = generation;
658
+ }
659
+
660
+ /** Mint a permit for one generation. Opaque, and only this module reads what it means. */
661
+ export function openSavePermit(generation = saveGenerationAt) {
662
+ const permit = Object.freeze({ generation });
663
+ livePermits.add(permit);
664
+ return permit;
665
+ }
666
+
667
+ /** Revoke a permit: the barrier that owned it is over. Idempotent. */
668
+ export function closeSavePermit(permit) {
669
+ livePermits.delete(permit);
670
+ }
671
+
672
+ function permitHolds(permit) {
673
+ return !!permit && livePermits.has(permit) && permit.generation === saveGenerationAt;
674
+ }
675
+
676
+ /**
677
+ * Tell the recovery service how this capture settled.
678
+ *
679
+ * One place, so every outcome is reported exactly once: a confirmed success is the
680
+ * only one that can acknowledge a recovery copy, and it acknowledges only what this
681
+ * capture's own bytes covered. A skip, a refusal, an error and an unknown outcome
682
+ * release the capture and leave every copy where it is.
683
+ */
684
+ function settleRecoveryCapture(captureId, result) {
685
+ if (captureId == null) return;
686
+ noteRecoverySettled({ captureId, ok: result.ok === true, etag: result.etag ?? null });
687
+ }
688
+
374
689
  /**
375
690
  * Apply one save result to the page's state.
376
691
  *
@@ -381,18 +696,32 @@ function skipped_(msg) {
381
696
  * either.
382
697
  */
383
698
  function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null, captureId = null, quietConflict = false) {
699
+ settleRecoveryCapture(captureId, result);
384
700
  if (result.ok) {
701
+ // These bytes are on the host now, so nothing a closed session parked is still
702
+ // waiting for a write.
703
+ parkedWork = false;
385
704
  // Both baselines advance from the SAME pre-request capture, never from the
386
705
  // live DOM, so an edit made while the request was on the wire stays unsaved.
387
706
  setLastSavedBaselines(forComparison, forDirty);
388
707
  lastSavedBytes = forSave;
708
+ // The receipt a mode exit hands the next reader, and the version the baselines above
709
+ // were taken at: both describe the bytes the host just accepted.
710
+ acceptedSave = { bytes: forSave, etag: lastSeenEtag(), generation: saveGenerationAt };
711
+ baselineEtag = acceptedSave.etag;
712
+ baselineKept = true;
713
+ // These bytes are on the host now, so nothing an earlier session left is still only here.
714
+ unsavedAtStop = false;
389
715
  unsavedChanges = false;
390
716
  // Generation-checked: clears the scoped-sync dirty gate only if nothing
391
717
  // changed while this save was on the wire.
392
718
  gateClearIfUnchanged(gateToken);
393
719
  // The server's severity rides through untouched: a save can land AND carry a
394
720
  // warning, and the UI module is what decides how to render that.
395
- setSaveState('saved', result.msg || 'Saved', result.msgType);
721
+ setSaveState('saved', result.msg || 'Saved', result.msgType, {
722
+ etag: result.etag ?? null,
723
+ captureId
724
+ });
396
725
  logBaseline(label, `${lastSavedContents.length} chars`);
397
726
  releaseConflictHold();
398
727
  } else if (result.msgType === 'conflict') {
@@ -458,10 +787,18 @@ function drainPendingSave() {
458
787
  savePage();
459
788
  return;
460
789
  }
790
+ // Through the scheduler, not straight back into the automatic lane: this runs
791
+ // inside the completed attempt, and a second full capture in the same task is
792
+ // exactly the work the split into admission-then-capture exists to remove.
461
793
  pendingAutosave = false;
462
- savePageThrottled();
794
+ autosaveScheduler.request();
463
795
  }
464
796
 
797
+ // The host's advertised save ceiling, and a measurement of bytes that are not this
798
+ // page's document. Neither sends anything; `inspect` also captures nothing and
799
+ // claims nothing, because an import preview asks about candidate bytes.
800
+ export { saveLimits, inspectSaveSize };
801
+
465
802
  // State accessors for autosave module
466
803
  export function getUnsavedChanges() { return unsavedChanges; }
467
804
  export function setUnsavedChanges(val) { unsavedChanges = val; }
@@ -494,26 +831,49 @@ export function setLastSavedContents(val) {
494
831
  setLastSavedBaselines(val, val);
495
832
  }
496
833
 
834
+ const monotonic = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
835
+
497
836
  /**
498
- * Save the current page with change detection and state management.
837
+ * One save attempt, explicit or automatic.
499
838
  *
500
- * Returns a Promise that resolves with {msg, msgType} — the same object
501
- * passed to the callback. Promise never rejects; errors resolve with
502
- * msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
839
+ * Both kinds take the same single combined capture — the save domain, the
840
+ * autosave comparison domain and the dirty domain all come from one live snapshot
841
+ * — and differ only in the question they ask of it. An explicit attempt asks the
842
+ * dirty question, so an edit inside a batching region is written. An automatic one
843
+ * asks the autosave question under the load-time baseline veto, so neither boot
844
+ * churn nor batching churn saves on its own.
503
845
  *
846
+ * @param {{kind: 'explicit' | 'automatic'}} options
504
847
  * @param {Function} callback - Optional callback for custom handling
505
848
  * @returns {Promise<{msg: string, msgType: string}>}
506
849
  */
507
- export function savePage(callback = () => {}) {
850
+ function attemptSave(options, callback = () => {}) {
851
+ // The first save of a session still learning the host's conditional-save capability
852
+ // waits for that answer, so it carries If-Match when the host honours one.
853
+ if (capabilityPending) return capabilityKnown.then(() => attemptSave(options, callback));
854
+ if (!adoptionsQueued) return attemptSaveNow(options, callback);
855
+ return adoptionQueue.then(() => attemptSaveNow(options, callback));
856
+ }
857
+
858
+ function attemptSaveNow({ kind, permit = null }, callback = () => {}) {
859
+ const explicit = kind === 'explicit';
860
+
508
861
  return new Promise((resolve) => {
509
862
  if (!isEditMode && !window.clay?.testMode) {
510
- clearExplicitSave();
863
+ if (explicit) clearExplicitSave();
511
864
  const skipped = skipped_('Not in edit mode');
512
865
  callback(skipped);
513
866
  return resolve(skipped);
514
867
  }
515
868
 
516
- if (heldForPreview(callback, resolve)) return;
869
+ // 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.
871
+ if (!savesAdmitted(permit)) {
872
+ if (!explicit) parkedAutosave = true;
873
+ const skipped = sessionClosed('Save session is quiesced');
874
+ callback(skipped);
875
+ return resolve(skipped);
876
+ }
517
877
 
518
878
  // A save is already on the wire. Remember that a newer state is waiting rather
519
879
  // than dropping it: the in-flight request carries the older bytes, and if no
@@ -521,13 +881,50 @@ export function savePage(callback = () => {}) {
521
881
  // reach disk at all.
522
882
  // Not cleared here: pendingSave means this request is deferred, not
523
883
  // abandoned, and drainPendingSave is what eventually sends it.
524
- if (isSaveInProgress()) {
525
- pendingSave = true;
526
- const skipped = skipped_('Save already in progress');
527
- callback(skipped);
528
- return resolve(skipped);
884
+ if (explicit) {
885
+ if (heldForPreview(callback, resolve)) return;
886
+ if (isSaveInProgress()) {
887
+ pendingSave = true;
888
+ const skipped = skipped_('Save already in progress');
889
+ callback(skipped);
890
+ return resolve(skipped);
891
+ }
892
+ } else {
893
+ if (previewHold > 0) {
894
+ previewAutosaveMissed = true;
895
+ const skipped = skipped_(saveHoldReason());
896
+ callback(skipped);
897
+ return resolve(skipped);
898
+ }
899
+ if (isSaveInProgress() && !baselineActive && ownSaveOnWire) {
900
+ pendingAutosave = true;
901
+ const skipped = skipped_('Save already in progress');
902
+ callback(skipped);
903
+ return resolve(skipped);
904
+ }
905
+ // The dirty gate counts every change no save has accounted for, and it is
906
+ // the cheap half of the old dirtyMatchesLastSave check. When it says clean
907
+ // there is nothing to compare, so answer like the old comparison did and
908
+ // take no capture at all: no snapshot hooks, no sync serialization, no
909
+ // clay:snapshot-ready. While the page is still settling the capture itself
910
+ // is the veto, so the gate cannot stand in for it yet.
911
+ if (!baselineActive && !pageMaybeDirty()) {
912
+ const skipped = skipped_('No changes to save');
913
+ callback(skipped);
914
+ return resolve(skipped);
915
+ }
529
916
  }
530
917
 
918
+ // The source map asks a save to wait here: after the gates, before the capture.
919
+ const prepare = window.clay?.source?.ensureForSave?.();
920
+ if (prepare) return prepare.then(() => resolve(attemptSaveNow({ kind, permit }, callback)));
921
+
922
+ // From here this call answers: it either writes, or reports why it did not,
923
+ // and the soon() callers waiting on the save queued behind the one that was
924
+ // on the wire when they asked get that answer.
925
+ const soonQueued = explicit ? takeSoonQueued() : [];
926
+ const settleSoonQueued = (result) => soonQueued.forEach((done) => done(result));
927
+
531
928
  // Check if offline - set DOM state immediately for UI feedback
532
929
  // but still try the fetch (navigator.onLine can be wrong)
533
930
  const wasOffline = !navigator.onLine;
@@ -535,40 +932,106 @@ export function savePage(callback = () => {}) {
535
932
  setOfflineStateQuiet();
536
933
  }
537
934
 
538
- // Single capture: clone once, get both versions
935
+ // Single capture: clone once, get all three versions
539
936
  // forSave strips non-persisted regions ([no-save]/[save-remove])
540
937
  // forComparison additionally strips every autosave-off region
541
- let forSave, forComparison, forDirty, captureId;
938
+ const startedAt = monotonic();
939
+ let capture, forSave, forComparison, forDirty, captureId;
542
940
  const gateToken = gateCaptureToken();
543
941
  try {
544
- ({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
942
+ capture = captureForSaveAndComparison({ requestRefresh: true, forRequest: true });
943
+ ({ forSave, forComparison, forDirty, captureId } = capture);
545
944
  } catch (err) {
546
- console.error('savePage: captureForSaveAndComparison failed', err);
547
- clearExplicitSave();
548
- setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
549
- const result = { msg: err.message, msgType: 'error', code: err.code ?? null, etag: null };
945
+ console.error(`${explicit ? 'savePage' : 'savePageThrottled'}: captureForSaveAndComparison failed`, err);
946
+ if (explicit) {
947
+ clearExplicitSave();
948
+ setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
949
+ } else {
950
+ setSaveState('error', err.message);
951
+ }
952
+ const result = { msg: err.message, msgType: 'error', code: explicit ? (err.code ?? null) : null, etag: null };
550
953
  if (typeof callback === 'function') {
551
954
  callback(result);
552
955
  }
553
- return resolve(result);
956
+ resolve(result);
957
+ settleSoonQueued(result);
958
+ return;
554
959
  }
960
+ const preparedMs = monotonic() - startedAt;
961
+
962
+ // The recovery hook sees the accepted capture's exact bytes, before anything is
963
+ // 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
+ }
555
1020
 
556
- // An explicit save asks the DIRTY question: write anything the person would
557
- // otherwise lose, including an edit inside a batching region that was never
558
- // going to autosave itself.
559
- unsavedChanges = (forDirty !== lastSavedDirty);
560
- logSaveCheck('savePage dirty check', !unsavedChanges);
561
-
562
- // Skip if content hasn't changed. Clearing the explicit intent here is the
563
- // whole point of scoping it: pressing Save on a clean page sends nothing, and
564
- // leaving "a human asked for this" armed would hand it to the next
565
- // background write and hide exactly the clobber the guard watches for.
566
- if (!unsavedChanges) {
567
- clearExplicitSave();
568
- gateClearIfUnchanged(gateToken);
569
- const skipped = skipped_('No changes to save');
570
- callback(skipped);
571
- return resolve(skipped);
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
+ }
572
1035
  }
573
1036
 
574
1037
  // Start debounced 'saving' state (only shows if save takes >500ms)
@@ -581,16 +1044,39 @@ export function savePage(callback = () => {}) {
581
1044
  saveCapturedHtml(forSave, captureId, (result) => {
582
1045
  ownSaveOnWire = false;
583
1046
  inFlightComparisons.delete(comparison);
1047
+ const completedAt = monotonic();
584
1048
  applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after save', gateToken, forSave, captureId);
1049
+ if (!explicit) autosaveScheduler.noteCost(preparedMs + (monotonic() - completedAt));
585
1050
  if (typeof callback === 'function') {
586
1051
  callback(result);
587
1052
  }
588
1053
  resolve(result);
1054
+ settleSoonQueued(result);
589
1055
  drainPendingSave();
590
- });
1056
+ }, { capture });
591
1057
  });
592
1058
  }
593
1059
 
1060
+ /**
1061
+ * Save the current page with change detection and state management.
1062
+ *
1063
+ * Returns a Promise that resolves with {msg, msgType} — the same object
1064
+ * passed to the callback. Promise never rejects; errors resolve with
1065
+ * msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
1066
+ *
1067
+ * @param {Function} callback - Optional callback for custom handling
1068
+ * @returns {Promise<{msg: string, msgType: string}>}
1069
+ */
1070
+ export function savePage(callback = () => {}) {
1071
+ return attemptSave({ kind: 'explicit' }, callback);
1072
+ }
1073
+
1074
+ // The exit flush's own entry, inside its permit. Not exported: the only caller is the
1075
+ // barrier below, which is the one save a closed session must still be able to send.
1076
+ function savePagePermitted(permit, callback = () => {}) {
1077
+ return attemptSave({ kind: 'explicit', permit }, callback);
1078
+ }
1079
+
594
1080
  /**
595
1081
  * Force-save the current page (skips dirty check).
596
1082
  *
@@ -598,6 +1084,7 @@ export function savePage(callback = () => {}) {
598
1084
  * @returns {Promise<{msg: string, msgType: string}>}
599
1085
  */
600
1086
  export function savePageForce(callback = () => {}, { quietConflict = false } = {}) {
1087
+ if (capabilityPending) return capabilityKnown.then(() => savePageForce(callback, { quietConflict }));
601
1088
  return new Promise((resolve) => {
602
1089
  if (!isEditMode && !window.clay?.testMode) {
603
1090
  const skipped = skipped_('Not in edit mode');
@@ -605,6 +1092,14 @@ export function savePageForce(callback = () => {}, { quietConflict = false } = {
605
1092
  return resolve(skipped);
606
1093
  }
607
1094
 
1095
+ // A forced save is still an ordinary one for admission: only the exit's own flush
1096
+ // carries the permit.
1097
+ if (!savesAdmitted()) {
1098
+ const skipped = sessionClosed('Save session is quiesced');
1099
+ callback(skipped);
1100
+ return resolve(skipped);
1101
+ }
1102
+
608
1103
  if (heldForPreview(callback, resolve)) return;
609
1104
 
610
1105
  if (isSaveInProgress()) {
@@ -614,15 +1109,19 @@ export function savePageForce(callback = () => {}, { quietConflict = false } = {
614
1109
  return resolve(skipped);
615
1110
  }
616
1111
 
1112
+ const prepare = window.clay?.source?.ensureForSave?.();
1113
+ if (prepare) return prepare.then(() => resolve(savePageForce(callback, { quietConflict })));
1114
+
617
1115
  const wasOffline = !navigator.onLine;
618
1116
  if (wasOffline) {
619
1117
  setOfflineStateQuiet();
620
1118
  }
621
1119
 
622
- let forSave, forComparison, forDirty, captureId;
1120
+ let capture, forSave, forComparison, forDirty, captureId;
623
1121
  const gateToken = gateCaptureToken();
624
1122
  try {
625
- ({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
1123
+ capture = captureForSaveAndComparison({ requestRefresh: true, forRequest: true });
1124
+ ({ forSave, forComparison, forDirty, captureId } = capture);
626
1125
  } catch (err) {
627
1126
  console.error('savePageForce: captureForSaveAndComparison failed', err);
628
1127
  setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
@@ -633,6 +1132,8 @@ export function savePageForce(callback = () => {}, { quietConflict = false } = {
633
1132
  return resolve(result);
634
1133
  }
635
1134
 
1135
+ noteRecoveryCapture({ captureId, html: forSave });
1136
+
636
1137
  setSavingState();
637
1138
 
638
1139
  const comparison = { forComparison, forDirty };
@@ -647,7 +1148,7 @@ export function savePageForce(callback = () => {}, { quietConflict = false } = {
647
1148
  }
648
1149
  resolve(result);
649
1150
  drainPendingSave();
650
- });
1151
+ }, { capture });
651
1152
  });
652
1153
  }
653
1154
 
@@ -703,24 +1204,29 @@ function nextEvent(name, deadline) {
703
1204
  // host but the page holds work a save cannot write, registered through
704
1205
  // clay.registerUnsavedState; only a person can resolve that). Never overwrites.
705
1206
  // `keepalive` is a transport hint honoured under 60 KB of UTF-8.
706
- export async function flushSave({ keepalive = false, timeoutMs = 15000 } = {}) {
1207
+ export async function flushSave({ keepalive = false, timeoutMs = 15000, permit = null } = {}) {
707
1208
  if (!isEditMode) return { state: 'view', etag: null };
708
1209
  const deadline = Date.now() + timeoutMs;
1210
+ // A caller with no permit of its own is the plain `clay.save.flush`: it mints one for
1211
+ // the generation that owns the page now, so a flush during a transition is still a
1212
+ // write in this generation and never the next one's.
1213
+ const held = permit || openSavePermit();
709
1214
  let timer = null;
710
1215
  const timeout = new Promise((_, reject) => {
711
1216
  timer = setTimeout(() => reject(flushError('Save timed out.', 'failed')), Math.max(0, timeoutMs));
712
1217
  });
713
1218
  try {
714
- return await Promise.race([flushBytes(keepalive, deadline), timeout]);
1219
+ return await Promise.race([flushBytes(keepalive, deadline, held), timeout]);
715
1220
  } catch (err) {
716
1221
  if (err && typeof err.state === 'string') throw err;
717
1222
  throw flushError(err?.message || 'Save failed', 'failed');
718
1223
  } finally {
719
1224
  clearTimeout(timer);
1225
+ if (!permit) closeSavePermit(held);
720
1226
  }
721
1227
  }
722
1228
 
723
- async function flushBytes(keepalive, deadline) {
1229
+ async function flushBytes(keepalive, deadline, permit) {
724
1230
  // Load-time module churn is not the person's work: autosave never saves it, and
725
1231
  // neither does a flush. Wait for the baseline to settle (at most MAX_SETTLE_MS).
726
1232
  if (!baselineSettled()) await nextEvent('clay:baseline-settled', deadline);
@@ -736,7 +1242,7 @@ async function flushBytes(keepalive, deadline) {
736
1242
  if (hasUnsavedState()) throw flushError('This document has unsaved work a save cannot write.', 'blocked');
737
1243
  return { state: saved ? 'saved' : 'clean', etag: etag ?? lastSeenEtag() };
738
1244
  }
739
- const result = await withKeepalive(keepalive, () => savePage());
1245
+ const result = await withKeepalive(keepalive, () => savePagePermitted(permit));
740
1246
  // The lane was busy (a drained pending save is on the wire): wait it out next turn.
741
1247
  if (result.msgType === 'skipped') continue;
742
1248
  if (!result.ok) throw flushError(result.msg || 'Save failed', result.msgType === 'conflict' || isSaveConflicted() ? 'conflict' : 'failed');
@@ -795,8 +1301,70 @@ export function replacePageWith(url) {
795
1301
  });
796
1302
  }
797
1303
 
798
- // Throttled version of savePage for auto-save
799
- const throttledSave = throttle(savePage, 1200);
1304
+ // The automatic lane's own throttle. It is the floor under the scheduler: a call
1305
+ // that arrives inside the window rides the trailing attempt instead of capturing.
1306
+ const throttledAutomatic = throttle(callback => attemptSave({ kind: 'automatic' }, callback), 1200);
1307
+
1308
+ const SOON_MS = 250;
1309
+ const SOON_MAX_MS = 1000;
1310
+ let soonTimer = null;
1311
+ let soonStarted = 0;
1312
+ let soonWaiters = [];
1313
+ // Callers whose timer fired while a save was on the wire. Their call queued one
1314
+ // behind it, and they wait for THAT save's result: the skipped answer to their
1315
+ // own call describes nothing they asked for.
1316
+ let soonQueuedWaiters = [];
1317
+ // The timer fired while autosave was suspended. The callers keep waiting and
1318
+ // resumeAutosave arms the timer again.
1319
+ let soonDeferred = false;
1320
+
1321
+ const takeSoonQueued = () => {
1322
+ const waiters = soonQueuedWaiters;
1323
+ soonQueuedWaiters = [];
1324
+ return waiters;
1325
+ };
1326
+
1327
+ function fireSoon() {
1328
+ soonTimer = null;
1329
+ if (autosaveSuspended > 0 || !savesAdmitted()) {
1330
+ soonDeferred = true;
1331
+ return;
1332
+ }
1333
+ const waiters = soonWaiters;
1334
+ soonWaiters = [];
1335
+ if (isSaveInProgress()) {
1336
+ soonQueuedWaiters.push(...waiters);
1337
+ savePage();
1338
+ return;
1339
+ }
1340
+ savePage().then((result) => waiters.forEach((done) => done(result)));
1341
+ }
1342
+
1343
+ export function savePageSoon() {
1344
+ const now = Date.now();
1345
+ if (soonTimer === null) soonStarted = now;
1346
+ else clearTimeout(soonTimer);
1347
+ return new Promise((resolve) => {
1348
+ soonWaiters.push(resolve);
1349
+ soonTimer = setTimeout(fireSoon, Math.max(0, Math.min(SOON_MS, soonStarted + SOON_MAX_MS - now)));
1350
+ });
1351
+ }
1352
+
1353
+ // The automatic attempt a closed session parked. Replayed when admission comes back, so
1354
+ // an edit typed while the page was between modes still reaches disk.
1355
+ function replayParkedAutosave() {
1356
+ if (!parkedAutosave) return;
1357
+ parkedAutosave = false;
1358
+ savePageThrottled();
1359
+ }
1360
+
1361
+ // A soon() timer that fired while admission was closed: the callers are still waiting on
1362
+ // a save that has not been attempted, so arm it again in the same way resumeAutosave does.
1363
+ function releaseDeferredSoon() {
1364
+ if (!soonDeferred) return;
1365
+ soonDeferred = false;
1366
+ soonTimer = setTimeout(fireSoon, 0);
1367
+ }
800
1368
 
801
1369
  // Baseline for autosave comparison
802
1370
  let baselineContents = '';
@@ -808,6 +1376,9 @@ let baselineActive = true;
808
1376
  // from an editmode:resource script) are in the page by now.
809
1377
  let baselineSettledAt = false;
810
1378
  export function baselineSettled() { return baselineSettledAt; }
1379
+ // True once a person edited the page during the boot settle. The settled capture is
1380
+ // skipped then, so the edit is never absorbed into the saved baseline.
1381
+ let bootUserEdited = false;
811
1382
 
812
1383
  // A repaint that leaves the page's painted values different from the file's means
813
1384
  // the file is stale: an agent wrote a record without painting it. One clean,
@@ -847,8 +1418,9 @@ export function currentPaintSignature() {
847
1418
  export function schedulePaintHeal(fileSignature) {
848
1419
  if (!isEditMode || isDraftMode) return;
849
1420
  clearTimeout(paintHealTimer);
850
- paintHealTimer = setTimeout(() => {
1421
+ paintHealTimer = setTimeout(async () => {
851
1422
  paintHealTimer = null;
1423
+ if (adoptionsQueued) await adoptionQueue;
852
1424
  const onDisk = typeof fileSignature === 'function' ? fileSignature() : fileSignature;
853
1425
  if (onDisk == null || !autosaveActive() || !baselineSettled() || isSaveConflicted() || autosaveIsSuspended()) return;
854
1426
  if (captureForDirtyCheck({ flushUndo: false }) !== lastSavedDirty) return;
@@ -859,17 +1431,145 @@ export function schedulePaintHeal(fileSignature) {
859
1431
  }, PAINT_HEAL_MIN_MS + Math.random() * PAINT_HEAL_SPREAD_MS);
860
1432
  }
861
1433
 
862
- registerStyleAdoption(identity => {
863
- const strip = styleComparisonStripper(identity);
864
- lastSavedContents = strip(lastSavedContents);
865
- lastSavedDirty = strip(lastSavedDirty);
866
- baselineContents = strip(baselineContents);
867
- for (const comparison of inFlightComparisons) {
868
- comparison.forComparison = strip(comparison.forComparison);
869
- comparison.forDirty = strip(comparison.forDirty);
1434
+ // parse5 is 38.5 KB Brotli, and an owned style is the only reason this module names
1435
+ // it, so the stripper loads the first time a script writes one. `clay.style()` stays
1436
+ // synchronous: it queues the identity here and the strip runs when the module lands.
1437
+ // Every reader that compares bytes awaits the queue first; see
1438
+ // `styleAdoptionPending` for the one synchronous reader that cannot.
1439
+ let adoptionStripper = null;
1440
+ let adoptionLoad = null;
1441
+ let adoptionsQueued = 0;
1442
+ let adoptionQueue = Promise.resolve();
1443
+ // A person edited since the oldest adoption now queued. The style write is not one,
1444
+ // so the pending window cannot speak for the page's bytes once this is true.
1445
+ let editedSinceAdoption = false;
1446
+ // The identities whose strip has not landed yet, oldest first, with the exact
1447
+ // attribute bytes their saved tag carries.
1448
+ const pendingStyles = [];
1449
+
1450
+ function loadAdoptionStripper() {
1451
+ adoptionLoad ??= import("./style-comparisons.js").catch(error => {
1452
+ adoptionLoad = null;
1453
+ console.error('style-comparisons failed to load', error);
1454
+ return null;
1455
+ });
1456
+ return adoptionLoad;
1457
+ }
1458
+
1459
+ async function stripAdoptedStyle(identity) {
1460
+ try {
1461
+ if (!adoptionStripper) adoptionStripper = (await loadAdoptionStripper())?.styleComparisonStripper ?? null;
1462
+ if (!adoptionStripper) return;
1463
+ const strip = adoptionStripper(identity);
1464
+ lastSavedContents = strip(lastSavedContents);
1465
+ lastSavedDirty = strip(lastSavedDirty);
1466
+ baselineContents = strip(baselineContents);
1467
+ for (const comparison of inFlightComparisons) {
1468
+ comparison.forComparison = strip(comparison.forComparison);
1469
+ comparison.forDirty = strip(comparison.forDirty);
1470
+ }
1471
+ } catch (error) {
1472
+ console.error('style adoption failed', error);
1473
+ } finally {
1474
+ adoptionsQueued--;
1475
+ pendingStyles.shift();
1476
+ }
1477
+ }
1478
+
1479
+ // The bytes a saved owned tag of this identity carries in the stored baselines. The
1480
+ // capture writes attribute values with `&` and `"` escaped and every other character
1481
+ // as itself, so the marker is the exact form the serializer wrote.
1482
+ const attrValue = value => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
1483
+
1484
+ function styleIdentityMarkers(identity) {
1485
+ if (identity.name !== undefined) {
1486
+ const markers = [`data-name="${attrValue(identity.name)}"`];
1487
+ if (identity.legacyId !== undefined) markers.push(`id="${attrValue(identity.legacyId)}"`);
1488
+ return markers;
870
1489
  }
1490
+ // The identity is a base URL, while the file may hold the href relative to the
1491
+ // page, so the path alone counts too.
1492
+ const markers = [attrValue(identity.href)];
1493
+ try {
1494
+ const { pathname } = new URL(identity.href);
1495
+ if (pathname) markers.push(attrValue(pathname));
1496
+ } catch {}
1497
+ return markers;
1498
+ }
1499
+
1500
+ /** Whether a stored baseline, or a save already in flight, carries the owned tag. */
1501
+ function baselineCarriesStyle(markers) {
1502
+ const stored = [lastSavedContents, lastSavedDirty, baselineContents];
1503
+ for (const comparison of inFlightComparisons) stored.push(comparison.forComparison, comparison.forDirty);
1504
+ return stored.some(html => html && markers.some(marker => marker && html.includes(marker)));
1505
+ }
1506
+
1507
+ // A stored page with the pending owned tags taken out, found by the exact attribute
1508
+ // bytes the capture writes and expanded to the element around them. The real strip is
1509
+ // parse5's; this only answers "is anything ELSE different", so a span it misses or
1510
+ // over-reaches leaves the page reading dirty, never clean.
1511
+ function withoutOwnedStyles(html, pending) {
1512
+ const spans = [];
1513
+ for (const { markers, tag } of pending) {
1514
+ for (const marker of markers) {
1515
+ for (let at = html.indexOf(marker); at >= 0; at = html.indexOf(marker, at + marker.length)) {
1516
+ const start = html.lastIndexOf('<', at);
1517
+ if (start < 0 || html.slice(start + 1, start + 1 + tag.length).toLowerCase() !== tag) continue;
1518
+ const close = tag === 'style' ? html.indexOf('</style', at) : at;
1519
+ if (close < 0) continue;
1520
+ const end = html.indexOf('>', close);
1521
+ if (end >= 0) spans.push([start, end + 1]);
1522
+ }
1523
+ }
1524
+ }
1525
+ spans.sort((a, b) => a[0] - b[0]);
1526
+ let out = '';
1527
+ let offset = 0;
1528
+ for (const [from, to] of spans) {
1529
+ if (from < offset) continue;
1530
+ out += html.slice(offset, from);
1531
+ offset = to;
1532
+ }
1533
+ return out + html.slice(offset);
1534
+ }
1535
+
1536
+ registerStyleAdoption(identity => {
1537
+ const markers = styleIdentityMarkers(identity);
1538
+ // Nothing to strip means nothing to load: a Document whose file has no such tag
1539
+ // never asks for parse5. A false hit only costs the parse, which decides precisely.
1540
+ if (!baselineCarriesStyle(markers)) return;
1541
+ if (!adoptionsQueued) editedSinceAdoption = false;
1542
+ adoptionsQueued++;
1543
+ pendingStyles.push({ markers, tag: identity.name !== undefined ? 'style' : 'link' });
1544
+ adoptionQueue = adoptionQueue.then(() => stripAdoptedStyle(identity));
871
1545
  });
872
1546
 
1547
+ /** Resolves once every style written so far has been stripped from the baselines. */
1548
+ export function styleAdoptionsSettled() {
1549
+ return adoptionQueue;
1550
+ }
1551
+
1552
+ /**
1553
+ * The window a style adoption opens. A fresh capture leaves an owned style out
1554
+ * (writing one marks it no-dirty), while the stored baselines still carry it until
1555
+ * the stripper lands, so a synchronous reader in that window reads the page as
1556
+ * dirty. That is only the adoption's own doing while nothing else differs: an edit
1557
+ * since the adoption was queued, work an owner registered, a save on the wire, or
1558
+ * any other byte `current` (the caller's own capture) carries turn this off. A clean
1559
+ * adoption is clean before and after the baseline settles, so no settle condition
1560
+ * belongs here.
1561
+ */
1562
+ export function styleAdoptionPending(current) {
1563
+ if (!adoptionsQueued) return false;
1564
+ if (editedSinceAdoption || hasUnsavedState()) return false;
1565
+ // Parked work is work too: a save a closed session refused leaves the baselines
1566
+ // untouched, so without this the adoption window would speak for the page's bytes
1567
+ // while they are still waiting to be written.
1568
+ if (isSaveInProgress() || pendingSave || pendingAutosave || parkedWork) return false;
1569
+ if (typeof current !== 'string') return false;
1570
+ return current === withoutOwnedStyles(lastSavedDirty, pendingStyles);
1571
+ }
1572
+
873
1573
  // ============================================
874
1574
  // BASELINE CAPTURE (Settled Signal)
875
1575
  // ============================================
@@ -904,7 +1604,6 @@ export function releaseBootSettle() {
904
1604
  function initBaselineCapture() {
905
1605
  if (!isEditMode) return;
906
1606
 
907
- let userEdited = false;
908
1607
  let settled = false;
909
1608
  let unsubscribeMutation = null;
910
1609
 
@@ -917,15 +1616,18 @@ function initBaselineCapture() {
917
1616
  baselineContents = immediateContents;
918
1617
  logBaseline('immediate capture', `${immediateContents.length} chars`);
919
1618
 
920
- // Track user edits to avoid overwriting real changes
921
- const userEditEvents = ['input', 'change', 'paste'];
1619
+ // Track user edits to avoid overwriting real changes. Every real edit counts,
1620
+ // whatever the target and however the page takes it: the editor handles keys and
1621
+ // clicks itself, a checklist box cancels mousedown, a picker or a menu item is a
1622
+ // click, a drop is neither key nor click. A style write is not an edit (it is
1623
+ // marked no-dirty), so a clean adoption stays clean. These stay attached after the
1624
+ // settle: an adoption queued later opens the same window again.
1625
+ const userEditEvents = ['keydown', 'pointerdown', 'drop', 'input', 'change', 'paste'];
1626
+ const modifierKeys = new Set(['Shift', 'Control', 'Alt', 'Meta']);
922
1627
  const markUserEdited = (e) => {
923
- const target = e.target;
924
- const isEditable = target.isContentEditable ||
925
- target.tagName === 'INPUT' ||
926
- target.tagName === 'TEXTAREA' ||
927
- target.tagName === 'SELECT';
928
- if (isEditable) userEdited = true;
1628
+ if (e.type === 'keydown' && modifierKeys.has(e.key)) return;
1629
+ editedSinceAdoption = true;
1630
+ if (!settled) bootUserEdited = true;
929
1631
  };
930
1632
  userEditEvents.forEach(evt => document.addEventListener(evt, markUserEdited, true));
931
1633
 
@@ -936,12 +1638,11 @@ function initBaselineCapture() {
936
1638
 
937
1639
  // Cleanup listeners
938
1640
  if (unsubscribeMutation) unsubscribeMutation();
939
- userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
940
1641
 
941
1642
  // Adoption only removes owned styles from comparison state. It does not
942
1643
  // advance this revision. A save or sync installation does, even when its
943
1644
  // comparison bytes match, so settle cannot absorb a later unsent edit.
944
- if (!userEdited && baselineRevision === immediateRevision) {
1645
+ if (!bootUserEdited && baselineRevision === immediateRevision) {
945
1646
  // Store stripped version so comparisons are direct (no parsing needed)
946
1647
  const gateToken = gateCaptureToken();
947
1648
  const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
@@ -952,7 +1653,7 @@ function initBaselineCapture() {
952
1653
  gateClearIfUnchanged(gateToken);
953
1654
  logBaseline('settled capture', `${contents.length} chars`);
954
1655
  } else {
955
- logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
1656
+ logBaseline('settled skipped', bootUserEdited ? 'user edited' : 'save occurred during settle');
956
1657
  }
957
1658
 
958
1659
  // The load-time veto has done its job. It exists so setup churn from modules
@@ -1026,6 +1727,15 @@ export function savePageThrottled(callback = () => {}) {
1026
1727
  return Promise.resolve(skipped);
1027
1728
  }
1028
1729
 
1730
+ // The page is between modes: no automatic attempt is admitted, and the activity that
1731
+ // asked for one is remembered so `resume` replays it instead of losing the edit.
1732
+ if (!savesAdmitted()) {
1733
+ parkedAutosave = true;
1734
+ const skipped = sessionClosed('Save session is quiesced');
1735
+ callback(skipped);
1736
+ return Promise.resolve(skipped);
1737
+ }
1738
+
1029
1739
  // Every autosave path lands here — the mutation-driven one, the [persist] input
1030
1740
  // timer, and live-sync's convergence save after a protected apply — which is
1031
1741
  // why the suspension lives at this one entry rather than at each caller.
@@ -1047,60 +1757,29 @@ export function savePageThrottled(callback = () => {}) {
1047
1757
  return Promise.resolve(skipped);
1048
1758
  }
1049
1759
 
1050
- // For autosave: while the page is still settling, content must differ from BOTH
1051
- // the load-time baseline and the last save, so module setup churn cannot trigger
1052
- // a save. Once settled, the baseline veto is disarmed and only the last save
1053
- // matters — otherwise undoing back to the page's original state can never be
1054
- // persisted, because it matches the baseline forever.
1055
- // Compare directly - stored versions are already stripped
1056
- const gateToken = gateCaptureToken();
1057
- let currentForCompare;
1058
- let dirtyMatchesLastSave;
1059
- try {
1060
- currentForCompare = captureForComparison();
1061
- // This comparison leaves out batching (no-trigger-autosave) regions, which the
1062
- // gate counts, so on a page that has one the dirty domain must match too.
1063
- dirtyMatchesLastSave = () => !document.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
1064
- || (pageMaybeDirty() && captureForDirtyCheck({ flushUndo: false }) === lastSavedDirty);
1065
- } catch (err) {
1066
- console.error('savePageThrottled: captureForComparison failed', err);
1067
- setSaveState('error', err.message);
1068
- const result = { msg: err.message, msgType: 'error', code: null, etag: null };
1069
- callback(result);
1070
- return Promise.resolve(result);
1071
- }
1072
- const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
1073
- const differsFromLastSave = currentForCompare !== lastSavedContents;
1074
-
1075
- logSaveCheck('throttled vs baseline', !differsFromBaseline);
1076
- logSaveCheck('throttled vs lastSave', !differsFromLastSave);
1077
-
1078
- if (!(differsFromBaseline && differsFromLastSave)) {
1079
- let clear = false;
1080
- try {
1081
- clear = !differsFromLastSave && dirtyMatchesLastSave();
1082
- } catch (err) {
1083
- console.error('savePageThrottled: captureForDirtyCheck failed', err);
1084
- }
1085
- if (clear) gateClearIfUnchanged(gateToken);
1086
- const skipped = skipped_('No changes to save');
1087
- callback(skipped);
1088
- return Promise.resolve(skipped);
1089
- }
1090
-
1091
- unsavedChanges = true;
1760
+ // The capture happens inside the attempt, after the throttle admits it: the
1761
+ // page is not copied to answer "is there anything to save?" any more, and an
1762
+ // admitted attempt takes one snapshot for both the decision and the bytes.
1092
1763
  // The throttled promise can reject now that a throwing save no longer strands
1093
1764
  // its callers (see throttle.js), and both autosave call sites are fire-and-forget,
1094
1765
  // with no reach into this module's error path. Catch it here, the one place that
1095
1766
  // owns that path, so every caller keeps the never-rejects contract savePage
1096
1767
  // documents and a throw surfaces as a failed save instead of an unhandled rejection.
1097
- return throttledSave(callback).catch(err => {
1768
+ return throttledAutomatic(callback).catch(err => {
1098
1769
  const result = { ok: false, msg: err?.message || 'Save failed', msgType: 'error', code: null, etag: null };
1099
1770
  setSaveState('error', result.msg);
1100
1771
  return result;
1101
1772
  });
1102
1773
  }
1103
1774
 
1775
+ // The one automatic-save scheduler. Every automatic trigger (a mutation, a
1776
+ // [persist] input, a merge that owes a save) only reports activity or intent to
1777
+ // it, and it admits the attempt through this module's automatic lane.
1778
+ export const autosaveScheduler = createAutosaveScheduler({
1779
+ now: monotonic,
1780
+ run: () => savePageThrottled()
1781
+ });
1782
+
1104
1783
  /**
1105
1784
  * Initialize keyboard shortcut for save (CMD/CTRL+S)
1106
1785
  */
@@ -1109,6 +1788,9 @@ export function initSaveKeyboardShortcut() {
1109
1788
  let isMac = window.navigator.platform.match("Mac");
1110
1789
  let metaKeyPressed = isMac ? event.metaKey : event.ctrlKey;
1111
1790
  if (metaKeyPressed && event.keyCode == 83) {
1791
+ // The listener outlives its session, so the mode is read now: after an exit the
1792
+ // page is the reader's and the browser's own Save has to work.
1793
+ if (!isEditMode) return;
1112
1794
  event.preventDefault();
1113
1795
  // isTrusted, so a script calling dispatchEvent cannot manufacture human
1114
1796
  // provenance for a write the person never asked for.
@@ -1149,6 +1831,11 @@ export function init() {
1149
1831
  // first save's own response has already supplied one.
1150
1832
  seedEtag();
1151
1833
 
1834
+ // The same discovery document states the host's save ceiling. Asked here for the
1835
+ // same reason and the same way: joined to the memoized answer, never awaited, so
1836
+ // the limit is known long before the first save and no save ever waits on it.
1837
+ saveLimit();
1838
+
1152
1839
  // Every editable page, not just autosave pages. A manual-save page makes
1153
1840
  // exactly the saves a person asked for, and used to report all of them as
1154
1841
  // background writes because this was installed behind the autosave gate.