@panphora/clayjs 1.15.2 → 1.17.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 (551) 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 +84291 -57611
  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-ZUE7PSCI.js +1 -0
  21. package/dist/m/attrs/save-freeze-MYNVXREN.js +1 -0
  22. package/dist/m/c-2AZDZWCK.js +1 -0
  23. package/dist/m/c-2SPVQSHC.js +1 -0
  24. package/dist/m/c-2YYDMLNZ.js +1 -0
  25. package/dist/m/c-336SGU5W.js +1 -0
  26. package/dist/m/c-33JJ6CBX.js +2 -0
  27. package/dist/m/c-3GJ4SDE2.js +1 -0
  28. package/dist/m/c-3H6BZDNJ.js +6 -0
  29. package/dist/m/c-3OWDMNJR.js +2 -0
  30. package/dist/m/c-3RUU4TPX.js +91 -0
  31. package/dist/m/c-3XIRD4NU.js +1 -0
  32. package/dist/m/c-46XYM55L.js +1 -0
  33. package/dist/m/c-4DFEJ2PO.js +3 -0
  34. package/dist/m/c-4JZDZFF6.js +1 -0
  35. package/dist/m/c-4L3BTFRK.js +1 -0
  36. package/dist/m/c-4Q2XTQ2N.js +2 -0
  37. package/dist/m/c-4WKFKEKF.js +1 -0
  38. package/dist/m/c-56QRZTZH.js +1 -0
  39. package/dist/m/c-5J4E7O4Y.js +4 -0
  40. package/dist/m/c-5LKDWQTL.js +19 -0
  41. package/dist/m/c-5QWQ7A5L.js +1 -0
  42. package/dist/m/c-5RJFTLSF.js +1 -0
  43. package/dist/m/c-5V3MOVJT.js +1 -0
  44. package/dist/m/c-5VYOWDTI.js +1 -0
  45. package/dist/m/c-5YIHJ3TJ.js +1 -0
  46. package/dist/m/c-6AZOGAPE.js +1 -0
  47. package/dist/m/c-6CRUUXOX.js +5 -0
  48. package/dist/m/c-6HMB24EV.js +1 -0
  49. package/dist/m/c-6KEVEQ7L.js +1 -0
  50. package/dist/m/c-6NC5I2TV.js +1 -0
  51. package/dist/m/c-74MGYWZF.js +1 -0
  52. package/dist/m/c-7DJDKPGL.js +1 -0
  53. package/dist/m/c-7OLLJPWP.js +1 -0
  54. package/dist/m/c-7SWMYKAI.js +1 -0
  55. package/dist/m/c-A3E3NIMC.js +1 -0
  56. package/dist/m/c-A7LS5UZW.js +1 -0
  57. package/dist/m/c-AMSNPOCX.js +1 -0
  58. package/dist/m/c-AQLFEATB.js +1 -0
  59. package/dist/m/c-B3JM5ZXC.js +1 -0
  60. package/dist/m/c-BBVGTQG7.js +1 -0
  61. package/dist/m/c-BPF2NSPV.js +1 -0
  62. package/dist/m/c-BQ6LOBEE.js +1 -0
  63. package/dist/m/c-BTDI4Y23.js +1 -0
  64. package/dist/m/c-BWFVSBDF.js +1 -0
  65. package/dist/m/c-BYTKL6UX.js +1 -0
  66. package/dist/m/c-CFHW5AMY.js +6 -0
  67. package/dist/m/c-CJ3A7ZKE.js +1 -0
  68. package/dist/m/c-CQHPGKX2.js +1 -0
  69. package/dist/m/c-CYWLPPUS.js +1 -0
  70. package/dist/m/c-CZUSOB7V.js +1 -0
  71. package/dist/m/c-DCFSEJZG.js +1 -0
  72. package/dist/m/c-DCXOXBF3.js +1 -0
  73. package/dist/m/c-DFS4XZJV.js +2 -0
  74. package/dist/m/c-DNENWY4O.js +1 -0
  75. package/dist/m/c-DNZ5IFM6.js +1 -0
  76. package/dist/m/c-E4LIR5X5.js +1 -0
  77. package/dist/m/c-EJTGYSLN.js +1 -0
  78. package/dist/m/c-EVCQ3BKA.js +1 -0
  79. package/dist/m/c-EZU56NQV.js +34 -0
  80. package/dist/m/c-FDNHHZY5.js +1 -0
  81. package/dist/m/c-FILLTJA7.js +1 -0
  82. package/dist/m/c-FRLHSY7E.js +1 -0
  83. package/dist/m/c-FROJUZMV.js +1 -0
  84. package/dist/m/c-G2JPKCTP.js +1 -0
  85. package/dist/m/c-GB6TU7WV.js +1 -0
  86. package/dist/m/c-GFJJEBTU.js +1 -0
  87. package/dist/m/c-GJAEGGXY.js +1 -0
  88. package/dist/m/c-GOWBDM6J.js +1 -0
  89. package/dist/m/c-GQ6F6IDK.js +1 -0
  90. package/dist/m/c-GSFIGKOP.js +1 -0
  91. package/dist/m/c-GY422N3Q.js +1 -0
  92. package/dist/m/c-H4LG442Q.js +1 -0
  93. package/dist/m/c-HA6INT6J.js +1 -0
  94. package/dist/m/c-HAWFNBYG.js +1 -0
  95. package/dist/m/c-HDDEEL77.js +1 -0
  96. package/dist/m/c-HFKUVACH.js +1 -0
  97. package/dist/m/c-HHDFCNHV.js +1 -0
  98. package/dist/m/c-HPURYVZS.js +2 -0
  99. package/dist/m/c-I2ZATQCD.js +2 -0
  100. package/dist/m/c-IEM4NTCS.js +1 -0
  101. package/dist/m/c-IJUK2GPW.js +1 -0
  102. package/dist/m/c-IW6GGOU2.js +1 -0
  103. package/dist/m/c-JAX2667S.js +6 -0
  104. package/dist/m/c-JDCX26UP.js +25 -0
  105. package/dist/m/c-JRKY44DK.js +1 -0
  106. package/dist/m/c-JU3NK25H.js +1 -0
  107. package/dist/m/c-KAU6E43D.js +1 -0
  108. package/dist/m/c-KBS3UV6B.js +1 -0
  109. package/dist/m/c-KGVGROZT.js +1 -0
  110. package/dist/m/c-KI7M4AY3.js +1 -0
  111. package/dist/m/c-KIPQJX4B.js +1 -0
  112. package/dist/m/c-KQ6TYJAP.js +135 -0
  113. package/dist/m/c-KU3ZFADW.js +1 -0
  114. package/dist/m/c-L4WQSC27.js +1 -0
  115. package/dist/m/c-LFPHCXY3.js +1 -0
  116. package/dist/m/c-LRPUO2MX.js +1 -0
  117. package/dist/m/c-LWLBHLVC.js +3 -0
  118. package/dist/m/c-LZJPNR2W.js +1 -0
  119. package/dist/m/c-M2L47S2X.js +7 -0
  120. package/dist/m/c-MFP2ZWD4.js +1 -0
  121. package/dist/m/c-MKSQ4Y57.js +1 -0
  122. package/dist/m/c-MTQHWQC3.js +1 -0
  123. package/dist/m/c-N326R2JD.js +1 -0
  124. package/dist/m/c-N7JPUPGC.js +1 -0
  125. package/dist/m/c-NRPJEVJS.js +1 -0
  126. package/dist/m/c-NUFFJVVJ.js +1 -0
  127. package/dist/m/c-NUHP2XDB.js +1 -0
  128. package/dist/m/c-NUHSPT5P.js +1 -0
  129. package/dist/m/c-O72HVYSA.js +1 -0
  130. package/dist/m/c-OCSZ4QGN.js +1 -0
  131. package/dist/m/c-OIBUTDXI.js +1 -0
  132. package/dist/m/c-OIDRZKAF.js +1 -0
  133. package/dist/m/c-OW5QIGG3.js +1 -0
  134. package/dist/m/c-P7KNDG34.js +1 -0
  135. package/dist/m/c-PGRCK73F.js +1 -0
  136. package/dist/m/c-PQ5LK6QE.js +993 -0
  137. package/dist/m/c-PS5ARVWR.js +9 -0
  138. package/dist/m/c-PZ4XEPUP.js +2 -0
  139. package/dist/m/c-Q622NS2Y.js +1 -0
  140. package/dist/m/c-Q7NQD65V.js +1 -0
  141. package/dist/m/c-QH63LQOS.js +1 -0
  142. package/dist/m/c-QHIWXHRO.js +1 -0
  143. package/dist/m/c-QHWJ5ZIJ.js +1 -0
  144. package/dist/m/c-QICP2YR5.js +1 -0
  145. package/dist/m/c-QJ4OM3G6.js +1 -0
  146. package/dist/m/c-QNKREOL6.js +1 -0
  147. package/dist/m/c-R3L7ZJGB.js +2 -0
  148. package/dist/m/c-R7IWVINQ.js +1 -0
  149. package/dist/m/c-RM7J7BV2.js +1 -0
  150. package/dist/m/c-RSMOODDU.js +1 -0
  151. package/dist/m/c-RVMWGXEJ.js +1 -0
  152. package/dist/m/c-RYUXUUBX.js +1 -0
  153. package/dist/m/c-S4ZEH46B.js +9 -0
  154. package/dist/m/c-S7H2DRCB.js +1 -0
  155. package/dist/m/c-S7LK4KUE.js +1 -0
  156. package/dist/m/c-SDAXDM2D.js +1 -0
  157. package/dist/m/c-SGIA4IEQ.js +188 -0
  158. package/dist/m/c-SMBZG5X4.js +1 -0
  159. package/dist/m/c-SMGU7RT3.js +235 -0
  160. package/dist/m/c-SPIORRYS.js +1 -0
  161. package/dist/m/c-SWLRT66R.js +1 -0
  162. package/dist/m/c-SZGYW5HT.js +1 -0
  163. package/dist/m/c-TCCA5BZN.js +2 -0
  164. package/dist/m/c-TCJTVAFN.js +1 -0
  165. package/dist/m/c-TJ6WOJL3.js +67 -0
  166. package/dist/m/c-TQEMVJCS.js +1 -0
  167. package/dist/m/c-TRS6TM6O.js +1 -0
  168. package/dist/m/c-TYLXX4NF.js +21 -0
  169. package/dist/m/c-U62OB4BT.js +1 -0
  170. package/dist/m/c-U6YWUAR3.js +8 -0
  171. package/dist/m/c-UDCSU5G5.js +2 -0
  172. package/dist/m/c-UDUUJPFZ.js +1 -0
  173. package/dist/m/c-UGHH4MB3.js +1 -0
  174. package/dist/m/c-UL2YUY52.js +1 -0
  175. package/dist/m/c-VAK7SG37.js +1 -0
  176. package/dist/m/c-VDSS4K3Z.js +1 -0
  177. package/dist/m/c-VE6ZMYMC.js +1 -0
  178. package/dist/m/c-VG5XIN4T.js +1 -0
  179. package/dist/m/c-VHM5XQHK.js +1 -0
  180. package/dist/m/c-VHNSBD2M.js +1 -0
  181. package/dist/m/c-VI5RMSVY.js +1 -0
  182. package/dist/m/c-VKEDWYDF.js +1 -0
  183. package/dist/m/c-VRZN56XY.js +1 -0
  184. package/dist/m/c-W5MCHEGA.js +1 -0
  185. package/dist/m/c-WDC5GVKU.js +1 -0
  186. package/dist/m/c-WN5AY3OZ.js +1 -0
  187. package/dist/m/c-WNYBLIOZ.js +1 -0
  188. package/dist/m/c-WRBKEJD4.js +1 -0
  189. package/dist/m/c-WWXIDH4O.js +28 -0
  190. package/dist/m/c-WYFDLITL.js +1 -0
  191. package/dist/m/c-XCNTK76U.js +1 -0
  192. package/dist/m/c-XMS6DPBR.js +1 -0
  193. package/dist/m/c-XN6MBKI6.js +67 -0
  194. package/dist/m/c-XPELB6E5.js +1 -0
  195. package/dist/m/c-XQXZAO7N.js +1 -0
  196. package/dist/m/c-Y5HBBIQ4.js +1 -0
  197. package/dist/m/c-ZE46QDIH.js +1 -0
  198. package/dist/m/c-ZUCOKOHT.js +1 -0
  199. package/dist/m/c-ZVPVXTJF.js +4 -0
  200. package/dist/m/c-ZVRKXKM7.js +1 -0
  201. package/dist/m/c-ZWIOJGMT.js +1 -0
  202. package/dist/m/core/admin-attrs-QHOKPJ7W.js +1 -0
  203. package/dist/m/core/autosave-HRYGUWV6.js +1 -0
  204. package/dist/m/core/conflict-notice-6MROJN2L.js +1 -0
  205. package/dist/m/core/draft-mode-WSQAT67S.js +1 -0
  206. package/dist/m/core/host-permission-FNXVEFNE.js +1 -0
  207. package/dist/m/core/is-edit-mode-KTWA3O5N.js +1 -0
  208. package/dist/m/core/persist-JFYRHMRJ.js +3 -0
  209. package/dist/m/core/recovery-FLTCC2OC.js +1 -0
  210. package/dist/m/core/save-HQZ4S667.js +1 -0
  211. package/dist/m/core/save-core-KGQWFUNH.js +1 -0
  212. package/dist/m/core/snapshot-EGQZNT45.js +1 -0
  213. package/dist/m/core/unsaved-warning-IDJLD66H.js +1 -0
  214. package/dist/m/dom/all-APS5ZSKP.js +1 -0
  215. package/dist/m/dom/dom-helpers-L7OSR6S4.js +1 -0
  216. package/dist/m/events/index-RN2JNGBV.js +1 -0
  217. package/dist/m/internals/index-PVXGPUDA.js +1 -0
  218. package/dist/m/lib/cache-bust-MUMFXZK6.js +1 -0
  219. package/dist/m/lib/region-policy-SB2AWVG5.js +1 -0
  220. package/dist/m/loader-S4I7GKXR.js +1 -0
  221. package/dist/m/manifest-4C419D67.js +1 -0
  222. package/dist/m/manifest-edit-B077F288.js +1 -0
  223. package/dist/m/manifest.json +3988 -0
  224. package/dist/m/options/options-EJMS3K24.js +1 -0
  225. package/dist/m/page/editor-NNJA4OKD.js +202 -0
  226. package/dist/m/plugins/ai-edit-QN4PMMNL.js +6 -0
  227. package/dist/m/plugins/bind-text-NX46N5UV.js +1 -0
  228. package/dist/m/plugins/cms-HP3LSTPN.js +2293 -0
  229. package/dist/m/plugins/demo-RM5IR6AO.js +1 -0
  230. package/dist/m/plugins/derive-UAAA4V4S.js +1 -0
  231. package/dist/m/plugins/indicator-URDBBOQO.js +1 -0
  232. package/dist/m/plugins/live-HFWD4OIH.js +1 -0
  233. package/dist/m/plugins/menu-3HIEQSI6.js +138 -0
  234. package/dist/m/plugins/message-LQ6EDH4V.js +1 -0
  235. package/dist/m/plugins/page-JOKB6XPR.js +2 -0
  236. package/dist/m/plugins/page-view-NXOJHNW6.js +1 -0
  237. package/dist/m/plugins/people-SGPJKANK.js +1 -0
  238. package/dist/m/plugins/quickcrop-QNY3PKLD.js +26 -0
  239. package/dist/m/plugins/quickcrop-QNY3PKLD.js.LEGAL.txt +7 -0
  240. package/dist/m/plugins/richclay-NRQDSWX6.js +517 -0
  241. package/dist/m/plugins/richclay-NRQDSWX6.js.LEGAL.txt +1 -0
  242. package/dist/m/plugins/sortable-GWT4PWDV.js +1 -0
  243. package/dist/m/plugins/source-4ZRSAIYM.js +1 -0
  244. package/dist/m/plugins/undo-VDA5P7FH.js +1 -0
  245. package/dist/m/plugins/unsent-HJFLQJ2O.js +1 -0
  246. package/dist/m/plugins/upgrade-W7FG5VFG.js +1 -0
  247. package/dist/m/plugins/upload-WLYKK6TL.js +1 -0
  248. package/dist/m/plugins/visitor-drafts-LIDY7NH6.js +21 -0
  249. package/dist/m/plugins/visitor-drafts-owner-ZXCKBZE7.js +1 -0
  250. package/dist/m/plugins/wire-4N6GYAF6.js +1 -0
  251. package/dist/m/reader-core-DXYCURFF.js +68 -0
  252. package/dist/m/route/route-KOCZSEGN.js +1 -0
  253. package/dist/m/sync/live-sync-J7VMYTKS.js +1 -0
  254. package/dist/m/ui/index-3JLVI27O.js +1 -0
  255. package/dist/m/utils/index-4TJCVLT7.js +1 -0
  256. package/dist/m/vendor/Sortable.vendor-A755ITLN.js +3 -0
  257. package/dist/m/vendor/Sortable.vendor-A755ITLN.js.LEGAL.txt +1 -0
  258. package/dist/m/vendor/sap-core.vendor-A7BMUYZT.js +6 -0
  259. package/dist/offline-manifest.json +4034 -0
  260. package/dist/page/themes/light-1.css +430 -0
  261. package/dist/server/core.cjs +23748 -22855
  262. package/dist/server/offline-runtime.cjs +4954 -0
  263. package/dist/server/offline-runtime.mjs +4923 -0
  264. package/dist/server/rooms.mjs +9 -0
  265. package/docs/guide.md +1078 -669
  266. package/entries/all.js +2 -0
  267. package/entries/clay-data.js +1 -1
  268. package/entries/clay-dom.js +2 -0
  269. package/entries/clay-events.js +2 -0
  270. package/entries/clay-internals.js +2 -0
  271. package/entries/clay-options.js +2 -0
  272. package/entries/clay-router.js +2 -0
  273. package/entries/clay-ui.js +2 -0
  274. package/entries/clay-utils.js +2 -0
  275. package/entries/clay.js +2 -0
  276. package/entries/sap.js +6 -6
  277. package/fonts/ibm-plex-mono-LICENSE +93 -0
  278. package/fonts/ibm-plex-mono-latin-300-normal.woff2 +0 -0
  279. package/fonts/ibm-plex-mono-latin-400-italic.woff2 +0 -0
  280. package/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  281. package/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  282. package/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  283. package/fonts/ibm-plex-sans-LICENSE +93 -0
  284. package/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  285. package/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  286. package/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  287. package/fonts/ibm-plex.css +71 -0
  288. package/fonts/inter-LICENSE +93 -0
  289. package/fonts/inter-cyrillic-ext-wght-normal.woff2 +0 -0
  290. package/fonts/inter-cyrillic-wght-normal.woff2 +0 -0
  291. package/fonts/inter-greek-ext-wght-normal.woff2 +0 -0
  292. package/fonts/inter-greek-wght-normal.woff2 +0 -0
  293. package/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  294. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  295. package/fonts/inter-vietnamese-wght-normal.woff2 +0 -0
  296. package/fonts/inter.css +62 -0
  297. package/fonts/newsreader-LICENSE +93 -0
  298. package/fonts/newsreader-latin-ext-opsz-normal.woff2 +0 -0
  299. package/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  300. package/fonts/newsreader-vietnamese-opsz-normal.woff2 +0 -0
  301. package/fonts/newsreader.css +26 -0
  302. package/fonts/work-sans-LICENSE +93 -0
  303. package/fonts/work-sans-latin-wght-normal.woff2 +0 -0
  304. package/fonts/work-sans.css +8 -0
  305. package/package.json +20 -3
  306. package/packed-contract.json +1 -0
  307. package/src/attrs/add-from.js +27 -4
  308. package/src/attrs/save-freeze.js +7 -52
  309. package/src/core/admin-attrs.js +119 -12
  310. package/src/core/admin-contenteditable.js +12 -10
  311. package/src/core/admin-inputs.js +14 -15
  312. package/src/core/admin-onclick.js +2 -0
  313. package/src/core/autosave.js +74 -21
  314. package/src/core/boot-phase.js +2 -1
  315. package/src/core/conflict-download.js +19 -23
  316. package/src/core/conflict-notice-ui.js +1173 -0
  317. package/src/core/conflict-notice.js +140 -697
  318. package/src/core/download.js +32 -0
  319. package/src/core/edit-mode.js +147 -14
  320. package/src/core/etag.js +30 -12
  321. package/src/core/host-meta.js +90 -8
  322. package/src/core/host-permission.js +29 -0
  323. package/src/core/is-edit-mode.js +145 -12
  324. package/src/core/mode-key.js +125 -0
  325. package/src/core/mode-lifecycle.js +544 -0
  326. package/src/core/mode-receipt.js +30 -0
  327. package/src/core/mode-refusal.js +13 -0
  328. package/src/core/mode-resources.js +52 -0
  329. package/src/core/mode-support.js +82 -0
  330. package/src/core/mode-switch.js +259 -0
  331. package/src/core/mode-transition.js +112 -0
  332. package/src/core/page-data-write.js +305 -0
  333. package/src/core/page-data.js +26 -227
  334. package/src/core/page-format-wire.js +4 -0
  335. package/src/core/page-format.js +58 -0
  336. package/src/core/persist.js +37 -9
  337. package/src/core/preserve-authored.js +114 -0
  338. package/src/core/recovery.js +772 -0
  339. package/src/core/runtime-url.js +105 -0
  340. package/src/core/save-core.js +113 -12
  341. package/src/core/save-replace.js +72 -0
  342. package/src/core/save-size-notice.js +134 -0
  343. package/src/core/save-size.js +140 -0
  344. package/src/core/save.js +806 -119
  345. package/src/core/snapshot.js +159 -11
  346. package/src/core/source-map.js +157 -9
  347. package/src/core/unsaved-warning.js +14 -4
  348. package/src/core/upgrade-run.js +408 -0
  349. package/src/core/version.js +18 -0
  350. package/src/lib/assets.js +30 -0
  351. package/src/lib/autosave-debug-gate.js +50 -0
  352. package/src/lib/autosave-scheduler.js +140 -0
  353. package/src/lib/boot-imports.js +8 -0
  354. package/src/lib/derived.js +3 -1
  355. package/src/lib/dirty-gate.js +15 -0
  356. package/src/lib/doc-storage.js +33 -0
  357. package/src/lib/effect.js +9 -4
  358. package/src/lib/mutation.js +4 -2
  359. package/src/lib/offline-runtime.js +226 -0
  360. package/src/lib/preload.js +48 -0
  361. package/src/lib/root-attrs.js +1 -1
  362. package/src/lib/trash.js +18 -0
  363. package/src/lib/undo/capture-move.js +152 -0
  364. package/src/live/local-channel.js +61 -0
  365. package/src/live/local-lane.js +60 -9
  366. package/src/live/server-lane.js +6 -3
  367. package/src/loader-logic.js +76 -15
  368. package/src/loader.js +218 -30
  369. package/src/page/adopt-boot.js +76 -22
  370. package/src/page/adopt.js +25 -4
  371. package/src/page/awareness.js +230 -0
  372. package/src/page/blocks.js +113 -6
  373. package/src/page/blocksel.js +20 -4
  374. package/src/page/board-face.js +13 -0
  375. package/src/page/board-panel.js +5 -17
  376. package/src/page/board-style.js +2 -13
  377. package/src/page/board-view.js +1 -11
  378. package/src/page/board-views.js +3 -0
  379. package/src/page/board.js +2 -2
  380. package/src/page/boot-pack.js +26 -0
  381. package/src/page/boot.js +237 -0
  382. package/src/page/chart-tables.js +11 -0
  383. package/src/page/chart.js +6 -10
  384. package/src/page/checklist.js +1 -1
  385. package/src/page/checkpoint-proof.js +289 -0
  386. package/src/page/chrome.js +431 -63
  387. package/src/page/csv-import.js +17 -2
  388. package/src/page/document-title-dom.js +52 -0
  389. package/src/page/document-title-runtime.js +90 -0
  390. package/src/page/document-title.js +12 -0
  391. package/src/page/drag.js +48 -13
  392. package/src/page/drop.js +2 -1
  393. package/src/page/editor.js +399 -90
  394. package/src/page/emoji-data.js +162 -0
  395. package/src/page/feature-loader.js +72 -0
  396. package/src/page/features/board.js +21 -0
  397. package/src/page/features/chart.js +11 -0
  398. package/src/page/features/code.js +13 -0
  399. package/src/page/features/dates.js +180 -0
  400. package/src/page/features/emoji.js +187 -0
  401. package/src/page/features/media.js +290 -0
  402. package/src/page/features/mentions.js +188 -0
  403. package/src/page/features/registry.js +28 -0
  404. package/src/page/features/table.js +383 -0
  405. package/src/page/{tool-view.js → features/tool.js} +33 -2
  406. package/src/page/format-codec.js +215 -0
  407. package/src/page/format-command.js +9 -0
  408. package/src/page/format-gate.js +179 -0
  409. package/src/page/format-host.js +54 -0
  410. package/src/page/format-level.js +157 -0
  411. package/src/page/format-native-commands.js +142 -0
  412. package/src/page/format-native.js +42 -0
  413. package/src/page/highlight-slot.js +23 -0
  414. package/src/page/highlight.js +30 -16
  415. package/src/page/history-intent.js +78 -0
  416. package/src/page/inline.js +45 -28
  417. package/src/page/items.js +67 -14
  418. package/src/page/link.js +1 -0
  419. package/src/page/markdown.js +112 -3
  420. package/src/page/media-views.js +3 -0
  421. package/src/page/media.js +33 -245
  422. package/src/page/mention-picker.js +99 -0
  423. package/src/page/merge.js +1 -1
  424. package/src/page/outline-cache.js +11 -0
  425. package/src/page/placeholder.js +27 -7
  426. package/src/page/projection.js +240 -51
  427. package/src/page/reader-patch.js +306 -0
  428. package/src/page/recover.js +1 -1
  429. package/src/page/rich/page-tool.js +1 -4
  430. package/src/page/runtime-owner.js +27 -0
  431. package/src/page/save-projection.js +46 -4
  432. package/src/page/save-states.js +1 -1
  433. package/src/page/schema.js +47 -0
  434. package/src/page/table-rect-key.js +3 -0
  435. package/src/page/table-select.js +4 -3
  436. package/src/page/table-style.js +1 -1
  437. package/src/page/table-views.js +3 -0
  438. package/src/page/table.js +8 -293
  439. package/src/page/themes/content.css +25 -1
  440. package/src/page/themes/light-1.css +163 -2
  441. package/src/page/themes/light-1.source.css +269 -0
  442. package/src/page/tool-capability.js +3 -0
  443. package/src/page/tool-views.js +3 -0
  444. package/src/page/view-hooks.js +32 -0
  445. package/src/page/view.js +390 -107
  446. package/src/page/ybind/awareness.js +172 -0
  447. package/src/page/ybind/binding.js +40 -5
  448. package/src/page/ybind/bridge.js +50 -47
  449. package/src/page/ybind/decode.js +52 -19
  450. package/src/page/ybind/doc.js +27 -2
  451. package/src/page/ybind/group.js +12 -3
  452. package/src/page/ybind/index.js +2 -0
  453. package/src/page/ybind/lineage.js +71 -0
  454. package/src/page/ybind/marks.js +26 -1
  455. package/src/page/ybind/migrate.js +48 -13
  456. package/src/page/ybind/moves.js +32 -21
  457. package/src/page/ybind/positions.js +50 -0
  458. package/src/page/ybind/provider.js +97 -24
  459. package/src/page/ybind/recovery-proof.js +191 -0
  460. package/src/page/ybind/recovery-replay.js +488 -0
  461. package/src/page/ybind/tokens.js +15 -6
  462. package/src/page/ybind/walk.js +10 -3
  463. package/src/page/ybind/writer.js +48 -4
  464. package/src/plugins/ai-edit.js +167 -32
  465. package/src/plugins/bind-text.js +218 -0
  466. package/src/plugins/cms.js +93 -0
  467. package/src/plugins/indicator.js +57 -5
  468. package/src/plugins/live.js +319 -35
  469. package/src/plugins/menu/claygoo.svg.js +1 -0
  470. package/src/plugins/menu/seo-head.js +323 -0
  471. package/src/plugins/menu/seo.js +849 -0
  472. package/src/plugins/menu.js +425 -0
  473. package/src/plugins/page-view.js +131 -0
  474. package/src/plugins/page.js +1131 -270
  475. package/src/plugins/people.js +158 -31
  476. package/src/plugins/richclay.js +119 -9
  477. package/src/plugins/sortable.js +370 -60
  478. package/src/plugins/source.js +189 -366
  479. package/src/plugins/undo.js +289 -19
  480. package/src/plugins/unsent.js +132 -0
  481. package/src/plugins/upgrade-run.js +1 -0
  482. package/src/plugins/upgrade.js +210 -0
  483. package/src/plugins/upload-task.js +571 -0
  484. package/src/plugins/upload-ui.js +265 -0
  485. package/src/plugins/upload.js +34 -153
  486. package/src/plugins/visitor-drafts/inert.js +32 -0
  487. package/src/plugins/visitor-drafts/intro.js +2 -1
  488. package/src/plugins/visitor-drafts/owner-chip.js +3 -0
  489. package/src/plugins/visitor-drafts/prompt.js +2 -2
  490. package/src/plugins/visitor-drafts-owner.js +85 -0
  491. package/src/plugins/visitor-drafts.js +31 -42
  492. package/src/plugins/wire.js +82 -1
  493. package/src/reader-core.js +26 -0
  494. package/src/recovery/conflicts.js +287 -0
  495. package/src/recovery/identity.js +154 -0
  496. package/src/recovery/store.js +792 -0
  497. package/src/recovery/undo.js +257 -0
  498. package/src/server/checkpoint/index.js +68 -4
  499. package/src/server/offline-runtime/index.js +214 -0
  500. package/src/server/rooms/index.js +2 -1
  501. package/src/server/rooms/live-data-rooms.js +2 -2
  502. package/src/server/rooms/page-admission.js +33 -0
  503. package/src/server/rooms/page-lineage.js +56 -0
  504. package/src/server/rooms/page-rebase.js +11 -5
  505. package/src/server/rooms/page-state.js +2 -0
  506. package/src/server/rooms/page-write-admission.js +9 -3
  507. package/src/source/session.js +705 -0
  508. package/src/sync/conflict-revert.js +5 -4
  509. package/src/sync/conflicts-edit.js +119 -0
  510. package/src/sync/conflicts-facade.js +49 -0
  511. package/src/sync/conflicts.js +99 -1
  512. package/src/sync/editing-marker.js +37 -6
  513. package/src/sync/editing-sender.js +8 -1
  514. package/src/sync/live-sync-edit.js +63 -0
  515. package/src/sync/live-sync.js +364 -119
  516. package/src/sync/local-presence.js +187 -0
  517. package/src/sync/merge-declarations.js +10 -0
  518. package/src/sync/merge-engine.js +12 -0
  519. package/src/sync/presence-store.js +412 -0
  520. package/src/sync/presence-target.js +243 -0
  521. package/src/sync/presence.js +72 -41
  522. package/src/sync/saved-handoff.js +200 -0
  523. package/src/sync/saved-stream.js +544 -0
  524. package/src/sync/version-guard.js +92 -0
  525. package/src/sync/wire-profile.js +81 -0
  526. package/src/ui/bevel-controls.js +2 -8
  527. package/src/ui/page-scheme.js +7 -0
  528. package/src/ui/skins/cms.js +1 -1
  529. package/src/ui/theme-config.js +150 -0
  530. package/src/ui/theme.js +6 -138
  531. package/src/ui/toast-lazy.js +11 -0
  532. package/src/vendor/LICENSES-optional.md +253 -0
  533. package/src/vendor/highlight.vendor.js +3 -0
  534. package/src/vendor/hljs-bash.vendor.js +2 -0
  535. package/src/vendor/hljs-css.vendor.js +2 -0
  536. package/src/vendor/hljs-javascript.vendor.js +2 -0
  537. package/src/vendor/hljs-json.vendor.js +2 -0
  538. package/src/vendor/hljs-python.vendor.js +2 -0
  539. package/src/vendor/hljs-xml.vendor.js +2 -0
  540. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  541. package/src/vendor/hyper-html-api-upgrade.vendor.js +5 -0
  542. package/src/vendor/hyper-morph-identity.vendor.js +2 -0
  543. package/src/vendor/hyper-morph-json.vendor.js +3 -0
  544. package/src/vendor/hyper-morph.vendor.js +6 -6
  545. package/src/vendor/hyper-undo.vendor.js +1 -1
  546. package/src/vendor/hypercms.vendor.js +169 -84
  547. package/src/vendor/marked.vendor.js +68 -0
  548. package/src/vendor/overtype.vendor.js +994 -0
  549. package/src/vendor/richclay.vendor.js +17 -17
  550. package/src/vendor/sap-core.vendor.js +5 -5
  551. package/src/vendor/sap-ownership.vendor.js +11 -7
@@ -1,26 +1,23 @@
1
- import { createPageEditor } from "../page/editor.js";
2
- import { readFile, installAdoption } from "../page/adopt-boot.js";
3
- import { classifyFile } from "../page/adopt.js";
4
- import { renderFromTags } from "../page/save-projection.js";
5
- import { blocksFromYValue } from "../page/ybind/decode.js";
6
- import { createYPageBinding } from "../page/ybind/binding.js";
7
- import { authorIdentity } from "../page/ybind/author.js";
8
- import { yValueOf } from "../page/ybind/provider.js";
9
- import { seedPageFromArticle } from "../page/ybind/migrate.js";
10
- import { serializeLive } from "../live/ops.js";
1
+ import { FEATURES, featureForType } from "../page/features/registry.js";
2
+ import { mergeEngine } from "../sync/merge-engine.js";
11
3
  import { registerUnsavedState } from "../lib/unsaved-state.js";
12
- import { hostMeta, hostSupports } from "../core/host-meta.js";
13
- import { savePageForce, setLastSavedBaselines, holdAllSaves, releaseAllSaves, getLastSavedBytes } from "../core/save.js";
14
- import { captureForComparisonAndDirty, captureForSaveAndComparison } from "../core/snapshot.js";
15
- import { storeOf, checkpointWithin, requestCheckpoint, fastLane, pageLaneReady, pageWriterReady } from "./live.js";
16
- import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
4
+ import { hostMeta } from "../core/host-meta.js";
5
+ import { savePageForce, holdAllSaves, releaseAllSaves, suspendAutosave, resumeAutosave, getLastSavedBytes } from "../core/save.js";
6
+ import { setSaveHeader } from "../core/save-core.js";
7
+ import { lastSeenEtag, representedEtag, recordEtag } from "../core/etag.js";
8
+ import { captureForSaveAndComparison } from "../core/snapshot.js";
9
+ import { storeOf, stageStore, closeStore, checkpointWithin, requestCheckpoint, fastLane, pageLaneReady, pageWriterReady, registerPageCheckpoints } from "./live.js";
10
+ import { serializeLive } from "../live/ops.js";
11
+ import { parseJsonRelaxed } from "../vendor/hyper-morph-json.vendor.js";
17
12
  import { saveStateFor, BLOCKED_TEXT, RETRY_TEXT, downloadPageCopy, downloadSnapshot, copyPageText } from "../page/save-states.js";
18
- import toast, { toastPersistent, dismissPersistent } from "../ui/toast.js";
19
- import { createRecoverList, focusBlock } from "../page/recover.js";
20
- import { createToolHost } from "../page/tools.js";
21
- import { createAiTool } from "../page/ai-tool.js";
22
- import { tablesFromDoc } from "../page/tool-data.js";
23
- import { capabilityAdvertised } from "../page/rich/page-tool.js";
13
+ import toast, { toastPersistent, dismissPersistent } from "../ui/toast-lazy.js";
14
+ import { claim } from "../page/runtime-owner.js";
15
+ import { authorIdentity } from "../page/ybind/author.js";
16
+ import { createInstall, disposeInstall, addDisposer, finishError, isCurrent, ownsView, runBoot, activateInput } from "../page/boot.js";
17
+ import { capabilityAdvertised } from "../page/tool-capability.js";
18
+ import { startsAtImport } from "../core/is-edit-mode.js";
19
+ import { toolViews } from "../page/tool-views.js";
20
+ import { style } from "../lib/hostile-css.js";
24
21
 
25
22
  const ARTICLE_SEL = "article[data-page]";
26
23
  const TAG_SEL = 'script[type="application/json"][merge="page"][clay~="live"]';
@@ -32,11 +29,91 @@ const REPLACED_TEXT = "This page was changed outside the editor. Reload to see i
32
29
  const TOO_LARGE_TEXT = "That change is too large to share, so it was not saved. Reload the page to keep editing.";
33
30
  const OVERWRITTEN_TEXT = "Some changes you made were replaced by a change made outside the editor.";
34
31
  const REPLACED_HOLD = "The page was changed outside the editor; reload to see it.";
32
+ const LOADING_MS = 150;
33
+ const LOADING_TEXT = "Loading the editor\u2026";
34
+ // The hold a managed session keeps between preparation and its commit: no save, no
35
+ // checkpoint and no live frame of this tab's reaches the file while the mode is still
36
+ // view. Released without replay, because a save missed here belongs to no session.
37
+ const SESSION_HOLD = "the page session has not committed yet";
35
38
 
36
39
  const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
37
40
 
41
+ // A tag that is not a page this release reads. It is decided from the JSON alone, so a
42
+ // page that cannot be booted says so without the page's own code.
43
+ function unreadablePage(value) {
44
+ if (!isPlainObject(value)) {
45
+ console.warn("clayjs: page plugin: the page tag is not JSON, leaving the page as saved HTML");
46
+ return "invalid-page-json";
47
+ }
48
+ if (value.v !== 1 || !isPlainObject(value.y) || Object.hasOwn(value, "blocks") || Object.hasOwn(value, "deleted")) {
49
+ console.warn("clayjs: page plugin: the page tag is not a strict Yjs page, leaving the page as saved HTML");
50
+ return "unsupported-page-format";
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // The page's own code reaches ProseMirror and Yjs, so it is a separate download: the
56
+ // plugin chunk evaluates without either vendor, and the room join starts while it
57
+ // arrives. Every step that needs it awaits the one boot-pack import, which carries the
58
+ // page code and the parts they share in one static graph, so a module they share is
59
+ // never linked by two imports at once; the pack starts the editor's own download
60
+ // beside it. A step that needs it awaits pageCodeReady(); the synchronous paths read
61
+ // pageCode, which the boot has awaited before the room's state reaches them.
62
+ let pageCode = null;
63
+ let pageCodeLoading = null;
64
+ function pageCodeReady() {
65
+ pageCodeLoading ??= import("../page/boot-pack.js").then((pack) => (pageCode = pack));
66
+ return pageCodeLoading;
67
+ }
68
+
69
+ // The parts a document asks for, read off the document itself: the state a view holds
70
+ // can have gained a block the saved article never had, from the room or from an
71
+ // adoption, and input waits for those parts as it does for the saved article's.
72
+ function docFeatureIds(doc) {
73
+ const ids = new Set();
74
+ doc.forEach((node) => {
75
+ const id = featureForType(node.type.name);
76
+ if (id) ids.add(id);
77
+ });
78
+ return [...ids];
79
+ }
80
+
81
+ // A wait longer than a moment says so. One quiet line inside the article, drawn as
82
+ // editor UI so it never reaches a snapshot, and gone the moment the boot settles.
83
+ function clearLoadingStatus(inst) {
84
+ if (inst.statusTimer) {
85
+ clearTimeout(inst.statusTimer);
86
+ inst.statusTimer = null;
87
+ }
88
+ inst.statusNote?.remove();
89
+ inst.statusNote = null;
90
+ }
91
+
92
+ function armLoadingStatus(inst, article) {
93
+ clearLoadingStatus(inst);
94
+ inst.statusTimer = setTimeout(() => {
95
+ inst.statusTimer = null;
96
+ if (!isCurrent(inst)) return;
97
+ const note = article.ownerDocument.createElement("div");
98
+ note.className = "page-boot-status";
99
+ note.setAttribute("clay", "editor-ui");
100
+ note.setAttribute("sap-ignore", "");
101
+ note.setAttribute("role", "status");
102
+ style(note, [
103
+ "display:block", "margin:0 0 1em", "font:500 13px/1.6 system-ui, sans-serif",
104
+ "color:color-mix(in srgb, currentColor 55%, transparent)", "user-select:none",
105
+ ]);
106
+ note.textContent = LOADING_TEXT;
107
+ article.prepend(note);
108
+ inst.statusNote = note;
109
+ }, LOADING_MS);
110
+ }
111
+
38
112
  let generation = 0;
39
113
  let active = null;
114
+ // A boot that ends in error says so after the installation it ended is gone, so the
115
+ // result outlives the dispose that releases what that boot started.
116
+ let ended = null;
40
117
 
41
118
  // Shorter than installAdoption's 10 s hold, so an unelected room still adopts.
42
119
  export const WRITER_WAIT_MS = 8_000;
@@ -52,10 +129,6 @@ function scrollToHashBlock(view, loc = globalThis.location) {
52
129
  globalThis.requestAnimationFrame?.(() => view.nodeDOM(found)?.scrollIntoView?.({ block: "start" }));
53
130
  }
54
131
 
55
- function abandon(inst) {
56
- if (active === inst) active = null;
57
- }
58
-
59
132
  function dispatchState(state, code, message) {
60
133
  document.dispatchEvent(new CustomEvent("clay:page-save-state", { detail: { state, code, message } }));
61
134
  }
@@ -122,8 +195,8 @@ function pageReplaced(html, savedRev, mergeTagless = false) {
122
195
  }
123
196
 
124
197
  function savedRevOf(inst) {
125
- if (!inst.store) return inst.savedRev;
126
- try { return renderFromTags([JSON.stringify(inst.store.value)]).rev; } catch { return inst.savedRev; }
198
+ if (!inst.store || !pageCode) return inst.savedRev;
199
+ try { return pageCode.renderFromTags([JSON.stringify(inst.store.value)]).rev; } catch { return inst.savedRev; }
127
200
  }
128
201
 
129
202
  // Nothing of this tab's may reach the file again: the editor is read-only, the
@@ -149,7 +222,7 @@ function downloadTabCopy(inst) {
149
222
  const doc = new DOMParser().parseFromString(forSave, "text/html");
150
223
  const articles = doc.querySelectorAll(ARTICLE_SEL);
151
224
  if (articles.length !== 1) return downloadPageCopy();
152
- const rendered = renderFromTags([JSON.stringify(inst.tabValue)]);
225
+ const rendered = pageCode.renderFromTags([JSON.stringify(inst.tabValue)]);
153
226
  articles[0].innerHTML = rendered.html;
154
227
  if (rendered.rev == null) articles[0].removeAttribute("data-rev");
155
228
  else articles[0].setAttribute("data-rev", rendered.rev);
@@ -185,6 +258,8 @@ function listen(inst, type, handler) {
185
258
  function listenForSaves(inst) {
186
259
  listen(inst, "clay:save-error", (event) => {
187
260
  const code = event.detail?.code ?? null;
261
+ // The host holds exactly what this tab would have written: nothing to report.
262
+ if (code === "page-nothing-to-write") return;
188
263
  applySaveState(inst, saveStateFor({ code }), code, event.detail?.msg ?? "");
189
264
  });
190
265
  listen(inst, "clay:save-offline", (event) => {
@@ -195,6 +270,9 @@ function listenForSaves(inst) {
195
270
  const code = event.detail?.code ?? null;
196
271
  applySaveState(inst, saveStateFor({ code }), code, event.detail?.msg ?? "");
197
272
  });
273
+ const onRefusedFirstWrite = (event) => { retryFirstWrite(inst, event); };
274
+ listen(inst, "clay:save-conflict", onRefusedFirstWrite);
275
+ listen(inst, "clay:save-error", onRefusedFirstWrite);
198
276
  listen(inst, "clay:save-saved", (event) => {
199
277
  applySaveState(inst, saveStateFor({ ok: true }), null, event.detail?.msg ?? "");
200
278
  inst.savedRev = savedRevOf(inst);
@@ -212,14 +290,56 @@ function listenForSaves(inst) {
212
290
  });
213
291
  }
214
292
 
293
+ // The page the host stores now, read once: the article a first write is offered to
294
+ // again, and the version it is offered against.
295
+ async function readStoredArticle() {
296
+ const response = await fetch(new URL(globalThis.location.href).href, { cache: "no-store" });
297
+ if (!response?.ok) return null;
298
+ const html = await response.text();
299
+ const doc = new DOMParser().parseFromString(html, "text/html");
300
+ const article = doc.querySelector(ARTICLE_SEL);
301
+ if (!article) return null;
302
+ return { article, etag: response.headers?.get?.("etag") || null };
303
+ }
304
+
305
+ // A refused first write: the stored document has moved on, or it does not descend
306
+ // from the seed this tab staged. The stored document is read once, the seed is rebuilt
307
+ // from its article, this tab's own pending edit replays over it, and the write goes
308
+ // out once more against the version just read. A second refusal keeps the text in the
309
+ // tab and shows the conflict, exactly as a lone refusal does.
310
+ async function retryFirstWrite(inst, event) {
311
+ const staged = inst.staged;
312
+ if (!staged || inst.staleRetry || inst.replaced) return;
313
+ const refusal = event?.detail?.code ?? null;
314
+ if (refusal !== "page-lineage-stale" && refusal !== "conflict") return;
315
+ if (!inst.store || !staged.isPending()) return;
316
+ inst.staleRetry = true;
317
+ try {
318
+ const found = await readStoredArticle();
319
+ if (!found || !isCurrent(inst)) return;
320
+ const page = await pageCodeReady();
321
+ if (!isCurrent(inst)) return;
322
+ const seed = page.seedPageFromArticle(found.article.cloneNode(true));
323
+ // This tab's next write is conditional on the version it has just read.
324
+ const at = found.etag ?? event?.detail?.etag ?? null;
325
+ if (at) recordEtag(at);
326
+ // The tab's own edit replays over the seed the stored article states; an op the
327
+ // stored article cannot hold is dropped by the store, never written over it.
328
+ inst.store.rebase(seed, { source: "state" });
329
+ await savePageForce();
330
+ } catch (error) {
331
+ console.warn("clayjs: page plugin could not offer the refused first write again", error);
332
+ }
333
+ }
334
+
215
335
  function restoreArticle(inst) {
216
336
  const article = inst.article;
217
337
  if (!article) return;
218
338
  let html = inst.bootHtml;
219
339
  let rev = inst.bootRev;
220
- if (inst.store) {
340
+ if (inst.store && pageCode) {
221
341
  try {
222
- const rendered = renderFromTags([JSON.stringify(inst.store.value)]);
342
+ const rendered = pageCode.renderFromTags([JSON.stringify(inst.store.value)]);
223
343
  html = rendered.html;
224
344
  rev = rendered.rev;
225
345
  } catch (error) {
@@ -231,306 +351,1045 @@ function restoreArticle(inst) {
231
351
  else article.setAttribute("data-rev", rev);
232
352
  }
233
353
 
354
+ // What a mounted editor installs besides its view: the document listeners that
355
+ // follow saves and replacements, the room's subscription to it, its unsaved
356
+ // participant, the recovery list, the tool views and this install's claim on the
357
+ // article. A boot that ends without an editor releases the same resources here, so
358
+ // nothing stays wired to a view that is gone.
359
+ function releaseMounted(inst) {
360
+ for (const [type, handler] of inst.listeners) document.removeEventListener(type, handler);
361
+ inst.listeners = [];
362
+ try { inst.adoption?.dispose(); } catch (error) { console.error("clayjs: page plugin adoption failed to dispose", error); }
363
+ inst.adoption = null;
364
+ try { inst.recover?.destroy(); } catch (error) { console.error("clayjs: page plugin recover list failed to dispose", error); }
365
+ inst.recover = null;
366
+ inst.aiTool?.dispose();
367
+ inst.aiTool = null;
368
+ inst.awareness?.dispose();
369
+ inst.awareness = null;
370
+ inst.toolHost?.dispose();
371
+ inst.toolHost = null;
372
+ if (inst.view) toolViews.delete(inst.view);
373
+ if (inst.article) {
374
+ const shell = inst.article.querySelector(".page-shell");
375
+ if (shell) shell.remove();
376
+ }
377
+ try { inst.binding?.dispose(); } catch (error) { console.error("clayjs: page plugin binding failed to dispose", error); }
378
+ try { inst.binding?.ydoc?.destroy(); } catch (error) { console.error("clayjs: page plugin document failed to destroy", error); }
379
+ try { inst.staged?.close(); } catch (error) { console.error("clayjs: page plugin staged store failed to close", error); }
380
+ inst.staged = null;
381
+ if (inst.pageStore) {
382
+ try { closeStore("page", inst.pageStore); } catch (error) { console.error("clayjs: page plugin store failed to close", error); }
383
+ inst.pageStore = null;
384
+ }
385
+ inst.binding = null;
386
+ inst.flush = null;
387
+ inst.releaseArticle?.();
388
+ inst.releaseArticle = null;
389
+ }
390
+
391
+ // Adoption did not land, so this tab must not hold a view of its own making: the
392
+ // mounted editor and everything it subscribed are released, its shell leaves the
393
+ // article, and the article comes back from the state the room accepted (or the
394
+ // source captured before the mount). The editor's own DOM is never read back into
395
+ // the page.
396
+ function abandonEditor(inst) {
397
+ const view = inst.view;
398
+ if (view && !view.isDestroyed && typeof view.destroy === "function") {
399
+ try { view.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
400
+ }
401
+ inst.titleObserver?.dispose();
402
+ inst.titleObserver = null;
403
+ clearLoadingStatus(inst);
404
+ releaseMounted(inst);
405
+ restoreArticle(inst);
406
+ }
407
+
234
408
  function dispose() {
235
409
  generation += 1;
236
410
  const inst = active;
237
411
  active = null;
412
+ setSaveHeader("Page-Write", null);
413
+ registerPageCheckpoints(null);
238
414
  if (!inst) return;
415
+ disposeInstall(inst);
239
416
  if (inst.retryTimer) {
240
417
  clearTimeout(inst.retryTimer);
241
418
  inst.retryTimer = null;
242
419
  }
243
- for (const [type, handler] of inst.listeners) document.removeEventListener(type, handler);
244
- inst.listeners = [];
245
420
  dismissPersistent(RETRY_TEXT);
246
421
  dismissPersistent(BLOCKED_TEXT);
247
422
  dismissPersistent(REPLACED_TEXT);
423
+ for (const message of inst.notices?.values() ?? []) dismissPersistent(message);
248
424
  if (inst.savesHeld) {
249
425
  inst.savesHeld = false;
250
426
  releaseAllSaves({ replay: false, reason: REPLACED_HOLD });
251
427
  }
252
- try { inst.adoption?.dispose(); } catch (error) { console.error("clayjs: page plugin adoption failed to dispose", error); }
253
- try { inst.recover?.destroy(); } catch (error) { console.error("clayjs: page plugin recover list failed to dispose", error); }
254
- try { inst.view?.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
255
- inst.aiTool?.dispose();
256
- inst.toolHost?.dispose();
257
- if (inst.article) {
258
- const shell = inst.article.querySelector(".page-shell");
259
- if (shell) shell.remove();
428
+ inst.titleObserver?.dispose();
429
+ inst.titleObserver = null;
430
+ clearLoadingStatus(inst);
431
+ // A session torn down from here rather than by its own lease gives back the holds
432
+ // it took, so nothing it closed stays closed.
433
+ if (inst.sessionHeld) releaseAllSaves({ replay: false, reason: SESSION_HOLD });
434
+ if (inst.autosaveSuspended) resumeAutosave();
435
+ if (inst.view && !inst.view.isDestroyed) {
436
+ try { inst.view.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
260
437
  }
261
- try { inst.binding?.dispose(); } catch (error) { console.error("clayjs: page plugin binding failed to dispose", error); }
262
- try { inst.binding?.ydoc?.destroy(); } catch (error) { console.error("clayjs: page plugin document failed to destroy", error); }
438
+ releaseMounted(inst);
263
439
  restoreArticle(inst);
264
440
  }
265
441
 
266
- async function run(inst, gen, options = {}) {
267
- const articles = document.querySelectorAll(ARTICLE_SEL);
268
- let tags = document.querySelectorAll(TAG_SEL);
269
- if (!articles.length && !tags.length) return abandon(inst);
270
- // A page saved without its merge state (HTML Clay, an outside edit, a download)
271
- // is booted from its article alone: the seed is deterministic, so every tab
272
- // rebuilding the same article holds the same state and their edits merge.
273
- const seeded = articles.length === 1 && tags.length === 0;
274
- if (seeded) {
275
- const saved = readFile(document);
276
- if (!saved) return abandon(inst);
277
- const tag = document.createElement("script");
278
- tag.setAttribute("type", "application/json");
279
- tag.setAttribute("merge", "page");
280
- tag.setAttribute("clay", "live");
281
- tag.textContent = serializeLive(seedPageFromArticle(saved.article));
282
- articles[0].before(tag);
283
- tags = document.querySelectorAll(TAG_SEL);
284
- }
285
- if (articles.length !== 1 || tags.length !== 1) {
286
- console.warn(`clayjs: page plugin needs exactly one article[data-page] and one merge="page" live tag; found ${articles.length} article(s) and ${tags.length} tag(s), leaving the page as saved HTML`);
287
- return abandon(inst);
288
- }
442
+ // The page's journal, its replay and the proof that retires it, all imported the first
443
+ // time a page can actually journal: a reader, a page without a pinned identity and a host
444
+ // with no recovery service pay for none of it.
445
+ let recoveryModules = null;
289
446
 
290
- const article = articles[0];
291
- const file = readFile(document);
292
- if (!file) return abandon(inst);
293
- let value = null;
294
- try { value = HyperMorph.parseJsonRelaxed(tags[0].textContent); } catch (error) { value = null; }
295
- if (!isPlainObject(value) || value.v !== 1 || !isPlainObject(value.y) || Object.hasOwn(value, "blocks") || Object.hasOwn(value, "deleted")) {
296
- console.warn("clayjs: page plugin: the page tag is not a strict Yjs page, leaving the page as saved HTML");
297
- return abandon(inst);
447
+ function pageRecovery() {
448
+ if (!recoveryModules) {
449
+ recoveryModules = Promise.all([import("../page/ybind/recovery-replay.js"), import("../page/checkpoint-proof.js")])
450
+ .then(([replay, proof]) => ({ replay, proof }))
451
+ .catch((error) => {
452
+ console.warn("clayjs: page journal could not load", error);
453
+ return null;
454
+ });
298
455
  }
456
+ return recoveryModules;
457
+ }
458
+
459
+ // What this session journals under, or null when this page cannot pin an identity or this
460
+ // host has no recovery service: the document is then edited with no local copy, as before.
461
+ async function openJournal(meta, store) {
462
+ const service = globalThis.window?.clay?.recovery ?? null;
463
+ if (!service || !store) return null;
464
+ const modules = await pageRecovery();
465
+ if (!modules) return null;
299
466
  try {
300
- blocksFromYValue(value);
467
+ return modules.replay.openPageJournal({
468
+ meta,
469
+ service,
470
+ base: modules.replay.journalBase(store.value),
471
+ });
301
472
  } catch (error) {
302
- console.warn("clayjs: page plugin: the page tag is not a valid Yjs page, leaving the page as saved HTML", error);
303
- return abandon(inst);
473
+ console.warn("clayjs: page journal could not open", error);
474
+ return null;
304
475
  }
305
- const source = { article: file.article, tags: [value] };
476
+ }
477
+
478
+ // Replay once, after the authority snapshot is adopted and before input starts. A capsule
479
+ // this page may not replay is kept and named for review; nothing of it is applied.
480
+ async function replayJournal(inst) {
481
+ const journal = inst.binding?.journal;
482
+ if (!journal || !inst.store) return { replayed: [], review: [] };
483
+ const modules = await pageRecovery();
484
+ if (!modules) return { replayed: [], review: [] };
485
+ const outcome = await modules.replay.replayInto({ journal, state: inst.store.value, into: inst.binding.ydoc });
486
+ // The recovered page is this tab's page now: it is offered to the room exactly as an
487
+ // edit would be, and it is not one more journal and not one giant undo step.
488
+ if (outcome.replayed.length) inst.binding.provider.settle();
489
+ if (outcome.review.length) inst.recoveryReview = outcome.review;
490
+ return outcome;
491
+ }
492
+
493
+ // One accepted save retires the records a proof covers, and no others.
494
+ function installCheckpointProof(inst) {
495
+ registerPageCheckpoints(async ({ capture, state, article, etag }) => {
496
+ const journal = inst.binding?.journal;
497
+ if (!journal) return;
498
+ const modules = await pageRecovery();
499
+ const context = modules ? await journal.context() : null;
500
+ if (!context) return;
501
+ // Where this host serves the page state, if it serves one at all: asked here, because
502
+ // a page that journals nothing never needs the answer.
503
+ const { hostPageStateHref } = await import("../core/host-meta.js");
504
+ const answer = await modules.proof.checkpointAccepted({
505
+ channel: journal.channel,
506
+ records: journal.records(),
507
+ capture,
508
+ accepted: context,
509
+ article,
510
+ state,
511
+ etag,
512
+ href: await hostPageStateHref(),
513
+ });
514
+ if (answer.ok) journal.retire(answer.retired);
515
+ });
516
+ }
306
517
 
518
+ export function createSteps(inst, options) {
307
519
  let meta = null;
308
- try { meta = await hostMeta(); } catch (error) { meta = null; }
309
- if (gen !== generation) return;
310
- if (!meta || !Array.isArray(meta.extensions) || !meta.extensions.includes("page")) {
311
- showBlocked(inst, BLOCKED.code, BLOCKED.message);
312
- return;
313
- }
520
+ let binding = null;
521
+ let editorView = null;
522
+ let editorReadyResolve = null;
523
+ let identity = null;
524
+ let features = null;
525
+ let featuresReady = null;
526
+ let savedContent = [];
527
+ let toolsStore = null;
528
+ let aiTool = null;
529
+ let toolsOpening = null;
530
+ let chrome = null;
531
+ let setEditorActive = null;
532
+
533
+ // The view parts install onto the editor, which exists only after the mount; an
534
+ // install that starts before it waits here, and a disposed install releases it.
535
+ const editorReady = new Promise((resolve) => { editorReadyResolve = resolve; });
536
+ inst.editorReadyResolve = editorReadyResolve;
537
+ addDisposer(inst, () => editorReadyResolve?.());
538
+ addDisposer(inst, () => clearLoadingStatus(inst));
314
539
 
315
- // The room was opened from a document that had no page tag, so it holds no page
316
- // store and would answer this tab's frames with unknown-store. One conditional
317
- // save of the document with the tag is the page's initial write; a tab that
318
- // loses that race reloads, which composes the state the winner initialized.
319
- inst.mergeTagless = !meta.extensions.includes("live-data");
320
- let initialSave = false;
321
- if (seeded && (await hostSupports("live-data"))) {
322
- if (gen !== generation) return;
323
- const result = await savePageForce();
324
- if (gen !== generation) return;
325
- if (!result?.ok) {
326
- if (result?.status === 412) {
327
- window.location.reload();
328
- return;
540
+ // The format gate, fetched when a newer-format command first asks for it: neither
541
+ // the gate nor its host adapter is on the editor's ready path. One gate per session,
542
+ // built against this editor installation, and disposed with the editor.
543
+ let formatGate = null;
544
+ let formatGateLoading = null;
545
+ inst.loadFormatGate = () => {
546
+ if (formatGate) return Promise.resolve(formatGate);
547
+ formatGateLoading ??= Promise.all([import("../page/format-gate.js"), import("../page/format-host.js")])
548
+ .then(([{ createFormatGate }, { createFormatHost }]) => {
549
+ const host = createFormatHost();
550
+ formatGate = createFormatGate({
551
+ host,
552
+ supported: host.supported,
553
+ installed: () => identity?.token ?? null,
554
+ revision: () => null,
555
+ });
556
+ return formatGate;
557
+ })
558
+ .catch((error) => {
559
+ formatGateLoading = null;
560
+ throw error;
561
+ });
562
+ return formatGateLoading;
563
+ };
564
+
565
+ // A document with a tool block, at boot or when one arrives, opens the store its
566
+ // tools keep state in and the host that mounts them. A document with no tool
567
+ // never waits for either.
568
+ const TOOLS_SEL = 'script[type="application/json"][merge="page.tools"][clay~="live"]';
569
+ const openTools = () => {
570
+ if (toolsOpening) return toolsOpening;
571
+ toolsOpening = (async () => {
572
+ // A tool written into the article by hand or by an agent arrives without the
573
+ // store its state lives in, and without one the tool could not keep its state:
574
+ // the page gets the empty store an editor-made page starts with.
575
+ if (!document.querySelector(TOOLS_SEL)) {
576
+ const tools = document.createElement("script");
577
+ tools.setAttribute("type", "application/json");
578
+ tools.setAttribute("merge", "page.tools");
579
+ tools.setAttribute("clay", "live");
580
+ tools.textContent = "{}";
581
+ document.head.appendChild(tools);
329
582
  }
330
- showBlocked(inst, result?.code ?? null, result?.msg ?? "");
331
- return;
583
+ if (document.querySelector(TOOLS_SEL)) {
584
+ try {
585
+ toolsStore = window.clay.live("page.tools");
586
+ await toolsStore.ready;
587
+ } catch (error) {
588
+ console.warn("clayjs: page plugin could not open the tool store", error);
589
+ }
590
+ }
591
+ if (!isCurrent(inst)) return null;
592
+ const [{ createToolHost }, { tablesFromDoc }] = await Promise.all([
593
+ import("../page/tools.js"),
594
+ import("../page/tool-data.js"),
595
+ ]);
596
+ if (!isCurrent(inst)) return null;
597
+ const toolHost = createToolHost({
598
+ store: toolsStore, mode: "edit",
599
+ tables: () => tablesFromDoc(editorView?.state.doc ?? binding.doc()).map(({ id, cols, rows }) => ({ id, cols, rows })),
600
+ });
601
+ inst.toolHost = toolHost;
602
+ if (capabilityAdvertised(meta)) {
603
+ const { createAiTool } = await import("../page/ai-tool.js");
604
+ if (!isCurrent(inst)) return null;
605
+ const transport = window.clay?.wire?.pageTool ?? options.transport ?? null;
606
+ aiTool = createAiTool({ getView: () => inst.view, store: toolsStore, transport, acceptState: options.acceptState });
607
+ inst.aiTool = aiTool;
608
+ }
609
+ return toolHost;
610
+ })().catch((error) => {
611
+ toolsOpening = null;
612
+ throw error;
613
+ });
614
+ return toolsOpening;
615
+ };
616
+ // One notice per feature a person needed and that failed, until Retry succeeds.
617
+ const notices = new Map();
618
+ const notifyFailed = (id) => {
619
+ if (notices.has(id)) return;
620
+ const message = `The ${id} controls could not load. Reload the page after saving.`;
621
+ notices.set(id, message);
622
+ toastPersistent(message, "warning", {
623
+ actions: [{
624
+ label: "Retry",
625
+ onClick: () => {
626
+ features.ensure(id).then(() => {
627
+ const open = notices.get(id);
628
+ if (!open) return;
629
+ notices.delete(id);
630
+ dismissPersistent(open);
631
+ }, () => {});
632
+ },
633
+ }],
634
+ });
635
+ };
636
+ inst.notices = notices;
637
+ // Making or revising a tool opens the store and the host first, so the panel
638
+ // keeps what it makes.
639
+ const openAi = async ({ target = null, remove = null } = {}) => {
640
+ if (chrome && editorView && remove) chrome.holdSlashRange(editorView, remove);
641
+ await features.ensure("tool").catch(() => {});
642
+ if (!isCurrent(inst) || !aiTool || !editorView) return;
643
+ aiTool.open({ target, remove: remove ? chrome.heldSlashRange(editorView.state) : null });
644
+ };
645
+ const aiRequested = () => capabilityAdvertised(meta);
646
+
647
+ // The article, its page tag and the file they describe, or the reason the page
648
+ // is not edit mode's to mount. The file it found is captured here, before anything
649
+ // moves; what the tag decodes to is the page's own code's to say, so it arrives in
650
+ // source.ready while the room joins, and the join never waits for it.
651
+ function readSource(inst) {
652
+ const liveArticles = document.querySelectorAll(ARTICLE_SEL);
653
+ // The file the reader represents, when there is one, states the page this session
654
+ // edits; the live DOM is only the page an explicit edit boot found.
655
+ const represented = typeof inst.context?.represented?.bytes === "string"
656
+ ? new DOMParser().parseFromString(inst.context.represented.bytes, "text/html")
657
+ : null;
658
+ const articles = represented ? represented.querySelectorAll(ARTICLE_SEL) : liveArticles;
659
+ const tags = represented ? represented.querySelectorAll(TAG_SEL) : document.querySelectorAll(TAG_SEL);
660
+ if (!articles.length && !tags.length) return { ok: false, reason: "no-page" };
661
+ // A page saved without its merge state (HTML Clay, an outside edit, a download)
662
+ // is booted from its article alone: the seed is deterministic, so every tab
663
+ // rebuilding the same article holds the same state and their edits merge. The tag
664
+ // that states it is built here but reaches the document only for the first save
665
+ // that writes it, so a boot that ends before that save leaves no tag behind for
666
+ // the next install to read as a page the file already has.
667
+ const seeded = articles.length === 1 && tags.length === 0;
668
+ if (articles.length !== 1 || (!seeded && tags.length !== 1)) {
669
+ console.warn(`clayjs: page plugin needs exactly one article[data-page] and one merge="page" live tag; found ${articles.length} article(s) and ${tags.length} tag(s), leaving the page as saved HTML`);
670
+ return { ok: false, reason: "ambiguous-page" };
671
+ }
672
+ const element = liveArticles[0];
673
+ if (!element) return { ok: false, reason: "no-page" };
674
+ // The file the reader represented is the page this session edits: its tag and the
675
+ // article the seed is built from come off those bytes once, so a tag another tab
676
+ // changed and a reader's own local state cannot reach what is staged. The live
677
+ // article stays the node the editor mounts over.
678
+ const seedNode = articles[0];
679
+ const tagNode = tags[0];
680
+ // A tag this tab can read on its own is read here, so a page it cannot boot says so
681
+ // before the room is opened.
682
+ let value = null;
683
+ if (!seeded) {
684
+ try { value = parseJsonRelaxed(tagNode?.textContent); } catch (error) { value = null; }
685
+ const unreadable = unreadablePage(value);
686
+ if (unreadable) return { ok: false, reason: unreadable };
332
687
  }
333
- initialSave = true;
688
+ const source = { article: seedNode.cloneNode(true), tags: value ? [value] : null, element, blocks: null, seeded, seedValue: null };
689
+ source.ready = (async () => {
690
+ const code = await pageCodeReady();
691
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
692
+ if (seeded) {
693
+ // The seed stays a value in memory: no tag and no write until someone edits.
694
+ try {
695
+ value = source.seedValue = code.seedPageFromArticle(source.article.cloneNode(true));
696
+ } catch (error) {
697
+ console.warn("clayjs: page plugin could not rebuild the page it found, leaving the page as saved HTML", error);
698
+ return { ok: false, reason: "invalid-page-source" };
699
+ }
700
+ const unreadable = unreadablePage(value);
701
+ if (unreadable) return { ok: false, reason: unreadable };
702
+ source.tags = [value];
703
+ }
704
+ try {
705
+ source.blocks = code.blocksFromYValue(value);
706
+ } catch (error) {
707
+ console.warn("clayjs: page plugin: the page tag is not a valid Yjs page, leaving the page as saved HTML", error);
708
+ return { ok: false, reason: "invalid-page-json" };
709
+ }
710
+ return { ok: true };
711
+ })();
712
+ armLoadingStatus(inst, element);
713
+ return { ok: true, source };
334
714
  }
335
715
 
336
- let handle;
716
+ // The page store the session mounts, brought to the file the reader holds. A page store's
717
+ // value is a set of Yjs updates and this tab's own unsaved ops are updates of their own, so
718
+ // installing the file's value as the store's own leaves the unconfirmed work replayed over
719
+ // the page the file states; the binding that mounts next applies both sets in one Y
720
+ // document, which is the merge. False when the store cannot take the value, and the entry
721
+ // refuses rather than mounting a page the file does not state.
722
+ function mergeRepresentedPage(store, value) {
723
+ if (unreadablePage(value)) return false;
724
+ if (serializeLive(store.authoritative) === serializeLive(value)) return true;
337
725
  try {
338
- handle = window.clay.live("page");
726
+ return store.rebase(value, { source: "state" });
339
727
  } catch (error) {
340
- console.warn("clayjs: page plugin could not open the page store", error);
341
- return abandon(inst);
728
+ console.warn("clayjs: page plugin could not reconcile the open page store with the saved file", error);
729
+ return false;
342
730
  }
343
- await handle.ready;
344
- if (gen !== generation) return;
345
- const store = storeOf("page");
346
- if (!store) return abandon(inst);
347
- inst.store = store;
348
-
349
- let flushFailed = false;
350
- let registered = null;
351
- const trackFlush = async (call) => {
352
- const result = await call();
353
- flushFailed = result?.ok === false;
354
- registered?.changed();
355
- return result;
356
- };
357
- const identity = authorIdentity();
358
- const binding = createYPageBinding({
359
- store,
360
- onStopped: (reason) => {
361
- inst.tabValue = yValueOf(binding.ydoc);
362
- if (reason === "lineage") {
363
- fastLane()?.abandon?.("page");
364
- showReplaced(inst);
731
+ }
732
+
733
+ // The page's own code, the editor it starts and the feature views the saved article
734
+ // demands are separate downloads, and the page's own starts here beside the room join.
735
+ // Nothing evaluates the editor until the coordinator calls it; the parts are
736
+ // installed at the mount, so a failed one is the loader's own Retry notice there.
737
+ async function loadEditorAssets(inst, source) {
738
+ const codeImport = pageCodeReady();
739
+ codeImport.catch(() => null);
740
+ const read = await source.ready;
741
+ if (!read.ok) return read;
742
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
743
+ const code = await codeImport;
744
+ const featureIds = [...new Set(Object.values(source.blocks).map((block) => featureForType(block.t)).filter(Boolean))];
745
+ features = code.createFeatureLoader({
746
+ load: (id) => FEATURES[id].load(),
747
+ install: async (id, mod) => {
748
+ if (!editorView) await editorReady;
749
+ if (!isCurrent(inst) || !editorView) return null;
750
+ const context = { view: editorView, upload: options.upload, board: binding.board, undo: binding.undo, redo: binding.redo, revise: aiRequested() ? (toolId, rev) => { openAi({ target: { toolId, rev } }); } : null };
751
+ if (id === "tool") {
752
+ context.toolHost = await openTools();
753
+ if (!isCurrent(inst)) return null;
754
+ }
755
+ return editor.installPageFeature(context, id, mod);
756
+ },
757
+ reportError: (error, id) => {
758
+ console.error("clayjs: an editor feature failed to load", error);
759
+ notifyFailed(id);
760
+ },
761
+ });
762
+ // The fetch only warms the module: the install waits for the mounted view, and the
763
+ // boot waits for that install before it enables input. A failure here is reported
764
+ // by that install, which offers the feature's Retry.
765
+ for (const id of featureIds) features.prepare(id).catch(() => null);
766
+ let editor = null;
767
+ try {
768
+ editor = await import("../page/editor.js");
769
+ setEditorActive = editor.setEditorActive;
770
+ chrome = { holdSlashRange: code.holdSlashRange, heldSlashRange: code.heldSlashRange };
771
+ } catch (error) {
772
+ // A host that blocked the download keeps the saved article and reads this reason.
773
+ console.error("clayjs: page plugin could not load the editor", error);
774
+ return { ok: false, reason: "editor-assets-failed" };
775
+ }
776
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
777
+ return { ok: true, editor: editor.createPageEditor, refreshView: editor.refreshMountedView, featureIds, code };
778
+ }
779
+
780
+ async function prepareAuthority(inst, source) {
781
+ // The page's own code, with the editor it starts and the parts they share, is one
782
+ // download: it starts here, beside authority, and only a page that must rebuild
783
+ // its tag waits for it before the join. Nothing else on this path touches a vendor.
784
+ const codePromise = pageCodeReady();
785
+ try { meta = await hostMeta(); } catch (error) { meta = null; }
786
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
787
+ if (!meta || !Array.isArray(meta.extensions) || !meta.extensions.includes("page")) {
788
+ showBlocked(inst, BLOCKED.code, BLOCKED.message);
789
+ return { ok: false, reason: "missing-capability" };
790
+ }
791
+
792
+ // A host that keeps no page data keeps its files untagged, and every one of them
793
+ // is merged by the boot instead of being read as a replacement.
794
+ inst.mergeTagless = !meta.extensions.includes("live-data");
795
+ // The one declaration that lets a host answer a seed-only body with nothing-to-write.
796
+ setSaveHeader("Page-Write", "v1");
797
+ // A seeded page is the one boot whose join needs the page code, which rebuilds
798
+ // the seed the article states.
799
+ let read = null;
800
+ let code = null;
801
+ if (source.seeded) {
802
+ read = await source.ready;
803
+ if (!read.ok) return read;
804
+ code = await codePromise;
805
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
806
+ }
807
+ // A tagless document has no page store for a room to hold, so its own is staged:
808
+ // the seed in memory and no write until a person edits. A tagged document opens
809
+ // the state the file already has.
810
+ let handle;
811
+ // Whether this session's own value is the store's: only a store the application
812
+ // opened itself keeps a page of its own.
813
+ let ownsStore = true;
814
+ try {
815
+ if (source.seeded) {
816
+ const staged = stageStore("page", {
817
+ value: source.seedValue,
818
+ represented: options.represented ?? { bytes: source.article.outerHTML, etag: representedEtag() ?? lastSeenEtag() },
819
+ });
820
+ inst.staged = staged;
821
+ handle = staged.handle;
365
822
  } else {
366
- showTooLarge(inst);
823
+ const opened = storeOf("page") === null;
824
+ ownsStore = opened;
825
+ handle = window.clay.live("page");
826
+ if (opened) inst.pageStore = storeOf("page");
367
827
  }
368
- },
369
- authorToken: identity.token,
370
- ownedTokens: identity.owned,
371
- checkpointWithin,
372
- requestCheckpoint,
373
- registerUnsavedState: (def) => {
374
- registered = registerUnsavedState({
375
- id: def.id,
376
- isPending: () => flushFailed || def.isPending(),
377
- flush: () => trackFlush(() => def.flush()),
378
- });
379
- return registered;
380
- },
381
- });
382
- inst.binding = binding;
383
- inst.flush = (options) => trackFlush(() => binding.flush(options));
384
- listenForSaves(inst);
385
-
386
- inst.source = source;
387
- inst.article = article;
388
- inst.bootHtml = source.article.innerHTML;
389
- inst.bootRev = source.article.getAttribute("data-rev");
390
- inst.savedRev = inst.bootRev;
391
- listen(inst, "clay:sync-applied", (event) => {
392
- if (inst.replaced) return;
393
- if (!pageReplaced(event.detail?.html, inst.savedRev, inst.mergeTagless)) return;
394
- showReplaced(inst);
395
- });
828
+ } catch (error) {
829
+ console.warn("clayjs: page plugin could not open the page store", error);
830
+ return { ok: false, reason: "missing-page-store" };
831
+ }
832
+ try {
833
+ await handle.ready;
834
+ } catch (error) {
835
+ console.warn("clayjs: page plugin could not join the page room", error);
836
+ return { ok: false, reason: "admission-failed" };
837
+ }
838
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
839
+ // A tag the page's own code cannot decode is the boot's reason to stop, and both
840
+ // branches report the same one, so whichever settles first does not change it.
841
+ read ??= await source.ready;
842
+ if (!read.ok) return read;
843
+ // A store the application opened itself keeps the page it was opened from, and the
844
+ // file the reader holds is the page this session edits: the store is brought to that
845
+ // file before anything mounts on it, and a store that cannot take it refuses the
846
+ // entry rather than mounting a page the file does not state. A store that has joined
847
+ // a room is the exception: the room is newer than any file, and its value stands.
848
+ const inRoom = storeOf("page")?.handle?.status?.mode === "fast";
849
+ if (!ownsStore && !inRoom && !mergeRepresentedPage(storeOf("page"), source.tags?.[0])) {
850
+ return { ok: false, reason: "stale-store" };
851
+ }
852
+ code ??= await codePromise;
853
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
854
+ const store = storeOf("page");
855
+ if (!store) return { ok: false, reason: "missing-page-store" };
856
+ inst.store = store;
396
857
 
397
- // A tool written into the article by hand or by an agent arrives without the
398
- // store its state lives in, and without one the tool could not keep its state:
399
- // the page gets the empty store an editor-made page starts with.
400
- const TOOLS_SEL = 'script[type="application/json"][merge="page.tools"][clay~="live"]';
401
- if (!document.querySelector(TOOLS_SEL) && article.querySelector('section[data-type="tool"]')) {
402
- const tools = document.createElement("script");
403
- tools.setAttribute("type", "application/json");
404
- tools.setAttribute("merge", "page.tools");
405
- tools.setAttribute("clay", "live");
406
- tools.textContent = "{}";
407
- document.head.appendChild(tools);
858
+ let flushFailed = false;
859
+ let registered = null;
860
+ const trackFlush = async (call) => {
861
+ const result = await call();
862
+ flushFailed = result?.ok === false;
863
+ registered?.changed();
864
+ return result;
865
+ };
866
+ identity = authorIdentity();
867
+ // The journal is installed before the editor is enabled, and whether or not this tab
868
+ // can reach the room: a refused offer, a lost room and an oversize update all leave
869
+ // the work in a document that a crash must still be able to recover.
870
+ const journal = await openJournal(meta, store);
871
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
872
+ binding = code.createYPageBinding({
873
+ store,
874
+ onStopped: (reason) => {
875
+ inst.tabValue = code.yValueOf(binding.ydoc);
876
+ if (reason === "lineage") {
877
+ fastLane()?.abandon?.("page");
878
+ showReplaced(inst);
879
+ } else {
880
+ showTooLarge(inst);
881
+ }
882
+ },
883
+ authorToken: identity.token,
884
+ ownedTokens: identity.owned,
885
+ checkpointWithin,
886
+ requestCheckpoint,
887
+ journal,
888
+ registerUnsavedState: (def) => {
889
+ registered = registerUnsavedState({
890
+ id: def.id,
891
+ isPending: () => flushFailed || def.isPending(),
892
+ flush: () => trackFlush(() => def.flush()),
893
+ });
894
+ return registered;
895
+ },
896
+ });
897
+ inst.binding = binding;
898
+ if (journal) installCheckpointProof(inst);
899
+ inst.flush = (options) => trackFlush(() => binding.flush(options));
900
+ listenForSaves(inst);
901
+ return { ok: true, store, handle };
408
902
  }
409
- let toolsStore = null;
410
- if (document.querySelector(TOOLS_SEL)) {
903
+
904
+ // Other people's carets arrive on their own: the decorations load only where a
905
+ // host offers page awareness, after the editor is mounted, so neither a reader nor
906
+ // an editor on a host without presence pays for them.
907
+ function installAwareness(inst, view) {
908
+ import("../page/awareness.js").then(async (mod) => {
909
+ if (inst.view !== view) return;
910
+ const handle = await mod.installPageAwareness({ binding: inst.binding, view });
911
+ if (!handle) return;
912
+ if (inst.view !== view || view.isDestroyed) { handle.dispose(); return; }
913
+ inst.awareness = handle;
914
+ }).catch((error) => console.error("clayjs: page plugin remote carets failed to load", error));
915
+ }
916
+
917
+ function mountAndAttach(inst, source, loaded) {
918
+ const article = source.element;
919
+ // The reader that showed this article gives its claim up here and keeps its queue,
920
+ // so a preparation that fails after this point hands the article back.
921
+ inst.context?.reader?.yieldArticle?.();
922
+ // The claim comes before this install records the article as its own to
923
+ // restore: a claim another runtime holds is refused, and neither the owner's
924
+ // article nor this install's restoration ownership may change.
411
925
  try {
412
- toolsStore = window.clay.live("page.tools");
413
- await toolsStore.ready;
926
+ inst.releaseArticle = claim(article, "editor");
414
927
  } catch (error) {
415
- console.warn("clayjs: page plugin could not open the tool store", error);
928
+ console.error("clayjs: page plugin could not claim the article", error);
929
+ return { ok: false, reason: "article-owned" };
416
930
  }
931
+ inst.source = source;
932
+ inst.article = article;
933
+ inst.bootHtml = source.article.innerHTML;
934
+ inst.bootRev = source.article.getAttribute("data-rev");
935
+ inst.savedRev = inst.bootRev;
936
+ listen(inst, "clay:sync-applied", (event) => {
937
+ if (inst.replaced) return;
938
+ if (!pageReplaced(event.detail?.html, inst.savedRev, inst.mergeTagless)) return;
939
+ showReplaced(inst);
940
+ });
941
+
942
+ // A page with blocks whose view parts are still coming keeps its saved article on
943
+ // screen, and the editor out of sight, until they are here. One with none of those
944
+ // blocks has nothing to wait for, so its saved article goes now. Either way it is
945
+ // this capture, taken before anything changes, that puts the article back.
946
+ savedContent = [...article.childNodes].filter((node) => node !== inst.statusNote);
947
+ // The shell is built out of the page, in an element of the same document, so a
948
+ // creation that fails never leaves half an editor over the saved article.
949
+ const staging = article.ownerDocument.createElement("div");
950
+ let view = null;
951
+ try {
952
+ view = loaded.editor(staging, {
953
+ doc: binding.doc(),
954
+ dispatch: binding.dispatch,
955
+ history: false,
956
+ undo: binding.undo,
957
+ redo: binding.redo,
958
+ board: binding.board,
959
+ editable: false,
960
+ features,
961
+ onAiTool: aiRequested() ? (_view, remove) => { openAi({ remove }); } : null,
962
+ reviseTool: aiRequested() ? (toolId, rev) => { openAi({ target: { toolId, rev } }); } : null,
963
+ formatGate: inst.loadFormatGate,
964
+ });
965
+ } catch (error) {
966
+ console.error("clayjs: page plugin could not mount the editor", error);
967
+ staging.replaceChildren();
968
+ inst.releaseArticle?.();
969
+ inst.releaseArticle = null;
970
+ inst.article = null;
971
+ return { ok: false, reason: "mount-failed" };
972
+ }
973
+ editorView = view;
974
+ inst.view = view;
975
+ const shell = view.dom.closest(".page-shell");
976
+ // A shell still waiting for its boot takes no input of any kind: a control inside
977
+ // a non-editable editor would otherwise write before the boot says ready. It is
978
+ // cleared at the one place input is enabled.
979
+ if (shell) shell.inert = true;
980
+ if (loaded.featureIds.length && shell) shell.style.visibility = "hidden";
981
+ // One synchronous move: the finished shell replaces the saved surface as a
982
+ // single change, and the article element itself stays where it is.
983
+ if (!loaded.featureIds.length) for (const node of savedContent) node.remove();
984
+ if (shell) article.appendChild(shell);
985
+ loaded.refreshView?.(view);
986
+ binding.attach(view);
987
+ installAwareness(inst, view);
988
+ editorReadyResolve();
989
+ // The parts the saved article demands install onto the mounted view now, whether
990
+ // their fetch already landed or is still on the wire; the saved article stays and
991
+ // input stays off until they are in.
992
+ featuresReady = Promise.allSettled(loaded.featureIds.map((id) => features.ensure(id)));
993
+ scrollToHashBlock(view);
994
+ inst.recover = loaded.code.createRecoverList({
995
+ binding,
996
+ mount: view.dom.closest(".page-shell") ?? article,
997
+ ownTokens: [identity.token, ...identity.owned],
998
+ onRestored: (id) => loaded.code.focusBlock(view, id),
999
+ onStale: () => toast("That change was updated. The list now shows the latest version.", "warning"),
1000
+ });
1001
+ inst.titleObserver = loaded.code.observeDocumentTitle(view);
1002
+ return { ok: true, view, featureIds: loaded.featureIds };
417
1003
  }
418
- if (gen !== generation) return;
419
- let editorView = null;
420
- const toolHost = createToolHost({
421
- store: toolsStore, mode: "edit",
422
- tables: () => tablesFromDoc(editorView?.state.doc ?? binding.doc()).map(({ id, cols, rows }) => ({ id, cols, rows })),
423
- });
424
- inst.toolHost = toolHost;
425
- const transport = window.clay?.wire?.pageTool ?? options.transport ?? null;
426
- if (capabilityAdvertised(meta)) {
427
- inst.aiTool = createAiTool({ getView: () => inst.view, store: toolsStore, transport, acceptState: options.acceptState });
428
- }
429
- article.innerHTML = "";
430
- const view = createPageEditor(article, {
431
- doc: binding.doc(),
432
- dispatch: binding.dispatch,
433
- history: false,
434
- undo: binding.undo,
435
- redo: binding.redo,
436
- board: binding.board,
437
- editable: false,
438
- toolHost,
439
- onAiTool: inst.aiTool ? (_view, remove) => inst.aiTool.open({ remove }) : null,
440
- reviseTool: inst.aiTool ? (toolId, rev) => inst.aiTool.open({ target: { toolId, rev } }) : null,
441
- });
442
- editorView = view;
443
- inst.view = view;
444
- binding.attach(view);
445
- scrollToHashBlock(view);
446
- inst.recover = createRecoverList({
447
- binding,
448
- mount: view.dom.closest(".page-shell") ?? article,
449
- ownTokens: [identity.token, ...identity.owned],
450
- onRestored: (id) => focusBlock(view, id),
451
- onStale: () => toast("That change was updated. The list now shows the latest version.", "warning"),
452
- });
453
1004
 
454
- // A hand-edited article is adopted by the room's elected writer only. With a
455
- // lane but no writer yet, this tab claims the room (a redundant set of an
456
- // existing Yjs update: a valid nonempty frame that changes nothing) and then
457
- // waits for the election, so exactly one tab adopts.
458
- const needsAdoption = classifyFile(source.article, source.tags).kind === "adopt";
459
- const awaitWriter = async () => {
460
- const lane = await pageLaneReady();
461
- if (!lane) return;
462
- if (gen !== generation) return;
463
- if (lane.status.writer === null) {
464
- const y = store.value.y;
465
- const keys = Object.keys(y);
466
- const key = keys.length ? keys[0] : `claim-${binding.ydoc.clientID.toString(36)}`;
467
- const value = keys.length ? y[keys[0]] : yValueOf(binding.ydoc).y.seed;
468
- store.handle.set(["y", key], value);
1005
+ // Whether the room's page state already carries the hand edit: merging the article's
1006
+ // changes into the room's blocks changes nothing. It is the comparison a checkpoint
1007
+ // makes before it decides the room holds an edit, run against the room's state
1008
+ // instead of the saved tag, so a room a peer changed after the adoption holds the
1009
+ // edit too.
1010
+ async function roomHoldsArticle(inst, found) {
1011
+ try {
1012
+ const code = await pageCodeReady();
1013
+ const { HyperMorph } = await mergeEngine();
1014
+ const blocks = code.blocksFromYValue(inst.store.authoritative);
1015
+ const current = code.readBack(blocks, document);
1016
+ const merged = code.mergePageModels(found.baseItems, current, found.parsed, {
1017
+ doc: document,
1018
+ merge3: HyperMorph.merge3,
1019
+ mergeJson: HyperMorph.mergeJson,
1020
+ });
1021
+ return code.canon(merged.items) === code.canon(current);
1022
+ } catch {
1023
+ return false;
469
1024
  }
470
- // A lane that never reports a writer must not cost the hand edit: give up
471
- // before adoption's own bound, and adopt as the file-mode tab would.
472
- let timer;
473
- await Promise.race([pageWriterReady(), new Promise((resolve) => { timer = setTimeout(resolve, WRITER_WAIT_MS); })]);
474
- clearTimeout(timer);
475
- };
476
- const ready = needsAdoption ? awaitWriter() : Promise.resolve();
477
-
478
- const adoption = installAdoption({
479
- view,
480
- source,
481
- ready,
482
- isAdopter: () => {
483
- const lane = fastLane();
484
- return !lane || lane.status.writer === null ? true : lane.isWriter();
485
- },
486
- requestCheckpoint: () => checkpointWithin(0),
487
- snapshot: () => captureForSaveAndComparison().forSave,
488
- mergeTagless: inst.mergeTagless,
489
- writerReady: awaitWriter,
490
- adopted: (rev) => fastLane()?.adopted?.("page", rev, store.mark),
491
- notice: (counts) => {
492
- if (!counts.copy) return;
493
- toastPersistent(OVERWRITTEN_TEXT, "warning", {
494
- actions: [{ label: "Download your copy", onClick: () => downloadSnapshot(counts.copy) }],
1025
+ }
1026
+
1027
+ // A tab that is not the room's writer does not adopt: the elected writer lands the
1028
+ // hand edit, and it reaches this tab as a page update through the room, which the
1029
+ // store hands to this tab's binding. A room that already holds the edit has nothing
1030
+ // to wait for. Otherwise the saved article stays up and takes no typing until the
1031
+ // writer's adoption arrives, and the wait is bounded like adoption itself.
1032
+ async function awaitWriterAdoption(inst, found) {
1033
+ const code = await pageCodeReady();
1034
+ if (await roomHoldsArticle(inst, found)) return { ok: true, outcome: "writer-adopts" };
1035
+ return new Promise((resolve) => {
1036
+ let off = null;
1037
+ let timer = null;
1038
+ let done = false;
1039
+ const finish = (result) => {
1040
+ if (done) return;
1041
+ done = true;
1042
+ if (timer) clearTimeout(timer);
1043
+ timer = null;
1044
+ off?.();
1045
+ off = null;
1046
+ resolve(result);
1047
+ };
1048
+ addDisposer(inst, () => finish({ ok: false, cancelled: true }));
1049
+ off = inst.store.onRebase((event) => {
1050
+ if (event.source === "remote") finish({ ok: true, outcome: "writer-adopts" });
495
1051
  });
496
- },
497
- });
498
- inst.adoption = adoption;
499
-
500
- await adoption.idle();
501
- if (gen !== generation) return;
502
- // A replacement that landed while the page was still booting keeps the editor
503
- // read-only: the notice is already up, and this tab must not edit the old article.
504
- if (inst.replaced) {
505
- stopEditing(inst);
506
- return;
1052
+ timer = setTimeout(() => {
1053
+ console.warn("page: gave up waiting for the writer\u0027s adoption; the hand edit was not adopted");
1054
+ finish({ ok: false, reason: "adoption-timeout" });
1055
+ }, code.ADOPTION_TIMEOUT_MS);
1056
+ });
1057
+ }
1058
+
1059
+ async function adoptSource(inst, source, mounted) {
1060
+ const code = await pageCodeReady();
1061
+ // A hand-edited article is adopted by the room's elected writer only. With a
1062
+ // lane but no writer yet, this tab claims the room (a redundant set of an
1063
+ // existing Yjs update: a valid nonempty frame that changes nothing) and then
1064
+ // waits for the election, so exactly one tab adopts.
1065
+ const found = code.classifyFile(source.article, source.tags);
1066
+ const needsAdoption = found.kind === "adopt";
1067
+ const awaitWriter = async () => {
1068
+ const lane = await pageLaneReady();
1069
+ if (!lane) return;
1070
+ if (!isCurrent(inst)) return;
1071
+ if (lane.status.writer === null) {
1072
+ const y = inst.store.value.y;
1073
+ const keys = Object.keys(y);
1074
+ const key = keys.length ? keys[0] : `claim-${binding.ydoc.clientID.toString(36)}`;
1075
+ const value = keys.length ? y[keys[0]] : code.yValueOf(binding.ydoc).y.seed;
1076
+ inst.store.handle.set(["y", key], value);
1077
+ // The session is not admitted until the commit, so its ops stay in the tab: the
1078
+ // claim alone is framed now, before adoption writes anything, or no election runs.
1079
+ lane.release("page");
1080
+ }
1081
+ // A lane that never reports a writer must not cost the hand edit: give up
1082
+ // before adoption's own bound, and adopt as the file-mode tab would.
1083
+ let timer;
1084
+ await Promise.race([pageWriterReady(), new Promise((resolve) => { timer = setTimeout(resolve, WRITER_WAIT_MS); })]);
1085
+ clearTimeout(timer);
1086
+ };
1087
+ const ready = needsAdoption ? awaitWriter() : Promise.resolve();
1088
+
1089
+ const adoption = code.installAdoption({
1090
+ view: mounted.view,
1091
+ source,
1092
+ ready,
1093
+ isAdopter: () => {
1094
+ const lane = fastLane();
1095
+ return !lane || lane.status.writer === null ? true : lane.isWriter();
1096
+ },
1097
+ requestCheckpoint: () => checkpointWithin(0),
1098
+ snapshot: () => captureForSaveAndComparison().forSave,
1099
+ mergeTagless: inst.mergeTagless,
1100
+ writerReady: awaitWriter,
1101
+ adopted: (rev) => fastLane()?.adopted?.("page", rev, inst.store.mark),
1102
+ notice: (counts) => {
1103
+ if (!counts.copy) return;
1104
+ toastPersistent(OVERWRITTEN_TEXT, "warning", {
1105
+ actions: [{ label: "Download your copy", onClick: () => downloadSnapshot(counts.copy) }],
1106
+ });
1107
+ },
1108
+ });
1109
+ inst.adoption = adoption;
1110
+
1111
+ let outcome = await adoption.idle();
1112
+ if (!outcome) {
1113
+ await replayJournal(inst);
1114
+ return { ok: true, outcome: "unchanged" };
1115
+ }
1116
+ // Another tab is the room's writer, so this tab's adoption is the one that tab
1117
+ // lands: the wait ends when it reaches this tab, or as the timeout a refusal is.
1118
+ if (outcome.outcome === "writer-adopts") outcome = await awaitWriterAdoption(inst, found);
1119
+ // Adoption refused, timed out or failed: the room's state is the page's, not the
1120
+ // view this tab built over the saved article, so the editor goes and the article
1121
+ // comes back from what the room accepted. A disposed install restores nothing:
1122
+ // dispose already did.
1123
+ if (!outcome.ok && isCurrent(inst)) abandonEditor(inst);
1124
+ else if (isCurrent(inst)) await replayJournal(inst);
1125
+ return outcome;
1126
+ }
1127
+
1128
+ // Everything input waits for, checked without turning anything on: the parts the
1129
+ // saved article demands, any part the document gained while the page booted, and this
1130
+ // installation's own view still in the article it claimed.
1131
+ async function prepareInput(inst, mounted) {
1132
+ // A replacement that landed while the page was still booting keeps the editor
1133
+ // read-only: the notice is already up, and this tab must not edit the old article.
1134
+ if (inst.replaced) return { ok: false, reason: "page-replaced" };
1135
+ await featuresReady;
1136
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
1137
+ // The gate: this installation's own view, still in the article it claimed, over
1138
+ // the binding the room accepted. Anything else is not something to type into.
1139
+ const shell = mounted.view.dom.closest(".page-shell");
1140
+ if (!ownsView(inst, mounted) || !shell || !shell.isConnected || mounted.view.isDestroyed) {
1141
+ return { ok: false, reason: "view-detached" };
1142
+ }
1143
+ // The document the view holds can ask for a part the saved article never did: a
1144
+ // peer put a table in the room, or a hand edit spliced one in during adoption.
1145
+ // Those are the last parts input waits for, before the article gives way.
1146
+ const booted = new Set(mounted.featureIds ?? []);
1147
+ const late = docFeatureIds(mounted.view.state.doc).filter((id) => !booted.has(id));
1148
+ if (late.length) {
1149
+ await Promise.allSettled(late.map((id) => features.ensure(id)));
1150
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
1151
+ }
1152
+ return { ok: true };
1153
+ }
1154
+
1155
+ // The one place the saved article gives way to the editor, and the only place input
1156
+ // opens. It changes activation only, and only after preparation already passed, so
1157
+ // the commit that enters edit cannot fail here.
1158
+ function openInput(inst, mounted) {
1159
+ const shell = mounted.view.dom.closest(".page-shell");
1160
+ const showEditor = () => {
1161
+ for (const node of savedContent) node.remove();
1162
+ if (shell) shell.style.visibility = "";
1163
+ };
1164
+ // A replacement that landed during the feature wait keeps the editor read-only,
1165
+ // exactly as one that landed before it does.
1166
+ if (inst.replaced) {
1167
+ showEditor();
1168
+ stopEditing(inst);
1169
+ return { ok: false, reason: "page-replaced" };
1170
+ }
1171
+ if (inst.inputOpen) return { ok: true };
1172
+ if (!ownsView(inst, mounted) || !shell || !shell.isConnected || mounted.view.isDestroyed) {
1173
+ return { ok: false, reason: "view-detached" };
1174
+ }
1175
+ showEditor();
1176
+ shell.inert = false;
1177
+ setEditorActive?.(mounted.view, true);
1178
+ inst.inputOpen = true;
1179
+ clearLoadingStatus(inst);
1180
+ return { ok: true };
1181
+ }
1182
+
1183
+ // A refused or unfinished exit closes admission again without touching a buffer, so
1184
+ // a `resume` puts the very same session back.
1185
+ function closeInput(inst) {
1186
+ if (!inst.inputOpen) return false;
1187
+ setEditorActive?.(inst.view, false);
1188
+ return true;
507
1189
  }
508
- view.setProps({ editable: () => true });
509
-
510
- // That initial write advanced the saved baselines from a capture taken before
511
- // the editor mounted, which is what the load-time settle would otherwise absorb
512
- // boot churn into. Install them once more from the booted page, so a tab that
513
- // was opened and left alone does not read as unsaved.
514
- if (initialSave) {
515
- const { forComparison, forDirty } = captureForComparisonAndDirty();
516
- setLastSavedBaselines(forComparison, forDirty);
1190
+
1191
+ function resumeInput(inst) {
1192
+ if (!inst.inputOpen) return false;
1193
+ setEditorActive?.(inst.view, true);
1194
+ return true;
517
1195
  }
1196
+
1197
+ // The legacy boot: preparation and the commit are one, because there is no
1198
+ // coordinator to wait for.
1199
+ async function enableInput(inst, mounted) {
1200
+ const prepared = await prepareInput(inst, mounted);
1201
+ if (!prepared.ok) return prepared;
1202
+ return openInput(inst, mounted);
1203
+ }
1204
+
1205
+ // A boot that ends without an editor gives up what its branches acquired: the room's
1206
+ // subscription, its save participant and its listeners. A boot that mounted a view
1207
+ // releases its own through the paths above and keeps the editor on screen.
1208
+ const release = (inst) => {
1209
+ if (inst.view) return;
1210
+ abandonEditor(inst);
1211
+ };
1212
+
1213
+ const steps = { readSource, loadEditorAssets, prepareAuthority, mountAndAttach, adoptSource, prepareInput, openInput, closeInput, resumeInput, enableInput, release };
1214
+ inst.steps = steps;
1215
+ return steps;
518
1216
  }
519
1217
 
520
- function install(options = {}) {
1218
+ async function run(inst, options = {}, { activate = true } = {}) {
1219
+ return runBoot(inst, createSteps(inst, options), { activate });
1220
+ }
1221
+
1222
+ // One installation, and the boot it runs. A managed session is the same installation
1223
+ // with its commit left to the coordinator: `activate: false` stops the boot where the
1224
+ // editor is staged and still takes no input.
1225
+ function begin(options = {}, { activate = true, context = null } = {}) {
521
1226
  dispose();
1227
+ ended = null;
522
1228
  const gen = ++generation;
523
- const inst = { gen, article: null, view: null, binding: null, adoption: null, source: null, store: null, toolHost: null, aiTool: null, bootHtml: "", bootRev: null, savedRev: null, replaced: false, savesHeld: false, flush: null, listeners: [], retryTimer: null, retryShown: false };
1229
+ const inst = {
1230
+ ...createInstall({
1231
+ gen,
1232
+ isCurrent: () => gen === generation && (context?.isCurrent?.() ?? true),
1233
+ onPhase: (result) => {
1234
+ // A settled boot says so by taking the waiting line away, whatever it settled
1235
+ // as: there is nothing left to wait for.
1236
+ if (result.phase === "ready" || result.phase === "blocked" || result.phase === "error") clearLoadingStatus(inst);
1237
+ options.onPhase?.(result);
1238
+ },
1239
+ }),
1240
+ article: null, view: null, binding: null, adoption: null, source: null, store: null, toolHost: null, aiTool: null, bootHtml: "", bootRev: null, savedRev: null, staged: null, pageStore: null, releaseArticle: null, replaced: false, savesHeld: false, flush: null, loadFormatGate: null, listeners: [], retryTimer: null, retryShown: false, statusTimer: null, statusNote: null, notices: null, editorReadyResolve: null, context, inputOpen: false, sessionHeld: false, autosaveSuspended: false, staleRetry: false,
1241
+ };
524
1242
  active = inst;
525
- return run(inst, gen, options).catch((error) => {
1243
+ const boot = run(inst, options, { activate }).catch((error) => {
526
1244
  console.error("clayjs: page plugin failed to install", error);
1245
+ ended = finishError(inst, "boot-failed");
527
1246
  if (active === inst) dispose();
1247
+ return ended;
1248
+ });
1249
+ return { inst, boot };
1250
+ }
1251
+
1252
+ function install(options = {}) {
1253
+ return begin(options).boot;
1254
+ }
1255
+
1256
+ // What a session's own end gives back, whether or not the coordinator still calls the
1257
+ // page: its view, its listeners, its binding and its staged store go, and the article
1258
+ // comes back from the page the host accepted rather than from the editor's own DOM.
1259
+ function endSession(inst) {
1260
+ if (active === inst) {
1261
+ active = null;
1262
+ generation += 1;
1263
+ }
1264
+ setSaveHeader("Page-Write", null);
1265
+ disposeInstall(inst);
1266
+ if (inst.retryTimer) {
1267
+ clearTimeout(inst.retryTimer);
1268
+ inst.retryTimer = null;
1269
+ }
1270
+ inst.titleObserver?.dispose();
1271
+ inst.titleObserver = null;
1272
+ clearLoadingStatus(inst);
1273
+ if (inst.view && !inst.view.isDestroyed) {
1274
+ try { inst.view.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
1275
+ }
1276
+ inst.view = null;
1277
+ releaseMounted(inst);
1278
+ restoreArticle(inst);
1279
+ }
1280
+
1281
+ // The save hold a managed session takes between its preparation and its commit: given
1282
+ // back once, by the enable that commits it or by the preparation that fails.
1283
+ function releaseSessionHold(inst) {
1284
+ if (!inst.sessionHeld) return;
1285
+ inst.sessionHeld = false;
1286
+ releaseAllSaves({ replay: false, reason: SESSION_HOLD });
1287
+ }
1288
+
1289
+ // What both roads hold between the commit that enters edit and the one that leaves it:
1290
+ // a refused exit is `quiesce` then `resume`, and the committed exit is `dispose`. Only a
1291
+ // session preparation holds takes a save hold to give back; an adopted one took none.
1292
+ function sessionLease(inst, { held = true } = {}) {
1293
+ return {
1294
+ // Synchronous after preparation: it opens admission, input and the lane's writes,
1295
+ // and nothing it does can fail. An adopted session is already open.
1296
+ enable() {
1297
+ if (!held) return;
1298
+ releaseSessionHold(inst);
1299
+ activateInput(inst);
1300
+ },
1301
+ quiesce() {
1302
+ // A composition in flight is not this session's to cut: the exit is refused, and
1303
+ // the text stays where the person is typing it.
1304
+ if (inst.view?.composing) {
1305
+ throw Object.assign(new Error("clayjs: a composition is still in progress"), { code: "composition" });
1306
+ }
1307
+ inst.steps?.closeInput?.(inst);
1308
+ // Scheduled autosaves stop here; the buffers, the selection and the exit's own
1309
+ // flush stay exactly as they were.
1310
+ if (!inst.autosaveSuspended) {
1311
+ inst.autosaveSuspended = true;
1312
+ suspendAutosave();
1313
+ }
1314
+ },
1315
+ resume() {
1316
+ releaseSessionHold(inst);
1317
+ if (inst.autosaveSuspended) {
1318
+ inst.autosaveSuspended = false;
1319
+ resumeAutosave();
1320
+ }
1321
+ inst.steps?.resumeInput?.(inst);
1322
+ },
1323
+ dispose() {
1324
+ releaseSessionHold(inst);
1325
+ if (inst.autosaveSuspended) {
1326
+ inst.autosaveSuspended = false;
1327
+ resumeAutosave();
1328
+ }
1329
+ endSession(inst);
1330
+ },
1331
+ };
1332
+ }
1333
+
1334
+ // What the coordinator holds between the commit that enters edit and the one that
1335
+ // leaves it: preparation is `ready`, the commit is `enable`, a refused exit is
1336
+ // `quiesce` then `resume`, and the committed exit is `dispose`.
1337
+ export function preparePageSession(context = null, options = {}) {
1338
+ holdAllSaves(SESSION_HOLD);
1339
+ const { inst, boot } = begin(options, { activate: false, context });
1340
+ inst.sessionHeld = true;
1341
+ const lease = sessionLease(inst, { held: true });
1342
+ // The facade reads a session as the boot that is staging it, so it settles at the
1343
+ // staging or at the blocked outcome, and never rejects.
1344
+ page.ready = boot.then(() => page.status);
1345
+ const ready = boot.then((result) => {
1346
+ if (!result?.deferred) {
1347
+ throw Object.assign(new Error("clayjs: the page session could not prepare"), { code: result?.reason ?? "load-failed" });
1348
+ }
1349
+ return undefined;
1350
+ });
1351
+ // A preparation the coordinator never holds, and never disposes either, must not
1352
+ // keep the page's saves closed: it gives its hold back as it fails, and a reader that
1353
+ // yielded the article takes it back before the failure reaches the coordinator.
1354
+ ready.catch(() => {
1355
+ releaseSessionHold(inst);
1356
+ try { context?.reader?.reclaimArticle?.(); } catch (error) { console.error("clayjs: the reader could not take the article back", error); }
528
1357
  });
1358
+ return { ready, ...lease };
1359
+ }
1360
+
1361
+ /**
1362
+ * The page as a mode participant: the editor the commit that enters edit opens, over
1363
+ * the page the host already stores, and the one the exit takes back. `prepare` stages
1364
+ * the article, the room and the editor without taking input or writing; the commit
1365
+ * enables the same session.
1366
+ */
1367
+ export function pageParticipant(options = {}) {
1368
+ return {
1369
+ id: "clay:page",
1370
+ // After live (200) and upload (340): the page builds its stores over the admission
1371
+ // those two already opened.
1372
+ order: 400,
1373
+ prepare(context = {}) {
1374
+ // An adoption is the session this tab's edit boot started: the exit takes that one
1375
+ // back, and the entry starts none.
1376
+ if (context.initial === true) {
1377
+ if (!active) return { enable() {}, quiesce() {}, resume() {}, dispose() {} };
1378
+ return sessionLease(active, { held: false });
1379
+ }
1380
+ return preparePageSession(context, options);
1381
+ },
1382
+ };
529
1383
  }
530
1384
 
1385
+ export { pageParticipant as modeParticipant };
1386
+
531
1387
  export const page = {
532
1388
  ready: null,
533
1389
  install,
1390
+ get status() {
1391
+ return active?.result ?? ended;
1392
+ },
534
1393
  flush(options = {}) {
535
1394
  const inst = active;
536
1395
  if (!inst || !inst.flush) return Promise.resolve({ ok: false, code: "disposed" });
@@ -542,6 +1401,8 @@ export const page = {
542
1401
  },
543
1402
  };
544
1403
 
545
- page.ready = install();
1404
+ // A live entry imports this module while the committed mode is still view: the install
1405
+ // belongs to the explicit edit boot alone, and a managed session starts its own.
1406
+ page.ready = startsAtImport() ? install() : null;
546
1407
 
547
1408
  export default page;