@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
package/docs/guide.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Building malleable documents with ClayJS
2
2
 
3
- This is the complete reference for building malleable documents with ClayJS: one HTML file that is the whole app, edits itself in place, saves itself back to its host, and merges edits from other tabs, other people and agents. Read it top to bottom once. After that, sections 2 (the skeleton), 9 (architecture) and 13 (checklist and trap index) are the parts to return to.
3
+ This is the complete reference for building malleable documents with ClayJS: one HTML file that is the whole app, edits itself in place, saves itself back to its host, and merges edits from other tabs, other people and agents. Read it top to bottom once. After that, [A document, line by line](https://clayjs.com/docs/guide/walkthrough/), [Structuring an app](https://clayjs.com/docs/guide/architecture/) and [Pre-ship checklist](https://clayjs.com/docs/guide/checklist/) are the parts to return to.
4
4
 
5
- It is written for a developer or an LLM building full apps with inline editing and inline controls: kanban boards with card detail, block writers, finance trackers, CRMs, flashcards, trackers. It covers the CMS plugin only in passing (section 8.12), because an app whose product is editing should not route editing through a form sidebar.
5
+ It is written for a developer or an LLM building full apps with inline editing and inline controls: kanban boards with card detail, block writers, finance trackers, CRMs, flashcards, trackers. It covers the CMS plugin only in passing ([cms: content-managed pages](https://clayjs.com/docs/guide/components/#cms-content-managed-pages)), because an app whose product is editing should not route editing through a form sidebar.
6
6
 
7
7
  Every behavior stated here was read from ClayJS 1.8.2 source or executed. The four example files below are complete, tested apps that this guide quotes. A [test suite](https://github.com/panphora/clayjs/tree/main/tests/unit/guide-examples) boots each one in jsdom with the real loader and checks what it saves, how it merges a peer's edit, and what a viewer receives. Open any of them and view source, or download one and open it in HTML Clay to edit it:
8
8
 
@@ -13,64 +13,26 @@ Every behavior stated here was read from ClayJS 1.8.2 source or executed. The fo
13
13
  | [`examples/writer.html`](https://clayjs.com/examples/writer.html) | A block document: rich text blocks, headings, callouts, to-do blocks, dividers, images with upload, a gutter with drag handle and block menu, a slash menu, an outline and a word count. |
14
14
  | [`examples/finance.html`](https://clayjs.com/examples/finance.html) | A budget built on sap.js: categories with budgets, transactions, computed totals saved into the file, search, CSV import and export. |
15
15
 
16
- Code in this guide assumes the loader URL `https://clayjs.com/v1/`. Section 3 covers versions. This guide is also served as plain Markdown at [clayjs.com/guide.md](https://clayjs.com/guide.md) for agents, and its ideas are drawn as diagrams in the [visual guide](https://clayjs.com/visual-guide).
16
+ Code in this guide assumes the loader URL `https://clayjs.com/v1/`. [Add ClayJS to a page](https://clayjs.com/docs/guide/loading/) covers versions. This guide is also served as plain Markdown at [clayjs.com/guide.md](https://clayjs.com/guide.md) for agents, and its ideas are drawn as diagrams in the [visual guide](https://clayjs.com/docs/guide/visual-guide/).
17
17
 
18
18
  ---
19
19
 
20
- ## 1. The model
21
-
22
- ### 1.1 Two forms of one file
23
-
24
- A malleable document exists in two forms.
25
-
26
- - **The saved file** is the bytes on the host. A visitor downloads exactly these bytes. They must be correct and complete with no JavaScript running: the content, its order, the state of every checkbox, the totals if the reader needs them.
27
- - **The open page** is the live DOM in one browser tab. It holds the saved content plus everything the tab adds at runtime: editing toolbars, a filter box, an open dialog, selection, computed badges, drag ghosts.
20
+ ## A document, line by line
28
21
 
29
- A save turns the open page back into the saved file. ClayJS does this by cloning the whole document, removing everything marked as runtime-only, applying a few transforms, serializing, and posting the full HTML to the host (`POST /_/save`). There is no partial save and no separate data store. **The document is the database.**
22
+ Start every app from [`examples/skeleton.html`](https://clayjs.com/examples/skeleton.html). It is a reading list with sortable rows, a checkbox and a title per row, an Add button, a summary line and a filter.
30
23
 
31
- ### 1.2 Three kinds of DOM
32
-
33
- Every element on an open page belongs to one of three kinds. Decide which before you write it.
34
-
35
- | Kind | What it is | Where it lives | Saved | Sent to peers | Edited by people |
36
- |---|---|---|---|---|---|
37
- | **Record** | The work: card titles, checklist items, amounts, block text, order | Ordinary elements, attributes, text and `persist` control values | yes | yes | yes |
38
- | **Chrome** | UI for editing: buttons, grips, toolbars, menus, filter boxes, status text | Authored in the file, hidden from viewers with `show-when:editmode="true"`. UI a script fills or changes goes in `clay="editor-ui"` (9.2) | hidden: yes, `editor-ui`: no | hidden: yes, `editor-ui`: no | no |
39
- | **Derived** | Values computed from the record: counts, progress, totals, outlines, a status pill's colour | sapjs paints (`attr:`, `class:`, `css:`, `text`, `show`, 8.11), `clay="editor-ui"` (tab only) or `clay="no-dirty"` (saved, remote wins) | paints and `no-dirty`: yes | `no-dirty` only; paints are repainted, never merged | no |
40
-
41
- Nearly every bug in a malleable file is one of these three leaking into another: chrome a script changes saved into the file, a derived count merged as if someone typed it, a record edited only in a copy that never reaches the DOM.
42
-
43
- ### 1.3 The rules
44
-
45
- 1. **The DOM is the record.** Everything durable is an element, an attribute, text, child order, or the value of a control marked `persist`. Never keep the authoritative copy in a JavaScript object.
46
- 2. **Chrome is authored in the file and hidden from viewers with `show-when:editmode="true"`.** This is the default: it needs no script and is in place before ClayJS loads. Use `clay="editor-ui"` only for UI a script fills or changes while the page is open, or that viewers must not receive (9.2).
47
- 3. **Derived values are recomputed, never stored as edits.** Store the value once, as the record (`bind`), and declare every other place it shows as a sapjs paint: `attr:data-state="item.state"`, `class:done="item.done"`, `text`, `show` (8.11). ClayJS loads the sapjs core for any page with a `[sap]` root and treats painted values as derived. Without sapjs, write derived values into `editor-ui` elements, or into `clay="no-dirty"` elements when the saved file must show them. Never copy a record into a second attribute with a script: the copy goes stale in the file, and a merge brings the stale copy back.
48
- 4. **Every repeated item has a unique `data-id` from birth.** Mint a fresh one on every create and every duplicate. Sync pairs elements by it.
49
- 5. **Per-tab state stays out of the record.** Which card is open, the filter, the current view: URL hash, `localStorage` or memory, painted through `ephemeral` attributes (5.7), a runtime `<style>` or `editor-ui` elements.
50
- 6. **A command writes the record once, in one place,** wrapped in an undo commit. The mutation is what autosave, undo and sync see.
51
- 7. **Adopt the file as it is on load.** Never rewrite the record at boot: it makes the page dirty, it can fall outside the save baseline, and it races peers.
52
- 8. **Reconcile after changes you did not make.** A peer's frame, an agent's disk edit and an undo replay change the DOM under you. Rebuild derived and chrome state on `clay:sync-applied` and on undo and redo.
24
+ #### The html element and autosave
53
25
 
54
- ### 1.4 What the host does
55
-
56
- ClayJS runs in the page. A **host** serves the file and accepts saves. The client never decides who may write: it sends the save, and the host accepts or refuses it. Hosts that run ClayJS pages today:
57
-
58
- - **HTML Clay**, the desktop app that makes `.htmlclay` files open and save like documents. Single user, local disk.
59
- - **Hyperclay Local**, the desktop sync client. Local folder, device sync to hyperclay.com.
60
- - **hyperclay.com**, hosted files with accounts, teams, share links, presence and version history.
61
- - **Any server** that implements `POST /_/save` (and optionally `/_/meta`, `/_/sync`, `/_/upload`). A plain static server or a file opened from disk can view the file but not save it.
62
-
63
- Section 12 covers what each host adds.
64
-
65
- ---
26
+ ```html
27
+ <!DOCTYPE html>
28
+ <html lang="en" autosave>
29
+ ```
66
30
 
67
- ## 2. The skeleton file
31
+ `autosave` turns on autosave for edit mode. Autosave is decided at boot. ([Autosave](https://clayjs.com/docs/guide/saving/#autosave))
68
32
 
69
- Start every app from [`examples/skeleton.html`](https://clayjs.com/examples/skeleton.html). It is short enough to read in full, and every later section refers back to its parts. It is a reading list: sortable rows, a checkbox and a title per row, an Add button, a summary line and a filter.
33
+ #### Hiding edit controls before ClayJS runs
70
34
 
71
35
  ```html
72
- <!DOCTYPE html>
73
- <html lang="en" autosave>
74
36
  <head>
75
37
  <meta charset="utf-8">
76
38
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -79,36 +41,108 @@ Start every app from [`examples/skeleton.html`](https://clayjs.com/examples/skel
79
41
  html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }
80
42
  /* app styles */
81
43
  </style>
44
+ ```
45
+
46
+ The fallback CSS rule hides `show-when:editmode="true"` controls before ClayJS runs. Without it, a viewer of a never-saved file sees the edit buttons for a moment, or for good with JavaScript off. ([Showing and hiding edit controls: clay-options](https://clayjs.com/docs/guide/edit-mode/#showing-and-hiding-edit-controls-clay-options))
47
+
48
+ ```html
82
49
  </head>
83
50
  <body>
51
+ ```
52
+
53
+ #### The record
54
+
55
+ ```html
84
56
  <main id="app">
57
+ ```
58
+
59
+ `<main id="app">` is the record, the part of the page that ClayJS saves. ([Element identity: `data-id` and `id`](https://clayjs.com/docs/guide/record/#element-identity-data-id-and-id))
60
+
61
+ ```html
85
62
  <h1 editmode:contenteditable inert-contenteditable="plaintext-only">Reading list</h1>
63
+ ```
64
+
65
+ `editmode:contenteditable` makes the text editable in edit mode. The file is written in its saved form with `inert-contenteditable`, so a viewer gets inert text from the very first open. ([Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers))
66
+
67
+ ```html
86
68
  <ul id="items" sortable="items">
87
69
  <li data-id="r-mmm"><span class="grip" sortable-handle show-when:editmode="true" aria-label="Drag">⠿</span><input type="checkbox" persist viewmode:disabled disabled checked> <span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">The Mythical Man-Month</span><button type="button" data-action="remove" show-when:editmode="true" aria-label="Remove">×</button></li>
70
+ ```
71
+
72
+ Each row has a `data-id`, its text is `editmode:contenteditable`, and its checkbox is `persist viewmode:disabled`. The saved file includes `disabled` so a viewer gets disabled checkboxes from the first open. ([Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers))
73
+
74
+ ```html
88
75
  <li data-id="r-sicp"><span class="grip" sortable-handle show-when:editmode="true" aria-label="Drag">⠿</span><input type="checkbox" persist viewmode:disabled disabled> <span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">Structure and Interpretation of Computer Programs</span><button type="button" data-action="remove" show-when:editmode="true" aria-label="Remove">×</button></li>
89
76
  </ul>
90
77
  <button type="button" data-action="add" show-when:editmode="true">+ Add book</button>
91
78
  </main>
92
79
 
80
+ ```
81
+
82
+ #### Templates for new rows
83
+
84
+ ```html
93
85
  <template id="item-tpl">
94
86
  <li><span class="grip" sortable-handle show-when:editmode="true" aria-label="Drag">⠿</span><input type="checkbox" persist viewmode:disabled> <span class="title" editmode:contenteditable contenteditable="plaintext-only"></span><button type="button" data-action="remove" show-when:editmode="true" aria-label="Remove">×</button></li>
87
+ ```
88
+
89
+ Templates are blueprints. `item-tpl` holds a new row in its active form (`contenteditable="plaintext-only"`, no `disabled`), because a clone is inserted while the owner is editing and the mode transform never touches template content. ([Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers))
90
+
91
+ ```html
95
92
  </template>
96
93
 
94
+ ```
95
+
96
+ #### Editor-only controls
97
+
98
+ ```html
97
99
  <template id="chrome-tpl">
98
100
  <p id="summary"></p>
99
101
  <input type="search" id="filter" placeholder="Filter" aria-label="Filter">
100
102
  <style id="view-state"></style>
103
+ ```
104
+
105
+ `chrome-tpl` holds the summary, the filter and the `#view-state` style outside the record, so the filter stays out of the saved file. ([Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state))
106
+
107
+ ```html
101
108
  </template>
102
109
 
110
+ ```
111
+
112
+ #### Loading ClayJS
113
+
114
+ ```html
103
115
  <script src="https://clayjs.com/v1/clay.js?plugins=sync,sortable,undo,indicator&exclude=richclay"></script>
104
116
  <script src="https://clayjs.com/v1/clay-options.js"></script>
105
117
  <script src="https://clayjs.com/v1/clay-ui.js"></script>
118
+ ```
119
+
120
+ The script tags load core with `sync,sortable,undo,indicator`, `exclude=richclay` because this app has no rich text, and then the `clay-options` and `clay-ui` satellites. ([Add ClayJS to a page](https://clayjs.com/docs/guide/loading/))
121
+
122
+ #### Starting the app
123
+
124
+ ```html
106
125
  <script>
107
126
  clay.start(() => {
108
127
  const items = document.getElementById("items");
128
+ ```
129
+
130
+ `clay.start` runs the app code once, after boot. A live-sync merge can re-insert the script, and the repeat call does nothing. ([Starting the app once](https://clayjs.com/docs/guide/architecture/#starting-the-app-once))
131
+
132
+ ```html
109
133
  const newId = (prefix) => prefix + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
134
+ ```
135
+
136
+ `newId` makes ids from a timestamp and random suffix. `crypto.randomUUID` exists only in secure contexts, so it fails on a file served over plain http from another machine. ([Element identity: `data-id` and `id`](https://clayjs.com/docs/guide/record/#element-identity-data-id-and-id))
137
+
138
+ ```html
110
139
  const command = (label, fn) => (clay.undo ? clay.undo.commit(label, fn) : fn());
111
140
 
141
+ ```
142
+
143
+ `command` wraps every record write in an undo step when the undo plugin is loaded. ([undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo))
144
+
145
+ ```html
112
146
  clay.onSnapshot?.((root) => {
113
147
  for (const el of root.querySelectorAll(".sortable-chosen, .sortable-ghost, .sortable-drag")) {
114
148
  el.classList.remove("sortable-chosen", "sortable-ghost", "sortable-drag");
@@ -117,6 +151,11 @@ clay.start(() => {
117
151
  for (const el of root.querySelectorAll('[draggable="false"]')) el.removeAttribute("draggable");
118
152
  for (const el of root.querySelectorAll('[style=""]')) el.removeAttribute("style");
119
153
  });
154
+ ```
155
+
156
+ The `onSnapshot` hook strips Sortable's drag residue (`draggable="false"`, empty `style`, drag classes) from every snapshot, so a drag never writes it into the file. ([Hooks](https://clayjs.com/docs/guide/saving/#hooks))
157
+
158
+ ```html
120
159
  const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };
121
160
 
122
161
  const chrome = document.createElement("div");
@@ -130,6 +169,13 @@ clay.start(() => {
130
169
  render();
131
170
  });
132
171
 
172
+ ```
173
+
174
+ `chrome-tpl` is cloned once into a `clay="editor-ui"` wrapper. Because the summary, the filter and `#view-state` live there, none of them is saved, synced or undone. ([Regions: the `clay` attribute](https://clayjs.com/docs/guide/record/#regions-the-clay-attribute))
175
+
176
+ #### Keeping derived text in step
177
+
178
+ ```html
133
179
  function render() {
134
180
  const rows = [...items.querySelectorAll(":scope > li")];
135
181
  const done = rows.filter((li) => li.querySelector("input").hasAttribute("checked")).length;
@@ -139,6 +185,11 @@ clay.start(() => {
139
185
  setText(chrome.querySelector("#view-state"), hidden.map((li) => `#items > [data-id="${CSS.escape(li.dataset.id)}"]{display:none}`).join("\n"));
140
186
  }
141
187
 
188
+ ```
189
+
190
+ `render` derives the summary and the filter style from the record. It writes only when text changes, because writing the same text is still a mutation. ([Derived values](https://clayjs.com/docs/guide/architecture/#derived-values))
191
+
192
+ ```html
142
193
  document.addEventListener("click", (event) => {
143
194
  const button = event.target.closest("[data-action]");
144
195
  if (!button || !clay.isEditMode) return;
@@ -163,48 +214,91 @@ clay.start(() => {
163
214
 
164
215
  window.addEventListener("hashchange", () => { filter.value = new URLSearchParams(location.hash.slice(1)).get("q") || ""; render(); });
165
216
  clay.effect(render);
217
+ ```
218
+
219
+ `clay.effect(render)` runs `render` now and again after any change to the document: local typing, a drag, a merge, undo or redo. `render` writes only into the chrome, and an effect's own writes never re-trigger it. ([Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code))
220
+
221
+ ```html
166
222
  });
167
223
  </script>
168
224
  </body>
169
225
  </html>
170
226
  ```
171
227
 
172
- What each part does, and the section that explains it:
228
+ The test suite checks that this file opens clean, saves only the record and never the filter or chrome, keeps undo and the screen in step, mints unique ids, merges peer edits beside unsaved local edits, and gives viewers inert text and disabled checkboxes.
229
+
230
+ ---
231
+
232
+ ## 1. The model
233
+
234
+ ### 1.1 Saved file and open page
173
235
 
174
- - **`<html autosave>`** turns on autosave for edit mode. Autosave is decided at boot (6.2).
175
- - **The fallback CSS rule** hides `show-when:editmode="true"` controls before ClayJS runs. Without it a viewer of a never-saved file sees the edit buttons for a moment, or for good with JavaScript off (4.3).
176
- - **The record** is `<main id="app">`. Each row has a `data-id`, its text is `editmode:contenteditable`, and its checkbox is `persist viewmode:disabled`. The file is written in its **saved form** (`inert-contenteditable`, `disabled`), so a viewer gets inert text from the very first open (4.4).
177
- - **The templates** are blueprints. `item-tpl` holds a new row in its **active form** (`contenteditable="plaintext-only"`, no `disabled`), because a clone is inserted while the owner is editing and the mode transform never touches template content (4.4).
178
- - **`chrome-tpl`** is cloned once into a `clay="editor-ui"` wrapper. The summary, the filter and the `#view-state` style live there, so none of them is saved, synced or undone (5.2, 9.3).
179
- - **The script tags**: core with `sync,sortable,undo,indicator`, `exclude=richclay` because this app has no rich text, then the `clay-options` and `clay-ui` satellites (3).
180
- - **`clay.start`** runs the app code once, after boot. A live-sync merge can re-insert the script, and the repeat call does nothing (9.10).
181
- - **`newId`** makes ids from a timestamp and random suffix. `crypto.randomUUID` exists only in secure contexts, so it fails on a file served over plain http from another machine (5.5).
182
- - **`command`** wraps every record write in an undo step when the undo plugin is loaded (8.3).
183
- - **The `onSnapshot` hook** strips Sortable's drag residue (`draggable="false"`, empty `style`, drag classes) from every snapshot, so a drag never writes it into the file (6.6).
184
- - **`render`** derives the summary and the filter style from the record. It writes only when text changes, because writing the same text is still a mutation (9.4).
185
- - **`clay.effect(render)`** runs `render` now and again after any change to the document: local typing, a drag, a merge, undo or redo. `render` writes only into the chrome, and an effect's own writes never re-trigger it (7.5).
236
+ A malleable document exists in two forms: the saved file and the open page.
237
+
238
+ - **The saved file** is the bytes on the host. A visitor downloads these exact bytes. They must be correct and complete with no JavaScript running: the content, its order, the state of every checkbox, and any totals the reader needs.
239
+ - **The open page** is the live DOM in one browser tab. It holds the saved content plus everything the tab adds at runtime: editing toolbars, a filter box, an open dialog, selection, computed badges, and drag ghosts.
240
+
241
+ A save turns the open page back into the saved file. ClayJS clones the whole document, removes everything marked as runtime-only, applies a few transforms, serializes the full HTML, and posts it to the host (`POST /_/save`). There is no partial save and no separate data store. **The document is the database.**
242
+
243
+ ### 1.2 Record, chrome and derived DOM
244
+
245
+ Every element on an open page is record, chrome, or derived DOM. Decide which before you write it.
246
+
247
+ | Kind | What it is | Where it lives | Saved | Sent to peers | Edited by people |
248
+ |---|---|---|---|---|---|
249
+ | **Record** | The work: card titles, checklist items, amounts, block text, order | Ordinary elements, attributes, text, and `persist` control values | yes | yes | yes |
250
+ | **Chrome** | UI for editing: buttons, grips, toolbars, menus, filter boxes, status text | Authored in the file, hidden from viewers with `show-when:editmode="true"`. UI a script fills or changes goes in `clay="editor-ui"` ([Chrome](https://clayjs.com/docs/guide/architecture/#chrome)) | hidden: yes, `editor-ui`: no | hidden: yes, `editor-ui`: no | no |
251
+ | **Derived** | Values computed from the record: counts, progress, totals, outlines, a status pill's colour | sapjs paints (`attr:`, `class:`, `css:`, `text`, `show`, [sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)), `clay="editor-ui"` (tab only), or `clay="no-dirty"` (saved, remote wins) | paints and `no-dirty`: yes | `no-dirty` only; paints are repainted, never merged | no |
252
+
253
+ Nearly every bug in a malleable file happens when one kind leaks into another: chrome that a script changes gets saved into the file, a derived count gets merged as if someone typed it, or a record is edited only in a copy that never reaches the DOM.
254
+
255
+ ### 1.3 Rules for DOM state
256
+
257
+ Follow these rules to keep record, chrome, and derived state separate.
258
+
259
+ 1. **The DOM is the record.** Everything durable is an element, an attribute, text, child order, or the value of a control marked `persist`. Never keep the authoritative copy in a JavaScript object.
260
+ 2. **Chrome is authored in the file and hidden from viewers with `show-when:editmode="true"`.** This default needs no script and is in place before ClayJS loads. Use `clay="editor-ui"` only for UI a script fills or changes while the page is open, or that viewers must not receive ([Chrome](https://clayjs.com/docs/guide/architecture/#chrome)).
261
+ 3. **Derived values are recomputed, never stored as edits.** Store the value once as the record (`bind`), and declare every other place it shows as a sapjs paint: `attr:data-state="item.state"`, `class:done="item.done"`, `text`, `show` ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)). ClayJS loads the sapjs core for any page with a `[sap]` root and treats painted values as derived. Without sapjs, write derived values into `editor-ui` elements, or into `clay="no-dirty"` elements when the saved file must show them. Never copy a record into a second attribute with a script. The copy goes stale in the file, and a merge brings the stale copy back.
262
+ 4. **Every repeated item has a unique `data-id` from birth.** Mint a fresh one on every create and every duplicate. Sync pairs elements by it.
263
+ 5. **Per-tab state stays out of the record.** Keep which card is open, the filter, and the current view in the URL hash, `localStorage`, or memory. Paint them through `ephemeral` attributes ([Ephemeral state: per-tab state on saved elements](https://clayjs.com/docs/guide/record/#ephemeral-state-per-tab-state-on-saved-elements)), a runtime `<style>`, or `editor-ui` elements.
264
+ 6. **A command writes the record once, in one place, wrapped in an undo commit.** The mutation is what autosave, undo, and sync see.
265
+ 7. **Adopt the file as it is on load.** Never rewrite the record at boot. Doing so makes the page dirty, can fall outside the save baseline, and races peers.
266
+ 8. **Reconcile after changes you did not make.** A peer's frame, an agent's disk edit, and an undo replay change the DOM under you. Rebuild derived and chrome state on `clay:sync-applied` and on undo and redo.
267
+
268
+ ### 1.4 What the host does
269
+
270
+ ClayJS runs in the page, while a **host** serves the file and accepts saves. The client never decides who may write: it sends the save, and the host accepts or refuses it. Hosts that run ClayJS pages today:
271
+
272
+ - **HTML Clay**, the desktop app that makes `.htmlclay` files open and save like documents. Single user, local disk.
273
+ - **Hyperclay Local**, the desktop sync client. Local folder, device sync to hyperclay.com.
274
+ - **hyperclay.com**, hosted files with accounts, teams, share links, presence, and version history.
275
+ - **Any server** that implements `POST /_/save` (and optionally `/_/meta`, `/_/sync`, `/_/upload`). A plain static server or a file opened from disk can view the file but cannot save it.
186
276
 
187
- The test suite checks this file opens without becoming dirty, saves the record and no chrome, keeps the filter out of the file, keeps undo and the screen in step, mints unique ids, merges a peer's edit beside an unsaved local edit, and gives a viewer inert text and disabled checkboxes.
277
+ [Hosts, assets and privacy](https://clayjs.com/docs/guide/hosts/) covers what each host adds.
188
278
 
189
279
  ---
190
280
 
191
- ## 3. Loading
281
+ ## 2. Add ClayJS to a page
282
+
283
+ ### 2.1 The loader tag
192
284
 
193
- ### 3.1 The loader tag
285
+ Load ClayJS with a single `<script>` tag and select plugins in its query string:
194
286
 
195
287
  ```html
196
288
  <script src="https://clayjs.com/v1/clay.js?plugins=sync,sortable,undo,indicator&exclude=richclay"></script>
197
289
  ```
198
290
 
199
- - It must be a **classic script** with a `src`. The loader finds its own URL through `document.currentScript` to locate its modules. As `type="module"` or pasted inline it logs an error and never boots.
291
+ - **Use a classic script with a `src`.** The loader reads `document.currentScript` to find its own URL and locate its modules. As `type="module"` or pasted inline, it logs an error and never boots.
200
292
  - **`/v1/`** is the rolling 1.x alias, cached for 10 minutes: files get fixes automatically. **`/1.8.2/`** and every exact version are immutable for a year: use one when a file must never change behavior.
201
- - **Every tag on the page uses the same prefix.** Satellites import ClayJS modules relative to their own URL. `clay.js` from `/1.8.2/` with `clay-ui.js` from `/v1/` gives two copies of shared modules: transforms registered through one are invisible to the other, and save feedback appears twice.
293
+ - **Use the same URL prefix on every tag.** Companion scripts (satellites, such as `clay-ui.js`) import ClayJS modules relative to their own URL. `clay.js` from `/1.8.2/` with `clay-ui.js` from `/v1/` gives two copies of shared modules: transforms registered through one are invisible to the other, and save feedback appears twice.
202
294
  - npm and jsDelivr package paths work too (`@panphora/clayjs`).
203
- - Put the tags at the end of `<body>`. The loader waits for DOMContentLoaded anyway.
295
+ - Put the tags at the end of `<body>`. The loader waits for `DOMContentLoaded` anyway.
204
296
 
205
- `plugins=` and `exclude=` are comma lists. Only the first of each parameter counts. Unknown names warn and are ignored.
297
+ `plugins=` and `exclude=` are comma-separated lists. Only the first of each parameter counts. Unknown names warn and are ignored.
206
298
 
207
- ### 3.2 Plugins
299
+ ### 2.2 Plugins
300
+
301
+ Plugins add editing, sync, and UI features to `clay.js`. Most are off by default, and edit-only plugins skip view mode.
208
302
 
209
303
  | Plugin | Default | Loads in view mode | Adds |
210
304
  |---|---|---|---|
@@ -219,51 +313,77 @@ The test suite checks this file opens without becoming dirty, saves the record a
219
313
  | `quickcrop` | off | yes | `clay.quickcrop` |
220
314
  | `cms` | off | yes | `clay.cms`; implies `quickcrop` and `upload` |
221
315
  | `wire` | off | yes | `clay.wire`, the channel to a local agent process |
222
- | `message` | off | yes | `message-form`: a visitor's contact form to the owner, `clay.message` (8.13) |
316
+ | `message` | off | yes | `message-form`: a visitor's contact form to the owner, `clay.message` ([message: contact forms](https://clayjs.com/docs/guide/components/#message-contact-forms)) |
223
317
  | `ai-edit` | off | no | select text, ask an agent to rewrite it; implies `wire` |
224
318
  | `demo` | off | yes | fakes saving into `localStorage` for public demos |
225
- | `visitor-drafts` | off | yes | the draft store, `clay.visitorDrafts`, the download prompt, the intro area and the owner chip on a page that opted in (14) |
226
- | `derive` | on for any page with a `[sap]` root | yes | the sapjs core: records and paints, treated as derived (8.11) |
319
+ | `visitor-drafts` | off | yes | `clay.visitorDrafts`; in draft mode, the draft store, download prompt and intro area; outside it, only the owner chip, fetched when the owner edits ([Free documents for visitors](https://clayjs.com/docs/guide/free-documents/)) |
320
+ | `derive` | on for any page with a `[sap]` root | yes | the sapjs core: records and paints, treated as derived ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)) |
227
321
 
228
- - Edit-only plugins never load in view mode, even when named. Their members are absent there: test `clay.isEditMode` or `'save' in clay`, never the presence of `clay.RichClay` alone.
322
+ - Edit-only plugins never load in view mode, even when named. Their members are absent there: test `clay.isEditMode` or `'save' in clay`, never `clay.RichClay` alone.
229
323
  - `exclude` wins over implications. `exclude=wire` also drops `ai-edit`.
230
324
  - Load order is fixed regardless of how you list them.
231
- - `exclude=richclay` when the app has no rich text. `exclude=source` saves one request and about 48 KB, at the cost of saves reprinting the whole document instead of keeping hand formatting.
232
- - Nothing implies `undo`, on purpose: it intercepts Cmd+Z in every text field on the page (8.3).
233
- - `derive` loads by itself when the page has a `[sap]` root at boot, and stays idle when full sapjs is on the page. `exclude=derive` opts out; `plugins=derive` loads it for a page that adds its `[sap]` root later.
234
- - `visitor-drafts` is inert outside draft mode: named on a page with no `visitor-drafts` slot, no `api` rules tag, or a rules tag this version cannot read (not `data-rules-version="1"`, or not JSON), it loads, warns in the console and draws nothing. Section 14 covers the opt in.
325
+ - Use `exclude=richclay` when the app has no rich text. `exclude=source` saves one request and about 48 KB, at the cost of saves reprinting the whole document instead of keeping hand formatting.
326
+ - Nothing implies `undo`, on purpose: it intercepts Cmd+Z in every text field on the page ([undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo)).
327
+ - In view mode, `page` loads only its reader half and nothing it implies. A Document on `plugins=page` resolves to `page-view`, with no live data service, no generic `sync`, and no `upload`. Name `sync` as well (`plugins=page,sync`) when the reader should take its saved files from sync's lane instead of its own.
328
+ - `derive` loads automatically when the page has a `[sap]` root at boot, and stays idle when full sapjs is on the page. `exclude=derive` opts out; `plugins=derive` loads it for a page that adds its `[sap]` root later.
329
+ - `visitor-drafts` loads its draft store only in draft mode. A reader or an owner editing the original gets a small module that publishes an inert `clay.visitorDrafts` and, for the owner, the owner chip. On a page with no `visitor-drafts` slot, no `api` rules tag, or a rules tag this version cannot read (not `data-rules-version="1"`, or not JSON), it warns in the console and draws nothing. [Free documents for visitors](https://clayjs.com/docs/guide/free-documents/) covers the opt in.
235
330
 
236
331
  **A good default for apps:** `?plugins=sync,sortable,undo,indicator`, plus `upload` for images or attachments, plus `exclude=richclay` when there is no rich text.
237
332
 
238
- ### 3.3 Satellites
333
+ ### 2.3 Satellites
239
334
 
240
- Satellites are separate classic script tags. Each publishes a promise on `clay.loaded`.
335
+ Satellites are separate classic script tags. Each publishes a readiness promise on `clay.loaded`.
241
336
 
242
337
  | Tag | Promise | Adds | Section |
243
338
  |---|---|---|---|
244
- | `clay-ui.js` | `clay.loaded.ui` | `clay.toast`, `toastPersistent`, `ask`, `confirm`, `tell`, `snippet`, `modal`; automatic save feedback toasts | 8.6 |
245
- | `clay-options.js` | `clay.loaded.options` | `show-when:`, `hide-when:`, `option:`, `option-not:` visibility | 4.3 |
246
- | `clay-events.js` | `clay.loaded.events` | `onclickaway`, `onclickchildren`, `onclone`, `onmutation`, `onglobalmutation`, `onrender` | 8.7 |
247
- | `clay-dom.js` | `clay.loaded.dom` | `el.nearest`, `el.val`, `el.text`, `el.exec`, `cycleAttr`, `clay.dom.createContentView` | 8.7 |
248
- | `all.js` | `clay.loaded.all` | `All(selector)` collection helper | 8.7 |
339
+ | `clay-ui.js` | `clay.loaded.ui` | `clay.toast`, `toastPersistent`, `ask`, `confirm`, `tell`, `snippet`, `modal`; automatic save feedback toasts | [clay-ui: toasts and dialogs](https://clayjs.com/docs/guide/components/#clay-ui-toasts-and-dialogs) |
340
+ | `clay-options.js` | `clay.loaded.options` | `show-when:`, `hide-when:`, `option:`, `option-not:` visibility | [Showing and hiding edit controls: clay-options](https://clayjs.com/docs/guide/edit-mode/#showing-and-hiding-edit-controls-clay-options) |
341
+ | `clay-events.js` | `clay.loaded.events` | `onclickaway`, `onclickchildren`, `onclone`, `onmutation`, `onglobalmutation`, `onrender` | [clay-events, clay-dom and All](https://clayjs.com/docs/guide/components/#clay-events-clay-dom-and-all) |
342
+ | `clay-dom.js` | `clay.loaded.dom` | `el.nearest`, `el.val`, `el.text`, `el.exec`, `cycleAttr`, `clay.dom.createContentView` | [clay-events, clay-dom and All](https://clayjs.com/docs/guide/components/#clay-events-clay-dom-and-all) |
343
+ | `all.js` | `clay.loaded.all` | `All(selector)` collection helper | [clay-events, clay-dom and All](https://clayjs.com/docs/guide/components/#clay-events-clay-dom-and-all) |
249
344
  | `clay-utils.js` | `clay.loaded.utils` | `clay.utils.debounce`, `throttle`, `cookie.get/remove`, `slugify`, `copyToClipboard` | |
250
345
  | `clay-internals.js` | `clay.loaded.internals` | `clay.internals`: capture, transforms, raw save | |
251
- | `clay-data.js` | `clay.loaded.data` | `clay.extractData`, `clay.applyData` | 9.9 |
252
- | `sap.js` | `clay.loaded.sap` | `Sap`, the reactive attribute layer | 8.11 |
346
+ | `clay-data.js` | `clay.loaded.data` | `clay.extractData`, `clay.applyData` | [Import and export](https://clayjs.com/docs/guide/architecture/#import-and-export) |
347
+ | `sap.js` | `clay.loaded.sap` | `Sap`, the reactive attribute layer | [sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup) |
348
+
349
+ Satellites do not wait for `clay.ready` and work without `clay.js`, including in view mode and on a static host. In code that runs early, await the promise before using what it adds (`await clay.loaded.ui` before `clay.toast`). Inside a `clay.ready` callback, satellites have usually finished loading, so `clay.toast?.(...)` is safe and the optional call costs nothing.
350
+
351
+ `clay.standalone.js` bundles the loader, every plugin, and every satellite into one 2 MB file for offline use. `plugins=` still decides what runs. With it, remove the satellite tags and always `await clay.loaded.sap` or `.data` before use.
352
+
353
+ In Hyperclay Local and HTML Clay, a document needs no special loading to work offline. Keep the ordinary `https://clayjs.com/v1/` tags and the host serves them from its bundled release. Use `clay.standalone.js` for a file opened with no host at all. [Offline in a local host](https://clayjs.com/docs/guide/hosts/#offline-in-a-local-host) covers the route and what a host must do.
354
+
355
+ ### 2.4 `clay.ready`
253
356
 
254
- Satellites do not wait for `clay.ready` and work without `clay.js` at all, including in view mode and on a static host. Await the promise before using what it adds: `await clay.loaded.ui` before `clay.toast` in code that runs early. Code inside a `clay.ready` callback can usually use `clay.toast?.(...)` safely, since the satellites finish loading at about the same time, but the optional call costs nothing.
357
+ `clay.ready` is a promise that resolves with `window.clay` once boot is done and every plugin has loaded. `clay:ready` fires on `document` at the same moment. Before it resolves, members like `clay.save` and `clay.undo` do not exist. Start app code with `clay.start(...)`: it waits for `clay.ready` and runs once per page ([Starting the app once](https://clayjs.com/docs/guide/architecture/#starting-the-app-once)).
255
358
 
256
- `clay.standalone.js` bundles the loader, every plugin and every satellite into one 2 MB file for offline use. `plugins=` still decides what runs. With it, remove the satellite tags and always `await clay.loaded.sap` or `.data` before use.
359
+ `clay.plugins` is the frozen list of plugin names in the plan the loader resolved, late plugins included. It says what the page asked for, not what has finished loading.
257
360
 
258
- ### 3.4 `clay.ready`
361
+ `clay.ready` means the loader is done, not that a plugin has finished its own startup. The `page` plugin keeps going: it exports no `ready`, so `clay.ready` fires while the person still sees the article's saved content. `clay.page.ready` is that plugin's boot, and `clay.page.status` is its current result, `{ phase, reason, installId, at }`. Await it before touching `clay.page`:
259
362
 
260
- `clay.ready` is a promise that resolves with `window.clay` once boot is done and every plugin has loaded. `clay:ready` fires on `document` at the same moment. Before it resolves, members like `clay.save` and `clay.undo` do not exist. Start app code with `clay.start(...)`: it waits for `clay.ready` and runs once per page (9.10).
363
+ ```js
364
+ clay.start("document-ui", async () => {
365
+ const boot = await clay.page?.ready;
366
+ if (boot?.phase !== "ready") return;
367
+ document.documentElement.setAttribute("document-ready", "true");
368
+ });
369
+ ```
370
+
371
+ Boot moves through `loading` (the saved article is read and validated), `joining` (the editor's code downloads beside the room join, plus capability, the first save, and the page handle), and `adopting` (the editor is mounted and the saved article is adopted into the room). It then settles at `ready` (input enabled), `blocked`, or `error`. Each boundary is a `performance.mark` carrying `{ detail: { installId } }`:
372
+
373
+ | Mark | Exact boundary |
374
+ |---|---|
375
+ | `document:boot` | an installation starts, before the source is validated |
376
+ | `document:assets-ready` | the editor's code is in hand |
377
+ | `document:authority-ready` | the capability and first-save gates passed, the page handle is admitted and the binding is created |
378
+ | `document:input-ready` | adoption succeeded and editable was enabled on the current mounted view |
379
+
380
+ A failed or cancelled installation never emits `document:input-ready`. Blocked readiness still resolves: `clay.page.ready` settles, `clay.page.status.phase` is `blocked` with a `reason`, the saved article stays as the host served it, and no editable page appears. [When the page editor is ready](https://clayjs.com/docs/guide/edit-mode/#when-the-page-editor-is-ready) covers the reasons and what a page can offer instead.
261
381
 
262
382
  You can set configuration before the tag, and the loader merges into it: `window.clay = { saveToast: true }`.
263
383
 
264
- ### 3.5 Theming ClayJS's own UI
384
+ ### 2.5 Theming ClayJS's own UI
265
385
 
266
- ClayJS draws a few things on your page: dialogs (`clay.ask`, `clay.confirm`, `clay.tell`, `clay.snippet`, `clay.modal`) and the people plugin's name prompt. By default they take your page's font, and on a page with a plain solid background, its background and text colours. Everything else comes from ClayJS's own built-in look.
386
+ ClayJS draws dialogs (`clay.ask`, `clay.confirm`, `clay.tell`, `clay.snippet`, `clay.modal`) and the `people` plugin's name prompt. By default they take your page's font. On a page with a plain solid background, they also take its background and text colours. Everything else comes from ClayJS's built-in look.
267
387
 
268
388
  To match them to your design, set `window.clayTheme` before the loader tag:
269
389
 
@@ -278,81 +398,113 @@ window.clayTheme = {
278
398
  ```
279
399
 
280
400
  - **Tokens** set the basics: `font`, `headingFont`, `text`, `mutedText`, `background`, `surface`, `border`, `accent`, `accentText`, `danger`, `dangerText`, `success`, `warning`, `radius`, `shadow`, `overlay`, `spacing`, `buttonHeight`. Each is a CSS value, and `var(--your-variable, fallback)` works. A page can also set a token in CSS as `--clay-ui-<name>` (`--clay-ui-accent-text` for `accentText`). `spacing` is one length, such as `20px`.
281
- - **Parts** set anything else. Every element ClayJS draws has a stable name, such as `dialog.panel`, `dialog.title`, `button.primary` or `people.choice`, and each takes `base`, `hover`, `active`, `focus` and `disabled` declarations. A part names its surface, so `people.title` overrides `dialog.title` in the name prompt only. The names are in `data-clay-part` on each element, and the full list is in `docs/injected-ui.md`.
282
- - A value wins in this order: `tokens`, then `--clay-ui-*` properties, then the page's sampled font and colours, then the built-in look.
401
+ - **Parts** set anything else. Every element ClayJS draws has a stable name in `data-clay-part`, such as `dialog.panel`, `dialog.title`, `button.primary`, or `people.choice`. Each takes `base`, `hover`, `active`, `focus`, and `disabled` declarations. A part names its surface, so `people.title` overrides `dialog.title` in the name prompt only. The full list is in `docs/injected-ui.md`.
402
+ - Values win in this order: `tokens`, then `--clay-ui-*` properties, then the page's sampled font and colours, then the built-in look.
283
403
  - `window.clayTheme = false` keeps the exact built-in look. After loading, `clay.theme(options)` replaces the theme, `clay.theme(false)` and `clay.theme(null)` restore the built-in look or the default, and `clay.theme()` returns the current one. A change affects the next dialog, not one already open.
284
404
 
285
- Your stylesheet still cannot reach these elements: every declaration is inline and `!important`, so a stray `button { ... }` rule never breaks a prompt. On a phone, dialog buttons stay at least 44px tall whatever the theme asks for.
405
+ Your stylesheet cannot reach these elements: every declaration is inline and `!important`, so a stray `button { ... }` rule never breaks a prompt. On a phone, dialog buttons stay at least 44px tall regardless of the theme.
406
+
407
+ ### 2.6 Plugins that load late
408
+
409
+ `source`, `sortable`, and `menu` load after `clay:ready`, one at a time, in the order the plan resolves them. Nothing on first paint or the first keystroke waits for them. A late plugin that fails to load is logged and left absent, like any other.
410
+
411
+ Each late plugin fires `clay:plugin-ready` on `document` once its own `ready` settles, with `detail.plugin` naming it. Use this event to tune something the plugin built for itself. For example, a Kanban board waits for `sortable` and then sets the drag delay its cards need on a phone:
412
+
413
+ ```js
414
+ document.addEventListener("clay:plugin-ready", (event) => {
415
+ if (event.detail.plugin !== "sortable") return;
416
+ for (const list of document.querySelectorAll("ol.cards")) {
417
+ const sortable = window.Sortable?.get?.(list);
418
+ sortable?.option("delay", 220);
419
+ sortable?.option("delayOnTouchOnly", true);
420
+ }
421
+ });
422
+ ```
423
+
424
+ Do not read `window.Sortable` in a `clay.ready` callback: the plugin may not have arrived. `clay.plugins` tells you it is coming; `clay:plugin-ready` tells you it is here.
425
+
426
+ ### 2.7 Libraries on demand
427
+
428
+ `clay.assets.load(name)` loads a library from the same release that served `clay.js` the first time a page asks for it. The document needs no vendor URL, so a copy served by another host or opened from disk still finds it. The names are `overtype`, `marked`, and `highlight`, plus one grammar per `highlight/<language>` for `bash`, `css`, `javascript`, `json`, `python`, and `xml`. `clay.assets.names()` lists them.
429
+
430
+ ```js
431
+ const { Marked } = await clay.assets.load("marked");
432
+ const { default: hljs } = await clay.assets.load("highlight");
433
+ const { default: javascript } = await clay.assets.load("highlight/javascript");
434
+ hljs.registerLanguage("javascript", javascript);
435
+ ```
436
+
437
+ `overtype` resolves the vendored OverType editor with its toolbar buttons. `marked` resolves the `Marked` class. `highlight` resolves the core with no grammar of its own, so a page registers only the grammars it uses. Each name loads once: a second call before the first settles shares it, a failed one is forgotten and can be tried again, and an unknown name rejects with `clay.assets: no asset named "<name>"`. The [`window.clay` reference](https://clayjs.com/docs/api/) lists what each name resolves to.
286
438
 
287
439
  ---
288
440
 
289
- ## 4. Edit mode and view mode
441
+ ## 3. Edit mode and view mode
290
442
 
291
- ### 4.1 How the mode is decided
443
+ ### 3.1 How the mode is decided
292
444
 
293
- `clay.isEditMode` is decided once at load, by the first of these that applies:
445
+ `clay.isEditMode` is decided once at load by the first rule that matches:
294
446
 
295
- 1. `?editmode=true` or `?editmode=false` in the URL. Only the literal `true` means edit.
296
- 2. `<html viewonly>` in the file means view. So does a response carrying only the pre-1.9.0 token `htmlclaytoken` (an out-of-date HTML Clay, which also shows an update bar).
447
+ 1. `?editmode=true` or `?editmode=false` in the URL. Only the literal `true` enables edit mode.
448
+ 2. `<html viewonly>` in the file forces view mode. So does a response carrying only the pre-1.9.0 token `htmlclaytoken` (an out-of-date HTML Clay, which also shows an update bar).
297
449
  3. `window.clayEditMode`, set by a script before boot.
298
450
  4. A `savetoken` attribute the host put on `<html>` (HTML Clay 1.9.0 and newer).
299
- 5. **Draft mode**, on a page that opted in (14): the visitor gets the editing UI and their work stays in their own browser. `?editmode=false`, a `viewonly` attribute, a stale token, `window.clayEditMode` or a save token all turn it off, and only the owner cookie plus `?editmode=true` edits the original, except on a `viewonly` document or one with a stale token, where `?editmode=true` is the plain edit mode it always was (14.3).
451
+ 5. **Draft mode**, on a page that opted in ([Free documents for visitors](https://clayjs.com/docs/guide/free-documents/)). The visitor gets the editing UI, and their work stays in their own browser. `?editmode=false`, a `viewonly` attribute, a stale token, `window.clayEditMode`, or a save token all turn draft mode off. Only the owner cookie plus `?editmode=true` edits the original. On a `viewonly` document or one with a stale token, `?editmode=true` is plain edit mode as usual ([The mode ladder](https://clayjs.com/docs/guide/free-documents/#the-mode-ladder)).
300
452
  6. The `isAdminOfCurrentResource` cookie (hyperclay.com, Hyperclay Local).
301
453
 
302
- Edit mode only asks for the editing UI. On a host that has not authorized this person every save is refused (`savestatus="error"`). The host decides who writes.
454
+ Edit mode only turns on the editing UI. The host decides who writes. On a host that has not authorized the current user, every save is refused (`savestatus="error"`).
303
455
 
304
- `clay.isOwner` reflects the cookie alone. On HTML Clay an editable page has `isOwner === false`. Use `clay.isEditMode` for "can edit", never `isOwner`.
456
+ `clay.isOwner` reflects the cookie alone. On HTML Clay, an editable page has `isOwner === false`. Check `clay.isEditMode` for whether the page can be edited, never `isOwner`.
305
457
 
306
458
  `clay.toggleEditMode()` flips `?editmode=` and reloads.
307
459
 
308
- ### 4.2 The root attributes
460
+ ### 3.2 The root attributes
309
461
 
310
- At load ClayJS sets `editmode="true|false"` and `pageowner="true|false"` on `<html>`, in both modes. In edit mode it also sets `savestatus` (`saving`, `saved`, `error`, `offline`, `conflict`) once the page has settled. **All three are removed from every save,** as are the host's `savetoken` and `documentetag`. The saved file's `<html>` carries none of them.
462
+ At load, ClayJS sets `editmode="true|false"` and `pageowner="true|false"` on `<html>` in both modes. In edit mode, it also sets `savestatus` (`saving`, `saved`, `error`, `offline`, `conflict`) once the page settles. **All three are removed on every save**, along with the host's `savetoken` and `documentetag`. The saved file's `<html>` carries none of them.
311
463
 
312
- Consequence: before ClayJS runs, `editmode` is **absent**, not `"false"`. CSS keyed on `html[editmode="false"]` shows edit chrome to visitors until boot. Key on `html:not([editmode="true"])` instead.
464
+ Before ClayJS runs, `editmode` is **absent**, not `"false"`. CSS keyed on `html[editmode="false"]` shows edit chrome to visitors until boot. Key on `html:not([editmode="true"])` instead.
313
465
 
314
- ### 4.3 Showing and hiding edit controls: clay-options
466
+ ### 3.3 Showing and hiding edit controls: clay-options
315
467
 
316
- Load `clay-options.js` and mark edit-only controls with `show-when:editmode="true"`:
468
+ Load the `clay-options.js` satellite and mark edit-only controls with `show-when:editmode="true"`:
317
469
 
318
470
  ```html
319
471
  <button type="button" data-action="add" show-when:editmode="true">+ Add book</button>
320
472
  ```
321
473
 
322
- The satellite compiles each distinct pattern into one CSS rule in `<style data-name="option-visibility">` in `<head>`, so showing and hiding costs no JavaScript and respects the element's own `display` when shown. That style is saved. After the first save, a viewer's browser hides edit controls at first paint, before any script runs.
474
+ The satellite compiles each distinct pattern into one CSS rule in `<style data-name="option-visibility">` in `<head>`. Showing and hiding uses no JavaScript and preserves each element's own `display` when shown. Because that `<style>` is saved, a viewer's browser hides edit controls at first paint after the first save, before any script runs.
323
475
 
324
- **Before the first save** there is no saved rule. Add the fallback rule to your own stylesheet in every file, as the skeleton does:
476
+ **Before the first save**, there is no saved rule. Add this fallback rule to your stylesheet in every file, as the skeleton does:
325
477
 
326
478
  ```css
327
479
  html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }
328
480
  ```
329
481
 
330
- The family:
482
+ Visibility attributes:
331
483
 
332
484
  | Attribute | Visible when |
333
485
  |---|---|
334
- | `show-when:NAME="a\|b"` (alias `option:NAME`) | the element or any ancestor has `NAME` equal to `a` or `b` |
335
- | `hide-when:NAME="a\|b"` | hidden when the element or any ancestor has `NAME` equal to one of them; shown otherwise, including when no ancestor has `NAME` |
336
- | `option-not:NAME="a"` | some ancestor has `NAME` with a value other than `a`; hidden when no ancestor has `NAME` |
486
+ | `show-when:NAME="a\|b"` (alias `option:NAME`) | The element or any ancestor has `NAME` equal to `a` or `b`. |
487
+ | `hide-when:NAME="a\|b"` | Hidden when the element or any ancestor has `NAME` equal to one of them; shown otherwise, including when no ancestor has `NAME`. |
488
+ | `option-not:NAME="a"` | An ancestor has `NAME` with a value other than `a`; hidden when no ancestor has `NAME`. |
337
489
 
338
- - It matches **any** ancestor, not the nearest. Nested views need different attribute names (`view` on the app, `cardview` on a card).
339
- - It compares literal strings only. Expressions belong to sap's `show=` (8.11).
490
+ - Rules match **any** ancestor, not the nearest. Nested views need different attribute names (`view` on the app, `cardview` on a card).
491
+ - Rules compare literal strings only. For expressions, use sap's `show=` ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)).
340
492
  - `show-when:savestatus="error|offline"` shows a "not saved" warning. `show-when:pageowner="true"` shows owner links.
341
- - For content only viewers should see, use `hide-when:editmode="true"`: it is visible when `editmode` is absent. `show-when:editmode="false"` is hidden until boot and then appears.
342
- - The attribute you switch views with is ordinary saved content. Setting `view="list"` on a saved element saves it and syncs it to every collaborator. Section 9.3 covers tab-local views.
493
+ - For viewer-only content, use `hide-when:editmode="true"`, which is visible when `editmode` is absent. `show-when:editmode="false"` stays hidden until boot and then appears.
494
+ - The attribute that switches views is ordinary saved content. Setting `view="list"` on a saved element saves and syncs it to every collaborator. [Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state) covers tab-local views.
343
495
 
344
- ### 4.4 Mode attributes: one file for editors and viewers
496
+ ### 3.4 Mode attributes: one file for editors and viewers
345
497
 
346
- Four attributes make an element behave differently by mode. A **save transform** writes the inert form into the file, and **edit mode** activates it on load. View mode never loads the code, so a viewer sees exactly what the file holds.
498
+ Four attributes make an element behave differently by mode. A **save transform** writes the inert form into the file, and **edit mode** activates it on load. View mode never loads the activation code, so a viewer sees what the file holds.
347
499
 
348
500
  | Attribute | In the saved file | In edit mode |
349
501
  |---|---|---|
350
502
  | `editmode:contenteditable` | `contenteditable` renamed to `inert-contenteditable` (value kept) | `contenteditable` restored |
351
503
  | `editmode:onclick` | `onclick` renamed to `inert-onclick` | `onclick` restored |
352
- | `editmode:resource` on `<script>`, `<style>`, `<link>` | `type` prefixed `inert/` | the `inert/` prefix stripped and the element re-inserted so it runs |
504
+ | `editmode:resource` on `<script>`, `<style>`, `<link>` | `type` prefixed `inert/` | `inert/` prefix stripped and element re-inserted so it runs |
353
505
  | `viewmode:disabled`, `viewmode:readonly` on controls | `disabled` / `readonly` added | removed |
354
506
 
355
- There is no general `editmode:<anything>`; these four are all.
507
+ There is no general `editmode:<anything>`. Only these four attributes exist.
356
508
 
357
509
  ```html
358
510
  <span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">Title</span>
@@ -361,9 +513,9 @@ There is no general `editmode:<anything>`; these four are all.
361
513
  <script editmode:resource type="inert/text/javascript" src="board-editor.js"></script>
362
514
  ```
363
515
 
364
- **Author records in the saved form**, as above. A file written with live `contenteditable` or without `disabled` gives every viewer editable text and live checkboxes until the owner's first save rewrites it. The test suite checks the examples' view mode from the very first open.
516
+ **Author records in the saved form**, as above. A file written with live `contenteditable` or without `disabled` gives every viewer editable text and live checkboxes until the owner's first save rewrites it. The test suite checks each example's view mode from the very first open.
365
517
 
366
- **Write templates in the active form.** The transform does not reach `<template>` content, and a clone is inserted while the owner is editing. A clone carrying `inert-contenteditable` would not be editable until reload. On the next save the transform converts the clone like any other element.
518
+ **Write templates in the active form.** The save transform does not reach `<template>` content, and clones are inserted while the owner edits. A clone with `inert-contenteditable` would stay uneditable until reload. On the next save, the transform converts the clone like any other element.
367
519
 
368
520
  ```html
369
521
  <template id="item-tpl">
@@ -371,75 +523,112 @@ There is no general `editmode:<anything>`; these four are all.
371
523
  </template>
372
524
  ```
373
525
 
374
- `contenteditable="plaintext-only"` is the right value for titles, labels and cells: no formatting, no pasted markup. Make Enter blur instead of inserting a line break, as in the skeleton's keydown handler. Use `editable` (8.1) only where rich text is the point.
526
+ Use `contenteditable="plaintext-only"` for titles, labels, and cells to prevent formatting and pasted markup. Make Enter blur instead of inserting a line break, as in the skeleton's keydown handler. Use `editable` ([richclay: rich text (`editable`)](https://clayjs.com/docs/guide/components/#richclay-rich-text-editable)) only where you need rich text.
527
+
528
+ ### 3.5 What works in view mode
529
+
530
+ In view mode, `window.clay` keeps `ready`, `isEditMode`, `isOwner`, `toggleEditMode`, `Mutation`, `region`, `readData`, `writeData`, and `hasUnsavedChanges` (always `false`). It also includes whatever view-mode plugins and satellites add (`clay.conflicts` and `clay.morph` with `sync`, `clay.cms`, `clay.quickcrop`, `clay.toast`, and others). There is no `save`, `getHTML`, `addDocumentTransform`, `onSnapshot`, `undo`, `RichClay`, or `upload`, and no `markDirty` unless `sync` is loaded (where it is harmless).
375
531
 
376
- ### 4.5 What a viewer gets
532
+ - Guard edit-only APIs with optional chaining (`clay.addDocumentTransform?.(fn)`), or return early with `if (!clay.isEditMode) return`. An unguarded call throws and stops the rest of the script for viewers.
533
+ - Click handlers run for viewers too. Hidden buttons cannot be clicked, but guard edit actions anyway with `if (!clay.isEditMode) return`, as the examples do.
534
+ - A viewer can still use read-only interactions: open a card, filter, search, export, or scroll the outline. Those use chrome and tab-local state, so they work the same in both modes.
535
+ - With `sync` loaded, a viewer rides the saved lane and receives each accepted save, so a published page updates live ([What sync is](https://clayjs.com/docs/guide/live-sync/#what-sync-is)).
536
+ - Clicking inside `[trigger-save]` in view mode dispatches `clay:view-save-attempt`, so you can explain why nothing saved.
377
537
 
378
- In view mode `window.clay` keeps `ready`, `isEditMode`, `isOwner`, `toggleEditMode`, `Mutation`, `region`, `readData`, `writeData` and `hasUnsavedChanges` (always `false`), plus whatever view-mode plugins and satellites add (`clay.conflicts` and `clay.morph` with `sync`, `clay.cms`, `clay.quickcrop`, `clay.toast` and the rest). There is no `save`, `getHTML`, `addDocumentTransform`, `onSnapshot`, `undo`, `RichClay`, `upload`, and no `markDirty` unless `sync` is loaded (it is harmless there).
538
+ ### 3.6 When the page editor is ready
379
539
 
380
- - Call edit-only APIs as optional calls (`clay.addDocumentTransform?.(fn)`), or return early with `if (!clay.isEditMode) return`. An unguarded call throws and kills the rest of the script for viewers.
381
- - Click handlers run for viewers too. Hidden buttons cannot be clicked, but guard edit actions anyway, as the examples do with `if (!clay.isEditMode) return`.
382
- - A viewer can still use read-only interactions: open a card, filter, search, export, scroll the outline. Those are chrome and tab-local state, so they work the same in both modes.
383
- - With `sync` loaded a viewer rides the saved lane and receives each accepted save, so a published page updates live (7.1).
384
- - A click inside `[trigger-save]` in view mode dispatches `clay:view-save-attempt`, so you can explain why nothing saved.
540
+ Edit mode is decided at load, but a `page` document is ready to edit later. `clay.page.ready` resolves when its editor is mounted and accepts typing. `clay.page.status` holds that boot result: `{ phase, reason, installId, at }`.
541
+
542
+ `clay.ready` does not wait for the page editor. Because `plugins/page.js` exports no `ready`, `await clay.ready` can resolve while the saved article is still on screen. Late plugins do wait, so a page that ships with them needs nothing extra.
543
+
544
+ Each boot stage records a `performance.mark` with `{ detail: { installId } }`:
545
+
546
+ - `document:boot`: an installation starts, before the source is validated.
547
+ - `document:assets-ready`: the editor's code is loaded.
548
+ - `document:authority-ready`: the capability and first-save gates passed, the page handle is admitted, and the binding is created. Writer election and the article's feature views are still ahead.
549
+ - `document:input-ready`: adoption succeeded and editing was enabled on the mounted view. A failed or cancelled installation never emits it.
550
+
551
+ When boot is blocked, `clay.page.ready` still resolves rather than throwing an error. `clay.page.status.phase` is `blocked`, and `reason` names where boot stopped (`missing-capability`, `first-save-refused`, `first-save-conflict`, `admission-failed`, `editor-assets-failed`, `mount-failed`, `adoption-refused`, `adoption-timeout`, `adoption-error`, `page-replaced`, `view-detached`, `no-page`, `ambiguous-page`, `invalid-page-source`, `invalid-page-json`, `unsupported-page-format`, `missing-page-store`, `boot-incomplete`). Nothing accepts keystrokes: the saved article stays as the host served it, or an already-mounted editor is left read-only because the page was replaced. Base your fallback on `phase`, not on a caught exception:
552
+
553
+ ```js
554
+ const boot = await clay.page?.ready;
555
+ if (boot?.phase !== "ready") document.documentElement.setAttribute("read-only", "true");
556
+ ```
557
+
558
+ `clay.page.install()` disposes the last installation and starts a fresh one with its own id, letting a page retry after a failure without reloading.
559
+
560
+ ### 3.7 The page editor on a phone
561
+
562
+ At widths up to 560 px, the page editor's toolbar keeps four controls: Bold, Italic, Link, and More. Each is at least 44 by 44 CSS px inside a 204 px bar with 4 px gaps and 8 px padding. The selection bubble is hidden so two bars never appear at once.
563
+
564
+ More holds the rest: Strikethrough, Code, bulleted, numbered, and checklist lists, Turn into, Insert, Move up, Move down, Indent, and Outdent. Turn into and Insert open the same pickers used on desktop. Move and indent actions apply to the caret's block when nothing more is selected.
565
+
566
+ The bar is positioned from the visual viewport, not the layout viewport. Its top and horizontal bounds come from the visible area, so an on-screen keyboard cannot cover it and panning the visual viewport moves it with the page. The safe-area inset is read once per layout frame. Menus stay inside the visual viewport, open above the bar when there is room, and scroll internally. Desktop layouts are unchanged.
567
+
568
+ ### 3.8 Keyboard navigation in the page editor
569
+
570
+ Escape closes an open menu or the link popover first, returning focus to the control that opened it or to the editor.
571
+
572
+ From a block selection, Escape moves focus from the writing surface to the first toolbar control while keeping the selection, so the keyboard is never trapped in `contenteditable`. In the toolbar, Left and Right move between controls, and Escape returns to the editor. The toolbar is a single tab stop with a roving tabindex: Tab leaves it forward and Shift-Tab leaves it backward. Inside a table, Tab moves across cells and Shift-Tab moves back. The editor takes Tab for nothing else.
573
+
574
+ A table's column resize handle is a focusable vertical separator named for its column, with `aria-valuemin` 48, `aria-valuemax` 1200, and the current width in `aria-valuenow`. Left and Right preview a 10 px change (50 px with Shift), while Home and End preview the minimum and maximum. Enter commits the change, and Escape cancels it. Holding an arrow key previews across repeated keydowns. The keyup that ends the repeat, Enter, or leaving the handle commits one command, so a held arrow key records a single undo item. Double-click fits the column, and pointer dragging is unchanged. On a phone, the column's Width menu (Wider, Narrower, Fit to content, and Reset width once the column has a width) does the same job.
385
575
 
386
576
  ---
387
577
 
388
- ## 5. The record
578
+ ## 4. What gets saved
389
579
 
390
- ### 5.1 What a save writes
580
+ ### 4.1 What a save writes
391
581
 
392
- A save writes the current document with these changes:
582
+ A save writes the current DOM with these changes:
393
583
 
394
- - Subtrees marked `no-save`, `no-snapshot` or `editor-ui` are removed.
395
- - `freeze` elements are written with the inner HTML they had when they first appeared (at load, or when inserted later). Their contents are a per-tab scratchpad.
396
- - Mode attributes go inert (4.4). Root attributes `editmode`, `pageowner`, `savestatus`, `savetoken`, `documentetag` are removed.
397
- - `persist` controls write their live values (5.3).
398
- - Runtime residue of ClayJS's own editors (richclay attributes, toolbars) and of browser extensions (password managers, Grammarly, Dark Reader) is stripped.
399
- - Your hooks run (6.6).
584
+ - Subtrees marked `no-save`, `no-snapshot`, or `editor-ui` are removed.
585
+ - `freeze` elements write the inner HTML they had when they first appeared (at load or on insertion). Their live contents are a per-tab scratchpad.
586
+ - Mode attributes go inert ([Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers)). Root attributes `editmode`, `pageowner`, `savestatus`, `savetoken`, and `documentetag` are removed.
587
+ - `persist` controls write their live values ([`persist`: form controls](https://clayjs.com/docs/guide/record/#persist-form-controls)).
588
+ - Runtime residue from ClayJS editors (richclay attributes, toolbars) and browser extensions (password managers, Grammarly, Dark Reader) is stripped.
589
+ - Your hooks run ([Hooks](https://clayjs.com/docs/guide/saving/#hooks)).
400
590
 
401
- Everything else on the page is written as it is. That includes things builders forget:
591
+ Everything else on the page is written as it is, including changes builders forget:
402
592
 
403
- - Classes and attributes toggled for UI: `.selected`, `aria-expanded`, `<details open>`, `hidden`. Mark them `ephemeral` instead (5.7). `<dialog open>` is the exception: it is never saved.
404
- - Inline styles from libraries and helpers: Sortable's `draggable="false"` and `style=""`, `el.hide()`, `All(...).css()`, `body.style.overflow = "hidden"` from an open modal or crop dialog.
405
- - Any element a script inserted outside an excluded region.
593
+ - Classes and attributes toggled for UI: `.selected`, `aria-expanded`, `<details open>`, `hidden`. Mark them `ephemeral` instead ([Ephemeral state: per-tab state on saved elements](https://clayjs.com/docs/guide/record/#ephemeral-state-per-tab-state-on-saved-elements)). `<dialog open>` is the exception: it is never saved.
594
+ - Inline styles from libraries and helpers: Sortable's `draggable="false"` and `style=""`, `el.hide()`, `All(...).css()`, and `body.style.overflow = "hidden"` from an open modal or crop dialog.
595
+ - Any element a script inserts outside an excluded region.
406
596
 
407
- Never saved: JavaScript objects, `localStorage`, canvas pixels, scroll position, focus, selection, shadow DOM content (only the host element's light DOM and attributes), iframe content, the live value of a control without `persist`, and password, hidden and file input values even with `persist`.
597
+ State outside the light DOM and attributes is never saved. This includes JavaScript objects, `localStorage`, canvas pixels, scroll position, focus, and selection. It also excludes shadow DOM content (only the host element's light DOM and attributes are saved), iframe content, the live value of a control without `persist`, and password, hidden, and file input values even with `persist`.
408
598
 
409
- ### 5.2 Regions: the `clay` attribute
599
+ ### 4.2 Regions: the `clay` attribute
410
600
 
411
- `clay="token token"` on an element applies to it and all its descendants. The bare attribute form (`<div no-save>`) means the same.
601
+ Region tokens on the `clay` attribute control how ClayJS saves, syncs, watches, and undoes an element and its descendants. Pass space-separated tokens in `clay="token token"` or use bare attributes like `<div no-save>`. Set tokens on creation or with `clay.region.addRegionToken(el, "editor-ui")`.
412
602
 
413
603
  | Token | Saved file | Sent to peers | Starts autosave | Counts as unsaved | In undo | Use for |
414
604
  |---|---|---|---|---|---|---|
415
605
  | (none) | current | yes | yes | yes | yes | the record |
416
- | `editor-ui` | removed | no | no | no | no | **UI a script fills or changes at runtime, and tab-only derived values** (9.2) |
606
+ | `editor-ui` | removed | no | no | no | no | **UI a script fills or changes at runtime, and tab-only derived values** ([Chrome](https://clayjs.com/docs/guide/architecture/#chrome)) |
417
607
  | `no-save` | removed | carried in the bytes, never applied | no | no | yes | rarely; prefer `editor-ui` |
418
608
  | `no-snapshot` | removed | no (each tab keeps its own) | no | no | yes | part of `editor-ui` |
419
- | `freeze` | element current, contents as when it appeared | element yes, contents never | no | no | yes | a scratchpad: per-tab generated content in a saved element (9.4) |
420
- | `no-dirty` | current | yes, **remote wins** | no | no | yes | derived values the saved file must show (9.4) |
421
- | `no-trigger-autosave` | current | yes | no | **yes** | yes | a heavy editor whose saves you batch yourself (6.3) |
609
+ | `freeze` | element current, contents as when it appeared | element yes, contents never | no | no | yes | a scratchpad: per-tab generated content in a saved element ([Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)) |
610
+ | `no-dirty` | current | yes, **remote wins** | no | no | yes | derived values the saved file must show ([Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)) |
611
+ | `no-trigger-autosave` | current | yes | no | **yes** | yes | a heavy editor whose saves you batch yourself ([Manual-save pages and batching](https://clayjs.com/docs/guide/saving/#manual-save-pages-and-batching)) |
422
612
  | `no-watch` | current | yes, remote wins | no | no | no | rarely: high-frequency content that is disposable |
423
613
  | `no-undo` | current | yes | yes | yes | no | content with its own undo (richclay sets it at runtime) |
424
614
  | `no-data` | current | yes | yes | yes | yes | content the data API and `extractData` must skip |
425
- | `live` | the store's current value, at checkpoints | never through the merge; the `live` plugin carries it (7.9) | no | no | no | a `clay="live"` JSON tag |
615
+ | `live` | the store's current value, at checkpoints | never through the merge; the `live` plugin carries it ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)) | no | no | no | a `clay="live"` JSON tag |
426
616
 
427
- `editor-ui` bundles `no-data no-save no-snapshot no-watch no-undo`. **When chrome needs a region, use `editor-ui`, not `no-save`.** A `no-save` element is still inside the snapshot live sync sends to peers. Peers never apply it, but its text travels to every edit-mode tab (viewers receive the saved file, without it), and `no-save` alone still records undo steps. Older docs advised `clay="no-save no-watch"` for chrome; `editor-ui` replaces that.
617
+ **When chrome needs a region, use `editor-ui`, not `no-save`.** `editor-ui` bundles `no-data no-save no-snapshot no-watch no-undo`. By itself, `no-save` still records undo steps and stays in the snapshot live sync sends to peers. Peers never apply it, but its text travels to every edit-mode tab (viewers receive the saved file without it). `editor-ui` replaces `clay="no-save no-watch"` from older docs.
428
618
 
429
619
  Legacy spellings still work: `save-remove` is `no-save no-undo`, `save-ignore` is `no-dirty no-undo`, `save-freeze` is `freeze no-undo`, `mutations-ignore` is `no-watch`, `snapshot-remove` is `no-snapshot`.
430
620
 
431
621
  Traps in this table:
432
622
 
433
- - **An edit inside `no-watch` or `no-dirty` is invisible to `clay.save()` and the close warning.** `save()` answers "No changes to save" and closing the tab loses it silently. It is saved only when some other change triggers a save, or by `clay.save.force()`. Never put typed work there.
434
- - **`freeze` is a scratchpad.** The element belongs to the document: its attributes save, it syncs, moves and merges like any element. Its contents belong to this tab: a runtime change inside is never saved, not even by `clay.save.force()`, and never reaches a peer. Every save restores the contents the element had when it first appeared.
435
- - **A scratchpad from a peer arrives empty.** When a peer adds a record that contains a frozen element, it is inserted without contents. Fill scratchpads from code that runs on every change (a sap `effect`, or your render function), never once at boot.
436
- - **Fill a scratchpad after `clay.ready`, and insert new ones empty.** The contents are captured when the element first appears, so content written before boot, or present in a cloned element when it is inserted, becomes its saved contents. Empty the scratchpads in a copied record before inserting it.
437
- - An `editor-ui` root disappears from the saved file, so it cannot be authored in the file. Create it at runtime from a `<template>`, every load. That includes elements inside the `<template>`: mark the blueprint `editor-ui` and the first save empties the template, so the next load has no chrome to clone. Leave the blueprint unmarked and set `clay="editor-ui"` on each clone as you insert it.
438
- - Mark a region by setting the attribute on creation, or with `clay.region.addRegionToken(el, "editor-ui")`.
623
+ - **An edit inside `no-watch` or `no-dirty` is invisible to `clay.save()` and the close warning.** `save()` answers "No changes to save" and closing the tab loses it silently. It is saved only when another change triggers a save, or by `clay.save.force()`. Never put typed work there.
624
+ - **`freeze` is a scratchpad.** The element belongs to the document: its attributes save, and it syncs, moves, and merges like any element. Its contents belong to this tab: a runtime change inside is never saved, not even by `clay.save.force()`, and never reaches a peer. Every save restores the contents the element had when it first appeared.
625
+ - **A scratchpad from a peer arrives empty.** When a peer adds a record that contains a frozen element, it is inserted without contents. Fill scratchpads from code that runs on every change (a sap `effect` or your render function), never once at boot.
626
+ - **Fill a scratchpad after `clay.ready`, and insert new ones empty.** The contents are captured when the element first appears. Content written before boot, or present in a cloned element when inserted, becomes its saved contents. Empty the scratchpads in a copied record before inserting it.
627
+ - **Create `editor-ui` roots at runtime from an unmarked `<template>` on every load.** An `editor-ui` root disappears from the saved file, so it cannot be authored in the file. Do not mark the blueprint inside the `<template>` as `editor-ui`, or the first save empties the template and leaves no chrome to clone on the next load. Leave the blueprint unmarked and set `clay="editor-ui"` on each clone as you insert it.
439
628
 
440
- ### 5.3 `persist`: form controls
629
+ ### 4.3 `persist`: form controls
441
630
 
442
- A control's typed value is a property, not an attribute, and serialization only sees attributes. Without `persist`, a typed value is lost on save: the file keeps the authored `value`. Put `persist` on every `<input>`, `<textarea>` and `<select>` whose value is record.
631
+ Put `persist` on every `<input>`, `<textarea>`, and `<select>` whose value belongs to the record. A control's typed value is a property, not an attribute, and serialization only reads attributes. Without `persist`, a typed value is lost on save and the file keeps the authored `value`.
443
632
 
444
633
  ```html
445
634
  <input type="checkbox" persist viewmode:disabled disabled checked>
@@ -448,21 +637,21 @@ A control's typed value is a property, not an attribute, and serialization only
448
637
  <select persist><option>todo</option><option selected>doing</option></select>
449
638
  ```
450
639
 
451
- - On `input` (text-like fields) and `change` (checkbox, radio, select) ClayJS mirrors the live value into `value`, `checked` or `selected`. A textarea's value goes into a `data-value` attribute while typing and becomes its text content in the save.
452
- - A snapshot also copies every persist control's live state, so a value set from script with no event is saved too.
453
- - Password, hidden and file inputs are never persisted. An authored `value` on them is still in the file.
640
+ - On `input` (text-like fields) and `change` (checkbox, radio, select), ClayJS mirrors the live value into `value`, `checked`, or `selected`. A textarea's value goes into a `data-value` attribute while typing and becomes its text content in the save.
641
+ - A snapshot also copies every `persist` control's live state, so a value set from script with no event is saved too.
642
+ - Password, hidden, and file inputs are never persisted. An authored `value` on them stays in the file.
454
643
  - `persist` goes on the control itself, never on a wrapper.
455
- - A persist control's value change counts as an edit and starts autosave.
456
- - Do not persist a filter or search box. Those are tab-local: put them in chrome (9.3).
644
+ - Changing a `persist` control's value counts as an edit and starts autosave.
645
+ - Do not persist a filter or search box. Those are tab-local: put them in chrome ([Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state)).
457
646
 
458
- **After undo, redo or a merge, ClayJS puts each control's display back in step with its attributes.** A control the user has touched shows its live property, not its attribute, so a merge and the undo plugin copy `checked`, `value` and `selected` back onto the property (undo does this for `persist` controls outside `no-undo` regions). A merge leaves the focused text field alone; undo, which the user asked for, restores it too.
647
+ **After undo, redo, or a merge, ClayJS puts each control's display back in step with its attributes.** A control the user has touched shows its live property, not its attribute. A merge and the undo plugin copy `checked`, `value`, and `selected` back onto the property (undo does this for `persist` controls outside `no-undo` regions). A merge leaves the focused text field alone, while undo restores it too.
459
648
 
460
- ### 5.4 Where to keep data that is not visible text
649
+ ### 4.4 Where to keep data that is not visible text
461
650
 
462
651
  In order of preference:
463
652
 
464
- 1. **Attributes on the element they describe.** `<li class="card" data-id="c-7f3a" data-due="2026-11-02" data-points="3">`. Saved, merged per attribute, readable by agents, moves with the element. The default.
465
- 2. **Hidden elements inside the record.** `<ul hidden class="archive">`, the card detail inside the card (9.5). Same properties, good for archived items and detail fields.
653
+ 1. **Attributes on the element they describe.** `<li class="card" data-id="c-7f3a" data-due="2026-11-02" data-points="3">`. The default. Saved, merged per attribute, readable by agents, and moves with the element.
654
+ 2. **Hidden elements inside the record.** `<ul hidden class="archive">`, or the card detail inside the card ([Detail views: the record is the modal](https://clayjs.com/docs/guide/architecture/#detail-views-the-record-is-the-modal)). Same properties, good for archived items and detail fields.
466
655
  3. **A JSON script with `merge`.** For data that markup expresses badly (formula grids, chart series, settings objects):
467
656
 
468
657
  ```html
@@ -475,16 +664,16 @@ In order of preference:
475
664
  }
476
665
  ```
477
666
 
478
- Always escape `<`: a value containing `</script>` ends the element early and corrupts the file on reload. Re-parse on every `clay:sync-applied`, because a merge changes the text, not your parsed object. When two writers change the same key, the incoming value wins and the lost value shows in the conflict notice (7.3).
479
- 4. **A live JSON script (`clay="live"`)** for state that changes faster than a save: game boards, scores, cursors (7.9).
667
+ Always escape `<` so a value containing `</script>` cannot close the element early and corrupt the file on reload. Re-parse on every `clay:sync-applied`, because a merge changes the text, not your parsed object. When two writers change the same key, the incoming value wins and the lost value shows in the conflict notice ([Regions and JSON under sync](https://clayjs.com/docs/guide/live-sync/#regions-and-json-under-sync)).
668
+ 4. **A live JSON script (`clay="live"`)** for state that changes faster than a save: game boards, scores, cursors ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)).
480
669
  5. **`<template>` content: never for mutable data.** It is saved, but edits there never trigger autosave.
481
670
 
482
- ### 5.5 Identity
671
+ ### 4.5 Element identity: `data-id` and `id`
483
672
 
484
673
  Live sync and undo pair elements across versions by **`data-id`, then `id`**, used only when the value is unique on its side and the tag names match. Without an id, elements pair by content, which works for distinct text and fails for identical new items.
485
674
 
486
- - **Every repeated item gets a unique `data-id` when it is created.** Two people adding an item at the same place without ids: with distinct text both usually land; with similar text ("New card", "New task") one is dropped into the conflict ledger; with identical or blank text one is dropped **with no ledger record**. With ids both always land.
487
- - **A clone gets a fresh id.** A duplicated `data-id` counts as no id at all. Re-id the clone and every id-carrying descendant, as the writer's duplicate command does:
675
+ - **Give every repeated item a unique `data-id` when it is created.** With ids, two people adding an item at the same place always keep both items. Without ids, items with distinct text usually both land, items with similar text ("New card", "New task") drop one into the conflict ledger, and items with identical or blank text drop one **with no ledger record**.
676
+ - **Assign fresh ids when cloning.** A duplicated `data-id` counts as no id at all. Re-id the clone and every id-carrying descendant, as the writer's duplicate command does:
488
677
 
489
678
  ```js
490
679
  const copy = block.cloneNode(true);
@@ -494,18 +683,18 @@ Live sync and undo pair elements across versions by **`data-id`, then `id`**, us
494
683
  copy.querySelectorAll("li[data-id]").forEach((li) => { li.dataset.id = newId("t"); });
495
684
  ```
496
685
 
497
- - **Templates carry no `data-id`.** The command that clones a template assigns one. For a plain "add an item" button, `<button add-from="#item-tpl" add-to="#list">` does the clone, the id and the focus with no script.
686
+ - **Leave `data-id` off templates.** The command that clones a template assigns one. For a plain "add an item" button, `<button add-from="#item-tpl" add-to="#list">` handles the clone, the id, and the focus with no script.
498
687
  - **Never stamp ids on open.** Opening an old file must not rewrite it. Add an id when an item is created or first changed.
499
- - **Make ids without `crypto.randomUUID`** unless the file is only ever served over https or localhost: it is missing in insecure contexts. The examples use `prefix + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)`.
500
- - Ids are also how commands find records: `board.querySelector(\`.card[data-id="${CSS.escape(id)}"]\`)`. Never find a record by position.
688
+ - **Make ids without `crypto.randomUUID`** unless the file is only ever served over https or localhost, because it is missing in insecure contexts. The examples use `prefix + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)`.
689
+ - **Use ids to find records in commands:** `` board.querySelector(`.card[data-id="${CSS.escape(id)}"]`) ``. Never find a record by position.
501
690
 
502
- ### 5.6 Assets
691
+ ### 4.6 Assets
503
692
 
504
- An image's durable form is its `src`. Upload the file and write the returned URL (8.5). Where the host cannot store files, embed a `data:` URL, which works anywhere but makes every save carry the bytes. Keep embedded images small. Every host returns a host path such as `/_/uploads/assets-<name>/photo-ab12.png`, which keeps working on that host when the document is renamed or moved. A document opened straight from disk shows those images broken; Export as zip takes a document with its files.
693
+ ClayJS saves an image through its `src`. Upload the file and write the returned URL ([upload and quickcrop](https://clayjs.com/docs/guide/components/#upload-and-quickcrop)). Every host returns a host path such as `/_/uploads/assets-<name>/photo-ab12.png`, which keeps working on that host when the document is renamed or moved. A document opened straight from disk shows those images broken; Export as zip takes a document with its files. Where the host cannot store files, embed a `data:` URL. Data URLs work anywhere, but every save carries the bytes, so keep embedded images small.
505
694
 
506
- ### 5.7 Ephemeral state: per-tab state on saved elements
695
+ ### 4.7 Ephemeral state: per-tab state on saved elements
507
696
 
508
- A selected card, a row being dragged, an open section: state that belongs to this tab but sits on a saved element. A class or attribute toggled for it is an edit like any other: it marks the page unsaved, starts an autosave, adds an undo step, reaches every peer and lands in the file. Mark it ephemeral instead:
697
+ Use `ephemeral` for per-tab state on a saved element, such as a selected card, a row being dragged, or an open section. Toggling a normal class or attribute is an edit like any other: it marks the page unsaved, starts an autosave, adds an undo step, reaches every peer, and lands in the file. Mark it `ephemeral` instead:
509
698
 
510
699
  ```html
511
700
  <li class="card ephemeral-selected:ring-2" data-id="c-7f3a">…</li>
@@ -519,28 +708,36 @@ clay.ephemeral.toggle(card, "selected"); // add, remove, toggle(el, word, forc
519
708
  - **`ephemeral="selected dragging"`** holds words a script sets for this tab. Style it with `[ephemeral~="selected"]`, or with the Tailwind variant `ephemeral-selected:` (`@plugin "@panphora/clayjs/tailwind";`; Hyperclay's on-save Tailwind has it built in).
520
709
  - **`ephemeral:NAME`** makes the element's own `NAME` attribute tab-only: `<details ephemeral:open>`, `<button ephemeral:aria-expanded>`. The marker is saved; the attribute it names is removed from the saved file. Tailwind's own `open:` and `aria-expanded:` variants style these.
521
710
  - **`open` on `<dialog>`** is tab-only with no marker: `showModal()` sets it, and a dialog saved open is always a bug.
522
- - None of it is saved, sent to peers, undone or counted as a change, and an incoming merge, from a peer or from the file on disk, never writes or removes it on an element this tab already has. The router's `route-active` gets the same treatment.
523
- - Identity (`class`, `id`, `data-id`), region attributes (`clay`, `no-save`, `freeze` and the rest) and form state (`value`, `checked`, `selected`, which `persist` owns) can never be made ephemeral.
711
+ - Ephemeral state is never saved, sent to peers, undone, or counted as a change. An incoming merge from a peer or from the file on disk never writes or removes it on an element this tab already has. The router's `route-active` gets the same treatment.
712
+ - Identity (`class`, `id`, `data-id`), region attributes (`clay`, `no-save`, `freeze`, and the rest), and form state (`value`, `checked`, `selected`, which `persist` owns) can never be made ephemeral.
524
713
  - Because ephemeral changes are not changes, `onmutation` and `onglobalmutation` handlers do not fire for them. Update anything derived from a selection in the same function that changes it.
525
714
 
526
- Use `ephemeral` for state on one element. State that comes from the URL hash and spans many records (the open card, a filter) is still painted with a runtime stylesheet (9.3).
715
+ Use `ephemeral` for state on one element. State that comes from the URL hash and spans many records (the open card, a filter) is still painted with a runtime stylesheet ([Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state)).
527
716
 
528
717
  ---
529
718
 
530
- ## 6. Saving
719
+ ## 5. Saving
720
+
721
+ ### 5.1 Edit mode and the save path
722
+
723
+ In edit mode, ClayJS saves the whole document to `POST /_/save` on the page's origin (or `/_/save/<savetoken>` on HTML Clay). Cmd+S (Ctrl+S elsewhere) and clicks inside any `[trigger-save]` element call `clay.save()` on every edit-mode page, with or without autosave. A close warning appears whenever there are unsaved changes.
531
724
 
532
- ### 6.1 Edit mode and the save path
725
+ #### 5.1.1 The saved title
533
726
 
534
- In edit mode ClayJS saves the whole document to `POST /_/save` on the page's origin (or `/_/save/<savetoken>` on HTML Clay). Cmd+S (Ctrl+S elsewhere) and a click inside any `[trigger-save]` element call `clay.save()` on every edit-mode page, with or without autosave. A close warning appears whenever there are unsaved changes.
727
+ ClayJS derives the saved `<title>` from the document rather than keeping the authored `<title>`. The save projection reads the captured model and takes the first top-level H1, collapsing and trimming whitespace runs. A page with no top-level H1 gets the title `Untitled`. The server checkpoint renderer runs the same function on the same model, so a room checkpoint writes the same title a client save would.
535
728
 
536
- ### 6.2 Autosave
729
+ To keep an authored `<title>`, put `<meta name="clay-seo-title-mode" content="custom">` in `<head>`. ClayJS then leaves `<title>` untouched. Setting a custom title in the SEO panel writes that marker, and removing it restores the derived title on the next save.
730
+
731
+ Title changes only update the document's content. ClayJS never derives or changes a file's name. A save writes bytes to the route the host already serves, so the file name, downloaded name, and local folder path stay the same. Renaming a file is up to the host or the user.
732
+
733
+ ### 5.2 Autosave
537
734
 
538
735
  `<html autosave>` present at load turns on autosave. Adding or removing it later changes nothing.
539
736
 
540
737
  - A watched DOM change or a `persist` input starts a 1500 ms debounce. Continuous changes save at most 10 s after the first one. Saves are throttled to one per 1.2 s.
541
- - Measured: one edit saves at about 1.5 s; 50 edits in a second save once at about 2.5 s; an edit every 300 ms saves at about 10 s.
542
- - Template content edits never trigger it.
543
- - A save that fails (500, network) is **not retried** until the next edit or the browser's `online` event. `savestatus="error"` stays up. If your app can sit idle after a failure, retry yourself:
738
+ - Measured: one edit saves at about 1.5 s, 50 edits in a second save once at about 2.5 s, and an edit every 300 ms saves at about 10 s.
739
+ - Template content edits never trigger autosave.
740
+ - A save that fails (500 or network) is **not retried** until the next edit or the browser's `online` event. `savestatus="error"` stays up. If your app can sit idle after a failure, retry yourself:
544
741
 
545
742
  ```js
546
743
  document.addEventListener("visibilitychange", () => {
@@ -548,14 +745,14 @@ In edit mode ClayJS saves the whole document to `POST /_/save` on the page's ori
548
745
  });
549
746
  ```
550
747
 
551
- **The load-settle window.** At load ClayJS takes a baseline of the document and retakes it once the page has been quiet for 500 ms (at most 3 s), unless the person typed or a save ran. Then it sets `savestatus="saved"` and fires `clay:baseline-settled`. Anything app code writes into the record during that window is absorbed into the baseline: it is not saved, not autosaved, and not counted as unsaved. It reaches the file only when some later edit triggers a save. So:
748
+ **The load-settle window.** At load, ClayJS takes a baseline of the document. It retakes the baseline once the page is quiet for 500 ms (at most 3 s), unless the user typed or a save ran. It then sets `savestatus="saved"` and fires `clay:baseline-settled`. Anything app code writes to the record during that window becomes part of the baseline: it is not saved, not autosaved, and not counted as unsaved. It reaches the file only when a later edit triggers a save:
552
749
 
553
750
  - Do not seed defaults, migrate a schema, or stamp ids at startup. Author defaults in the file.
554
- - If an app truly must write at startup, write after `clay:baseline-settled` (or once `<html savestatus>` exists), or follow the write with `await clay.save.force()`.
751
+ - If an app must write at startup, write after `clay:baseline-settled` (or once `<html savestatus>` exists), or follow the write with `await clay.save.force()`.
555
752
 
556
- ### 6.3 Manual-save pages and batching
753
+ ### 5.3 Manual-save pages and batching
557
754
 
558
- For a long-form writer where each autosave is unwelcome, omit `autosave`. Cmd+S, `[trigger-save]` and the close warning still work. Save when the tab hides:
755
+ To disable autosave, such as for a long-form writer where frequent saves are unwelcome, omit `autosave`. Cmd+S, `[trigger-save]`, and the close warning still work. Save when the tab hides:
559
756
 
560
757
  ```js
561
758
  document.addEventListener("visibilitychange", () => {
@@ -565,58 +762,58 @@ document.addEventListener("visibilitychange", () => {
565
762
  });
566
763
  ```
567
764
 
568
- A collaborative page should keep autosave on: peers see an edit only after it saves (7.1).
765
+ Keep autosave on for collaborative pages. Peers see an edit only after it saves ([What sync is](https://clayjs.com/docs/guide/live-sync/#what-sync-is)).
569
766
 
570
- To batch saves inside an autosaving page, wrap the heavy region in `clay="no-trigger-autosave"`: edits there still count as unsaved and still warn on close, but never start a save. Your own `clay.save()` (on blur, or every 30 s) writes them, and so does any save another edit triggers.
767
+ To batch saves in part of an autosaving page, wrap that region in `clay="no-trigger-autosave"`. Edits there still count as unsaved and warn on close, but never start a save. Your own `clay.save()` (on blur, or every 30 s) writes them, as does any save triggered by another edit.
571
768
 
572
- `clay.Mutation.pause()` and `resume()` hide DOM mutations from the mutation hub's listeners (autosave's mutation trigger among them) for the duration, but the next save still writes them. They do not pause input: `input` and `change` events on controls, including synthetic ones, still mark the page dirty and start autosave. Pair them in `try/finally`; an unbalanced pause silences mutation-driven autosave for the rest of the session.
769
+ `clay.Mutation.pause()` and `resume()` hide DOM mutations from the mutation hub's listeners, including autosave's mutation trigger, while paused. The next save still writes those mutations. They do not pause input: `input` and `change` events on controls, including synthetic ones, still mark the page dirty and start autosave. Pair them in `try/finally`. An unbalanced pause silences mutation-driven autosave for the rest of the session.
573
770
 
574
- ### 6.4 The save API
771
+ ### 5.4 The save API
575
772
 
576
- `clay.save()` returns `Promise<{ ok, msg, msgType, code, etag }>` and never rejects. One exception to that shape in 1.8.2: when capturing the page fails (a registered hook threw, 6.6) the result has no `ok` field at all, only `msgType: "error"`.
773
+ `clay.save()` returns `Promise<{ ok, msg, msgType, code, etag }>` and never rejects. One exception in 1.8.2: when capturing the page fails because a registered hook threw ([Hooks](https://clayjs.com/docs/guide/saving/#hooks)), the result has `msgType: "error"` and no `ok` field.
577
774
 
578
775
  | Result | Meaning |
579
776
  |---|---|
580
- | `ok: true`, `msgType: "success"` (or `"warning"`) | the host accepted these bytes |
581
- | `ok: false`, `msgType: "skipped"`, `msg: "No changes to save"` | nothing in the dirty domain changed |
582
- | `ok: false`, `msgType: "skipped"`, `msg: "Save already in progress"` | a save is in flight; these bytes are queued and sent after it |
583
- | `ok: false`, `msgType: "conflict"` | HTTP 412: the file changed elsewhere (6.5); also carries `changedBy`, `afterTimeout`, `conflictEtag` |
584
- | `ok: false`, `msgType: "error"` | the host refused or the network failed |
777
+ | `ok: true`, `msgType: "success"` (or `"warning"`) | The host accepted these bytes |
778
+ | `ok: false`, `msgType: "skipped"`, `msg: "No changes to save"` | Nothing in the dirty domain changed |
779
+ | `ok: false`, `msgType: "skipped"`, `msg: "Save already in progress"` | A save is in flight; these bytes are queued and sent after it |
780
+ | `ok: false`, `msgType: "conflict"` | HTTP 412: the file changed elsewhere ([Conflicts on save (412)](https://clayjs.com/docs/guide/saving/#conflicts-on-save-412)); also carries `changedBy`, `afterTimeout`, `conflictEtag` |
781
+ | `ok: false`, `msgType: "error"` | The host refused or the network failed |
585
782
  | `ok: false`, `msgType: "unknown"` | 12 s timeout; ClayJS asks the host whether it landed |
586
783
 
587
- **Branch on `msgType`, not `ok`.** `ok: false` also means "nothing to save" and "queued behind another save". To know that the host holds what the page holds now, use flush:
784
+ **Branch on `msgType`, not `ok`.** `ok: false` also means "nothing to save" or "queued behind another save". To ensure the host holds what the page holds now, use `flush`:
588
785
 
589
- - `clay.save.flush({ keepalive, timeoutMs })` resolves `{ state: "view" | "clean" | "saved", etag }` only when the host holds the current bytes. It rejects with `err.state` of `failed`, `conflict` or `blocked`. It waits out the settle window and any in-flight save and retries while the page keeps changing.
590
- - `clay.save.force()` saves without the dirty check: it writes `no-dirty` and `no-watch` edits. (Template content edits need no force: `save()` and the close warning see them; only autosave does not.)
591
- - `clay.save.overwrite()` re-reads the host's current version stamp and force-saves over it. It is "keep mine" after a conflict.
592
- - `clay.getHTML()` returns exactly the bytes a save would send. It runs your `onbeforesnapshot` and `onbeforesave` hooks and closes the open undo batch, so do not call it on every keystroke.
786
+ - `clay.save.flush({ keepalive, timeoutMs })` resolves `{ state: "view" | "clean" | "saved", etag }` only when the host holds the current bytes. It waits out the settle window and any in-flight save, and retries while the page keeps changing. It rejects with `err.state` of `failed`, `conflict`, or `blocked`.
787
+ - `clay.save.force()` saves without the dirty check, writing `no-dirty` and `no-watch` edits. Template content edits need no force: `save()` and the close warning see them, and only autosave does not.
788
+ - `clay.save.overwrite()` re-reads the host's current version stamp and force-saves over it. This is "keep mine" after a conflict.
789
+ - `clay.getHTML()` returns the exact bytes a save would send. It runs your `onbeforesnapshot` and `onbeforesave` hooks and closes the open undo batch, so do not call it on every keystroke.
593
790
  - `clay.hasUnsavedChanges()` is synchronous and costs one full capture of the document.
594
- - `clay.registerUnsavedState({ id, isPending })` registers work held outside the DOM (a form not yet applied) so the close warning and `flush` respect it. Call the returned `changed()` when it changes and `dispose()` when done.
791
+ - `clay.registerUnsavedState({ id, isPending })` registers work held outside the DOM (such as an unapplied form) so the close warning and `flush` respect it. Call the returned `changed()` when it changes and `dispose()` when done.
595
792
  - `clay.markDirty()` tells the dirty gate about an edit no mutation shows, for an editor whose surface is `editor-ui` and which writes the record itself.
596
793
 
597
- ### 6.5 Conflicts on save (412)
794
+ ### 5.5 Conflicts on save (412)
598
795
 
599
- On hosts that announce `conditional` (all three hosts in 1.4), every save carries `If-Match` with the version the tab last saw. If the file changed elsewhere and live sync has not merged that change yet, the host answers 412 and writes nothing.
796
+ On hosts that announce `conditional` (all three hosts in [What the host does](https://clayjs.com/docs/guide/model/#what-the-host-does)), every save sends `If-Match` with the version the tab last saw. If the file changed elsewhere and live sync has not merged that change yet, the host answers 412 and writes nothing.
600
797
 
601
- - The result is `msgType: "conflict"`, `<html savestatus="conflict">` and `clay:save-conflict`. Unsaved work stays in the page. **Autosave stops** until the conflict is resolved. Cmd+S is refused again.
602
- - ClayJS shows a built-in notice: **Keep mine** (`clay.save.overwrite()`), **Accept theirs** (press twice; reloads and drops local edits), **Download my copy** (an HTML file with your version and a list of replaced edits).
603
- - With `sync` loaded, most 412s resolve themselves: the merge brings in the version that beat this tab, and `clay:save-conflict-resolved` fires.
604
- - The first save after load can go out without `If-Match` on a slow host, because version discovery is not awaited. It is last-write-wins.
798
+ - The save returns `msgType: "conflict"`, sets `<html savestatus="conflict">`, and fires `clay:save-conflict`. Unsaved work stays in the page. **Autosave stops** until the conflict is resolved, and Cmd+S is refused again.
799
+ - ClayJS shows a built-in notice with three actions: **Keep mine** (`clay.save.overwrite()`), **Accept theirs** (press twice; reloads and drops local edits), and **Download my copy** (an HTML file with your version and a list of replaced edits).
800
+ - With `sync` loaded, most 412s resolve themselves. The merge brings in the version that beat this tab and fires `clay:save-conflict-resolved`.
801
+ - The first save after load can go out without `If-Match` on a slow host because version discovery is not awaited. That save is last-write-wins.
605
802
  - If you hide the built-in notice, you must still offer overwrite and reload.
606
803
 
607
- ### 6.6 Hooks
804
+ ### 5.6 Hooks
608
805
 
609
- Hooks shape the saved bytes without touching the live page. They run on a clone.
806
+ Hooks shape the saved bytes without touching the live page. Except for `onaftersave`, they run on a clone.
610
807
 
611
808
  | Hook | Runs on | Use for |
612
809
  |---|---|---|
613
- | `onbeforesnapshot="code"` attribute | the clone, for every snapshot: save, change check, sync broadcast | normalizing tab-local state out of anything that leaves the tab |
614
- | `onbeforesave="code"` attribute | the save clone and change-check clones (not sync broadcasts) | cleaning a subtree before it is written |
615
- | `clay.onSnapshot(fn)` | every snapshot; `fn(clone, { original })` | stripping library residue everywhere |
616
- | `clay.addDocumentTransform(fn)` | the save clone and every change check | writing derived values into the saved copy only |
617
- | `onaftersave="code"` attribute | the **live** element after each accepted save | rarely |
810
+ | `onbeforesnapshot="code"` attribute | The clone, for every snapshot: save, change check, sync broadcast | Normalizing tab-local state out of anything that leaves the tab |
811
+ | `onbeforesave="code"` attribute | The save clone and change-check clones (not sync broadcasts) | Cleaning a subtree before it is written |
812
+ | `clay.onSnapshot(fn)` | Every snapshot; `fn(clone, { original })` | Stripping library residue everywhere |
813
+ | `clay.addDocumentTransform(fn)` | The save clone and every change check | Writing derived values into the saved copy only |
814
+ | `onaftersave="code"` attribute | The **live** element after each accepted save | Post-save live DOM updates (rarely) |
618
815
 
619
- In attribute hooks `this` is the cloned element. Example from the finance tracker, which keeps sap's search filter (rows hidden with `hidden`) out of the file and out of peers' copies:
816
+ In attribute hooks, `this` is the cloned element (or the live element in `onaftersave`). Here, the finance tracker keeps sap's search filter (rows hidden with `hidden`) out of the file and out of peers' copies:
620
817
 
621
818
  ```html
622
819
  <tbody items="txns" onbeforesnapshot="this.querySelectorAll(':scope > [item][hidden]').forEach((row) => row.removeAttribute('hidden'))">
@@ -624,9 +821,9 @@ In attribute hooks `this` is the cloned element. Example from the finance tracke
624
821
 
625
822
  Rules:
626
823
 
627
- - **Hooks must be pure and repeatable.** They run on every autosave check, every `hasUnsavedChanges()`, every `getHTML()`. A hook that writes a timestamp or counter makes the page permanently dirty and autosaves forever.
628
- - A throwing **attribute** hook costs only that hook. A throwing `clay.onSnapshot` or `clay.addDocumentTransform` callback aborts the capture: `getHTML()` throws and every `save()` fails with `msgType: "error"` until it stops throwing. Registered callbacks must handle missing elements themselves; remember that change-check captures have already removed `editor-ui`, `no-save` and `no-snapshot` regions.
629
- - `onaftersave` writes to the live DOM, so anything it changes is a new edit and, with autosave, a save loop. Mark what it writes `editor-ui`, or write it inside a `freeze` scratchpad.
824
+ - **Hooks must be pure and repeatable.** They run on every autosave check, every `hasUnsavedChanges()`, and every `getHTML()`. A hook that writes a timestamp or counter makes the page permanently dirty and autosaves forever.
825
+ - A throwing **attribute** hook aborts only that hook. A throwing `clay.onSnapshot` or `clay.addDocumentTransform` callback aborts the capture: `getHTML()` throws and every `save()` fails with `msgType: "error"` until it stops throwing. Registered callbacks must handle missing elements themselves, because change-check captures have already removed `editor-ui`, `no-save`, and `no-snapshot` regions.
826
+ - `onaftersave` writes to the live DOM, so any record change it makes counts as a new edit and causes a save loop with autosave. Mark what it writes `editor-ui`, or write inside a `freeze` scratchpad.
630
827
  - `clay:snapshot-ready` listeners receive the shared clone and must not change it.
631
828
 
632
829
  Strip library residue once, at the snapshot level:
@@ -644,9 +841,9 @@ clay.onSnapshot?.((root) => {
644
841
 
645
842
  Only strip `draggable="false"` if your own markup never authors it.
646
843
 
647
- ### 6.7 Save status
844
+ ### 5.7 Save status
648
845
 
649
- Style from the root attribute, with no JavaScript:
846
+ ClayJS reflects save state on `<html>`, so you can style a status indicator with CSS alone:
650
847
 
651
848
  ```css
652
849
  html[savestatus="saving"] #status::after { content: "Saving…" }
@@ -654,7 +851,16 @@ html[savestatus="error"] #status::after { content: "Not saved" }
654
851
  html[savestatus="conflict"] #status::after { content: "Changed elsewhere" }
655
852
  ```
656
853
 
657
- Put `#status` in chrome. Or load the `indicator` plugin for a ready-made chip. With `clay-ui.js` loaded, save errors and offline become persistent toasts. Without the `indicator` plugin, clay-ui also toasts "Saved" after every save; with it, set `clay.saveToast = true` to get those toasts too.
854
+ `savestatus` records what the last save did and keeps its five values. A second root attribute, `unsaved="true"`, tracks whether there is new work that a save has not written yet. It stays on `<html>` from the first edit until a save accounts for it. That split keeps a saved document's status CSS accurate while a separate chip shows unsaved work:
855
+
856
+ ```css
857
+ .chip-unsaved { display: none }
858
+ html[unsaved="true"]:not([savestatus="error"]):not([savestatus="offline"]) .chip-unsaved { display: inline }
859
+ ```
860
+
861
+ In draft mode, nothing is saved on purpose, and `html[draftmode="true"]` indicates why the page is not marked saved.
862
+
863
+ Put `#status` in chrome, or load the `indicator` plugin for a ready-made chip. With `clay-ui.js` loaded, save errors and offline states become persistent toasts. Without the `indicator` plugin, `clay-ui.js` also toasts "Saved" after every save; with it, set `clay.saveToast = true` to get those toasts too.
658
864
 
659
865
  Events, all on `document`:
660
866
 
@@ -663,32 +869,77 @@ Events, all on `document`:
663
869
  | `clay:ready` | boot finished | `{ clay }` |
664
870
  | `clay:baseline-settled` | the load-settle baseline was taken | none |
665
871
  | `clay:save-saving` | a save has been in flight 500 ms | `{ msg, msgType, timestamp }` (`msgType` is `""`) |
666
- | `clay:save-saved` | the host accepted | `{ msg, msgType, timestamp }` |
667
- | `clay:save-error` | refused, or a network failure while online | `{ msg, msgType, timestamp }` (`msgType` is `""` in 1.8.2; the event name is the type) |
668
- | `clay:save-offline` | failed while the browser is offline | `{ msg, msgType, timestamp }` (`msgType` is `""`) |
669
- | `clay:save-conflict` | 412 | adds `changedBy`, `afterTimeout`, `etag` |
872
+ | `clay:save-saved` | the host accepted | `{ msg, msgType, timestamp, etag, captureId }` |
873
+ | `clay:save-error` | refused, or a network failure while online | `{ msg, msgType, timestamp, captureId }` (`msgType` is `""` in 1.8.2; the event name is the type) |
874
+ | `clay:save-offline` | failed while the browser is offline | `{ msg, msgType, timestamp, captureId }` (`msgType` is `""`) |
875
+ | `clay:save-conflict` | 412 | adds `changedBy`, `afterTimeout`, `etag`, `captureId` |
670
876
  | `clay:save-conflict-resolved` | sync merged the version that caused the 412 | `{ timestamp }` |
671
877
  | `clay:dirty`, `clay:clean` | unsaved-work state changed | none |
672
878
  | `clay:unsaved-state-changed` | a registered state called `changed()` | `{ pending }` |
673
879
 
674
880
  `clay:dirty` means "maybe dirty": typing a letter and deleting it leaves it raised until the next check.
675
881
 
882
+ `etag` and `captureId` on these events are additive. `clay:save-saved` carries the accepted `etag` (or `null`), and every save result event carries the `captureId` naming the bytes captured before the save was sent.
883
+
884
+ A save writes only the document's own bytes, so a downloaded HTML file links to its images rather than embedding them. When the file opens, the browser fetches uploaded images from the host's URLs. Opening a copy where those URLs do not resolve shows broken images.
885
+
886
+ ### 5.8 Recovery copies
887
+
888
+ `clay.recovery` keeps a local copy of edits the host has not accepted. It is not a save, and ClayJS never renders a page directly from it. After a reload, ClayJS compares the stored copies with the loaded file and asks before restoring one. A document is covered only for the channels its adapters register, so a page that registers no channel keeps no local copy.
889
+
890
+ A recovery copy is cleared by one save's own capture, not by the page's later state:
891
+
892
+ - Before sending a save, ClayJS hands the recovery service the exact bytes it captured and that capture's id. Each channel's `readSaved` reads those bytes once, and a copy is covered only when the payload it staged matches what the capture holds.
893
+ - A save that was skipped, refused, conflicted, errored, or left `unknown` clears nothing. A confirmed save clears only the revision its bytes covered, so typing during the request stays available.
894
+ - A newer revision from the same tab and every other tab's copy survive that clear. Any write that arrives after its save was acknowledged is refused by the stored watermark.
895
+ - **`clay:snapshot-ready` is not this proof.** It fires on a clone taken before `onbeforesave` hooks, document transforms, and final serialization, so what it shows can differ from the bytes the host accepts. Compare against `captureId` on `clay:save-saved` instead.
896
+
897
+ ```js
898
+ document.addEventListener("clay:save-saved", (event) => {
899
+ if (!event.detail.captureId) return;
900
+ console.log("this save covered capture", event.detail.captureId, "etag", event.detail.etag);
901
+ });
902
+ ```
903
+
904
+ The store lives in IndexedDB (`clay-recovery`), keeps 32 MiB per document scope and 64 MiB per database on this origin, holds plain text that any script on this origin can read, and reports `durable: false` whenever a write did not complete. It runs in edit mode only, draft mode included, and a reader never opens it. See [Recovery](https://clayjs.com/docs/recovery/) for the receipts, failure codes, and the difference between journaled, acknowledged, and checkpointed states.
905
+
676
906
  ---
677
907
 
678
- ## 7. Live sync and collaboration
908
+ ## 6. Live sync and collaboration
679
909
 
680
- ### 7.1 What sync is
910
+ ### 6.1 What sync is
681
911
 
682
- `?plugins=sync` keeps every open copy of the file converging. It is a **save-gated, whole-document, three-way DOM merge**, not keystroke streaming.
912
+ `?plugins=sync` keeps every open copy of the file converging through a **save-gated, whole-document, three-way DOM merge**, not keystroke streaming.
683
913
 
684
- - An edit-mode tab sends nothing until the host accepts its save. Then it relays that version to the other editors (the live lane). With autosave that is about 1.5 s after typing pauses, at most about 10 s.
914
+ - An edit-mode tab sends nothing until the host accepts its save. It then relays that saved version as a frame to the other editors on the live lane. With `autosave`, this happens about 1.5 s after typing pauses, at most about 10 s.
685
915
  - Each receiving tab merges the frame three ways: the last version both agreed on (base), its own current page including unsaved edits (local), and the frame (remote).
686
- - View-mode tabs ride the saved lane: they receive every accepted save and update in place.
687
- - Outside edits to the file on disk (an agent, a text editor, a version restore on desktop hosts) arrive as `disk` frames and merge the same way.
916
+ - View-mode tabs use the saved lane: they receive every accepted save and update in place.
917
+ - Outside edits to the file on disk (an agent, a text editor, or a version restore on desktop hosts) arrive as `disk` frames and merge the same way.
918
+
919
+ Collaborators see each other's work a few seconds after each pause. Without `autosave`, they see nothing until someone saves, and overlapping work only meets at save time. **Every collaborative file uses `autosave`.**
920
+
921
+ #### Sync in the view-mode reader
922
+
923
+ A Document in view mode loads its reader (`page-view`), not the editor. The reader has no write half and sees saved files only:
924
+
925
+ - **Saved, not live.** It uses its own receive-only transport (`src/sync/saved-stream.js`): a startup GET once the stream opens, then frames from the saved lane, and a refetch and repair after a reconnect or gap. It never POSTs, never sends a live relay, and needs no write credentials.
926
+ - **One receipt.** The reader holds the bytes on screen, their ETag, and the sequence they cover as one `{ bytes, etag, seq }` receipt. Duplicate or older frames are dropped without advancing it, a stamp alone never repairs missing bytes, and a refused or failed read keeps the existing article and receipt.
927
+ - **Keyed replacement.** An accepted file replaces only the blocks whose saved form changed (`src/page/reader-patch.js`). Unchanged blocks keep their nodes, so open toggles, mounted tools, and find-in-page survive.
928
+ - **Explicit `sync` keeps one owner.** On `plugins=page,sync` in view mode, the `sync` plugin already owns the saved lane. The reader opens no stream of its own and takes the files that lane applied (`clay:sync-applied` with `source: "disk"`). Neither case adopts a peer's unsaved preview as a saved file.
929
+
930
+ #### A frame from another template version
931
+
932
+ When an incoming frame has a different template version than the open page, ClayJS refuses to merge it and stops saving until you reload.
688
933
 
689
- So collaborators see each other's work a few seconds after each pause. On a page without autosave they see nothing until someone saves, and overlapping work only meets at save time. **Every collaborative file uses `autosave`.**
934
+ A document copied from a starter template carries `<meta name="hyper-version" content="1.1.0">` and `hyper-source` beside it so an upgrade can find it. An upgrade replaces layout, code, and data together. If a saved file names a **different** version, it is not a change to this template: the frame is refused whole, never merged, and never applied, because morphing it in would interleave two templates.
690
935
 
691
- ### 7.2 What a merge does
936
+ Once a frame is refused, this tab's DOM holds a version the host no longer stores. To prevent the next save from overwriting the upgrade, the tab holds every save from then on and drops every later frame. The notice says **This document was upgraded.** with one action, **Reload**, and `clay:document-upgraded` fires on `document` with `{ message }`.
937
+
938
+ Only a page that states its own version can make this check, and only a frame that states one can disagree, so a document without the meta tag behaves as usual. Holding saves belongs to edit mode, so a reader with nothing to write downloads none of it for the hold.
939
+
940
+ ### 6.2 What a merge does
941
+
942
+ When an incoming frame (B) meets unsaved edits in this tab (A), ClayJS merges the DOM and records any replaced local work in the conflict ledger:
692
943
 
693
944
  | Situation (this tab A unsaved, incoming B) | Result |
694
945
  |---|---|
@@ -697,7 +948,9 @@ So collaborators see each other's work a few seconds after each pause. On a page
697
948
  | A and B change the same word | B wins; A's version goes to the conflict ledger |
698
949
  | Both add class tokens (`done` and `urgent`) | both kept |
699
950
  | Both set one attribute (`data-status`) differently | B wins; ledger record |
700
- | Both reorder a list differently | B's order; ledger record |
951
+ | Both move different items in one list | both moves kept |
952
+ | Both move the same item | B's order; ledger record |
953
+ | Both change an attribute declared with `merge-tokens` or `merge-max` | merged (token set union, or the larger number), no conflict |
701
954
  | A edits a card, B reorders the list | B's order with A's edit, no conflict |
702
955
  | A edits a card B deleted | the card survives with A's edit |
703
956
  | A deleted a card B edited | the card comes back with B's edit; ledger record |
@@ -707,22 +960,39 @@ So collaborators see each other's work a few seconds after each pause. On a page
707
960
 
708
961
  More engine rules:
709
962
 
710
- - Text merges word by word inside a block, and formatting merges per character. Inline `<style>` text merges word by word. Scripts, textarea text and comments merge as whole values.
711
- - Matched live nodes are moved, not recreated, so listeners, focus, caret and iframes survive. An element that matches nothing (re-keyed, or changed beyond recognition among similar siblings) is removed and inserted fresh, and listeners attached directly to it are gone. **Use delegated listeners** on `document` or a stable root.
712
- - A new or changed inline `<script>` in the frame runs once after the merge. Hence `clay.start` (9.10).
963
+ - Text merges word by word inside a block, and formatting merges per character. Inline `<style>` and textarea text merge word by word. Scripts and comments merge as whole values.
964
+ - Matched live nodes are moved, not recreated, so listeners, focus, caret, and iframes survive. An element that matches nothing (re-keyed, or changed beyond recognition among similar siblings) is removed and inserted fresh, and listeners attached directly to it are gone. **Use delegated listeners** on `document` or a stable root.
965
+ - A new or changed inline `<script>` in the frame runs once after the merge. Guard one-time setup with `clay.start` ([Starting the app once](https://clayjs.com/docs/guide/architecture/#starting-the-app-once)).
713
966
  - Window scroll is restored. Inner scroll containers are not, except the focused element's own.
714
- - The merge runs with the mutation hub, the dirty gate and undo paused: incoming changes never autosave and never enter undo history. `clay.effect` re-runs after it through `clay:sync-applied`, which is how the examples re-render (7.5).
967
+ - The merge runs with the mutation hub, the dirty gate, and undo paused: incoming changes never autosave and never enter undo history. `clay.effect` re-runs after it through `clay:sync-applied`, which is how the examples re-render ([Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code)).
968
+
969
+ #### Declared attribute merges
715
970
 
716
- ### 7.3 Regions and JSON under sync
971
+ A `data-*` attribute that both sides change is normally a conflict: B wins and A's value goes to the ledger. A container can declare two merge rules for attributes inside it instead:
972
+
973
+ - `merge-tokens="data-labels"`: the attribute is a space-separated set. Additions and removals from both sides apply.
974
+ - `merge-max="data-next-card"`: the attribute is a number that only grows, such as a counter for the next card number. The larger value wins, with no ledger record.
975
+
976
+ ```html
977
+ <main merge-tokens="data-labels" merge-max="data-next-card" data-next-card="15">
978
+ <article data-id="c1" data-labels="bug urgent">…</article>
979
+ </main>
980
+ ```
981
+
982
+ Separate multiple attribute names with spaces. `data-id` is never merged this way, and undeclared attributes still conflict as before. The Kanban starter template uses both: labels as tokens, the card counter as a maximum.
983
+
984
+ ### 6.3 Regions and JSON under sync
985
+
986
+ `clay` region attributes and `<script type="application/json">` tags control how parts of the page merge:
717
987
 
718
988
  - `editor-ui`, `no-save`, `no-snapshot`: **ignored on every side.** Your copy is never touched, and the incoming copy is never read.
719
- - `freeze`: **the element merges, its contents never do.** A frozen element pairs, moves, is inserted and removed with its record, and its attributes merge. Your copy's contents are never touched, the incoming contents are never read, and a frozen element new to this tab arrives empty.
720
- - `no-dirty`, `no-watch`: **remote wins.** The incoming copy replaces yours with no merge. Correct for derived values that every tab recomputes.
989
+ - `freeze`: **the element merges, its contents never do.** A frozen element pairs, moves, is inserted and removed with its record, and merges its attributes. Your copy's contents are never touched, the incoming contents are never read, and a frozen element new to this tab arrives empty.
990
+ - `no-dirty`, `no-watch`: **remote wins.** The incoming copy replaces yours with no merge. Use this for derived values that every tab recomputes.
721
991
  - `no-trigger-autosave`: merged like ordinary content.
722
- - `live`: **ignored by the merge on every side.** The `live` plugin takes the value itself (7.9).
992
+ - `live`: **ignored by the merge on every side.** The `live` plugin takes the value itself ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)).
723
993
  - Root attributes the library owns (`editmode`, `savestatus`, tokens) are never written by a frame.
724
994
 
725
- **Known issue (ClayJS 1.8.2):** when a frame replaces a `no-dirty` region that this tab had also changed, while the tab also holds an unsaved edit outside that region, the replacement is logged in `clay.conflicts` as a loss and raises the conflict notice, although the docs say it happens with no warning. Two tabs that each edited the data will each have recomputed the totals differently, so this fires on concurrent edits whenever computed totals live in `no-dirty`. Until it is fixed, acknowledge those records yourself, as the finance tracker does:
995
+ **Known issue (ClayJS 1.8.2):** If a tab holds an unsaved edit outside `no-dirty` and also changed a `no-dirty` region, a frame that replaces that `no-dirty` region logs a loss in `clay.conflicts` and raises the conflict notice, even though `no-dirty` replacements should happen without warning. Because two tabs editing data will each recompute totals differently, this fires on concurrent edits whenever computed totals live in `no-dirty`. Until it is fixed, acknowledge those records yourself, as the finance tracker does:
726
996
 
727
997
  ```js
728
998
  document.addEventListener("clay:sync-applied", () => {
@@ -732,17 +1002,21 @@ document.addEventListener("clay:sync-applied", () => {
732
1002
  });
733
1003
  ```
734
1004
 
735
- **JSON.** A JSON script without `merge` is one value: two writers changing it means one whole blob goes to the ledger. With `merge="name"` it merges per key: objects per key, arrays of objects by an `id` field, arrays of primitives by value, and lists without an id (grids, boards, repeated values) position by position when both sides kept the same length. `merge-key="taskId"` names another id field for every list, and `merge-key="players:uid, items:sku"` names one per list path (no spaces inside an entry). **A key both sides changed takes the incoming value**, and the lost value goes to the ledger like any other replaced edit: the notice shows a row such as `game → players → alice → x`, with yours and now. Revert cannot put a JSON value back; Download keeps your copy. Use per-entry keys (`notes: { "n-1": ..., "n-2": ... }`) so two people adding entries never touch the same key. For data several people change at once, use a live store (7.9) instead. When a merge mixes both sides, the text is rewritten in the incoming text's style (one line stays one line, indented stays indented) with `<` written as `\u003c`.
1005
+ **JSON.** Without `merge`, a JSON script is one value: if two writers change it, one whole blob goes to the ledger. With `merge="name"`, it merges by key: objects per key, arrays of objects by an `id` field, arrays of primitives by value, and lists without an id (grids, boards, repeated values) position by position when both sides kept the same length. `merge-key="taskId"` names another id field for every list, and `merge-key="players:uid, items:sku"` names one per list path (no spaces inside an entry).
1006
+
1007
+ **A key both sides changed takes the incoming value.** The lost value goes to the ledger like any other replaced edit, and the notice shows a row such as `game → players → alice → x` with yours and now. Revert cannot put a JSON value back; Download keeps your copy. Use per-entry keys (`notes: { "n-1": ..., "n-2": ... }`) so two people adding entries never touch the same key. For data several people change at once, use a live store ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)) instead. When a merge mixes both sides, the text is rewritten in the incoming text's style (one line stays one line, indented stays indented) with `<` written as `\u003c`.
736
1008
 
737
- ### 7.4 Focus, typing and long-running editors
1009
+ ### 6.4 Focus, typing and long-running editors
738
1010
 
739
- - The focused `<input>` or `<textarea>` keeps its value through a merge. Checkbox, radio and select state are not protected.
740
- - A caret in contenteditable text follows its characters through the merge.
741
- - A dirty tab that has no merge base (because the record was rewritten at boot) **holds** incoming frames and fires `clay:sync-held` until it saves. Another reason not to touch the record at boot.
1011
+ Merges protect active typing in the current tab:
742
1012
 
743
- ### 7.5 Reconciling in app code
1013
+ - The focused `<input>` or `<textarea>` keeps its value through a merge. Checkbox, radio, and select state are not protected.
1014
+ - A caret in `contenteditable` text follows its characters through the merge.
1015
+ - A dirty tab that has no merge base (because the record was rewritten at boot) **holds** incoming frames and fires `clay:sync-held` until it saves. Avoid touching the record at boot so merges can run immediately.
744
1016
 
745
- Draw everything derived from the record inside `clay.effect(fn)`. It runs `fn` on the next microtask, then again after every change the mutation hub observes (local edits, drags, undo and redo) and after every merge, batched to one run per microtask. Changes inside `editor-ui` or `no-watch` regions, ephemeral attributes and live properties with no attribute change do not re-run it. `fn` runs with the hub paused, so its own writes never re-trigger it, start an autosave or enter undo history. It returns a function that stops it.
1017
+ ### 6.5 Reconciling in app code
1018
+
1019
+ Draw everything derived from the record inside `clay.effect(fn)`. It runs `fn` on the next microtask, then again after every change the mutation hub observes (local edits, drags, undo, and redo) and after every merge, batched to one run per microtask. Changes inside `editor-ui` or `no-watch` regions, ephemeral attributes, and live properties with no attribute change do not re-run it. `fn` runs with the hub paused, so its own writes never re-trigger it, start an autosave, or enter undo history. It returns a function that stops the effect.
746
1020
 
747
1021
  ```js
748
1022
  clay.effect(() => {
@@ -754,12 +1028,12 @@ clay.effect(() => {
754
1028
 
755
1029
  Rules for `fn`:
756
1030
 
757
- - **Keep it synchronous.** Writes after an `await` land outside the pause and would re-trigger it forever, so an effect whose `fn` returns a promise is stopped with a console error.
1031
+ - **Keep it synchronous.** Writes after an `await` land outside the pause and would re-trigger the effect forever. An effect whose `fn` returns a promise is stopped with a console error.
758
1032
  - **Do not call `clay.undo.commit` inside it.** A commit drains the hub's records, so the effect's writes reach autosave and the effect again.
759
1033
  - **One effect does not see another's writes**, because each runs paused. Draw values that depend on each other in one effect.
760
- - **A derived value saved in the record** (a count painted in markup, 9.4) is written with the hub paused, so a repaint after a merge leaves the page dirty with no save scheduled. Put saved derived values in `clay="no-dirty"`, or in chrome, or declare them as sapjs paints, which ClayJS leaves out of every comparison (8.11).
1034
+ - **A derived value saved in the record** (a count painted in markup, [Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)) is written with the hub paused, so a repaint after a merge leaves the page dirty with no save scheduled. Put saved derived values in `clay="no-dirty"`, in chrome, or in sapjs paints, which ClayJS leaves out of every comparison ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)).
761
1035
 
762
- In edit mode each effect warns once in the console when it breaks one of these rules: a write to saved content outside `no-dirty` and `editor-ui`, a `clay.undo.commit` call, an effect created inside it, or more than 50 runs in a second (usually a timer or async callback it started that keeps changing the document). The effect keeps running; the warning names the element or command.
1036
+ In edit mode, each effect warns once in the console when it breaks one of these rules: writing to saved content outside `no-dirty` and `editor-ui`, calling `clay.undo.commit`, creating an effect inside `fn`, or running more than 50 times in a second (usually a timer or async callback it started that keeps changing the document). The effect keeps running; the warning names the element or command.
763
1037
 
764
1038
  Work that belongs to a merge alone goes in a `clay:sync-applied` listener:
765
1039
 
@@ -771,36 +1045,36 @@ document.addEventListener("clay:sync-applied", (event) => {
771
1045
 
772
1046
  - Close or repoint any view whose record a peer deleted. The kanban closes the open card when its id is gone: `if (openId && !cardById(openId)) return writeHash({ card: "" }, { replace: true });`
773
1047
  - Re-parse any JSON you parsed earlier.
774
- - Re-attach any per-element setup that a re-created element lost (the writer re-injects block gutters on every render).
1048
+ - Re-attach any per-element setup that a recreated element lost (the writer re-injects block gutters on every render).
775
1049
 
776
- `detail` on `clay:sync-applied`: `{ seq, source: "peer" | "disk", by, report, conflictIds, unresolved }`, plus `etag` and `html` for disk frames. `by` is `{ id, name }` on hyperclay.com peer frames and `null` elsewhere. `report.applied`, `report.conflicts` and `report.localDiverged` describe the merge.
1050
+ `detail` on `clay:sync-applied` carries `{ seq, source: "peer" | "disk", by, report, conflictIds, unresolved }`, plus `etag` and `html` for disk frames. `by` is `{ id, name }` on hyperclay.com peer frames and `null` elsewhere. `report.applied`, `report.conflicts`, and `report.localDiverged` describe the merge.
777
1051
 
778
- ### 7.6 The conflict ledger and notice
1052
+ ### 6.6 The conflict ledger and notice
779
1053
 
780
- Losses (where this tab's work was replaced) enter `clay.conflicts`:
1054
+ Losses (where a merge replaced this tab's work) enter `clay.conflicts`:
781
1055
 
782
- - `list()`, `get(id)`, `size`, `recoveryOf(id)` (a clone of the page as it was just before the frame).
783
- - `acknowledge(ids, { reason })` with `reason` of `accepted`, `reverted` or `reconciled`. It is the only way a record leaves. Nothing clears them automatically, and the close warning stays up while any is open.
1056
+ - `list()`, `get(id)`, `size`, and `recoveryOf(id)` (a clone of the page as it was just before the frame).
1057
+ - `acknowledge(ids, { reason })` with `reason` of `accepted`, `reverted`, or `reconciled`. It is the only way a record leaves. Nothing clears records automatically, and the close warning stays up while any is open.
784
1058
  - `claim(ids, { owner })` hides records from the built-in notice so your app can resolve them itself, then `acknowledge` them.
785
1059
 
786
- The built-in notice reads "Another edit replaced N changes" and offers **Revert to mine**, **Accept theirs**, **Download my copy** and a list of the edits. Revert puts the selected losses back as an ordinary local edit, then saves.
1060
+ The built-in notice reads "Another edit replaced N changes" and offers **Revert to mine**, **Accept theirs**, **Download my copy**, and a list of the edits. Revert puts the selected losses back as an ordinary local edit, then saves.
787
1061
 
788
- ### 7.7 Presence
1062
+ ### 6.7 Presence
789
1063
 
790
- hyperclay.com shows a fixed avatar stack (initials in hollow squares, a "+N viewing" count) when two or more people have the file open. **There is no API**: no event, no roster, no cursor or section data. An app cannot build its own "who is here" or "X is editing this card" from it; share that through a never-saved live store instead (7.9). HTML Clay and Hyperclay Local have no presence.
1064
+ hyperclay.com shows a fixed avatar stack (initials in hollow squares, a "+N viewing" count) when two or more people have the file open. **There is no API**: no event, no roster, no cursor or section data. An app cannot build its own "who is here" or "X is editing this card" from it; share that through a never-saved live store instead ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)). HTML Clay and Hyperclay Local have no presence.
791
1065
 
792
- ### 7.8 Agents
1066
+ ### 6.8 Agents
793
1067
 
794
- An agent edits a malleable file the way a person in another tab does.
1068
+ An agent edits a malleable file the way a person in another tab does:
795
1069
 
796
- - **Editing the file on disk** (HTML Clay, Hyperclay Local): the host notices the change and pushes a `disk` frame. The open tab merges it three ways, so the person's unsaved typing in other paragraphs survives, and an overlap goes to the ledger. Tell agents to address elements by `data-id`, never by position, and to leave chrome alone (it is not in the file anyway).
797
- - **`clay.wire`** (`?plugins=wire`, HTML Clay and Hyperclay Local): `clay.wire.send(payload)` sends a request to a local process started with `htmlclay wire serve file.htmlclay -- my-agent`. The page saves first, so the agent reads what the person sees, and autosave pauses until the agent's edit lands as a disk frame. `run.done` resolves then.
798
- - **`ai-edit`** (`?plugins=ai-edit`): select text, press Cmd+J, describe the change. The rewrite is previewed in place; Keep saves it, Revert discards it. Available where the host offers the `ai-edit` helper: HTML Clay, Hyperclay Local, and hyperclay.com (for the owner and team editors on a paid plan, billed to the document's AI credits).
799
- - **The data API**: a host that announces `data-read`/`data-write` serves `/_/api/<file>`, a JSON read and write face over the file defined by a rules tag (9.9). `clay.readData()` and `clay.writeData()` call it from the page; `writeData` is for agents and other processes, and refuses while the page has unsaved edits.
1070
+ - **Editing the file on disk** (HTML Clay, Hyperclay Local): the host notices the change and pushes a `disk` frame. The open tab merges it three ways, so the user's unsaved typing in other paragraphs survives, and an overlap goes to the ledger. Tell agents to address elements by `data-id`, never by position, and to leave chrome alone (it is not in the file anyway).
1071
+ - **`clay.wire`** (`?plugins=wire`, HTML Clay and Hyperclay Local): `clay.wire.send(payload)` sends a request to a local process started with `htmlclay wire serve file.htmlclay -- my-agent`. The page saves first so the agent reads what the user sees, and autosave pauses until the agent's edit lands as a disk frame. `run.done` resolves then.
1072
+ - **`ai-edit`** (`?plugins=ai-edit`): select text, press Cmd+J, and describe the change. The rewrite is previewed in place; Keep saves it, Revert discards it. Available where the host offers the `ai-edit` helper: HTML Clay, Hyperclay Local, and hyperclay.com (for the owner and team editors on a paid plan, billed to the document's AI credits).
1073
+ - **The data API**: a host that announces `data-read`/`data-write` serves `/_/api/<file>`, a JSON read and write interface over the file defined by a rules tag ([Import and export](https://clayjs.com/docs/guide/architecture/#import-and-export)). `clay.readData()` and `clay.writeData()` call it from the page; `writeData` is for agents and other processes, and refuses while the page has unsaved edits.
800
1074
 
801
- ### 7.9 Live data: `clay="live"`
1075
+ ### 6.9 Live data: `clay="live"`
802
1076
 
803
- Everything above moves at save speed: a peer sees your change after your next save. A live store is for state that changes faster than that, such as a game board, a score, a cursor or a timer. It is one JSON script that ClayJS holds as an object in memory. Page code changes it through ops, every tab of the document sees each change at once, and the value is written back into the file at checkpoints.
1077
+ Everything above moves at save speed: a peer sees your change after your next save. A live store is for state that changes faster than that, such as a game board, a score, a cursor, or a timer. It is one JSON script that ClayJS holds as an object in memory. Page code changes it through ops, every tab of the document sees each change at once, and ClayJS writes the value back into the file at checkpoints.
804
1078
 
805
1079
  ```html
806
1080
  <script type="application/json" merge="game" clay="live">
@@ -830,40 +1104,42 @@ function render() {
830
1104
  }
831
1105
  ```
832
1106
 
833
- **The tag.** A `<script type="application/json">` with `merge="name"` and `clay="live"`, holding a JSON object or list. The name is 1 to 64 letters, digits, `_`, `.`, `:` or `-`, and two live tags with the same name are both ignored. Write the tag into the file before people share it: a tag added later is not in the rooms of tabs that loaded before. Checkpoints add a `live-at` attribute to the tag (the room position the value was saved at, and a hash of the text). A save made while the tab is out of the room also adds `live-by` (that tab and the last update it sent), so the room does not apply those updates a second time. The room trusts `live-by` only while the value still matches the hash in `live-at`, and never on a restored version. Leave both in place when you or an agent edit the tag's JSON by hand: the room then merges your edit with the play since that checkpoint. Without it, the edit is merged over what the room last saved. A save hook (`onbeforesave`) may reformat a live tag's JSON, but must not change its value. A tag is written in the layout it was loaded with: one line, or indented when the authored JSON had line breaks. `live-format="lines"` writes each entry of each top-level object on its own line instead, so the file's version history shows only the entries that changed, which suits a large store keyed by id (the page editor's blocks).
1107
+ **The tag.** Declare a `<script type="application/json">` with `merge="name"` and `clay="live"`, holding a JSON object or list. The name is 1 to 64 letters, digits, `_`, `.`, `:`, or `-`, and two live tags with the same name are both ignored. Write the tag into the file before people share it: a tag added later is not in the rooms (in-memory sessions that relay ops between tabs) of tabs that loaded before.
1108
+
1109
+ Checkpoints add a `live-at` attribute to the tag with the room position the value was saved at and a hash of the text. A save made while the tab is out of the room also adds `live-by` (that tab and the last update it sent), so the room does not apply those updates a second time. The room trusts `live-by` only while the value still matches the hash in `live-at`, and never on a restored version. Leave both in place when you or an agent edit the tag's JSON by hand: the room then merges your edit with the play since that checkpoint. Without them, the edit is merged over what the room last saved. A save hook (`onbeforesave`) may reformat a live tag's JSON, but must not change its value. A tag is written in the layout it was loaded with: one line, or indented when the authored JSON had line breaks. `live-format="lines"` writes each entry of each top-level object on its own line instead, so version history shows only the entries that changed, which suits a large store keyed by id (the page editor's blocks).
834
1110
 
835
- **The API.** `clay.live(name)` returns the same handle every time.
1111
+ **The API.** `clay.live(name)` returns the same handle every time:
836
1112
 
837
1113
  - `game.data` is the value, read like a plain object. Writes to it become ops: `game.data.players.alice = { x: 0 }`, `delete game.data.players.bob`.
838
- - `game.set(path, value)`, `game.delete(path)` and `game.add(path, n = 1)`. A path is a dotted string (`"players.alice.x"`) or an array (`["players", "alice", "x"]`).
1114
+ - `game.set(path, value)`, `game.delete(path)`, and `game.add(path, n = 1)`. A path is a dotted string (`"players.alice.x"`) or an array (`["players", "alice", "x"]`).
839
1115
  - `game.update(fn)` runs `fn(data)` synchronously and sends what it changed as one batch. If `fn` throws, every change it made is undone.
840
1116
  - `game.on("change", fn)` and `game.off("change", fn)`. `source` is `local` (this tab), `remote` (another tab), or `state` (the value was replaced from the room or a save).
841
- - `game.toJSON()` is a copy, `game.ready` a promise of the handle, `game.status.mode` is `fast` or `file` (below).
1117
+ - `game.toJSON()` is a copy, `game.ready` a promise of the handle, and `game.status.mode` is `fast` or `file` (below).
842
1118
 
843
1119
  Two rules for the proxy:
844
1120
 
845
1121
  - **Counters use `add`.** `game.data.score += 1` reads the number and writes the sum, so two tabs adding at once both write the same sum and one point is lost. `game.add("score", 1)` sends the increment, and both count.
846
- - **Lists are written whole.** `push`, `splice` and `sort` replace the entire list, so two tabs pushing at once keep one push. An index assignment (`grid[2][3] = "x"`) changes one cell. Keep collections as objects keyed by id (`players: { "p-7f3a": {...} }`).
1122
+ - **Lists are written whole.** `push`, `splice`, and `sort` replace the entire list, so two tabs pushing at once keep one push. An index assignment (`grid[2][3] = "x"`) changes one cell. Keep collections as objects keyed by id (`players: { "p-7f3a": {...} }`).
847
1123
 
848
- **What gets saved, and when.** A live change never marks the page unsaved, never starts autosave and never raises the close warning. A checkpoint writes the current value into the file 8 seconds after the first change, and again when the tab is hidden or closed. Up to 8 seconds of play can be lost, and that is the trade. The close save uses `keepalive`, which browsers only honour for bodies under about 60 KB, so a large page may lose its last few seconds. Every checkpoint is a save, with a version in the host's history. A game page needs no `autosave` attribute for its live data.
1124
+ **What gets saved, and when.** A live change never marks the page unsaved, never starts autosave, and never raises the close warning. A checkpoint writes the current value into the file 8 seconds after the first change, and again when the tab is hidden or closed. Up to 8 seconds of play can be lost. The close save uses `keepalive`, which browsers only honor for bodies under about 60 KB, so a large page may lose its last few seconds. Every checkpoint is a save, with a version in the host's history. A game page needs no `autosave` attribute for its live data.
849
1125
 
850
- **Never-saved stores.** `clay="live ephemeral"` shares the value like any store but never writes it into the file: the tag keeps the text you authored. Use it for cursors, lobbies and presence. Anyone who can open the file can read the authored text, so keep secrets out of it.
1126
+ **Never-saved stores.** `clay="live ephemeral"` shares the value like any store but never writes it into the file: the tag keeps the text you authored. Use it for cursors, lobbies, and presence. Anyone who can open the file can read the authored text, so keep secrets out of it.
851
1127
 
852
1128
  **How it travels.** Which lane a store uses depends on the host:
853
1129
 
854
- - **hyperclay.com** (the `live-data` capability): every change goes to the document's room on the server, which numbers it and passes it to every editor within a network round trip. Tabs that join late, or that miss a change, fetch the room's whole state. Only one tab, the room's writer, saves the checkpoints. Viewers do not get the fast lane: they see the game at each checkpoint, through ordinary live sync. When an outside save changes a live tag (the code editor, a version restore, a device sync, an agent), the room takes the file's value and puts its own changes since the version the file came from back on top, so the edit lands and no move is lost or counted twice. An edit of a version older than the room's history is overwritten by the room's next checkpoint instead. The history covers 256 KB of changes per store, and the writer saves early when half of it is used, so this takes a restore of an old version or a writer that cannot save.
855
- - **Tabs of one browser on HTML Clay or Hyperclay Local**: the tabs share the store at the same speed through the browser itself. One tab runs the room and saves the checkpoints. When it closes, another tab takes over with every move the room had accepted. Use this to playtest a game in several tabs.
856
- - **Everywhere else** (another device on a local host, a browser without Web Locks): the store moves at save speed. Each incoming save replaces the value, and this tab's own changes that are not yet saved are replayed on top. A change counts as saved only when this tab's own save holding it is accepted, so an `add` counts exactly once.
1130
+ - **hyperclay.com** (the `live-data` capability): every change goes to the document's room on the server, which numbers it and passes it to every editor within a network round trip. Tabs that join late or miss a change fetch the room's whole state. Only one tab, the room's writer, saves checkpoints. Viewers do not get the fast lane; they see the game at each checkpoint through ordinary sync. When an outside save changes a live tag (the code editor, a version restore, a device sync, or an agent), the room takes the file's value and replays its own changes since the version the file came from on top, so the edit lands and no move is lost or counted twice. An edit of a version older than the room's history is overwritten by the room's next checkpoint instead. The history covers 256 KB of changes per store, and the writer saves early when half of it is used, so this requires restoring an old version or a writer that cannot save.
1131
+ - **Tabs of one browser on HTML Clay or Hyperclay Local**: the tabs share the store at the same speed through the browser itself. One tab runs the room and saves checkpoints. When it closes, another tab takes over with every move the room accepted. Use this to playtest a game in several tabs.
1132
+ - **Everywhere else** (another device on a local host, or a browser without Web Locks): the store moves at save speed. Each incoming save replaces the value, and this tab's own unsaved changes are replayed on top. A change counts as saved only when this tab's own save holding it is accepted, so an `add` counts once.
857
1133
 
858
- `game.status.mode` is `fast` on the first two lanes and `file` on the third. In view mode a store reads normally, but writes stay in the tab and are never sent or saved.
1134
+ `game.status.mode` is `fast` on the first two lanes and `file` on the third. In view mode, a store reads normally, but writes stay in the tab and are never sent or saved.
859
1135
 
860
- **The page editor's own store.** A Document's blocks are a live store named `page`, and its merge state is not in the file: hyperclay.com and Hyperclay Local keep it beside the file and hand it back to an editor only while it still belongs to the article (the article's `data-rev` matches), and HTML Clay keeps none, so open tabs share it and a fresh open rebuilds it from the article. Saved files and downloads never carry it. A page changed outside the editor (the code editor, an agent, a version restore, another device, a text editor) merges into the page tabs that are open, the way a peer's edit does: each block is merged three ways against the article the change started from, so edits on both sides survive. Where both sides changed the same words, the outside change wins, and the tab offers a download of what it had. An edit beats a delete. Only a change with no article this host can trace it back to shows a reload notice, and the tab stops saving so the old article is not written back over the new one.
1136
+ **The page editor's own store.** A Document's blocks are a live store named `page`, and its merge state is not in the file. Saved files and downloads never carry it. On hyperclay.com and Hyperclay Local, the host keeps the merge state beside the file and hands it back to an editor only while it matches the article's `data-rev`. HTML Clay keeps none on disk: open tabs share it in memory, and a fresh open rebuilds it from the article. A page changed outside the editor (by the code editor, an agent, a version restore, another device, or a text editor) merges into open page tabs the way a peer's edit does: each block merges three ways against the article the change started from, so edits on both sides survive. An edit beats a delete. Where both sides changed the same words, the outside change wins and the tab offers a download of what it had. Only a change with no article this host can trace it back to shows a reload notice and stops the tab from saving over the new article.
861
1137
 
862
- **When a room is lost.** A room lives in memory: a server restart, or two minutes with nobody connected, ends it. The next room starts from the last checkpoint, so up to 8 seconds of play can be gone. A change of yours that the old room may already have taken is not sent to the new one, even when its answer was lost: if the old room's last checkpoint lands afterwards, it brings that change back, and sending it again would count it twice. A change that never left the tab is sent as usual. Closing the tab that runs a local room is not a loss: the next tab takes over the room with every move.
1138
+ **When a room is lost.** A room lives in memory: a server restart or two minutes with nobody connected ends it. The next room starts from the last checkpoint, so up to 8 seconds of play can be lost. A change that the old room may already have taken is not resent to the new one, even when its acknowledgment was lost: if the old room's last checkpoint lands afterward, it brings that change back, and resending it would count it twice. A change that never left the tab is sent as usual. Closing the tab that runs a local room is not a loss: the next tab takes over the room with every move.
863
1139
 
864
1140
  **Load the plugin wherever a live tag exists.** A page without `?plugins=live` still leaves live tags out of the merge, so an incoming save does not update them and the next save writes the old text back.
865
1141
 
866
- **Limits on hyperclay.com.** A frame carries at most 128 ops and 64 KB, except that one value larger than that is sent in a request of its own of up to 5 MB, so a single change can write a value up to the store's 4 MB; the page editor splits its changes into 12 KB pieces, so a Document's size is not limited by the frame. A store holds at most 4 MB of JSON, a document at most 6 MB across at most 16 stores, which gives a Document roughly 900 KB of text that keeps compacting. One person sends at most 25 frames a second per document (bursts to 40), and ClayJS paces itself to stay under that. A change the room refuses is undone in the tab, with a warning. When the server holds as much live data as it can, a document without a room, or a change that would grow one, moves at save speed and tries the room again a minute later.
1142
+ **Limits on hyperclay.com.** A store holds at most 4 MB of JSON, and a document holds at most 6 MB across at most 16 stores, giving a Document roughly 900 KB of text that keeps compacting. A frame carries at most 128 ops and 64 KB. One value larger than 64 KB is sent in its own request of up to 5 MB, so a single change can write a value up to the store's 4 MB. The page editor splits its changes into 12 KB pieces, so frame size does not limit a Document's size. One person sends at most 25 frames a second per document (bursts to 40), and ClayJS paces itself to stay under that. A change the room refuses is undone in the tab with a warning. When the server holds as much live data as it can, a document without a room (or a change that would grow one) moves at save speed and tries the room again a minute later.
867
1143
 
868
1144
  **A two-player example.** The board above plus a never-saved lobby for pointers:
869
1145
 
@@ -884,11 +1160,11 @@ Wins are counters, so they use `add`: `game.add(["wins", winner], 1)`.
884
1160
 
885
1161
  ---
886
1162
 
887
- ## 8. Editing components
1163
+ ## 7. Editing components
888
1164
 
889
- ### 8.1 richclay: rich text (`editable`)
1165
+ ### 7.1 richclay: rich text (`editable`)
890
1166
 
891
- `editable` on an element makes it a rich text editor in edit mode (Squire underneath, DOMPurify on paste). It is the default plugin.
1167
+ `editable` turns an element into a rich text editor in edit mode. It is the default plugin, using Squire for editing and DOMPurify on paste.
892
1168
 
893
1169
  ```html
894
1170
  <div class="desc" editable><p>Two paragraphs on <b>scope</b>.</p></div>
@@ -896,22 +1172,22 @@ Wins are counters, so they use `add`: `game.add(["wins", winner], 1)`.
896
1172
  <h1 editable="single-line">Title</h1>
897
1173
  ```
898
1174
 
899
- Tokens: `single-line` (Enter does nothing, pasted blocks flatten), `no-toolbar` (shortcuts only), `toolbar-on-select` (the floating toolbar appears only for a non-empty selection: the "bubble toolbar"). Toolbar presets: `standard` (block menu with Paragraph, H1 to H3, Quote, Code block; bold, italic, underline, strikethrough, inline code, link, undo, redo, clear; bulleted and numbered lists, quote, indent, outdent), `inline`, `minimal`.
1175
+ Mode tokens: `single-line` (Enter does nothing and pasted blocks flatten), `no-toolbar` (shortcuts only), and `toolbar-on-select` (the bubble toolbar appears only for a non-empty selection). Toolbar presets: `standard` (block menu with Paragraph, H1 to H3, Quote, Code block; bold, italic, underline, strikethrough, inline code, link, undo, redo, clear; bulleted and numbered lists, quote, indent, outdent), `inline`, and `minimal`.
900
1176
 
901
- What saves: the region's own markup, with every runtime attribute, toolbar and Squire artifact stripped. Opening a page in edit mode does not change its bytes.
1177
+ Saves keep only the region's own markup, with every runtime attribute, toolbar, and Squire artifact stripped. Opening a page in edit mode does not change its bytes.
902
1178
 
903
- What it does not do, and what to build instead:
1179
+ What it leaves out, and what to build instead:
904
1180
 
905
1181
  | Missing | Build it |
906
1182
  |---|---|
907
- | Tables, checklists, dividers, callouts, toggles, embeds | As separate blocks outside the rich region (the writer's block model, 10.2) |
1183
+ | Tables, checklists, dividers, callouts, toggles, embeds | Separate blocks outside the rich region (the writer's block model, [Block writer](https://clayjs.com/docs/guide/recipes/#block-writer)) |
908
1184
  | Image insert, captions, alignment | An image block (`<figure>` with an `<img>` and an `editmode:contenteditable` caption), or `editor.squire.insertImage(src)` for inline images |
909
1185
  | Screenshot paste | Squire swallows image-only pastes. Listen for `pasteImage` (below) |
910
1186
  | Markdown shortcuts | Only `* ` and `1. ` start lists. Watch `editor.squire` `input` events and call `editor.setBlockType("H2")` for others |
911
- | Slash menu | A keyup handler for `/` on an empty paragraph opening an `editor-ui` menu (10.2) |
1187
+ | Slash menu | A `keyup` handler for `/` on an empty paragraph opening an `editor-ui` menu ([Block writer](https://clayjs.com/docs/guide/recipes/#block-writer)) |
912
1188
  | Change callback | Auto-mounted editors cannot take `onChange`. Use `new clay.RichClay(el).squire.addEventListener("input", …)` or a `MutationObserver` |
913
1189
 
914
- `new clay.RichClay(el)` returns the instance already mounted on `el`. Use it to reach `squire`, `getHTML()`, `setHTML()`, `setBlockType(tag)`, `registerButton(def)`.
1190
+ `new clay.RichClay(el)` returns the instance mounted on `el`. Use it to reach `squire`, `getHTML()`, `setHTML()`, `setBlockType(tag)`, and `registerButton(def)`.
915
1191
 
916
1192
  Screenshot paste into an upload, from the writer:
917
1193
 
@@ -925,7 +1201,7 @@ new clay.RichClay(body).squire.addEventListener("pasteImage", async (event) => {
925
1201
  });
926
1202
  ```
927
1203
 
928
- Custom toolbar buttons must be registered **per instance**, after mount; a global `RichClay.registerButton` after load does not reach editors already mounted:
1204
+ Register custom toolbar buttons **per instance**, after mount. Calling `RichClay.registerButton` globally after load does not reach editors already mounted:
929
1205
 
930
1206
  ```js
931
1207
  const editor = new clay.RichClay(el);
@@ -935,18 +1211,48 @@ editor.options.toolbar = [...clay.RichClay.presets.standard, "stamp"];
935
1211
 
936
1212
  Traps:
937
1213
 
938
- - **Mode by attribute.** `editable` is inline mode: it never sanitizes your authored markup on load. `data-richclay` without `editable` is card mode: it sanitizes in place on load with an allowlist that has no `img`, so **it deletes images on every edit-mode open**. Use `editable`.
939
- - **Nested `editable` regions are refused.** The outer one mounts. Siblings are fine.
940
- - **Custom elements** (tag with a hyphen) are skipped for bare `editable`; use `clay-editable`.
941
- - **The floating toolbar is mounted on `document.body`, not in the top layer.** Inside `dialog.showModal()` or a `popover` it is unusable (shortcuts still work). Do not put `editable` inside a modal dialog. The kanban shows the card itself as a fixed overlay instead (9.5).
942
- - **Two undo histories.** Inside an `editable`, Cmd+Z goes to Squire's own stack. Outside, it goes to page undo, which never records rich text. A person who types in a rich region, clicks elsewhere and presses Cmd+Z does not undo their typing.
943
- - **Squire's undo can roll back a peer's edit** in the same region: its history is whole-region snapshots. Keep collaborative documents in many small regions (one `editable` per block), which also limits the damage.
1214
+ - **Mode by attribute.** `editable` is inline mode and never sanitizes your authored markup on load. `data-richclay` without `editable` is card mode: it sanitizes in place on load with an allowlist that excludes `img`, so **it deletes images on every edit-mode open**. Use `editable`.
1215
+ - **Nested `editable` regions are refused.** Only the outer region mounts. Siblings are fine.
1216
+ - **Custom elements** (tags with a hyphen) are skipped for bare `editable`. Use `clay-editable`.
1217
+ - **The floating toolbar mounts on `document.body`, not in the top layer.** Inside `dialog.showModal()` or a `popover`, it is unusable, though shortcuts still work. Do not put `editable` inside a modal dialog. The kanban shows the card itself as a fixed overlay instead ([Detail views: the record is the modal](https://clayjs.com/docs/guide/architecture/#detail-views-the-record-is-the-modal)).
1218
+ - **Two undo histories.** Inside an `editable`, Cmd+Z goes to Squire's stack. Outside, it goes to page undo, which never records rich text. Typing in a rich region, clicking elsewhere, and pressing Cmd+Z does not undo that typing.
1219
+ - **Squire's undo can roll back a peer's edit** in the same region because its history stores whole-region snapshots. Keep collaborative documents in many small regions (one `editable` per block) to limit the damage.
944
1220
  - **Many editors are expensive.** Each multi-line editor installs document-level listeners and its own Squire instance. Do not put `editable` on every cell of a grid; use `editmode:contenteditable`.
945
- - **Paste is an allowlist**: tables, `<hr>`, `<details>`, `<figure>`, iframes and inline styles are reduced to text. A pasted remote image keeps its hotlinked `src`; a pasted `data:` image bloats every save.
946
- - **Cloning an active region** copies runtime attributes. Strip them with `clay.RichClay.stripElement(clone)` (as in 5.5), or clone from a `<template>`.
947
- - **`editable` is ignored in view mode**; viewers get plain markup.
1221
+ - **Paste uses an allowlist.** Tables, `<hr>`, `<details>`, `<figure>`, iframes, and inline styles are reduced to text. A pasted remote image keeps its hotlinked `src`. A pasted `data:` image bloats every save.
1222
+ - **Cloning an active region** copies runtime attributes. Strip them with `clay.RichClay.stripElement(clone)` ([Element identity: `data-id` and `id`](https://clayjs.com/docs/guide/record/#element-identity-data-id-and-id)), or clone from a `<template>`.
1223
+ - **`editable` is ignored in view mode.** Viewers get plain markup.
1224
+
1225
+ #### 7.1.1 The page editor's Markdown shortcuts
1226
+
1227
+ `editable` regions and the page editor are different editors with different Markdown input rules. The page editor (`plugins/page`) converts these as you type:
1228
+
1229
+ | Typed at the start of a block | Becomes |
1230
+ |---|---|
1231
+ | `# `, `## `, `### ` | heading 1, 2 or 3 |
1232
+ | `- `, `* `, `+ ` | bulleted list item |
1233
+ | `1. `, `1) ` | numbered list item |
1234
+ | `[] `, `[ ] `, `[x] ` | checklist item, unchecked or checked |
1235
+ | `> ` | toggle |
1236
+ | ` ``` ` | code block, when the whole paragraph is the fence |
1237
+ | `---` | divider, when the whole paragraph is the dashes |
1238
+
1239
+ | Typed around text | Becomes |
1240
+ |---|---|
1241
+ | `**bold**`, `__bold__` | bold |
1242
+ | `*italic*`, `_italic_` | italic |
1243
+ | `` `code` `` | inline code |
1244
+ | `~~strike~~` | strikethrough |
1245
+ | `[label](address)` | a link, if the address is a safe one |
1246
+
1247
+ An opening delimiter must follow the start of the line, whitespace, or opening punctuation. Neither delimiter may be escaped, so `\**this\**` stays literal text. Nothing converts inside inline code, and a shortcut typed during an IME composition converts when the composition ends. Inline shortcuts delete the delimiters and apply the mark in place, keeping any existing marks on the text. Block shortcuts retype the block rather than replacing it, so its `data-id` and position survive.
1248
+
1249
+ **Backspace immediately after a shortcut restores the typed characters**, delimiters included, through input-rule undo. It is a single history step, does not drop a peer's concurrent edit, and reapplies on redo. After another keystroke, the shortcut is plain text and Backspace deletes one character as usual.
948
1250
 
949
- ### 8.2 sortable: drag to reorder
1251
+ None of this loads for a reader. The input rules, menus, and title adapter are editor code, and a Document's view mode fetches none of them.
1252
+
1253
+ ### 7.2 sortable: drag to reorder
1254
+
1255
+ `sortable` on a container makes its children draggable in edit mode:
950
1256
 
951
1257
  ```html
952
1258
  <div class="columns" sortable="columns">
@@ -959,54 +1265,54 @@ Traps:
959
1265
  </div>
960
1266
  ```
961
1267
 
962
- - `sortable` on a container makes its children draggable in edit mode: `<li>` children for `<ul>`/`<ol>`, all children otherwise, never `editor-ui` children.
963
- - `sortable="name"`: every container with the same name accepts items from the others. That is cross-column drag.
964
- - A `[sortable-handle]` inside the container (outside nested sortables) switches the container to handle-only dragging. Nested sortables are separate.
965
- - Order is saved as child order. There is no order attribute.
966
- - Every drop fires `clay:sorted` (bubbling) with `{ item, from, to, oldIndex, newIndex }`. In a cross-container move it fires on the source container. Read `item.dataset.id` and `to.closest(...)`, not indexes.
1268
+ - `sortable` drags `<li>` children in `<ul>` or `<ol>`, and all children otherwise, except `editor-ui` children.
1269
+ - `sortable="name"` lets every container with the same name accept items from the others for cross-column drag.
1270
+ - A `[sortable-handle]` inside the container (outside nested sortables) switches it to handle-only dragging. Nested sortables are separate.
1271
+ - Order saves as child order. There is no order attribute.
1272
+ - Every drop fires `clay:sorted` (bubbling) with `{ item, from, to, oldIndex, newIndex }`. In a cross-container move, it fires on the source container. Read `item.dataset.id` and `to.closest(...)`, not indexes.
967
1273
  - `onsorted="code"` runs on drop with `this` and `evt`.
968
- - Containers added later are bound after about 200 ms.
1274
+ - Containers added later bind after about 200 ms.
969
1275
  - `window.Sortable` is SortableJS 1.15.6. `Sortable.get(el).option(name, value)` changes an instance.
970
1276
 
971
1277
  Traps:
972
1278
 
973
- - **Handle-only mode is decided when the container is bound.** If handles are injected later (the writer's gutters), set it yourself: `Sortable.get(blocks).option("handle", "[sortable-handle]")`.
1279
+ - **Handle-only mode is decided when the container binds.** If handles are injected later (like the writer's gutters), set it yourself: `Sortable.get(blocks).option("handle", "[sortable-handle]")`.
974
1280
  - **One stray `[sortable-handle]` makes every other item undraggable** except by its handle.
975
- - **`onsorting` cannot veto a move**: its return value is lost. Use `Sortable.get(el).option("onMove", (evt) => …)`.
1281
+ - **`onsorting` cannot veto a move** because its return value is lost. Use `Sortable.get(el).option("onMove", (evt) => …)`.
976
1282
  - **Hidden template children count as items.** Keep blueprints in `<template>` outside the list.
977
- - **Sortable leaves residue** (`draggable="false"`, `style=""`, drag classes) that saves. The `sortable` plugin strips it inside `[sortable]` lists itself, including from a save taken mid-drag; a Sortable you load and wire up yourself needs the snapshot hook in 6.6.
1283
+ - **Sortable leaves residue** (`draggable="false"`, `style=""`, drag classes) that saves. The `sortable` plugin strips it inside `[sortable]` lists, including from a mid-drag save. A Sortable you load and wire up yourself needs a snapshot hook to strip it.
978
1284
  - A peer frame arriving mid-drag merges under the drag. Rare at autosave cadence, but possible.
979
1285
  - Write durable side effects of a move in a `clay:sorted` handler (the kanban logs "Moved to Doing" into the card's activity). If a record attribute must match its container (`data-status`), set it there too.
980
1286
 
981
- ### 8.3 undo: whole-page undo
1287
+ ### 7.3 undo: whole-page undo
982
1288
 
983
- `?plugins=undo` gives `clay.undo`: Cmd+Z, Cmd+Shift+Z and Cmd+Y over DOM mutations, restoring the same live nodes.
1289
+ `?plugins=undo` provides `clay.undo`: Cmd+Z, Cmd+Shift+Z, and Cmd+Y over DOM mutations, restoring the same live nodes.
984
1290
 
985
- - Mutations outside a commit close into an "Edit" step after 500 ms idle. **Wrap every command** so it is one labelled step:
1291
+ - Mutations outside a commit group into an "Edit" step after 500 ms idle. **Wrap every command** so it records as one labeled step:
986
1292
 
987
1293
  ```js
988
1294
  const command = (label, fn) => (clay.undo ? clay.undo.commit(label, fn) : fn());
989
1295
  command("Add card", () => list.append(card));
990
1296
  ```
991
1297
 
992
- - `commit` must be synchronous. For async work (an upload, a fetch), await it first, then make every DOM write in one synchronous `commit`. Pausing undo across an `await` loses the mutations made before the `await`.
993
- - Not recorded: `editor-ui`, `no-undo`, `no-watch` regions, richclay regions, and every change a merge brings in.
994
- - Attribute and text steps are skipped if what they would restore has changed since (for example by a peer). Structural steps are not: undoing an "Add card" removes the card even if a peer has edited it since, and redo replays without checking. In a collaborative file, treat undo as safe for the person's own recent work only; if that matters, call `clay.undo.clear()` when a `clay:sync-applied` frame touches records the history refers to.
995
- - `clay.undo.on("undo" | "redo" | "commit" | "clear", fn)`, `canUndo`, `canRedo`, `history`.
1298
+ - `commit` must be synchronous. For async work (an upload or fetch), `await` it first, then make every DOM write in one synchronous `commit`. Pausing undo across an `await` loses the mutations made before the `await`.
1299
+ - Not recorded: `editor-ui`, `no-undo`, `no-watch` regions, `richclay` regions, and every change a merge brings in.
1300
+ - Attribute and text steps are skipped if what they would restore has changed since (for example, by a peer). Structural steps are not: undoing "Add card" removes the card even if a peer edited it since, and redo replays without checking. In a collaborative file, treat undo as safe only for the person's own recent work. If that matters, call `clay.undo.clear()` when a `clay:sync-applied` frame touches records in the history.
1301
+ - Also exposed: `clay.undo.on("undo" | "redo" | "commit" | "clear", fn)`, `canUndo`, `canRedo`, and `history`.
996
1302
 
997
1303
  Traps:
998
1304
 
999
- - **It takes Cmd+Z in every text field**, including inputs it does not record (a non-persist input), so their native undo is lost.
1305
+ - **It takes Cmd+Z in every text field**, including inputs it does not record (such as a non-persist input), so their native undo is lost.
1000
1306
  - History is per tab, capped at 100, and not saved.
1001
- - Undoing a delete re-inserts the original node even if a peer re-created an equivalent; dedupe by `data-id` on `clay:sync-applied` if that matters.
1307
+ - Undoing a delete re-inserts the original node even if a peer re-created an equivalent. Dedupe by `data-id` on `clay:sync-applied` if needed.
1002
1308
 
1003
- ### 8.4 Plain contenteditable
1309
+ ### 7.4 Plain contenteditable
1004
1310
 
1005
- For titles, labels, list items and table cells, use `editmode:contenteditable` with `plaintext-only` (4.4). No toolbar, no Squire, no formatting, cheap at any count. Handle Enter yourself (blur, or create the next item as the writer's to-do list does).
1311
+ For titles, labels, list items, and table cells, use `editmode:contenteditable` with `plaintext-only` ([Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers)). It has no toolbar, Squire instance, or formatting, and is cheap at any count. Handle Enter yourself by blurring or creating the next item, as the writer's to-do list does.
1006
1312
 
1007
- ### 8.5 upload and quickcrop
1313
+ ### 7.5 upload and quickcrop
1008
1314
 
1009
- `clay.upload(file, { onProgress, signal })` posts a file you supply to the host and never rejects. **It is not a file picker** and inserts nothing. Supply the `File` from an `<input type="file">`, a drop or a paste, and write the URL yourself.
1315
+ `clay.upload(file, { onProgress, signal })` posts a file you supply to the host and never rejects. **It is not a file picker** and inserts nothing. Supply the `File` from an `<input type="file">`, a drop, or a paste, and write the URL yourself.
1010
1316
 
1011
1317
  ```js
1012
1318
  async function imageSource(file) {
@@ -1024,66 +1330,99 @@ async function imageSource(file) {
1024
1330
  }
1025
1331
  ```
1026
1332
 
1027
- - Embed a `data:` URL **only** on `code === "unsupported"` (the host does not store files). On `forbidden` (the host stores files, but not for this person on this document), `too-large`, `unsupported-type`, `payment-required` and other failures, write nothing: embedding a refused 20 MB photo would ride every future save.
1028
- - Other codes: `unauthorized`, `forbidden`, `timeout` (may have landed; retrying is safe), `aborted`, `network`, `bad-response`.
1029
- - Events: `clay:upload-start`, `clay:upload-progress`, `clay:upload-done`, `clay:upload-error`.
1030
- - Host limits: HTML Clay and Hyperclay Local 25 MB per file; hyperclay.com 10, 20 or 100 MB by plan.
1031
- - The URL is a host path, `/_/uploads/assets-<document>/<name>`. Write it exactly as returned: every address of the host serves it, so it survives renaming or moving the document. Never rebuild it from parts.
1333
+ - Embed a `data:` URL **only** on `code === "unsupported"` (the host does not store files). On `forbidden` (the host stores files, but not for this person on this document), `too-large`, `unsupported-type`, `payment-required`, and other failures, write nothing. Embedding a refused 20 MB photo would bloat every future save.
1334
+ - Other codes: `unauthorized`, `forbidden`, `timeout` (may have landed; retrying is safe), `aborted`, `network`, and `bad-response`.
1335
+ - Events: `clay:upload-start`, `clay:upload-progress`, `clay:upload-done`, and `clay:upload-error`.
1336
+ - Host limits: HTML Clay and Hyperclay Local allow 25 MB per file; hyperclay.com allows 10, 20, or 100 MB by plan.
1337
+ - The URL is a host path: `/_/uploads/assets-<document>/<name>`. Write it exactly as returned. Every address of the host serves it, so it survives renaming or moving the document. Never rebuild it from parts.
1032
1338
  - Uploaded assets on hyperclay.com are publicly reachable by URL even when the document is private.
1033
1339
 
1034
- `clay.quickcrop(file, { aspect, maxWidth, maxHeight, type, quality })` opens a crop dialog and resolves `{ blob, dataURL, width, height }`, or `null` on cancel. It does not upload. Pass the blob to `clay.upload`, wrapped as a `File`. One cropper at a time; it sets `body.style.overflow = "hidden"` while open, which a save at that moment writes into the file.
1340
+ `clay.quickcrop(file, { aspect, maxWidth, maxHeight, type, quality })` opens a crop dialog and resolves `{ blob, dataURL, width, height }`, or `null` on cancel. It does not upload; wrap the blob as a `File` and pass it to `clay.upload`. Only one cropper opens at a time, and it sets `body.style.overflow = "hidden"` while open, which a save at that moment writes into the file.
1341
+
1342
+ Put the file picker for an image block in chrome: `<input type="file" accept="image/*" hidden>` inside an `editor-ui` wrapper, clicked by your menu.
1035
1343
 
1036
- The file picker for an image block lives in chrome: `<input type="file" accept="image/*" hidden>` inside the `editor-ui` wrapper, clicked by your menu.
1344
+ ### 7.6 clay-ui: toasts and dialogs
1037
1345
 
1038
- ### 8.6 clay-ui: toasts and dialogs
1346
+ `clay-ui` provides built-in notifications and modal dialogs:
1039
1347
 
1040
- - `clay.toast(message, type)` with `success`, `error`, `warning`, `info`. The message is text, never markup. No handle to dismiss early.
1348
+ - `clay.toast(message, type)` takes `success`, `error`, `warning`, or `info`. The message is text, never markup, with no handle to dismiss early.
1041
1349
  - `clay.toastPersistent(message, type)` stays until closed.
1042
- - `clay.confirm(prompt)` resolves on confirm and **rejects with `undefined` on cancel**. Always pass a rejection handler: `clay.confirm("Delete this card?").then(del, () => {})`. `try { await clay.confirm(...) } catch (e) { e.message }` throws inside the catch.
1350
+ - `clay.confirm(prompt)` resolves on confirm and **rejects with `undefined` on cancel**. Always pass a rejection handler: `clay.confirm("Delete this card?").then(del, () => {})`. `try { await clay.confirm(...) } catch (e) { e.message }` throws inside the `catch`.
1043
1351
  - `clay.ask(prompt, onYes, defaultValue)` resolves with the typed text.
1044
- - **Every dialog prompt and title is HTML**: `clay.confirm(prompt, extraContent)`, `clay.ask(prompt)`, `clay.tell(title, ...paragraphs)` and `clay.snippet(title, code)` all insert their strings as markup. ``clay.confirm(`Delete "${card.title}"?`)`` runs whatever markup a collaborator typed into the title. Keep prompts constant, or escape record text first.
1045
- - `clay.modal` is a single modal with `title`, `html`, `yes`, `no`, `onYes`, `open()`, `close()`. Every setting resets on close, and opening any dialog rejects the one already open.
1352
+ - **Every dialog prompt and title is HTML.** `clay.confirm(prompt, extraContent)`, `clay.ask(prompt)`, `clay.tell(title, ...paragraphs)`, and `clay.snippet(title, code)` insert their strings as markup. ``clay.confirm(`Delete "${card.title}"?`)`` runs whatever markup a collaborator typed into the title. Keep prompts constant, or escape record text first.
1353
+ - `clay.modal` is a single modal with `title`, `html`, `yes`, `no`, `onYes`, `open()`, and `close()`. Every setting resets on close, and opening any dialog rejects the one already open.
1046
1354
 
1047
- **Never pass a live record element as `clay.modal.html`.** The modal moves the node into its runtime-only root and removes that root on close: the element is gone from the page, and a save during the dialog drops it from the file. Pass a form built from a template, and write values back to the record on yes. For card detail, prefer the record-as-modal pattern (9.5).
1355
+ **Never pass a live record element as `clay.modal.html`.** The modal moves the node into its runtime-only root and removes that root on close. The element is gone from the page, and a save during the dialog drops it from the file. Pass a form built from a template, and write values back to the record on confirm. For card detail, prefer the record-as-modal pattern ([Detail views: the record is the modal](https://clayjs.com/docs/guide/architecture/#detail-views-the-record-is-the-modal)).
1048
1356
 
1049
- ClayJS's own UI (notice, indicator, dialogs, toasts) is drawn with inline, reset styles, so page CSS cannot restyle it.
1357
+ ClayJS's own UI (notice, indicator, dialogs, toasts) uses inline reset styles, so page CSS cannot restyle it.
1050
1358
 
1051
- ### 8.7 clay-events, clay-dom and All
1359
+ ### 7.7 clay-events, clay-dom and All
1052
1360
 
1053
- Useful pieces, each with a trap:
1361
+ Plain delegated `addEventListener` handlers on `document`, as in the examples, avoid every trap below and are the default. If you use these helpers, watch for their traps:
1054
1362
 
1055
- - **`onclone="code"`** runs on every element clone, with `this` the clone. Good for minting a fresh id on duplicate. It does not run for `template.content.cloneNode()` (a fragment): clone `template.content.firstElementChild` instead. It also runs for library clones, so keep it pure.
1363
+ - **`onclone="code"`** runs on every element clone, with `this` as the clone. Good for minting a fresh ID on duplicate. It does not run for `template.content.cloneNode()` (a fragment); clone `template.content.firstElementChild` instead. It also runs for library clones, so keep it pure.
1056
1364
  - **`onclickaway="code"`** closes a menu on outside click. It runs on every outside click, even when already closed.
1057
- - **`onclickchildren="code"`** runs with `this` as the clicked direct child: tab bars and segmented controls.
1058
- - **`onmutation` and `onglobalmutation`** run code when the subtree or the page changes. **A handler that writes into what it watches loops forever**, even writing the same text. Guard every write: `if (this.textContent !== n) this.textContent = n`. They do not fire for merged peer changes.
1059
- - **`onrender`** runs once per element at load and again whenever the element is inserted, including every drag and every merge re-insert. Keep it idempotent.
1060
- - **`el.nearest.NAME`** searches outward through siblings and cousins, so in a list it reads (and with `val`/`text` assignment writes) a neighbour's field when the record lacks one. For record-scoped access use `el.closest("[data-id]").querySelector(...)`.
1061
- - **`el.hide()`, `el.show()` and `All(...).css()`** write inline styles that save. Use clay-options or a runtime stylesheet.
1365
+ - **`onclickchildren="code"`** runs with `this` as the clicked direct child, for tab bars and segmented controls.
1366
+ - **`onmutation` and `onglobalmutation`** run when the subtree or page changes. **A handler that writes into what it watches loops forever**, even writing the same text. Guard every write: `if (this.textContent !== n) this.textContent = n`. They do not fire for merged peer changes.
1367
+ - **`onrender`** runs once per element at load and again whenever the element is inserted, including every drag and merge re-insert. Keep it idempotent.
1368
+ - **`el.nearest.NAME`** searches outward through siblings and cousins. In a list, it reads (and with `val` or `text` assignment writes) a neighbor's field when the record lacks one. For record-scoped access, use `el.closest("[data-id]").querySelector(...)`.
1369
+ - **`el.hide()`, `el.show()`, and `All(...).css()`** write inline styles that save. Use `clay-options` or a runtime stylesheet.
1062
1370
  - **`cycleAttr`** cycles through values in alphabetical order across the whole document. Write status cycles explicitly: `const order = ["todo", "doing", "done"]; el.dataset.status = order[(order.indexOf(el.dataset.status) + 1) % order.length];`
1063
1371
  - **`All`**: `All.name` and `All.length` are the function's own properties, so write `All("[name]")`. `sortBy("data-amount")` compares strings; pass a function.
1064
1372
 
1065
- Plain delegated `addEventListener` handlers on `document`, as in the examples, avoid every one of these traps and are the default.
1373
+ ### 7.8 Libraries that rewrite content: highlighting, math, diagrams
1066
1374
 
1067
- ### 8.8 Libraries that rewrite content: highlighting, math, diagrams
1375
+ Prism, highlight.js, KaTeX, and Mermaid replace source text with generated markup when they run. In edit mode, that goes wrong two ways:
1068
1376
 
1069
- Prism, highlight.js, KaTeX and Mermaid replace source text with generated markup when they run. In edit mode that goes wrong two ways:
1377
+ - **The output is saved.** If the library runs after ClayJS reads the page, its spans or SVGs count as an edit, starting an autosave that writes them into the file.
1378
+ - **Sync wipes it.** An outside change brings in plain source, replacing the decorated markup. The library does not run again, so the decoration disappears until reload.
1070
1379
 
1071
- - **The output is saved.** If the library runs after ClayJS has read the page, its spans or SVG count as an edit: they start an autosave and land in the file.
1072
- - **Sync wipes it.** An outside change brings in the plain source, which replaces the decorated markup in the open page. The library does not run again, so the decoration is gone until a reload.
1380
+ For fixed content, such as code samples in a docs page, mark the block `clay="freeze"` so sync never touches its contents. For content people or agents edit, keep the plain source as the record and render the decorated version into a `clay="editor-ui"` mount. Redraw it on load and on `clay:sync-applied` ([Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code)).
1073
1381
 
1074
- For fixed content, such as code samples in a docs page, mark the block `clay="freeze"`: sync never touches its contents. For content people or agents edit, keep the plain source as the record and render the decorated version into a `clay="editor-ui"` mount, and redraw it on load and on `clay:sync-applied` (7.5).
1382
+ ### 7.9 OverType: a markdown editor
1075
1383
 
1076
- ### 8.9 OverType: a markdown editor
1384
+ For a notes or journal app where Markdown is the format, OverType (`https://cdn.jsdelivr.net/npm/overtype@2.6.1/dist/overtype.min.js`) is a textarea under a styled preview. **The textarea is not the record.** Keep each entry's text in a text element of the record, put the editor in a `clay="freeze"` region outside the saved records, and bind it to the open record with `clay.bindText` (`?plugins=bind-text`):
1077
1385
 
1078
- For a notes or journal app where markdown is the format, OverType (`https://cdn.jsdelivr.net/npm/overtype@2.6.1/dist/overtype.min.js`) is a textarea under a styled preview. With `new OverType(el, { persist: true })` the saved file holds only a `<textarea persist>` and its wrapper; everything else is `editor-ui`. Keep one live instance at a time, and normalize OverType's layout state in an `onbeforesave` on the container. A journal built this way keeps entries as `<article data-id>` records, clones its chrome from a `<template>`, and paints the open entry with a runtime stylesheet.
1386
+ ```html
1387
+ <article data-id="a"><pre><code class="entry-body"># Morning
1388
+
1389
+ Walk by the river.</code></pre></article>
1390
+
1391
+ <div id="writer" clay="freeze">
1392
+ <div class="overtype-container"><div class="overtype-wrapper"><textarea class="overtype-input"></textarea></div></div>
1393
+ </div>
1394
+ ```
1395
+
1396
+ ```js
1397
+ const binding = clay.bindText(document.querySelector('#writer textarea'), {
1398
+ onWrite: (code) => { code.closest('article').dataset.updated = Date.now(); },
1399
+ onShow: () => overtype?.updatePreview(),
1400
+ onConflict: (typed) => note('Someone changed the same words while you were typing.', typed),
1401
+ onGone: () => note('This entry was deleted somewhere else.'),
1402
+ });
1403
+ binding.open(document.querySelector('.entry-body'), {
1404
+ key: 'a',
1405
+ resolve: () => document.querySelector('[data-id="a"] .entry-body'),
1406
+ });
1407
+ ```
1408
+
1409
+ Three reasons keep the record as text and the textarea outside it:
1410
+
1411
+ - **Word-by-word merges.** A `<textarea>` merges as one value, so two people editing different paragraphs collide and one loses their words. A text element merges word by word, so both edits land.
1412
+ - **No library attributes in records.** OverType stamps `id`, `autocomplete`, `spellcheck`, and Grammarly attributes onto a textarea. Inside a record, those save into the file and mark the page dirty at boot.
1413
+ - **Readable without JavaScript.** An `<article>` holding `<pre><code>` renders in any browser and keeps the file smaller, whereas `<textarea persist>` shows nothing until the library runs.
1414
+
1415
+ `bindText` returns `{ open, close, flush, current, attach, composing }`. Every `input` writes into the record at once, so a save captures it as that record's text. Changes the editor did not make (a peer merge, a restored draft, or a JSON API write) appear in the textarea with the caret kept. During an IME composition, nothing is written or shown. When it ends, both edits apply unless they touch the same stretch of text; on conflict, the record keeps the other person's words and `onConflict` gets what was typed. Opening the same `key` after a merge replaced the record's element rebinds without touching the textarea. Opening another `key` flushes the open record and shows the new one with the caret at the start.
1079
1416
 
1080
- ### 8.10 Attributes from older Hyperclay files
1417
+ Keep one OverType instance for the life of the tab. Normalize OverType's layout state in an `onbeforesave` on the container, and call `binding.attach(textarea)` if a library ever swaps the element. A journal built this way keeps entries as `<article data-id>` records, clones its chrome from a `<template>`, and paints the open entry with a runtime stylesheet.
1081
1418
 
1082
- Older Hyperclay files can carry attributes ClayJS ignores: `movable`, `movable-handle`, `prevent-enter`, `autosize`, `ajax-form`, `ajax-button`, `send-message`, `upgrade`. Replace them with a few lines of code (`prevent-enter`: a keydown handler; `autosize`: CSS `field-sizing: content`; `movable`: pointer events writing `data-x`/`data-y`; `send-message`: the `message` plugin, 8.13). Old saved `<html editmode="false">` and hand-written `option-visibility-styles` style tags are dead weight; ClayJS strips the root attributes on the next save.
1419
+ ### 7.10 Attributes from older Hyperclay files
1083
1420
 
1084
- ### 8.11 sap.js: records, views and totals in markup
1421
+ Older Hyperclay files can carry attributes ClayJS ignores: `movable`, `movable-handle`, `prevent-enter`, `autosize`, `ajax-form`, `ajax-button`, `send-message`, and `upgrade`. Replace `prevent-enter` with a `keydown` handler, `autosize` with CSS `field-sizing: content`, `movable` with pointer events writing `data-x` and `data-y`, and `send-message` with the `message` plugin ([message: contact forms](https://clayjs.com/docs/guide/components/#message-contact-forms)). Old saved `<html editmode="false">` and hand-written `option-visibility-styles` style tags are dead weight; ClayJS strips the root attributes on the next save.
1085
1422
 
1086
- `sap.js` (a satellite, also published standalone as `@panphora/sapjs`) is a reactive attribute layer whose only store is the DOM. Every change runs one pass: it reads the app out of the live page, computes `calc:` fields in dependency order, and writes only the paints that changed. It is the way to build the reactive parts of a malleable document app: totals, filtered and sorted views, a form that adds records, an editor for the selected record.
1423
+ ### 7.11 sap.js: records, views and totals in markup
1424
+
1425
+ `sap.js` (also published standalone as `@panphora/sapjs`) is a reactive attribute layer whose only store is the DOM. Every change runs one pass: it reads the app from the live page, computes `calc:` fields in dependency order, and writes only the paints that changed. Use it to build the reactive parts of a malleable document app: totals, filtered and sorted views, a form that adds records, and an editor for the selected record.
1087
1426
 
1088
1427
  **Before building an app with sapjs, read the sapjs guide: [saphtml.com/guide.html](https://saphtml.com/guide.html) (Markdown: [saphtml.com/guide.md](https://saphtml.com/guide.md)).** It covers designing records, `each=` views, the three kinds of state, detail editors and dialogs, three complete tested apps, and a trap table. The attribute reference is [saphtml.com/llms.txt](https://saphtml.com/llms.txt). This section covers only what is specific to running sapjs inside ClayJS.
1089
1428
 
@@ -1100,19 +1439,19 @@ Older Hyperclay files can carry attributes ClayJS ignores: `movable`, `movable-h
1100
1439
  <script src="https://clayjs.com/v1/sap.js"></script>
1101
1440
  ```
1102
1441
 
1103
- The vocabulary in one breath: `sap` marks an app root; `items="list"` holds `[item]` records and `[item][template]` (or a shared `<template items="list">`) is the blueprint; `bind` two-way binds a control or a text leaf; `state="data-due data-done:presence"` declares fields stored as attributes; `calc:`, `text:fmt`, `show`, `class:`, `css:`, `attr:` and `effect` paint; `each="expr"` renders a view of any array whose rows edit the records they show; `detail="LIST by KEY"` projects one record into a panel or a `<dialog>`; actions are `trigger-add`, `trigger-remove`, `move:`, `sort:`, `set:` and `confirm=`; `Sap(el)` is a write-through proxy and `Sap.batch(label, fn)` makes one undo step. Done means no `sap ✗` console line and `Sap.status().ok === true`.
1442
+ Core vocabulary: `sap` marks an app root. `items="list"` holds `[item]` records, and `[item][template]` (or a shared `<template items="list">`) is the blueprint. `bind` two-way binds a control or text leaf, and `state="data-due data-done:presence"` declares fields stored as attributes. `calc:`, `text:fmt`, `show`, `class:`, `css:`, `attr:`, and `effect` paint derived values. `each="expr"` renders a view of any array whose rows edit the records they show, and `detail="LIST by KEY"` projects one record into a panel or `<dialog>`. Actions are `trigger-add`, `trigger-remove`, `move:`, `sort:`, `set:`, and `confirm=`. `Sap(el)` is a write-through proxy, and `Sap.batch(label, fn)` makes one undo step. Done means no `sap ✗` console line and `Sap.status().ok === true`.
1104
1443
 
1105
1444
  How it fits ClayJS:
1106
1445
 
1107
- - **The core loads by itself.** On any page with a `[sap]` root, ClayJS loads the sapjs core (the `derive` plugin, 3.2). The core covers records (`bind`, `state=`, `items` and `item`) and paints (`text`, `text:fmt`, `show`, `attr:`, `class:`, `css:`, the `show-when:` family). Markup that needs full sapjs (`calc:`, `each`, `detail`, `effect`, `invalid`, the actions, `Sap(el)`) halts with `E50`, which names it. Load `https://clayjs.com/v1/sap.js` for full sapjs; the markup stays the same, and full sapjs takes the page over from the core.
1108
- - **Painted values are derived.** Comparisons, the dirty check, autosave, undo and every merge input leave out what a paint owns: the attribute an `attr:` names, the one class token a `class:` names, the `--` property a `css:` names, the children of a `text` element, and `hidden` under `show`. A paint never makes the page unsaved, and two tabs that change different records while repainting the same total end with the right total and no conflict.
1109
- - **Ownership is read from the markup.** It comes from the element's own attributes, never from evaluating an expression, so it holds the same in the live page, a save clone and a frame from disk. `attr:` never owns `id`, `data-id`, `class`, `style`, `value`, `checked`, `selected`, or a name the same element declares in `state=`: those stay record.
1110
- - **Repainted before anything records a baseline.** The engine mounts in a boot phase, after frozen regions record their markup and visitor drafts restore, and the boot baseline settles on the painted page. After every merge it repaints right away, before the saved, peer and disk baselines and before `clay:sync-applied`.
1111
- - **Painted values are still saved,** so the file shows them without JavaScript. When a repaint leaves the file's painted values different from the page's (an agent wrote a record and left the old paint), one clean tab with autosave on saves the painted page after a short random delay, and other tabs that repainted the same frame see that save and skip theirs.
1112
- - **Agents and `api` rules read and write records, never paints.** Point a rule at the `bind` element or a `state=` attribute (`"state": ".state"`), never at a painted attribute: a painted value on disk can lag the record until a tab repaints and saves it.
1446
+ - **The core loads by itself.** On any page with a `[sap]` root, ClayJS loads the sapjs core (the `derive` plugin, [Plugins](https://clayjs.com/docs/guide/loading/#plugins)). The core covers records (`bind`, `state=`, `items`, `item`) and paints (`text`, `text:fmt`, `show`, `attr:`, `class:`, `css:`, and the `show-when:` family). Markup that needs full sapjs (`calc:`, `each`, `detail`, `effect`, `invalid`, actions, or `Sap(el)`) halts with `E50`, which names it. Load `https://clayjs.com/v1/sap.js` for full sapjs; the markup stays the same, and full sapjs takes over from the core.
1447
+ - **Painted values are derived.** Comparisons, dirty checks, autosave, undo, and merge inputs leave out what a paint owns: the attribute an `attr:` names, the one class token a `class:` names, the `--` property a `css:` names, the children of a `text` element, and `hidden` under `show`. A paint never marks the page unsaved, and two tabs that change different records while repainting the same total end with the right total and no conflict.
1448
+ - **Ownership is read from the markup.** It comes from the element's own attributes, never from evaluating an expression, so it holds the same in the live page, a save clone, and a frame from disk. `attr:` never owns `id`, `data-id`, `class`, `style`, `value`, `checked`, `selected`, or a name the same element declares in `state=`; those stay record.
1449
+ - **Repainted before anything records a baseline.** The engine mounts during boot after frozen regions record their markup and visitor drafts restore, so the boot baseline settles on the painted page. After every merge, it repaints right away before the saved, peer, and disk baselines and before `clay:sync-applied`.
1450
+ - **Painted values are still saved** so the file shows them without JavaScript. When a repaint leaves the file's painted values different from the page's (an agent wrote a record and left the old paint), one clean tab with autosave on saves the painted page after a short random delay. Other tabs that repainted the same frame see that save and skip theirs.
1451
+ - **Agents and `api` rules read and write records, never paints.** Point a rule at the `bind` element or a `state=` attribute (`"state": ".state"`), never at a painted attribute. A painted value on disk can lag the record until a tab repaints and saves it.
1113
1452
  - Rows of an `each=` view carry `clay="editor-ui"`, so views never save or sync.
1114
1453
 
1115
- A status that sets text, colour and a label, with the state stored once:
1454
+ A status that sets text, color, and a label, with the state stored once:
1116
1455
 
1117
1456
  ```html
1118
1457
  <main sap>
@@ -1124,25 +1463,25 @@ A status that sets text, colour and a label, with the state stored once:
1124
1463
  </main>
1125
1464
  ```
1126
1465
 
1127
- `tr[data-state="accepted"] .state { background: var(--ok) }` colours it, and `htmlfile data --write` or the JSON API writes only `.state`.
1466
+ `tr[data-state="accepted"] .state { background: var(--ok) }` colors it, and `htmlfile data --write` or the JSON API writes only `.state`.
1128
1467
 
1129
1468
  Rules for sapjs in a malleable file:
1130
1469
 
1131
- - **Store each record once, as an element; derive everything else.** Counts, totals and card faces computed only from records are ordinary saved content: paint them into the record markup and ship the file pre-painted, so the first pass writes nothing (`mount writes 0`). A stale painted value repaints at mount; under ClayJS the paint heal saves it, and on other hosts sapjs logs `W30` and the value goes out with the next real save.
1132
- - **Per-tab values go in `clay="freeze"`**: anything from today's date, the locale, the URL hash, the search box or runtime data. Freeze saves an element's contents as they were at load, so put the per-tab values inside the frozen element, or fill it from an `effect`, which re-runs every pass.
1470
+ - **Store each record once as an element, and derive everything else.** Counts, totals, and card faces computed only from records are ordinary saved content. Paint them into the record markup and ship the file pre-painted so the first pass writes nothing (`mount writes 0`). A stale painted value repaints at mount; under ClayJS the paint heal saves it, and on other hosts sapjs logs `W30` and the value goes out with the next real save.
1471
+ - **Per-tab values go in `clay="freeze"`.** This includes today's date, the locale, the URL hash, the search box, or runtime data. Freeze saves an element's contents as they were at load, so put per-tab values inside the frozen element or fill it from an `effect`, which re-runs every pass.
1133
1472
  - **Per-tab state is `transient`** (`state="q:transient open:transient"`, and `transient` on the bound search input), or the URL hash through `clay-router.js`.
1134
- - **Filter with an `each=` view, or a frozen stylesheet**, never with `show=` on saved records: `show=` paints `hidden`, which ClayJS treats as derived, so it never syncs, but a save still writes the saving tab's filter into the file.
1135
- - **Every `detail=` panel and `<dialog>` sits inside a `clay="freeze"` wrapper.** Its controls are painted with the open record's values; outside freeze they would save a second copy. Not `editor-ui`, which removes the element from the file. sapjs warns `W31` for a dialog without the wrapper.
1136
- - **Give records a `data-id`**, and `data-id=""` on blueprints: `$add` and `trigger-add` mint fresh ids. Live sync pairs elements by it.
1137
- - **`persist` on bound inputs inside records**, so typed values reach the attributes a save captures.
1138
- - **Undo and redo restore `persist` controls' live values** from their attributes, and a sync merge does the same, so sapjs reads what the file says. Controls without `persist` are not restored; give them `persist` or re-sync them yourself.
1139
- - **`viewmode:` attributes log `W03`** (unknown attribute) in sapjs, for example 4.4's `viewmode:readonly`. The warning is harmless, but it makes a "no warnings" check fail.
1473
+ - **Filter with an `each=` view or a frozen stylesheet**, never with `show=` on saved records. `show=` paints `hidden`, which ClayJS treats as derived and never syncs, but a save still writes the saving tab's filter into the file.
1474
+ - **Every `detail=` panel and `<dialog>` sits inside a `clay="freeze"` wrapper.** Its controls are painted with the open record's values and would save a second copy outside freeze. Do not use `editor-ui`, which removes the element from the file. Sapjs logs `W31` for a dialog without the wrapper.
1475
+ - **Give records a `data-id`**, and `data-id=""` on blueprints so `$add` and `trigger-add` mint fresh IDs. Live sync pairs elements by it.
1476
+ - **Put `persist` on bound inputs inside records** so typed values reach the attributes a save captures.
1477
+ - **Undo, redo, and sync merges restore `persist` controls' live values** from their attributes, so sapjs reads what the file says. Controls without `persist` are not restored; give them `persist` or re-sync them yourself.
1478
+ - **`viewmode:` attributes log `W03`** (unknown attribute) in sapjs, such as [Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers)'s `viewmode:readonly`. The warning is harmless, though it makes a "no warnings" check fail.
1140
1479
  - **Hide blueprints in your own CSS** (`[item][template] { display: none }`); sapjs's own rule arrives only with the script. Sapjs also adds `[hidden] { display: none !important }` page-wide.
1141
1480
  - A pass is batched: after a write, call `Sap.refresh()` before reading painted output. Expressions compile with `new Function`, so a Content Security Policy without `unsafe-eval` breaks them.
1142
1481
 
1143
- ### 8.12 The CMS, in passing
1482
+ ### 7.12 cms: content-managed pages
1144
1483
 
1145
- `?plugins=cms` turns a page into a content-managed page: a JSON rules tag maps field names to selectors, and an "Edit content" button opens a sidebar form (or edits text in place) that writes straight into the DOM.
1484
+ `?plugins=cms` turns a page into a content-managed page. A JSON rules tag maps field names to selectors, and an "Edit content" button opens a sidebar form (or edits text in place) that writes straight into the DOM.
1146
1485
 
1147
1486
  ```html
1148
1487
  <script data-rules-name="cms" data-rules-version="1" type="application/json">
@@ -1150,13 +1489,15 @@ Rules for sapjs in a malleable file:
1150
1489
  </script>
1151
1490
  ```
1152
1491
 
1153
- It fits content sites: landing pages, menus, pricing, FAQs, team pages, where the layout is fixed and a non-developer changes copy and images. It does not fit apps where editing is the product: its form is a separate surface from the content, new list items clone the first item's attributes (including its state), and rich text inside lists is flattened in the sidebar. A file can use both: the CMS owns the header copy, the app owns `<main>`, and the rules never reach into the app's region. Give every list that can become empty a `cms-template` seed hidden by CSS, not by the `hidden` attribute (which is copied into every new item). Add `sortable` to the plugin list for drag in the sidebar.
1492
+ It fits fixed-layout content sites such as landing pages, menus, pricing, FAQs, and team pages, where a non-developer changes copy and images. It does not fit apps where editing is the product: its form is a separate surface from the content, new list items clone the first item's attributes (including its state), and rich text inside lists is flattened in the sidebar. A file can use both if the CMS owns the header copy, the app owns `<main>`, and the rules never reach into the app's region. Give every list that can become empty a `cms-template` seed hidden by CSS, not by the `hidden` attribute (which is copied into every new item). Add `sortable` to the plugin list for drag in the sidebar.
1154
1493
 
1155
- ---
1494
+ The rules tag can declare which editor opens. `data-cms-view="inline"` edits text in place and opens small popovers for everything else, while no attribute (or `"sidebar"`) opens the form in a side panel. The "Edit content" button opens whichever the page declares.
1495
+
1496
+ The CMS also edits how a page looks. A rule ending in `@--name` reads and writes one CSS custom property in an element's `style` attribute. For example, `"accent": "html@--color-accent"` with `a { color: var(--color-accent) }` in your CSS gives the owner a color picker. Any `--color-*` name gets a color picker, any `--font-*` name gets a list of 40 Google Fonts, and the CMS keeps one `<link data-hcms-fonts>` loading the fonts in use. For a layout choice, put `data-hcms-component:data-layout="variant"` and `data-hcms-options:data-layout="1:cols-1 2:cols-2 3:cols-3"` on a section and key your grid CSS on `[data-layout]`: the owner gets icon tiles, and the choice writes only the attribute. Style fields on `html`, `body`, or `:root` gather in a "Page style" group at the top of the form.
1156
1497
 
1157
- ### 8.13 message: contact forms
1498
+ ### 7.13 message: contact forms
1158
1499
 
1159
- `?plugins=message` sends a visitor's form to the document's owner. On hyperclay.com the owner gets it by email and in their messages.
1500
+ `?plugins=message` sends a visitor's form to the document's owner. On hyperclay.com, the owner gets it by email and in their messages.
1160
1501
 
1161
1502
  ```html
1162
1503
  <form message-form="Thanks, I'll reply soon">
@@ -1166,37 +1507,36 @@ It fits content sites: landing pages, menus, pricing, FAQs, team pages, where th
1166
1507
  </form>
1167
1508
  ```
1168
1509
 
1169
- - On submit the fields go as JSON to the host's `/_/message`. The form resets, the attribute's text shows as a toast, and the form fires `clay:message-sent` or `clay:message-failed` (`detail` is `{ ok, error }`). Call `event.preventDefault()` on it to draw your own confirmation instead of the toast.
1170
- - A form whose `action` is `/_/message` is taken over the same way, so a hand-written `<form method="post" action="/_/message">` works once the plugin is loaded.
1510
+ - On submit, the fields go as JSON to the host's `/_/message`. The form resets, the attribute's text shows as a toast, and the form fires `clay:message-sent` or `clay:message-failed` (`detail` is `{ ok, error }`). Call `event.preventDefault()` on it to draw your own confirmation instead of the toast.
1511
+ - A form whose `action` is `/_/message` is taken over the same way, so a hand-written `<form method="post" action="/_/message">` works once the plugin loads.
1171
1512
  - `clay.message.send(data)` sends from your own code. It resolves `{ ok, error }` and never rejects.
1172
- - **The bot check is invisible and needs the plugin.** When the visitor first focuses or types in the form, the plugin fetches a one-time token from `/_/form-token`; the host refuses a message without one. A form posted without the plugin, or with JavaScript off, gets an error, never a silent drop.
1513
+ - **The bot check is invisible and needs the plugin.** When the visitor first focuses or types in the form, the plugin fetches a one-time token from `/_/form-token`, and the host refuses a message without one. A form posted without the plugin or with JavaScript off gets an error, never a silent drop.
1173
1514
  - Field names are what the owner reads (`**email**: ...`). Do not name a field `formToken` or `formSignals`.
1174
1515
  - A submit handler of your own that calls `preventDefault()` keeps the form to itself; the plugin leaves it alone.
1516
+ ## 8. Structuring an app
1175
1517
 
1176
- ## 9. App architecture
1518
+ ### 8.1 The record container
1177
1519
 
1178
- ### 9.1 The record container
1520
+ Keep every record in one container in the saved document, with `data-id` per record and fields as attributes, text, or persist controls. Shape the container by the primary editing dimension:
1179
1521
 
1180
- One container in the saved document holds every record, with `data-id` per record and fields as attributes, text or persist controls. Shape it by the primary editing dimension:
1181
-
1182
- - **Board-first** (kanban, pipeline): records live inside their column's list. `sortable` gives cross-column drag; the column a card sits in is its status.
1183
- - **Table-first** (finance, CRM contacts, inventory): records are `<tr data-id>` rows in one `<tbody>`. A board, if any, is a projection.
1184
- - **Document-first** (writer, wiki page): records are blocks, siblings in one `sortable` container (10.2).
1522
+ - **Board-first** (kanban, pipeline): records live inside their column's list. `sortable` gives cross-column drag, and the column a card sits in is its status.
1523
+ - **Table-first** (finance, CRM contacts, inventory): records are `<tr data-id>` rows in one `<tbody>`. Any board is a projection.
1524
+ - **Document-first** (writer, wiki page): records are blocks, siblings in one `sortable` container ([Block writer](https://clayjs.com/docs/guide/recipes/#block-writer)).
1185
1525
 
1186
1526
  Never store a record twice. A JSON mirror of DOM records, or a rendered copy beside a model, drifts the first time a merge or a hand edit touches one of them.
1187
1527
 
1188
- ### 9.2 Chrome
1528
+ ### 8.2 Chrome
1189
1529
 
1190
- Author chrome in the file and hide it from viewers with `show-when:editmode="true"` (4.3). It needs no script, it is in place before ClayJS loads, and viewers never see it. The file carries its markup, which is harmless as long as nothing changes it.
1530
+ Chrome is the app UI around your records. Author static chrome in the file and hide it from viewers with `show-when:editmode="true"` ([Showing and hiding edit controls: clay-options](https://clayjs.com/docs/guide/edit-mode/#showing-and-hiding-edit-controls-clay-options)). It needs no script, is in place before ClayJS loads, and viewers never see it. The file carries its markup, which is harmless as long as nothing changes it.
1191
1531
 
1192
1532
  Use `clay="editor-ui"` when hiding is not enough:
1193
1533
 
1194
- - **A script changes it after load.** Counts, summaries, status text, a filter bar that reflects its state. (A selection or an open state on a saved element is `ephemeral` instead, 5.7.) In a saved element each of these changes is an edit: it starts an autosave, adds an undo step, is sent to peers and can conflict with theirs. Inside `editor-ui` none of that happens.
1195
- - **It is drawn from data.** Charts, canvases and grids rendered from the record.
1196
- - **It holds per-tab state.** The open card, the current view, the style that paints them (9.3).
1534
+ - **A script changes it after load.** This includes counts, summaries, status text, and a filter bar that reflects its state. In a saved element, each change is an edit: it starts an autosave, adds an undo step, syncs to peers, and can conflict with theirs. Inside `editor-ui`, none of that happens. For a selection or open state on a saved element, use `ephemeral` instead.
1535
+ - **It is drawn from data.** Charts, canvases, and grids rendered from the record.
1536
+ - **It holds per-tab state.** The open card, the current view, and the style that paints them ([Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state)).
1197
1537
  - **Viewers must not receive it.** The saved file has to be clean HTML without the controls.
1198
1538
 
1199
- An `editor-ui` element is removed from every save, so it cannot be authored in the file. Clone it from a `<template>` into one `clay="editor-ui"` wrapper after `clay.ready`:
1539
+ An `editor-ui` element is removed from every save, so do not author it in the file. Clone it from a `<template>` into one `clay="editor-ui"` wrapper after `clay.ready`:
1200
1540
 
1201
1541
  ```js
1202
1542
  const chrome = document.createElement("div");
@@ -1206,21 +1546,23 @@ chrome.append(document.getElementById("chrome-tpl").content.cloneNode(true));
1206
1546
  board.querySelector(".board-head").append(chrome);
1207
1547
  ```
1208
1548
 
1209
- Chrome may sit inside the record container (the kanban puts its filter in the board header): `editor-ui` removes it wherever it is. Per-item controls that never change, such as a grip or a remove button, are authored in the item's `<template>` with `show-when:editmode="true"`. Per-item controls a script manages (the writer's block gutter, a list's menu button) are inserted into each item as their own `editor-ui` element and re-inserted by `render()` when missing, so items added by a peer get theirs too. A per-item value computed from the record, such as a count, is not chrome: it is saved content (9.4).
1549
+ Chrome may sit inside the record container (the kanban puts its filter in the board header): `editor-ui` removes it wherever it is. Author static per-item controls, such as a grip or remove button, in the item's `<template>` with `show-when:editmode="true"`. Insert script-managed per-item controls (the writer's block gutter, a list's menu button) into each item as their own `editor-ui` element, and re-insert them in `render()` when missing so items added by a peer get theirs too. A per-item value computed from the record, such as a count, is saved content rather than chrome ([Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)).
1550
+
1551
+ Edit-only chrome lives in the same wrapper with `show-when:editmode="true"`, or is skipped in view mode (`if (clay.isEditMode) …`).
1210
1552
 
1211
- Edit-only chrome lives in the same wrapper with `show-when:editmode="true"`, or is not built at all in view mode (`if (clay.isEditMode) …`).
1553
+ ### 8.3 Per-tab state
1212
1554
 
1213
- ### 9.3 Per-tab state
1555
+ Keep UI state outside saved records unless it belongs to the document for every visitor.
1214
1556
 
1215
1557
  | State | Where | Why |
1216
1558
  |---|---|---|
1217
1559
  | Which card or entry is open, the filter, search text, current view, sort | URL hash: `#card=c-brief&q=pricing` | per tab, survives reload, shareable, Back closes the card, never saved or synced |
1218
- | Collapsed sections, column widths, density, last opened entry | `localStorage`, keyed by the file's path | per device and viewer; wrap reads and writes in `try/catch` |
1219
- | Selection, hover, drag state, open menu | `ephemeral` on the element (5.7), memory, or chrome DOM | transient, never saved or synced |
1220
- | Pointers, lobbies, who is looking at what, shared live but never saved | a never-saved live store (7.9) | every tab sees it at once, the file never holds it |
1560
+ | Collapsed sections, column widths, density, last opened entry | `clay.storage.local.get(key)` / `set(key, value)` / `remove(key)` | per device and viewer, scoped to this file's path (`clay.storage.session` lasts one tab) |
1561
+ | Selection, hover, drag state, open menu | `ephemeral` on the element ([Ephemeral state: per-tab state on saved elements](https://clayjs.com/docs/guide/record/#ephemeral-state-per-tab-state-on-saved-elements)), memory, or chrome DOM | transient, never saved or synced |
1562
+ | Pointers, lobbies, who is looking at what, shared live but never saved | a never-saved live store ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)) | every tab sees it at once, the file never holds it |
1221
1563
  | A default view visitors land on, WIP limits, column names, label definitions | attributes on a settings element in the record | genuinely part of the document |
1222
1564
 
1223
- For state on one element, such as the selected card, use `ephemeral` (5.7). **Paint state that comes from the hash with a runtime stylesheet** inside chrome, rewritten from the hash, instead of toggling classes or attributes on records:
1565
+ For state on one element, such as the selected card, use `ephemeral` ([Ephemeral state: per-tab state on saved elements](https://clayjs.com/docs/guide/record/#ephemeral-state-per-tab-state-on-saved-elements)). **Paint state that comes from the hash with a runtime stylesheet** inside chrome, rewritten from the hash, instead of toggling classes or attributes on records:
1224
1566
 
1225
1567
  ```js
1226
1568
  const rules = [];
@@ -1232,24 +1574,26 @@ if (openId) {
1232
1574
  setText(document.getElementById("view-state"), rules.join("\n"));
1233
1575
  ```
1234
1576
 
1235
- Nothing on a record changes, so opening a card or filtering never makes the page dirty and never reaches a peer. The kanban and skeleton tests check exactly that.
1577
+ Nothing on a record changes, so opening a card or filtering never makes the page dirty or reaches a peer. The kanban and skeleton tests check that.
1236
1578
 
1237
- When a view switch must use clay-options on a saved ancestor attribute, normalize it out of every snapshot so it stays tab-local:
1579
+ When a view switch must use `clay-options` on a saved ancestor attribute, normalize it out of every snapshot so it stays tab-local:
1238
1580
 
1239
1581
  ```html
1240
1582
  <main id="app" view="board" onbeforesnapshot="this.setAttribute('view', 'board')">
1241
1583
  ```
1242
1584
 
1243
- Never put per-viewer state on `<html>` or any record element without that: it autosaves, syncs to every collaborator and lands in version history.
1585
+ Never put per-viewer state on `<html>` or any record element without that reset: it autosaves, syncs to every collaborator, and lands in version history.
1586
+
1587
+ `clay.storage` keeps JSON under `clay:doc:<location.pathname>:<key>`, so two documents on one subdomain never share a key. Without it, a per-document key must be hand-built from the path or every document's app collides with the next. `session` survives a reload and copies into a duplicated tab, so it cannot keep one tab's state out of the next. When the browser blocks storage, every call works from memory for the life of the tab and `set` returns `false`: save the value somewhere that outlives the tab if `set` returns `false`.
1244
1588
 
1245
- ### 9.4 Derived values
1589
+ ### 8.4 Derived values
1246
1590
 
1247
1591
  Pick by whether the saved file must show the value:
1248
1592
 
1249
- - **Computed only from the records** (counts, progress badges, totals, a card's face): ordinary saved content. Write it into the record markup, ship the file pre-painted, and recompute on every change. With sap this is just a paint (8.11), and ClayJS treats it as derived: merges leave it out and the engine repaints it from the merged records, so two tabs that changed different records end with the right value and no conflict. Hand-written derived text in saved content does not get this; give it `no-dirty`. The starter Kanban paints its list counts and card faces this way.
1250
- - **Different per tab** (search results, which entry is open, "overdue" against today's date, OS shortcut labels, names loaded from the people plugin): write it inside a `clay="freeze"` scratchpad (5.2). The element saves and syncs; its contents never do.
1251
- - **Pure UI** (buttons, menus, handles): `editor-ui`, created from a `<template>` (9.2).
1252
- - **Optionally, `no-dirty`** for a computed value the saved file shows: it saves with the next real edit, never counts as an edit itself, and remote wins under sync. Apply the 7.3 workaround. The finance tracker does this.
1593
+ - **Computed only from the records** (counts, progress badges, totals, a card's face): ordinary saved content. Write it into the record markup, ship the file pre-painted, and recompute on every change. With sap, this is a paint ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)) that ClayJS treats as derived. Merges leave it out and the engine repaints it from the merged records, so two tabs that changed different records end with the right value and no conflict. Hand-written derived text in saved content does not get this, so give it `no-dirty`. The starter Kanban paints its list counts and card faces this way.
1594
+ - **Different per tab** (search results, which entry is open, "overdue" against today's date, OS shortcut labels, names loaded from the people plugin): write it inside a `clay="freeze"` scratchpad ([Regions: the `clay` attribute](https://clayjs.com/docs/guide/record/#regions-the-clay-attribute)). The element saves and syncs, while its contents never do.
1595
+ - **Pure UI** (buttons, menus, handles): `editor-ui`, created from a `<template>` ([Chrome](https://clayjs.com/docs/guide/architecture/#chrome)).
1596
+ - **Optionally, `no-dirty`** for a computed value the saved file shows: it saves with the next real edit, never counts as an edit itself, and remote wins under sync. Apply the [Regions and JSON under sync](https://clayjs.com/docs/guide/live-sync/#regions-and-json-under-sync) workaround. The finance tracker does this.
1253
1597
  - **Only in the saved file**: compute it into the save clone with `clay.addDocumentTransform`, never the live page.
1254
1598
 
1255
1599
  Always write derived text through a guard, because writing the same text is still a mutation:
@@ -1258,13 +1602,13 @@ Always write derived text through a guard, because writing the same text is stil
1258
1602
  const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };
1259
1603
  ```
1260
1604
 
1261
- Recompute inside `clay.effect(render)`, which covers record mutations, merges, undo and redo (7.5), and call `render()` from a `hashchange` listener when the hash affects the view.
1605
+ Recompute inside `clay.effect(render)`, which covers record mutations, merges, undo, and redo ([Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code)). Call `render()` from a `hashchange` listener when the hash affects the view.
1262
1606
 
1263
- With sapjs, every derived value here is a paint. sapjs recomputes on record mutations and `clay:sync-applied` by itself, and on hash changes only through `clay-router.js` (`clay:route`); an app with its own hash format keeps a `hashchange` listener that calls `Sap.refresh()`. See [sapjs guide section 6](https://saphtml.com/guide.html).
1607
+ With sapjs, every derived value here is a paint. sapjs recomputes on record mutations and `clay:sync-applied` by itself, and on hash changes only through `clay-router.js` (`clay:route`). An app with its own hash format keeps a `hashchange` listener that calls `Sap.refresh()`. See the [sapjs guide](https://saphtml.com/guide.html).
1264
1608
 
1265
- ### 9.5 Detail views: the record is the modal
1609
+ ### 8.5 Detail views: the record is the modal
1266
1610
 
1267
- Keep a record's detail fields **inside the record element**, hidden on the card face by CSS, and show the record itself as an overlay with the runtime stylesheet from 9.3.
1611
+ Keep a record's detail fields **inside the record element**, hidden on the card face by CSS, and show the record itself as an overlay with the runtime stylesheet from [Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state):
1268
1612
 
1269
1613
  ```html
1270
1614
  <li class="card" data-id="c-brief">
@@ -1286,17 +1630,31 @@ Keep a record's detail fields **inside the record element**, hidden on the card
1286
1630
  Why this beats a dialog with a copy:
1287
1631
 
1288
1632
  - There is no copy, so nothing to write back and nothing to lose.
1289
- - The detail moves with the card when it is dragged, and merges per card under sync.
1633
+ - The detail moves with the card when dragged, and merges per card under sync.
1290
1634
  - A peer's edit to the open card appears in place, with your caret kept.
1291
- - richclay's body-mounted toolbar works, because nothing is in the top layer. `showModal()` would also write `open` into the file.
1635
+ - richclay's body-mounted toolbar works because nothing is in the top layer. `showModal()` would also write `open` into the file.
1292
1636
 
1293
- Open on a click that is not on an editable field, a handle, an input or a link. Close on the backdrop, Escape (unless typing) and Back. When a peer deletes the open record, `render()` finds it gone and clears the hash. Caveat: `position: fixed` breaks inside an ancestor with `transform`, `filter` or `contain`.
1637
+ Open on a click that is not on an editable field, handle, input, or link. Close on the backdrop, Escape (unless typing), and Back. When a peer deletes the open record, `render()` finds it gone and clears the hash. Caveat: `position: fixed` breaks inside an ancestor with `transform`, `filter`, or `contain`.
1638
+
1639
+ When a dialog must aggregate several records, render it into `editor-ui` and write each change back to its record by id. Call `clay.markDirty()` per edit if the record is not written immediately, and re-render it on `clay:sync-applied` without disturbing the focused field.
1640
+
1641
+ **Half-typed text in a detail field outlives the view.** A comment box or description being edited sits in chrome (`clay="editor-ui"` or `clay="freeze"`) and carries `unsent="<name>"`. ClayJS keeps its value per record (the nearest `[data-id]`) and name in this tab, outside the file. Close the detail, take a sync redraw, or reload, and the text is back with the close warning on until it is sent.
1642
+
1643
+ ```html
1644
+ <div class="detail" clay="editor-ui">
1645
+ <textarea class="comment" unsent="comment" placeholder="Add a comment"></textarea>
1646
+ <button data-action="send-comment">Send</button>
1647
+ </div>
1648
+ ```
1294
1649
 
1295
- When a dialog must aggregate several records, render it into `editor-ui`, write each change back to its record by id, call `clay.markDirty()` per edit if the record is not written immediately, and re-render it on `clay:sync-applied` without disturbing the focused field.
1650
+ - The attribute is only a marker in the file: the region is chrome, so no save, sync frame, or undo step ever carries the text.
1651
+ - Restoration runs from a `MutationObserver` rather than a microtask, after the code that rebuilt the detail, so the view's `openDetail` can clear and refill the field in the same task. To react to it, listen for `clay:unsent-restored`, which bubbles from the control with `detail: {base, current, stale}`. `stale` is true when the record's saved value changed since the person started typing, so the view can warn instead of silently overwriting.
1652
+ - Send and cancel call `clay.unsent.clear(input)`. Typing the text back to what the record already holds, or emptying it, drops it too, so the warning clears without an explicit call.
1653
+ - `clay.unsent.size` and `clay.unsent.list()` (`{record, name, base, text}`) say what is kept, and `clay:unsent-changed` fires on `document` with the count.
1296
1654
 
1297
- With sapjs, the record-as-modal pattern works unchanged over `items=` records, and a plain-field editor can be a `detail=` panel or `<dialog>` instead: see [sapjs guide section 7](https://saphtml.com/guide.html).
1655
+ With sapjs, the record-as-modal pattern works unchanged over `items=` records, and a plain-field editor can be a `detail=` panel or `<dialog>` instead. See the [sapjs guide](https://saphtml.com/guide.html).
1298
1656
 
1299
- ### 9.6 Commands
1657
+ ### 8.6 Commands
1300
1658
 
1301
1659
  A command is a function that changes the record once:
1302
1660
 
@@ -1328,23 +1686,41 @@ document.addEventListener("click", (event) => {
1328
1686
  ```
1329
1687
 
1330
1688
  - Build new elements off-document (fill, id, log), then insert once inside the commit.
1331
- - One delegated click listener on `document` for all actions. It survives merges that recreate elements.
1689
+ - Use one delegated click listener on `document` for all actions. It survives merges that recreate elements.
1332
1690
  - Write activity entries from the command that made the change, never from a mutation observer, which would also log peer changes and undo replays.
1333
1691
  - Bulk inserts (an import of hundreds of rows) cost one mutation record per inserted element. Insert a fragment once rather than row by row, inside one commit or `Sap.batch`.
1334
1692
 
1335
- ### 9.7 Filters and view switching
1693
+ **Soft delete is `clay.trash`.** `card.remove()` above deletes for good. A delete a person might want back, or one a peer might be editing at the same moment, is an attribute instead: a record is in the trash while it carries `data-deleted-at`, and the record keeps its place, its id, and its content.
1336
1694
 
1337
- - **Filter by hiding** records with a runtime stylesheet (skeleton, kanban), or with sapjs through an `each=` view or a frozen stylesheet (8.11). The finance example still uses sap `show=` plus snapshot normalization, an older pattern: `show=` writes `hidden` into saved records. Never remove records to filter.
1695
+ ```js
1696
+ const item = button.closest("article.entry");
1697
+ const { undo } = clay.trash(item); // stamps data-deleted-at, fires clay:trashed
1698
+ clay.toastPersistent("Moved to Trash", "info", {
1699
+ actions: [{ label: "Undo", onClick: () => { undo(); clay.toastPersistent("Restored", "info"); } }],
1700
+ });
1701
+ ```
1702
+
1703
+ - `clay.trash(el, {at})` stamps the timestamp (`at`, or now), `clay.trash.is(el)` checks trashed state, `clay.trash.restore(el)` removes the attribute and fires `clay:restored`, and `clay.trash.purge(el)` removes the record for good. Both events bubble from the record.
1704
+ - Because a delete is an attribute change, a peer editing the same record at that moment keeps their edit and the delete merges with it. Undo restores the attribute instead of an old copy of the record. `card.remove()` has neither.
1705
+ - Leave trashed records out with a rules selector, `"article.entry:not([data-deleted-at])"`, or keep them in the list and show a `deletedAt` field (`Date.parse(el.dataset.deletedAt)`) for a Trash view. Never remove records to filter.
1706
+ - Readers without JavaScript get the raw file, so hide trashed records there too: `<noscript><style>article.entry[data-deleted-at]{display:none}</style></noscript>`.
1707
+ - The undo toast stays yours: `clay.toastPersistent(message, type, {actions})` takes the Undo button where `clay-ui` is loaded, and an app with its own toast keeps it.
1708
+
1709
+ ### 8.7 Filters and view switching
1710
+
1711
+ Filter and switch views without removing or mutating record elements:
1712
+
1713
+ - **Filter by hiding** records with a runtime stylesheet (skeleton, kanban), or with sapjs through an `each=` view or a frozen stylesheet ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)). The finance example still uses sap `show=` plus snapshot normalization, an older pattern where `show=` writes `hidden` into saved records. Never remove records to filter.
1338
1714
  - **Switch layouts with CSS** over the same records where possible: a list view can be the board with `display: contents` on columns.
1339
- - **Projection views** (a table of all cards, a calendar, a due-today list) render into an `editor-ui` container and rebuild on record mutation, `clay:sync-applied` and `hashchange`. Edits made in a projection write to the record by id.
1715
+ - **Projection views** (a table of all cards, a calendar, a due-today list) render into an `editor-ui` container and rebuild on record mutation, `clay:sync-applied`, and `hashchange`. Edits made in a projection write to the record by id.
1340
1716
 
1341
- With sapjs, a projection view is an `each=` attribute, and its rows edit the records they show by themselves: see [sapjs guide section 5](https://saphtml.com/guide.html).
1717
+ With sapjs, a projection view is an `each=` attribute, and its rows edit the records they show by themselves. See the [sapjs guide](https://saphtml.com/guide.html).
1342
1718
 
1343
- ### 9.8 Computed totals
1719
+ ### 8.8 Computed totals
1344
1720
 
1345
- Plain JavaScript into `editor-ui` (9.4) for a count or two. Sapjs for anything with arithmetic across rows, categories and formats (8.11, and the expense tracker in sapjs guide section 9). Store money as integers or as plain numbers in `value` attributes, never as formatted strings.
1721
+ Use plain JavaScript into `editor-ui` ([Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)) for a count or two. Use sapjs for arithmetic across rows, categories, and formats ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup), and the expense tracker in the sapjs guide). Store money as integers or plain numbers in `value` attributes, never as formatted strings.
1346
1722
 
1347
- ### 9.9 Import and export
1723
+ ### 8.9 Import and export
1348
1724
 
1349
1725
  Export reads the record, import writes it, and both buttons live in chrome. The finance tracker's export:
1350
1726
 
@@ -1363,7 +1739,7 @@ await clay.loaded.data;
1363
1739
  const { rows } = clay.extractData({ rows: [".txn", { id: "@data-id", date: ".date", amount: ".amt", memo: ".memo" }] });
1364
1740
  ```
1365
1741
 
1366
- `clay.applyData({ rows }, { rules })` writes a whole list back: it grows and shrinks the list to match and refuses unknown keys, scripts and event handlers without a partial write. To append, pass the existing rows plus the new ones. It pairs incoming rows with existing elements by content and order, **not** by an `id` field: reorder the rows and edit them in the same call, and an element can be rewritten as a different record while keeping the old record's unexposed attributes. When identity matters, update records yourself: find each element by `data-id`, write its fields, and append new ones from a template.
1742
+ `clay.applyData({ rows }, { rules })` writes a whole list back. It grows and shrinks the list to match and refuses unknown keys, scripts, and event handlers without a partial write. To append, pass the existing rows plus the new ones. `applyData` pairs incoming rows with existing elements by content and order, **not** by an `id` field. Reorder the rows and edit them in the same call, and an element can be rewritten as a different record while keeping the old record's unexposed attributes. When identity matters, update records yourself: find each element by `data-id`, write its fields, and append new ones from a template.
1367
1743
 
1368
1744
  ClayJS has no CSV parser. The finance tracker has a 20-line RFC 4180 parser (quoted fields, doubled quotes, CRLF) and imports with sap:
1369
1745
 
@@ -1381,9 +1757,9 @@ Sap.batch("Import CSV", () => {
1381
1757
 
1382
1758
  A rules tag with `data-rules-name="api"` gives agents and the host's data API the same shape your export uses.
1383
1759
 
1384
- ### 9.10 Starting the app once
1760
+ ### 8.10 Starting the app once
1385
1761
 
1386
- A live-sync merge can re-insert an inline script, which runs it again: every listener doubles and every chrome element appears twice. Start the app with `clay.start`. It runs its function once per page, after `clay.ready`, and a repeat call does nothing:
1762
+ Start the app with `clay.start` so initialization runs once per page. A live-sync merge can re-insert an inline script and run it again, which doubles every listener and every chrome element. `clay.start` runs its function once after `clay.ready`, and a repeat call does nothing:
1387
1763
 
1388
1764
  ```js
1389
1765
  clay.start(() => {
@@ -1393,7 +1769,7 @@ clay.start(() => {
1393
1769
 
1394
1770
  A page with more than one app script names each one so they do not share a slot: `clay.start("board", fn)`.
1395
1771
 
1396
- ### 9.11 Size
1772
+ ### 8.11 Document size and record limits
1397
1773
 
1398
1774
  Every save captures and serializes the whole document, every change check captures it again, and every peer merges the whole document per accepted save. Measured in jsdom and scaled to a browser (estimates):
1399
1775
 
@@ -1404,9 +1780,9 @@ Every save captures and serializes the whole document, every change check captur
1404
1780
  | 3,000 | 1.3 MB | ~120 ms | ~300 ms | ~190 ms |
1405
1781
  | 6,000 | 2.6 MB | ~250 ms | ~650 ms | ~400 ms |
1406
1782
 
1407
- Up to about 1,000 records feels instant. From 1,000 to 3,000 there is a visible hitch at each save. Past about 3,000 records or 1.5 MB, autosave stalls typing and peers lag. Hosts cap a save at 5 MB (hyperclay.com), 20 MB (Hyperclay Local) and 50 MB (HTML Clay). Design for archiving: one file per year, a hidden archive section that can move to another file, or one file per project.
1783
+ Up to about 1,000 records feels instant. From 1,000 to 3,000 there is a visible hitch at each save. Past about 3,000 records or 1.5 MB, autosave stalls typing and peers lag. Hosts cap a save at 5 MB (hyperclay.com), 20 MB (Hyperclay Local), and 50 MB (HTML Clay). Design for archiving: one file per year, a hidden archive section that can move to another file, or one file per project.
1408
1784
 
1409
- ### 9.12 Styles a script writes
1785
+ ### 8.12 Styles a script writes
1410
1786
 
1411
1787
  Use `clay.style` when a script owns CSS that should stay in the saved document. The saved style paints the next load before the script runs. When the script runs, it refreshes that same tag with its current CSS:
1412
1788
 
@@ -1415,7 +1791,7 @@ await clay.ready;
1415
1791
  clay.style("app-colors", ".status { color: blue; }");
1416
1792
  ```
1417
1793
 
1418
- The name identifies a `<style data-name="app-colors" clay="no-dirty">` in the document's head. A repeated call updates the existing tag in place. Refreshing it does not add an undo step, start an autosave, or raise the close warning. The next real save includes the current CSS. On the first load, before any save has carried the tag, the script creates it.
1794
+ The name identifies a `<style data-name="app-colors" clay="no-dirty">` in the document's head. On the first load, before any save has carried the tag, `clay.style` creates it. A repeated call updates the existing tag in place without adding an undo step, starting an autosave, or raising the close warning. The next real save includes the current CSS.
1419
1795
 
1420
1796
  The one-argument form keeps an external stylesheet link current:
1421
1797
 
@@ -1424,30 +1800,126 @@ await clay.ready;
1424
1800
  const link = clay.style("/app.css?v=2");
1425
1801
  ```
1426
1802
 
1427
- If the saved file has `/app.css?v=1`, this updates that link's `href` to `/app.css?v=2`. Matching uses the URL's origin and path, ignoring its query and fragment. The link is also `no-dirty` and travels with the next real save. Both forms return the element synchronously; the link form does not wait for the stylesheet to load. Both work in edit and view mode.
1803
+ If the saved file has `/app.css?v=1`, this updates that link's `href` to `/app.css?v=2`. Matching uses the URL's origin and path, ignoring its query and fragment. The link is also `no-dirty` and travels with the next real save. Both forms work in edit and view mode and return the element synchronously, without waiting for the stylesheet to load.
1428
1804
 
1429
- Use `clay="editor-ui"` instead for CSS that viewers must not receive or that represents per-tab state, such as a temporary filter. Those styles are stripped from saved HTML. `clay.style` is for CSS that belongs in the file; changing its name on every load creates another saved tag. Adding or rewriting a plain style tag without `no-dirty` can raise the leave warning even though the person edited nothing.
1805
+ Use `clay="editor-ui"` instead for CSS that viewers must not receive or that represents per-tab state, such as a temporary filter. Those styles are stripped from saved HTML. `clay.style` is for CSS that belongs in the file, and changing its name on every load creates another saved tag. Adding or rewriting a plain style tag without `no-dirty` can raise the leave warning even though the person edited nothing.
1430
1806
 
1431
1807
  ---
1432
1808
 
1433
- ## 10. Recipes
1809
+ ## 9. Hosts, assets and privacy
1810
+
1811
+ ### 9.1 What each host gives
1812
+
1813
+ | | HTML Clay | Hyperclay Local | hyperclay.com |
1814
+ |---|---|---|---|
1815
+ | Who edits | the local user | the local user; teammates through device sync | owner, team editors, edit-link holders |
1816
+ | Live sync | tabs on this machine | tabs, plus other devices through the sync engine | every editor and viewer |
1817
+ | Outside disk edits reach open tabs | yes (agents, text editors) | yes | no disk except through device sync |
1818
+ | Presence | none | none | avatar stack, no API |
1819
+ | `by` on frames, editing marker | no | no | yes |
1820
+ | Live stores ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)) | tabs of one browser at full speed | tabs of one browser at full speed; other devices at save speed | every editor at full speed |
1821
+ | Page editor merge state ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)) | none in the file: open tabs share it, a fresh open rebuilds it from the article | kept beside the file | kept beside the file |
1822
+ | Wire and ai-edit | yes | yes | ai-edit only, for owners and team editors on a paid plan |
1823
+ | Data API write | yes | yes | read only |
1824
+ | Uploads | 25 MB, in `~/htmlclay/uploads/` | 25 MB, in the root's `uploads/` | 10, 20 or 100 MB by plan, in the account's `uploads/` |
1825
+ | Upload access (`document.upload.access`) | `local` | `local` | `document` once the account's uploaded files are protected; no field before that |
1826
+ | Version history | yes | 60 days, newest 20 | a version per save; a restore does not reach open editors' tabs, so reload after restoring |
1827
+ | Max save | 50 MB | 20 MB | 5 MB |
1828
+
1829
+ A plain static server or a file opened from disk can show the file in view mode, but cannot save it. Use the `demo` plugin for a public demo that saves into each visitor's browser.
1830
+
1831
+ On hyperclay.com, every tab holds its own sync connection. Over HTTP/1.1, a browser allows six connections per origin, so a seventh tab of the same site can stall.
1832
+
1833
+ ### 9.2 Getting started on each
1834
+
1835
+ - **HTML Clay:** save the file as `name.htmlclay` and open it with HTML Clay 1.9.0 or newer. The extension changes nothing about the content. The file is ordinary HTML.
1836
+ - **Hyperclay Local:** put the file in the synced folder and open it from the app.
1837
+ - **hyperclay.com:** create a site and paste the file, or upload it.
1838
+
1839
+ ### 9.3 Privacy
1840
+
1841
+ The saved file is the data. Everyone who can download it can read everything in it: hidden elements, `data-*` attributes, JSON scripts, CSS-hidden sections and everything the owner typed. `show-when:editmode`, `hidden` and `display: none` are presentation only.
1842
+
1843
+ - To publish a version without private values, make a copy and remove them, or keep private data in a different file with different access.
1844
+ - A file you hand out as a template should ship with sample data, not yours.
1845
+ - On hyperclay.com, a file uploaded from inside a document is bound to that document. When `document.upload.access` is `document`, its bytes follow the document's read permission on every route. A reader without access gets the same 404 as a missing file, and the file is sent `Cache-Control: private, no-store`. Uploads made before that binding existed stay reachable by URL until the account's protection migration has run. A copy someone already downloaded cannot be taken back.
1846
+ - HTML Clay and Hyperclay Local report `local`, meaning their own file-serving rules, not a sharing ACL. A file there is as private as the folder on that machine.
1847
+ - Password inputs are never persisted. An authored `value` on them is still in the file.
1848
+
1849
+ ### 9.4 Offline in a local host
1850
+
1851
+ Hyperclay Local and HTML Clay each bundle one exact, published ClayJS release, chosen in their `offline-runtime.lock.json`. They ship the whole release listed in `dist/offline-manifest.json` in the npm package, not only what one document boots with. That index lists about 550 files: every module, plugin, satellite such as `sap.js` and `clay-router.js`, vendored library, theme sheet and font, plus the exact library builds older Journals load from other CDNs. A plugin, the merge engine, a drag library or a font that a document uses for the first time offline still loads.
1852
+
1853
+ - **The route.** The release is served at `/_/clayjs/<version>/<path>`, so `https://clayjs.com/v1/clay.js` is `/_/clayjs/<version>/clay.js`, with an immutable cache and an `ETag` of the file's sha256. A path the index does not list answers 404 and is never fetched from the network.
1854
+ - **Projection on serve.** A served document is rewritten in two places only. First, `src` and `href` attributes for recognized ClayJS runtime URLs become the local address, with the same spelling the file used for the rest of the URL. Recognized URLs are `https://clayjs.com/v1/...`, exact-version URLs of the bundled release, and the few exact library and font URLs the release carries a copy of. Second, a `<script type="application/json" id="clay-runtime-map">` descriptor records the mapping. Text, code samples, Markdown and every other URL stay byte for byte.
1855
+ - **Canonicalization on every write.** Save, data API writes, restore, export and live-sync frames turn those local addresses back into the public `https://clayjs.com/...` URLs and remove the descriptor before anything reaches disk. A saved or downloaded file never contains a `/_/clayjs/` address, so it stays portable to any host. A request with `Clay-Source: 1` receives the canonical bytes unprojected.
1856
+ - **Versions.** A rolling `https://clayjs.com/v1/` URL is served from the host's bundled release. If a document pins a release the host does not bundle, or names a runtime file the bundled release lacks, every runtime URL is left public. It loads online, is not promised offline, and the response carries a `Clay-Runtime-Unavailable` header that says why. The host never substitutes another release's files into a page.
1857
+ - **Older Journals.** Journals created before the release bundled its libraries load OverType and highlight.js from other CDNs, and their fonts from Google Fonts. The release maps three exact URLs to copies it ships: `https://cdn.jsdelivr.net/npm/overtype@2.6.1/dist/overtype.min.js` and `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js` to the same classic builds (which define `window.OverType` and `window.hljs`, with their licenses and pinned hashes in `compat/journal-assets.lock.json`), and the Journal's Google Fonts stylesheet URL to a stylesheet over the bundled IBM Plex files. Each match is the whole URL. Another version, a query string, another file or another host stays on the network. A Journal saved with those URLs edits offline, and a document that names anything else from those CDNs does not.
1858
+ - **What stays online.** Pictures and media hosted elsewhere, AI features and collaboration with other people need the network. A picture uploaded into the local folder is a local file and works offline.
1859
+ - **Checking a host.** `node scripts/verify-offline-closure.mjs` in the ClayJS repository checks a release's closure: every listed file is present with its recorded hash, and every module import resolves inside the release. It runs in three modes: `--package-root <dir>` for a built checkout, `--published-root <dir>` for an unpacked release tree and `--base-url <url>` for a running host's `/_/clayjs/<version>/` prefix. With `--base-url`, pass `--index <file>` naming the host's own bundled index. Without it, the check compares the host against the checkout's index.
1860
+
1861
+ Why the whole release and not a boot closure: a sap document loads much of its code late. A Kanban board loaded with `plugins=sortable,people,upload,sync,visitor-drafts,unsent,menu` boots with a few dozen modules. It then asks for `derive`, the merge engine, SortableJS on the first drag and the live-sync modules as they are needed, about 116 release paths in all. None of those appear in what the page requests at boot, so a host that served only the boot graph would break on the first drag with the network off. ClayJS's cross-host contract test opens that real board in both local hosts and requests every one of those paths through the host.
1862
+
1863
+ ---
1864
+
1865
+ ## 10. What ClayJS does not do
1866
+
1867
+ ClayJS gives you one file per document that edits and saves itself. Here is what it does not do, so you can promise what the file can do and pick the right tool.
1868
+
1869
+ ### 10.1 What changed recently
1870
+
1871
+ Two plugins replace the old advice to hand-build a [block writer](https://clayjs.com/docs/guide/recipes/#block-writer) or wait for autosave:
1872
+
1873
+ - **The [page editor](https://clayjs.com/docs/plugins/page/)** (`?plugins=page`) is a Notion-like block editor for one `article[data-page]`, backed by Yjs, where typing reaches other people as they type. It includes headings, lists, checklists, toggles, callouts, code, images, files, typed tables, boards, charts from a table, and CSV import.
1874
+ - **[Live stores](https://clayjs.com/docs/plugins/live-sync/)** (`clay="live"` and `clay.live()`) move fast-changing state at network speed (25 frames per second) instead of save speed. Use [live data](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live) for games, scores, and shared state.
1875
+
1876
+ The old limits apply elsewhere. Ordinary `editable` regions and DOM records have no CRDT and merge at save time. Rich text outside the page editor has no tables, and without `live-data`, the page editor falls back to file saves.
1877
+
1878
+ ### 10.2 Compared with the tools you know
1879
+
1880
+ | What you are comparing to | What ClayJS gives | What is missing | What to build instead |
1881
+ |---|---|---|---|
1882
+ | **Google Docs** | Keystroke co-editing, tables, code, callouts, and images in the [page editor](https://clayjs.com/docs/plugins/page/); word-level save merge in `editable`; recoverable conflicts; version history; `ai-edit` | Comments, suggestion mode, live cursors, pagination, footnotes, `.docx` export, tables in `editable` rich text | The [page editor](https://clayjs.com/docs/plugins/page/) or a [block writer](https://clayjs.com/docs/guide/recipes/#block-writer); comments as `<aside data-id data-anchor>`; print CSS for PDF |
1883
+ | **Google Sheets** | Persisted inputs, sap formulas over named fields, aggregates, formats, CSV, page tables and charts from a table, 10,000-row pages saving in about 400 ms | A1 references, ranges, fill-down, recalculation graph, virtualization, range selection, pivots | A page table and chart, or a [typed table](https://clayjs.com/docs/guide/recipes/#typed-table-a-spreadsheet-you-can-be-honest-about) of a few thousand rows, one file per year |
1884
+ | **Google Slides** | Slides as sections, sortable, uploads, view mode as the published deck | Free positioning, guides, masters, presenter view in a second window, transitions, PPTX export | A flow-layout deck with fixed layouts per slide ([slides](https://clayjs.com/docs/guide/recipes/#slides)) |
1885
+ | **Trello and Linear** | Page boards where cards have an owner and a due date; sortable columns, inline editing, checklists, attachments, undo, per-card sync | Notifications, reminders, cross-board search, mentions, integrations, boards past a few thousand cards | A page board, or the [kanban with card detail](https://clayjs.com/docs/guide/recipes/#kanban-with-card-detail) with labels, due dates, and a due-soon projection |
1886
+ | **Notion** | One document per file in the [page editor](https://clayjs.com/docs/plugins/page/) with real-time co-editing, Markdown shortcuts, derived titles, blocks, tables, CSV import, boards, and charts | A workspace of many pages, databases with relations and rollups, backlinks, sub-pages, workspace search, page permissions | A folder of files, one per page or database, plus an index file; links are `<a href>` |
1887
+ | **CRM** | Records, pipeline board, card owners and due dates, notes, CSV, totals, contact forms via `message` | Email logging, reminders, dedupe, per-row permissions | The [CRM follow-up list](https://clayjs.com/docs/guide/recipes/#crm-follow-up-list), computing due rows on open |
1888
+ | **Finance apps** | Rows with amounts, budgets, sap totals painted in view mode by `derive`, CSV import of bank exports, charts from a table | Bank sync, learned categorization, multi-currency, scheduled transactions, encryption at rest | A monthly or yearly budget file with CSV import ([finance tracker](https://clayjs.com/docs/guide/recipes/#finance-tracker)) |
1889
+ | **Full CMS** | Per-page [CMS](https://clayjs.com/docs/plugins/cms/) sidebar, per-page style group for colors, fonts, and variants, uploads with crop, view mode as the site, JSON data API | Multi-page collections, shared layouts, drafts and scheduled publishing, roles, media library, i18n | A [content site](https://clayjs.com/docs/guide/recipes/#content-site) with one file per page, a shared stylesheet, and the [CMS](https://clayjs.com/docs/plugins/cms/) per page |
1890
+
1891
+ ### 10.3 Limits that apply to every document
1892
+
1893
+ - **No comments.** ClayJS has no comment model (`ai-edit` is an AI prompt, not stored comments). Store comments as `<aside data-id data-anchor>` elements.
1894
+ - **No live cursors.** Presence shows who is here in an avatar stack and outlines which block someone is editing. There are no character cursors and no app-facing presence API.
1895
+ - **No suggestion mode, pagination, footnotes, or `.docx` and PPTX export.** Edits apply directly without tracked changes, page breaks, footnotes, or Office exporters. Use print CSS for PDF.
1896
+ - **Per-file edit access.** There are no per-row permissions. Split data for different audiences into separate files.
1897
+ - **No background work in a closed file.** Nothing runs in a closed file, and a live room ends after two minutes with nobody connected. Compute due states on open.
1898
+ - **Undo is per tab.** History is capped at 100 steps per tab, is not saved, and never reverts a peer's merged edits.
1899
+
1900
+ > [!NOTE]
1901
+ > **Privacy: the saved file is the data.** Hiding an element with CSS, `hidden`, or `show-when` is presentation, not access control. See [Privacy](https://clayjs.com/docs/guide/hosts/#privacy).
1902
+
1903
+ ---
1434
1904
 
1435
- ### 10.1 Kanban with card detail
1905
+ ## 11. Recipes
1906
+
1907
+ ### 11.1 Kanban with card detail
1436
1908
 
1437
1909
  [`examples/kanban.html`](https://clayjs.com/examples/kanban.html). Plugins `sync,sortable,undo,indicator`, satellites `clay-options` and `clay-ui`.
1438
1910
 
1439
1911
  - `#board > .columns[sortable="columns"] > section.column[data-id]`, each with a handle and an `h2`, then `ol.cards[sortable="cards"]` of `li.card[data-id]`.
1440
1912
  - Each card holds a hidden `.detail`: an `editable` description, a checklist of `li[data-id]` with `persist` checkboxes, an `ol.activity` of `li[data-at]`, and edit-only actions.
1441
1913
  - Count per column and progress per card are `editor-ui` spans re-rendered on every mutation.
1442
- - The open card is in the hash and painted by the view-state style (9.5). The filter is in the hash too.
1914
+ - The open card is in the hash and painted by the view-state style ([Detail views: the record is the modal](https://clayjs.com/docs/guide/architecture/#detail-views-the-record-is-the-modal)). The filter is in the hash too.
1443
1915
  - `clay:sorted` logs "Moved to *Column*" in the card when it changes column.
1444
1916
  - Templates: card, checklist item, column, chrome.
1445
1917
 
1446
1918
  Extend with labels (a settings element listing `<li data-label="bug" data-color="…">`, cards carrying `data-labels="bug ui"`), due dates (`data-due` plus a hash filter "due this week"), WIP limits (an attribute on the column, a warning in an `editor-ui` badge), and a list view (a projection or `display: contents`).
1447
1919
 
1448
- With sapjs, the counts and progress badges become paints, the filters an `each=` view or a frozen stylesheet, and checklists nested `items=` lists with a shared blueprint: sapjs guide section 9.2 builds a smaller version as a complete, tested app.
1920
+ With sapjs, the counts and progress badges become paints, the filters an `each=` view or a frozen stylesheet, and checklists nested `items=` lists with a shared blueprint: sapjs guide section [Chrome](https://clayjs.com/docs/guide/architecture/#chrome) builds a smaller version as a complete, tested app.
1449
1921
 
1450
- ### 10.2 Block writer
1922
+ ### 11.2 Block writer
1451
1923
 
1452
1924
  [`examples/writer.html`](https://clayjs.com/examples/writer.html). Plugins `sync,sortable,undo,upload,indicator`, satellite `clay-ui`.
1453
1925
 
@@ -1463,275 +1935,103 @@ With sapjs, the counts and progress badges become paints, the filters an `each=`
1463
1935
 
1464
1936
  This model gives drag, block menus, non-rich blocks and per-block Squire undo (which limits the peer-rollback trap), at the cost of selection and Enter-to-split across blocks.
1465
1937
 
1466
- - A gutter (handle, `+` insert, `⋯` block menu) is cloned from a template into each block as `editor-ui`, with `contenteditable="false"`. Because the handles are injected, the script sets Sortable's `handle` option (8.2).
1467
- - The menu is one `editor-ui` element positioned under the clicked button. Insert actions clone a block template and give it an id; block actions duplicate (re-iding every descendant, 5.5) or delete.
1938
+ - A gutter (handle, `+` insert, `⋯` block menu) is cloned from a template into each block as `editor-ui`, with `contenteditable="false"`. Because the handles are injected, the script sets Sortable's `handle` option ([sortable: drag to reorder](https://clayjs.com/docs/guide/components/#sortable-drag-to-reorder)).
1939
+ - The menu is one `editor-ui` element positioned under the clicked button. Insert actions clone a block template and give it an id; block actions duplicate (re-iding every descendant, [Element identity: `data-id` and `id`](https://clayjs.com/docs/guide/record/#element-identity-data-id-and-id)) or delete.
1468
1940
  - The slash menu opens on keyup `/` when the caret's paragraph contains only `/`; choosing an item removes the `/` (and the block, if it held nothing else) and inserts after it.
1469
1941
  - Images come from a hidden file input in chrome or from a pasted screenshot (`pasteImage`), through `clay.upload` with the `data:` fallback.
1470
1942
  - The outline (H1 to H3 across blocks) and the word count are `editor-ui` projections; the outline rebuilds only when its signature changes.
1471
1943
 
1472
1944
  Missing pieces to add on the same model: a table block (`<table>` with `editmode:contenteditable` cells and your own Tab and add-row code), a code block (`<pre><code editmode:contenteditable>` with a copy button in chrome), a toggle (`<details>`, noting `open` saves), anchor ids on headings (assign `id` from text on blur, not on load).
1473
1945
 
1474
- ### 10.3 Finance tracker
1946
+ ### 11.3 Finance tracker
1475
1947
 
1476
1948
  [`examples/finance.html`](https://clayjs.com/examples/finance.html). Plugins `sync,undo,indicator&exclude=richclay`, satellites `clay-options`, `clay-ui`, `sap.js`.
1477
1949
 
1478
1950
  - `main#app[sap]` with a summary of `no-dirty` outputs (budgeted, spent, left), a categories table (`items="categories"`, each row a name, a persisted budget input, computed spent and left, a progress bar via `css:used`) and a transactions table (`items="txns"`, each row date, memo, category with a datalist, amount).
1479
1951
  - Category spent: `calc:spent="sum(root.txns.filter(t => t.cat === item.name), 'amount')"`.
1480
1952
  - Search: a `transient` bound input, `calc:match` and `show="item.match"` per row, `hidden` stripped from snapshots.
1481
- - Rows get stable ids from `claimRowIds`, a workaround from before sapjs minted unique ids. A new app writes `data-id=""` on the blueprint and lets `trigger-add` mint ids (8.11).
1953
+ - Rows get stable ids from `claimRowIds`, a workaround from before sapjs minted unique ids. A new app writes `data-id=""` on the blueprint and lets `trigger-add` mint ids ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)).
1482
1954
  - CSV import (edit only) and export (both modes).
1483
- - The 7.3 acknowledge workaround for `no-dirty` totals.
1955
+ - The [Regions and JSON under sync](https://clayjs.com/docs/guide/live-sync/#regions-and-json-under-sync) acknowledge workaround for `no-dirty` totals.
1484
1956
 
1485
1957
  The authored file already shows every computed value, so sap mounts without changing the record and a reader without JavaScript sees correct totals.
1486
1958
 
1487
- ### 10.4 CRM follow-up list
1959
+ ### 11.4 CRM follow-up list
1488
1960
 
1489
1961
  Table-first. `<tr data-id data-stage="lead" data-next="2026-10-14">` rows with `editmode:contenteditable` name and company cells, a persisted `<select>` for stage, a persisted `<input type="date">` for the next contact, and an `editable` notes cell or a hidden notes element shown in the record-as-modal detail. Projections into `editor-ui`: "due today" (rows whose `data-next` is today or earlier) and a pipeline board (columns by stage, each card a link to the row's detail). Totals by stage with sap if deals carry amounts. `mailto:` links for contact. CSV import merges by email or id. Single owner.
1490
1962
 
1491
- ### 10.5 Flashcards
1963
+ ### 11.5 Flashcards
1492
1964
 
1493
1965
  Decks as `<section data-id>` records, cards as `<article data-id data-due data-interval data-ease>` with `editmode:contenteditable` front and back. Study mode is tab-local: the hash holds the deck and the current card, a runtime stylesheet shows one card and hides the back until revealed. Grading writes the new `data-due`, `data-interval` and `data-ease` on the card (an SM-2 style schedule), inside one undo commit. The "due today" count is an `editor-ui` projection.
1494
1966
 
1495
- ### 10.6 Typed table (a spreadsheet you can be honest about)
1967
+ ### 11.6 Typed table (a spreadsheet you can be honest about)
1496
1968
 
1497
1969
  A fixed schema of named columns, rows as `<tr item data-id>` in a sap `items` list, inputs bound and persisted per cell, computed columns with `calc:`, totals with `sum`, filters by hiding, CSV in and out. Tab between cells with a keydown handler on the table. A few hundred to two thousand rows per file.
1498
1970
 
1499
- ### 10.7 Slides
1971
+ ### 11.7 Slides
1500
1972
 
1501
1973
  One `<section class="slide" data-id data-layout="title|bullets|image|quote">` per slide in a 16:9 box, `editable` text, `sortable` to reorder, images through upload. Present mode is tab-local (hash `#present=3`): fullscreen, `scroll-snap`, arrow keys, speaker notes in an `<aside>` hidden while presenting. PDF through `@page { size: 16in 9in }` and the browser's print.
1502
1974
 
1503
- ### 10.8 Content site
1504
-
1505
- Without the CMS: `editable` regions for copy, upload and quickcrop for images, `sortable` lists for features. With the CMS: a rules tag and `?plugins=cms` (8.12).
1506
-
1507
- ---
1508
-
1509
- ## 11. Limits, and what to build instead
1510
-
1511
- ClayJS gives one file per document, whole-document saves, save-gated three-way merging, per-tab undo, and editing components that write plain HTML. It does not give keystroke-level co-editing, operational transforms or CRDTs, cursors, comments, per-row permissions, a formula engine, virtualization, background jobs or cross-file queries. Promise what the file can do.
1512
-
1513
- | Competitor | What ClayJS gives | What is missing | Build this instead |
1514
- |---|---|---|---|
1515
- | **Google Docs** | Rich text with a bubble toolbar, word-level merge at save time, recoverable conflicts, version history from the host, ai-edit | Tables in rich text, comments, suggestion mode, live cursors, keystroke co-editing, pagination, footnotes, .docx export | A block writer (10.2): table, code, callout and image blocks; comments as `<aside data-id data-anchor>` elements; print CSS for PDF; turn-taking or different-section editing |
1516
- | **Google Sheets** | Persisted inputs, sap formulas over named fields, aggregates, formats, CSV | A1 references, ranges, fill-down, recalculation graph, virtualization, range selection, charts, pivots, 10k-row data | A typed table (10.6) of a few thousand rows, one file per year |
1517
- | **Google Slides** | Slides as sections, sortable, uploads, view mode as the published deck | Free positioning, guides, masters, presenter view in a second window, transitions, PPTX | A flow-layout deck with fixed layouts per slide (10.7) |
1518
- | **Trello, Linear** | Sortable columns, inline editing, checklists, attachments, undo, sync with per-card identity | Notifications, reminders, assignments, cross-board search, mentions, integrations, boards past a few thousand cards | The kanban (10.1) with labels, due dates and a due-soon projection |
1519
- | **Notion** | One rich document per file, blocks, structured pages | A workspace of many pages, databases with relations and rollups, backlinks, workspace search, page permissions | A folder of files, one per page or database, plus an index file; links are `<a href>` |
1520
- | **CRM** | Records, pipeline board, notes, CSV, totals | Email logging, reminders, dedupe, multi-user ownership, per-row permissions, public intake | The follow-up list (10.4) |
1521
- | **Finance apps** | Rows with amounts, budgets, sap totals, CSV import of bank exports | Bank sync, learned categorization, multi-currency, scheduled transactions, encryption at rest | A monthly or yearly budget file with CSV import (10.3) |
1522
- | **Full CMS** | Per-page CMS sidebar, uploads with crop, view mode as the site, a JSON data API | Multi-page collections, shared layouts, drafts and scheduled publishing, roles, media library, i18n | One file per page, a shared stylesheet, the CMS for non-technical editors of each page |
1523
-
1524
- Cross-cutting:
1525
-
1526
- - **Collaboration latency** is the autosave cadence: a few seconds after each pause. State that has to move faster (games, scores, cursors) goes in a live store (7.9).
1527
- - **Per-row permissions** do not exist: edit access is per file. Split data with different audiences into different files.
1528
- - **Background work** (reminders, recurring transactions) cannot run in a closed file. Compute "due" when the file is open.
1529
- - **Privacy:** the saved file is the data. Hiding an element with CSS or `show-when` is presentation, not access control (12.3).
1530
-
1531
- ---
1532
-
1533
- ## 12. Hosts, assets and privacy
1534
-
1535
- ### 12.1 What each host gives
1536
-
1537
- | | HTML Clay | Hyperclay Local | hyperclay.com |
1538
- |---|---|---|---|
1539
- | Who edits | the local user | the local user; teammates through device sync | owner, team editors, edit-link holders |
1540
- | Live sync | tabs on this machine | tabs, plus other devices through the sync engine | every editor and viewer |
1541
- | Outside disk edits reach open tabs | yes (agents, text editors) | yes | no disk except through device sync |
1542
- | Presence | none | none | avatar stack, no API |
1543
- | `by` on frames, editing marker | no | no | yes |
1544
- | Live stores (7.9) | tabs of one browser at full speed | tabs of one browser at full speed; other devices at save speed | every editor at full speed |
1545
- | Page editor merge state (7.9) | none in the file: open tabs share it, a fresh open rebuilds it from the article | kept beside the file | kept beside the file |
1546
- | Wire and ai-edit | yes | yes | ai-edit only, for owners and team editors on a paid plan |
1547
- | Data API write | yes | yes | read only |
1548
- | Uploads | 25 MB, in `~/htmlclay/uploads/` | 25 MB, in the root's `uploads/` | 10, 20 or 100 MB by plan, in the account's `uploads/` |
1549
- | Version history | yes | 60 days, newest 20 | a version per save; a restore does not reach open editors' tabs, so reload after restoring |
1550
- | Max save | 50 MB | 20 MB | 5 MB |
1551
-
1552
- A plain static server or a file opened from disk can show the file in view mode but cannot save it. Use the `demo` plugin for a public demo that saves into each visitor's browser.
1553
-
1554
- On hyperclay.com every tab holds its own sync connection. Over HTTP/1.1 a browser allows six connections per origin, so a seventh tab of the same site can stall.
1555
-
1556
- ### 12.2 Getting started on each
1557
-
1558
- - **HTML Clay:** save the file as `name.htmlclay` and open it with HTML Clay 1.9.0 or newer. The extension changes nothing about the content; it is ordinary HTML.
1559
- - **Hyperclay Local:** put the file in the synced folder and open it from the app.
1560
- - **hyperclay.com:** create a site and paste the file, or upload it.
1561
-
1562
- ### 12.3 Privacy
1563
-
1564
- The saved file is the data. Everyone who can download it can read everything in it: hidden elements, `data-*` attributes, JSON scripts, CSS-hidden sections and everything the owner typed. `show-when:editmode`, `hidden` and `display: none` are presentation.
1565
-
1566
- - To publish a version without private values, make a copy and remove them, or keep private data in a different file with different access.
1567
- - A file you hand out as a template should ship with sample data, not yours.
1568
- - Uploaded assets on hyperclay.com are reachable by URL even for private documents.
1569
- - Password inputs are never persisted; an authored `value` on them is still in the file.
1570
-
1571
- ---
1572
-
1573
- ## 13. Checklist and trap index
1574
-
1575
- ### 13.1 Before you ship a file
1576
-
1577
- 1. Loads with `clay.js` as a classic script at the end of `<body>`, every tag on the same version prefix.
1578
- 2. `<html autosave>` unless it is a deliberate manual-save writer.
1579
- 3. `sync` is in the plugin list, and `exclude=richclay` if there is no rich text.
1580
- 4. The fallback rule `html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }` is in the stylesheet, and every edit-only control has `show-when:editmode="true"`.
1581
- 5. Records are authored in the saved form (`inert-contenteditable`, `disabled`, `readonly`); templates in the active form.
1582
- 6. Every repeated record has a unique `data-id`; templates carry none; every create and duplicate mints a fresh one.
1583
- 7. Every control whose value matters has `persist`; no filter or search box does.
1584
- 8. All chrome is cloned from a `<template>` into `clay="editor-ui"` after `clay.ready`; nothing in the file is chrome.
1585
- 9. Tab-local state is in the hash, `localStorage` or memory, painted through a runtime style; opening, filtering and searching leave `clay.hasUnsavedChanges()` false.
1586
- 10. Derived values are sapjs paints, in `editor-ui`, or `no-dirty` when the file must show them; every hand-written derived write is guarded, and no script copies a record into a second attribute.
1587
- 11. Commands are wrapped in `clay.undo.commit`; derived views are drawn in `clay.effect`, with no effect warnings in the console.
1588
- 12. Views whose record a peer deleted are closed, in the effect or a `clay:sync-applied` handler.
1589
- 13. The app script starts with `clay.start`; all listeners are delegated.
1590
- 14. Nothing writes the record at boot.
1591
- 15. Edit-only calls are guarded for view mode, and edit actions check `clay.isEditMode`.
1592
- 16. `clay.confirm` calls have a rejection handler.
1593
- 17. Save failures are visible (indicator, `savestatus` CSS or clay-ui).
1594
- 18. Library residue (Sortable, third-party editors) is stripped in a snapshot hook.
1595
- 19. Images use `clay.upload`, embedding only on `unsupported`.
1596
- 20. The file opens in view mode with JavaScript disabled and shows correct content and no edit controls.
1597
- 21. With sap: `Sap.status().ok`, the live root matches the file after load (no computed value rewritten), no `[sap-error]`, rows with your own ids.
1975
+ ### 11.8 Content site
1598
1976
 
1599
- ### 13.2 Trap index
1977
+ Without the CMS: `editable` regions for copy, upload and quickcrop for images, `sortable` lists for features. With the CMS: a rules tag and `?plugins=cms` ([cms: content-managed pages](https://clayjs.com/docs/guide/components/#cms-content-managed-pages)).
1600
1978
 
1601
- | Trap | Section |
1602
- |---|---|
1603
- | CSS keyed on `editmode="false"` shows edit chrome before boot | 4.2 |
1604
- | A never-saved file shows `show-when:editmode` controls to viewers without the fallback rule | 4.3 |
1605
- | `show-when` matches any ancestor, not the nearest | 4.3 |
1606
- | A view attribute on a saved element syncs to every collaborator | 4.3, 9.3 |
1607
- | Records authored with live `contenteditable` stay editable for viewers until the first save | 4.4 |
1608
- | Templates in the inert form give clones that cannot be edited until reload | 4.4 |
1609
- | Unguarded edit-only API calls throw in view mode | 4.5 |
1610
- | `hidden`, `.selected`, `<details open>` and library inline styles save; use `ephemeral` | 5.1, 5.7 |
1611
- | `no-save` chrome still reaches peers; use `editor-ui` | 5.2 |
1612
- | Edits in `no-watch` or `no-dirty` are invisible to `save()` and the close warning | 5.2 |
1613
- | Runtime changes inside `freeze` are never saved or synced, even by `force()` | 5.2 |
1614
- | A `freeze` scratchpad from a peer arrives empty; fill it on every change | 5.2 |
1615
- | Content in a `freeze` element when it first appears becomes its saved contents | 5.2 |
1616
- | A control without `persist` loses its typed value | 5.3 |
1617
- | Undo and merges restore `checked`/`value` attributes but not what the control shows, and the next save writes what is shown | 5.3, 8.3 |
1618
- | JSON with an unescaped `</script>` corrupts the file | 5.4 |
1619
- | `<template>` content edits never autosave | 5.4 |
1620
- | `+=` on a live counter loses increments from other tabs; use `add` | 7.9 |
1621
- | A list in a live store pushed from two tabs keeps one push | 7.9 |
1622
- | A live tag added after people loaded the page is not in their rooms | 7.9 |
1623
- | Live data gets no close warning; up to 8 s can be lost | 7.9 |
1624
- | Items without ids added in two tabs: one is dropped | 5.5, 7.2 |
1625
- | Cloned records share a `data-id`, which counts as no id | 5.5 |
1626
- | `crypto.randomUUID` is missing outside secure contexts | 5.5 |
1627
- | Writes in the first 0.5 to 3 s after load are absorbed into the baseline and not saved | 6.2 |
1628
- | A failed save is not retried until the next edit | 6.2 |
1629
- | `ok: false` also means "nothing to save" and "queued" | 6.4 |
1630
- | `clay.getHTML()` runs hooks and closes the undo batch | 6.4 |
1631
- | A 412 conflict stops autosave until resolved | 6.5 |
1632
- | Impure hooks keep the page permanently dirty | 6.6 |
1633
- | A throwing `onSnapshot` or `addDocumentTransform` callback blocks every save | 6.6 |
1634
- | `onaftersave` writing to the live DOM loops autosave | 6.6 |
1635
- | Without autosave, collaborators see nothing until a save | 7.1 |
1636
- | Listeners attached to records are lost when a merge recreates them | 7.2 |
1637
- | A peer's frame re-runs inline scripts | 7.2, 9.10 |
1638
- | A `no-dirty` region replaced by a peer raises a false conflict notice (1.8.2) | 7.3 |
1639
- | Same-key JSON edits under `merge`: incoming wins, yours goes to the notice | 7.3 |
1640
- | Rewriting the record at boot makes incoming frames hold | 7.4 |
1641
- | Parsed JSON goes stale after a merge | 7.5 |
1642
- | A script that copies a record into a styling attribute goes stale in the file, and a merge brings the stale copy back | 1.3, 8.11 |
1643
- | An `api` rule that reads a painted value can read a stale one until a tab repaints and saves | 8.11 |
1644
- | Full sapjs markup under the sapjs core halts with `E50` | 8.11 |
1645
- | There is no presence API | 7.7 |
1646
- | `data-richclay` card mode deletes images on load | 8.1 |
1647
- | The richclay toolbar is unusable inside `showModal()` and popovers | 8.1 |
1648
- | Rich text has its own undo; page undo never records it | 8.1, 8.3 |
1649
- | Squire undo can roll back a peer's edit in the same region | 8.1 |
1650
- | Global `registerButton` after load does not reach mounted editors | 8.1 |
1651
- | Screenshot paste does nothing without a `pasteImage` listener | 8.1 |
1652
- | Sortable handle mode is fixed at bind time | 8.2 |
1653
- | `onsorting` cannot veto a move | 8.2 |
1654
- | Sortable residue saves | 8.2, 6.6 |
1655
- | The undo plugin takes Cmd+Z in every text field | 8.3 |
1656
- | Undoing an add removes the record even after a peer edited it | 8.3 |
1657
- | Pausing undo across an `await` loses the earlier mutations | 8.3 |
1658
- | Sap reads live control values: undo without re-syncing inputs saves the undone number | 5.3, 8.11 |
1659
- | `clay.upload` is not a picker; embed only on `unsupported` | 8.5 |
1660
- | `clay.confirm` rejects with `undefined` on cancel | 8.6 |
1661
- | A live element passed to `clay.modal.html` is deleted | 8.6 |
1662
- | Mutation hooks that write what they watch loop forever | 8.7 |
1663
- | `onclone` does not fire for template fragments | 8.7 |
1664
- | `el.nearest` reads a neighbour record's field | 8.7 |
1665
- | `hide()`, `show()`, `css()` write saved inline styles | 8.7 |
1666
- | A file whose painted values do not match sap's state writes at load | 8.11 |
1667
- | Dialog prompts are HTML: record text in `clay.confirm` runs a collaborator's markup | 8.6 |
1668
- | `editmode:resource type="inert/javascript"` activates as `javascript` and never runs | 4.4 |
1669
- | Sap's template row shows before sap loads | 8.11 |
1670
- | CMS `cms-template` with `hidden` makes every new item invisible | 8.12 |
1671
- | Records stored twice drift | 9.1 |
1672
- | `applyData` pairs rows by content and order, not by id | 9.9 |
1673
- | Activity logged from a mutation observer also logs peers and undo | 9.6 |
1674
- | Saves slow down past about 1,000 records and stall past 3,000 | 9.11 |
1675
- | Updating a runtime style tag without `no-dirty` can raise the leave warning | 9.12 |
1676
- | A version restore on hyperclay.com does not reach open editors | 12.1 |
1677
- | Hidden is not private | 12.3 |
1678
- | Typed values vanish from a visitor's draft: the control has no `persist` | 14.2 |
1679
1979
  ---
1680
1980
 
1681
- ## 14. Free documents for visitors
1981
+ ## 12. Free documents for visitors
1682
1982
 
1683
1983
  A **free document** is a published app anyone can use and keep. The visitor gets the same editing UI the owner gets, their work is kept in their own browser, and the download hands them a file with that work in it. Nothing a visitor does reaches the host.
1684
1984
 
1685
- ### 14.1 What a visitor gets
1985
+ ### 12.1 What a visitor gets
1686
1986
 
1687
- On a page that opts in (14.2), a visitor who is not the owner is in **draft mode**. Every edit-only module loads, so `editable`, `persist`, undo, sortable and the rest behave as they do for the owner, and the autosave pipeline still runs. The save lane is swapped: instead of sending the document to the host, ClayJS extracts the page's `api` JSON out of the bytes a real save would write and stores `{ "v": 1, "savedAt": "2026-10-15T00:00:00.000Z", "data": { ... } }` under `localStorage["clay:visitor-drafts:<slot>"]`. The next load applies that JSON over the file the host served, so an owner's later fix reaches a returning visitor while the visitor's own work stays. The host is never told: no save, no sync, no upload, and no `/_/meta` discovery. Two tabs on the same page share that record: a save takes what only the other tab changed, and when both changed the same thing, this tab's version is saved and the other tab's record is kept under `clay:visitor-drafts:<slot>:conflict`.
1987
+ On a page that opts in ([The contract](https://clayjs.com/docs/guide/free-documents/#the-contract)), a visitor who is not the owner is in **draft mode**. Every edit-only module loads, so `editable`, `persist`, undo, sortable and the rest behave as they do for the owner, and the autosave pipeline still runs. The save lane is swapped: instead of sending the document to the host, ClayJS extracts the page's `api` JSON out of the bytes a real save would write and stores `{ "v": 1, "savedAt": "2026-10-15T00:00:00.000Z", "data": { ... } }` under `localStorage["clay:visitor-drafts:<slot>"]`. The next load applies that JSON over the file the host served, so an owner's later fix reaches a returning visitor while the visitor's own work stays. The host is never told: no save, no sync, no upload, and no `/_/meta` discovery. Two tabs on the same page share that record: a save takes what only the other tab changed, and when both changed the same thing, this tab's version is saved and the other tab's record is kept under `clay:visitor-drafts:<slot>:conflict`.
1688
1988
 
1689
- The owner is a visitor here too. Signed in, they get the same private draft unless the URL carries `?editmode=true`, which edits the original file and shows every visitor the change. Two things always edit the file itself: a document opened from `file:`, where draft mode never applies, and HTML Clay, which stamps a save token on the response. Hyperclay Local is not one of them. It serves the folder over `http:` and injects no save token, so its owner is in draft mode like anyone else and adds `?editmode=true` to edit the file (14.3).
1989
+ The owner is a visitor here too. Signed in, they get the same private draft unless the URL carries `?editmode=true`, which edits the original file and shows every visitor the change. Two things always edit the file itself: a document opened from `file:`, where draft mode never applies, and HTML Clay, which stamps a save token on the response. Hyperclay Local is not one of them. It serves the folder over `http:` and injects no save token, so its owner is in draft mode like anyone else and adds `?editmode=true` to edit the file ([The mode ladder](https://clayjs.com/docs/guide/free-documents/#the-mode-ladder)).
1690
1990
 
1691
- ### 14.2 The contract
1991
+ ### 12.2 The contract
1692
1992
 
1693
- A page that wants draft mode needs all of this. The example in 14.8 carries every item, and its test proves the round trip:
1993
+ A page that wants draft mode needs all of this. The example in [A complete example](https://clayjs.com/docs/guide/free-documents/#a-complete-example) carries every item, and its test proves the round trip:
1694
1994
 
1695
- 1. An `api` rules tag naming every saved field, with a `cms-template` seed row for every list the rules describe. Hide the seed in your own CSS (`[cms-template] { display: none !important }`), never with `hidden`, which the CMS reads as data (8.12).
1995
+ 1. An `api` rules tag naming every saved field, with a `cms-template` seed row for every list the rules describe. Hide the seed in your own CSS (`[cms-template] { display: none !important }`), never with `hidden`, which the CMS reads as data ([cms: content-managed pages](https://clayjs.com/docs/guide/components/#cms-content-managed-pages)).
1696
1996
  2. `<html visitor-drafts="<site-stem>-v1" autosave>`. The value is the slot: letters, digits, dot, underscore and hyphen, up to 100 characters. It names this app's corner of `localStorage`, so two apps on one origin never mix.
1697
1997
  3. `plugins=visitor-drafts` on the loader tag, plus whatever else the app needs.
1698
- 4. `persist` on every control the rules name. A typed value without `persist` is lost at save (5.3), and in draft mode a save is the visitor's own copy.
1699
- 5. `data-id` on every record and `data-id=""` on every blueprint. New rows mint their own (`trigger-add` does it, 8.11).
1998
+ 4. `persist` on every control the rules name. A typed value without `persist` is lost at save ([`persist`: form controls](https://clayjs.com/docs/guide/record/#persist-form-controls)), and in draft mode a save is the visitor's own copy.
1999
+ 5. `data-id` on every record and `data-id=""` on every blueprint. New rows mint their own (`trigger-add` does it, [sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)).
1700
2000
  6. `<meta name="application-name" content="...">`. The prompt, the intro and the download name the app from it, falling back to the title before the first `:` or `|`.
1701
2001
  7. A feedback link the visitor can use: a `mailto:` address in the page, not in the plugin.
1702
- 8. Optional: a `<template visitor-intro>` (14.6). No template, no intro; the prompt still shows.
2002
+ 8. Optional: a `<template visitor-intro>` ([The download prompt and the intro area](https://clayjs.com/docs/guide/free-documents/#the-download-prompt-and-the-intro-area)). No template, no intro; the prompt still shows.
1703
2003
  9. A round trip test in your own repo: write a draft, load the page again with that storage, and check that the values and the computed counts come back.
1704
2004
 
1705
- Bump the slot (`-v2`) only when an old draft cannot fit the new rules. A draft that no longer fits is parked, not thrown away (14.5).
2005
+ Bump the slot (`-v2`) only when an old draft cannot fit the new rules. A draft that no longer fits is parked, not thrown away ([Restoring and redrawing](https://clayjs.com/docs/guide/free-documents/#restoring-and-redrawing)).
1706
2006
 
1707
- ### 14.3 The mode ladder
2007
+ ### 12.3 The mode ladder
1708
2008
 
1709
- 4.1's list decides the mode. Draft mode is a rung in it, between a save token and the owner cookie:
2009
+ [How the mode is decided](https://clayjs.com/docs/guide/edit-mode/#how-the-mode-is-decided)'s list decides the mode. Draft mode is a rung in it, between a save token and the owner cookie:
1710
2010
 
1711
2011
  1. `?editmode=true` or `?editmode=false` in the URL. Only the literal `true` means edit.
1712
2012
  2. `<html viewonly>` in the file, or a response carrying only the pre-1.9.0 token `htmlclaytoken`.
1713
2013
  3. `window.clayEditMode`, set by a script before boot.
1714
2014
  4. A `savetoken` attribute the host put on `<html>`.
1715
- 5. **Draft mode**, on a page that opted in (14.2).
2015
+ 5. **Draft mode**, on a page that opted in ([The contract](https://clayjs.com/docs/guide/free-documents/#the-contract)).
1716
2016
  6. The `isAdminOfCurrentResource` cookie (hyperclay.com, Hyperclay Local).
1717
2017
 
1718
2018
  The rung is where a free document lives, so the details matter:
1719
2019
 
1720
2020
  - `?editmode=false` is view mode, and nothing below it changes that.
1721
2021
  - `?editmode=true` edits the original **only with the owner cookie**, and only on a page that draft mode would otherwise claim. A query parameter on its own never grants writes to the host: an anonymous visitor with `?editmode=true` stays in draft mode.
1722
- - The owner cookie alone is not enough either. The signed in owner gets the same draft as everyone else until they open the page with `?editmode=true`, and the owner chip (14.6) is their way there.
1723
- - `viewonly`, a stale token, `window.clayEditMode` or a `savetoken` the host stamped on `<html>` all turn draft mode off. The first two leave it in view mode; the last two switch the load to edit mode against the host, which still decides who may write (4.1). On a `viewonly` document or one with a stale token, `?editmode=true` is not draft mode either: it is the same edit mode as before, saving to a host that refuses the write.
2022
+ - The owner cookie alone is not enough either. The signed in owner gets the same draft as everyone else until they open the page with `?editmode=true`, and the owner chip ([The download prompt and the intro area](https://clayjs.com/docs/guide/free-documents/#the-download-prompt-and-the-intro-area)) is their way there.
2023
+ - `viewonly`, a stale token, `window.clayEditMode` or a `savetoken` the host stamped on `<html>` all turn draft mode off. The first two leave it in view mode; the last two switch the load to edit mode against the host, which still decides who may write ([How the mode is decided](https://clayjs.com/docs/guide/edit-mode/#how-the-mode-is-decided)). On a `viewonly` document or one with a stale token, `?editmode=true` is not draft mode either: it is the same edit mode as before, saving to a host that refuses the write.
1724
2024
  - Draft mode needs all four of the plugin name in `plugins=`, a valid slot, an `http:` or `https:` page and an `api` rules tag. That tag also has to carry `data-rules-version="1"` and hold JSON that parses; a newer version or unreadable JSON leaves the page out of draft mode with one console warning naming the problem. On `file:` it never applies, which is exactly why the download is a real file.
1725
- - `<html draftmode="true">` says which mode this load is in, beside `editmode` and `pageowner` (4.2), and is removed from every save.
2025
+ - `<html draftmode="true">` says which mode this load is in, beside `editmode` and `pageowner` ([The root attributes](https://clayjs.com/docs/guide/edit-mode/#the-root-attributes)), and is removed from every save.
1726
2026
 
1727
- ### 14.4 What runs in draft mode
2027
+ ### 12.4 What runs in draft mode
1728
2028
 
1729
2029
  `clay.isDraftMode` is true, and everything else follows from it:
1730
2030
 
1731
2031
  - **The plugins that talk to the host are dropped**, even when the page names them: `sync`, `live`, `upload`, `quickcrop`, `cms`, `people`, `wire`, `ai-edit`, `demo` and `source`. `clay.upload`, `clay.cms`, `clay.conflicts` and `clay.source` are absent, and code that guards them (`clay.upload?.(...)`) behaves as it does for a viewer. The `[refetch-on-save]` attribute is dropped too, since it would fetch its element from the host again after every save.
1732
2032
  - **The save lane is the browser.** `clay.save()` and autosave both run, `savestatus` moves as usual, and the save answers `msgType: "success"` with `msg: "Saved in this browser"`. A storage failure answers the 507 path with `code: "draft-storage"` and fires `clay:drafts-store-failed`; the next good write clears it.
1733
2033
  - **No request reaches the host.** The download link goes to the host's download page, which holds the email sign-up. The prompt's own stylesheet is a Google Fonts link.
1734
- - **`clay.readData()` and `clay.writeData()` refuse.** Both reject with `code: "DataUnavailableInDraft"`: the saved file belongs to the owner, and this visitor's work is in their browser, so there is nothing for them to read or write there. A page that calls them in draft mode gets that refusal instead of a request (9.9).
2034
+ - **`clay.readData()` and `clay.writeData()` refuse.** Both reject with `code: "DataUnavailableInDraft"`: the saved file belongs to the owner, and this visitor's work is in their browser, so there is nothing for them to read or write there. A page that calls them in draft mode gets that refusal instead of a request ([Import and export](https://clayjs.com/docs/guide/architecture/#import-and-export)).
1735
2035
  - **`clay.visitorDrafts`** is the plugin's whole surface:
1736
2036
 
1737
2037
  | Member | What it is |
@@ -1747,19 +2047,19 @@ The rung is where a free document lives, so the details matter:
1747
2047
  | `editOriginal()` | navigate to this page with `?editmode=true` |
1748
2048
  | `backToDraft()` | navigate back with `editmode` removed |
1749
2049
  | `downloadUrl()` | the download link the prompt is using, with `src` |
1750
- | `source` | the `?src=` this slot remembers, or `null` (14.6) |
2050
+ | `source` | the `?src=` this slot remembers, or `null` ([The download prompt and the intro area](https://clayjs.com/docs/guide/free-documents/#the-download-prompt-and-the-intro-area)) |
1751
2051
 
1752
2052
  - **Three events** on `document`: `clay:drafts-restored` with `{ restored, hadDraft, parked }`, `clay:drafts-saved` with `{ data }`, and `clay:drafts-store-failed` with `{ error }`.
1753
2053
 
1754
- ### 14.5 Restoring and redrawing
2054
+ ### 12.5 Restoring and redrawing
1755
2055
 
1756
- The restore runs while ClayJS boots, before `clay.ready` resolves, and applies the stored JSON to the live page with `applyData`. sapjs and any code that waits for `clay.ready` therefore see the restored values, not the authored ones, and sapjs re-derives on the mutations the restore makes, so a count or a total painted in markup is right on the first paint after it. Load `clay.js` before `sap.js` for that: sapjs mounts when `clay.ready` resolves, and a page that mounts it first paints the authored values with no later mutation to correct them (8.11).
2056
+ The restore runs while ClayJS boots, before `clay.ready` resolves, and applies the stored JSON to the live page with `applyData`. sapjs and any code that waits for `clay.ready` therefore see the restored values, not the authored ones, and sapjs re-derives on the mutations the restore makes, so a count or a total painted in markup is right on the first paint after it. Load `clay.js` before `sap.js` for that: sapjs mounts when `clay.ready` resolves, and a page that mounts it first paints the authored values with no later mutation to correct them ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)).
1757
2057
 
1758
2058
  Code that drew before `clay.ready` has two ways to hear about the restore: listen for `clay:drafts-restored` on `document`, or call `clay.visitorDrafts.onRestore(fn)`, which runs `fn` at once when the restore already happened. Either way the detail is `{ restored, hadDraft, parked }`.
1759
2059
 
1760
2060
  A draft that no longer fits the page is never overwritten. When the stored JSON is not `{ v, data }` (garbage, a bare array, a missing `data`), the raw value is moved to `localStorage["clay:visitor-drafts:<slot>:unreadable:<ISO timestamp>"]`, the slot is cleared, and the page loads as authored with `parked: true` in the detail. When the write is refused only for keys the rules do not know, those keys are dropped and the rest is applied, so adding a field to the rules never costs a visitor their work.
1761
2061
 
1762
- ### 14.6 The download prompt and the intro area
2062
+ ### 12.6 The download prompt and the intro area
1763
2063
 
1764
2064
  Both are runtime chrome: `clay="editor-ui"` and `sap-ignore`, built by the plugin on every load. Neither is in the file, neither is saved or synced, and neither is read by sapjs.
1765
2065
 
@@ -1787,13 +2087,13 @@ An element inside the template with `[visitor-intro-start]` scrolls to the app,
1787
2087
 
1788
2088
  **The owner chip** shows only to the signed in owner, at the bottom left: it says whether this load is their private draft ("Edit the original") or the original ("Back to my draft") and switches with `clay.visitorDrafts.editOriginal()` and `backToDraft()`.
1789
2089
 
1790
- ### 14.7 Migrating an app's own storage
2090
+ ### 12.7 Migrating an app's own storage
1791
2091
 
1792
2092
  **Not supported yet:** the plugin reads no migration hook, so an app that kept its earlier data under its own `localStorage` key has to move those values into the page from its own code, on the first load after the switch.
1793
2093
 
1794
- ### 14.8 A complete example
2094
+ ### 12.8 A complete example
1795
2095
 
1796
- [`examples/free-reading-list.html`](https://clayjs.com/examples/free-reading-list.html). Plugins `visitor-drafts,indicator&exclude=richclay`, satellites `clay-options` and `sap.js`. A reading list: a title, an author and a status select per book, one scalar goal for the year, and sapjs counting the books that are read. It is the shortest file that carries every item of the contract, and `tests/unit/guide-examples/free-document.test.js` runs it end to end: edit a title, add a book, check the slot, then load the file again with that storage and check the books and the counts come back. The record is authored in the saved form, so sapjs logs its harmless W03 warning for each `viewmode:` attribute (8.11). The `reading` option of a status select carries no `selected`: it is the first option, which is the default, and the row that is `read` marks it.
2096
+ [`examples/free-reading-list.html`](https://clayjs.com/examples/free-reading-list.html). Plugins `visitor-drafts,indicator&exclude=richclay`, satellites `clay-options` and `sap.js`. A reading list: a title, an author and a status select per book, one scalar goal for the year, and sapjs counting the books that are read. It is the shortest file that carries every item of the contract, and `tests/unit/guide-examples/free-document.test.js` runs it end to end: edit a title, add a book, check the slot, then load the file again with that storage and check the books and the counts come back. The record is authored in the saved form, so sapjs logs its harmless W03 warning for each `viewmode:` attribute ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)). The `reading` option of a status select carries no `selected`: it is the first option, which is the default, and the row that is `read` marks it.
1797
2097
 
1798
2098
  ```html
1799
2099
  <!DOCTYPE html>
@@ -1873,3 +2173,112 @@ An element inside the template with `[visitor-intro-start]` scrolls to the app,
1873
2173
  </body>
1874
2174
  </html>
1875
2175
  ```
2176
+
2177
+ ---
2178
+
2179
+ ## 13. Pre-ship checklist
2180
+
2181
+ ### 13.1 Before you ship a file
2182
+
2183
+ 1. Loads with `clay.js` as a classic script at the end of `<body>`, every tag on the same version prefix.
2184
+ 2. `<html autosave>` unless it is a deliberate manual-save writer.
2185
+ 3. `sync` is in the plugin list, and `exclude=richclay` if there is no rich text.
2186
+ 4. The fallback rule `html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }` is in the stylesheet, and every edit-only control has `show-when:editmode="true"`.
2187
+ 5. Records are authored in the saved form (`inert-contenteditable`, `disabled`, `readonly`); templates in the active form.
2188
+ 6. Every repeated record has a unique `data-id`; templates carry none; every create and duplicate mints a fresh one.
2189
+ 7. Every control whose value matters has `persist`; no filter or search box does.
2190
+ 8. All chrome is cloned from a `<template>` into `clay="editor-ui"` after `clay.ready`; nothing in the file is chrome.
2191
+ 9. Tab-local state is in the hash, `localStorage` or memory, painted through a runtime style; opening, filtering and searching leave `clay.hasUnsavedChanges()` false.
2192
+ 10. Derived values are sapjs paints, in `editor-ui`, or `no-dirty` when the file must show them; every hand-written derived write is guarded, and no script copies a record into a second attribute.
2193
+ 11. Commands are wrapped in `clay.undo.commit`; derived views are drawn in `clay.effect`, with no effect warnings in the console.
2194
+ 12. Views whose record a peer deleted are closed, in the effect or a `clay:sync-applied` handler.
2195
+ 13. The app script starts with `clay.start`; all listeners are delegated.
2196
+ 14. Nothing writes the record at boot.
2197
+ 15. Edit-only calls are guarded for view mode, and edit actions check `clay.isEditMode`.
2198
+ 16. `clay.confirm` calls have a rejection handler.
2199
+ 17. Save failures are visible (indicator, `savestatus` CSS or clay-ui).
2200
+ 18. Library residue (Sortable, third-party editors) is stripped in a snapshot hook.
2201
+ 19. Images use `clay.upload`, embedding only on `unsupported`.
2202
+ 20. The file opens in view mode with JavaScript disabled and shows correct content and no edit controls.
2203
+ 21. With sap: `Sap.status().ok`, the live root matches the file after load (no computed value rewritten), no `[sap-error]`, rows with your own ids.
2204
+
2205
+ ### 13.2 Trap index
2206
+
2207
+ | Trap | Section |
2208
+ |---|---|
2209
+ | CSS keyed on `editmode="false"` shows edit chrome before boot | [The root attributes](https://clayjs.com/docs/guide/edit-mode/#the-root-attributes) |
2210
+ | A never-saved file shows `show-when:editmode` controls to viewers without the fallback rule | [Showing and hiding edit controls: clay-options](https://clayjs.com/docs/guide/edit-mode/#showing-and-hiding-edit-controls-clay-options) |
2211
+ | `show-when` matches any ancestor, not the nearest | [Showing and hiding edit controls: clay-options](https://clayjs.com/docs/guide/edit-mode/#showing-and-hiding-edit-controls-clay-options) |
2212
+ | A view attribute on a saved element syncs to every collaborator | [Showing and hiding edit controls: clay-options](https://clayjs.com/docs/guide/edit-mode/#showing-and-hiding-edit-controls-clay-options), [Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state) |
2213
+ | Records authored with live `contenteditable` stay editable for viewers until the first save | [Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers) |
2214
+ | Templates in the inert form give clones that cannot be edited until reload | [Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers) |
2215
+ | Unguarded edit-only API calls throw in view mode | [What works in view mode](https://clayjs.com/docs/guide/edit-mode/#what-works-in-view-mode) |
2216
+ | `hidden`, `.selected`, `<details open>` and library inline styles save; use `ephemeral` | [What a save writes](https://clayjs.com/docs/guide/record/#what-a-save-writes), [Ephemeral state: per-tab state on saved elements](https://clayjs.com/docs/guide/record/#ephemeral-state-per-tab-state-on-saved-elements) |
2217
+ | `no-save` chrome still reaches peers; use `editor-ui` | [Regions: the `clay` attribute](https://clayjs.com/docs/guide/record/#regions-the-clay-attribute) |
2218
+ | Edits in `no-watch` or `no-dirty` are invisible to `save()` and the close warning | [Regions: the `clay` attribute](https://clayjs.com/docs/guide/record/#regions-the-clay-attribute) |
2219
+ | Runtime changes inside `freeze` are never saved or synced, even by `force()` | [Regions: the `clay` attribute](https://clayjs.com/docs/guide/record/#regions-the-clay-attribute) |
2220
+ | A `freeze` scratchpad from a peer arrives empty; fill it on every change | [Regions: the `clay` attribute](https://clayjs.com/docs/guide/record/#regions-the-clay-attribute) |
2221
+ | Content in a `freeze` element when it first appears becomes its saved contents | [Regions: the `clay` attribute](https://clayjs.com/docs/guide/record/#regions-the-clay-attribute) |
2222
+ | A control without `persist` loses its typed value | [`persist`: form controls](https://clayjs.com/docs/guide/record/#persist-form-controls) |
2223
+ | Undo and merges restore `checked`/`value` attributes but not what the control shows, and the next save writes what is shown | [`persist`: form controls](https://clayjs.com/docs/guide/record/#persist-form-controls), [undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo) |
2224
+ | JSON with an unescaped `</script>` corrupts the file | [Where to keep data that is not visible text](https://clayjs.com/docs/guide/record/#where-to-keep-data-that-is-not-visible-text) |
2225
+ | `<template>` content edits never autosave | [Where to keep data that is not visible text](https://clayjs.com/docs/guide/record/#where-to-keep-data-that-is-not-visible-text) |
2226
+ | `+=` on a live counter loses increments from other tabs; use `add` | [Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live) |
2227
+ | A list in a live store pushed from two tabs keeps one push | [Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live) |
2228
+ | A live tag added after people loaded the page is not in their rooms | [Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live) |
2229
+ | Live data gets no close warning; up to 8 s can be lost | [Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live) |
2230
+ | Items without ids added in two tabs: one is dropped | [Element identity: `data-id` and `id`](https://clayjs.com/docs/guide/record/#element-identity-data-id-and-id), [What a merge does](https://clayjs.com/docs/guide/live-sync/#what-a-merge-does) |
2231
+ | Cloned records share a `data-id`, which counts as no id | [Element identity: `data-id` and `id`](https://clayjs.com/docs/guide/record/#element-identity-data-id-and-id) |
2232
+ | `crypto.randomUUID` is missing outside secure contexts | [Element identity: `data-id` and `id`](https://clayjs.com/docs/guide/record/#element-identity-data-id-and-id) |
2233
+ | Writes in the first 0.5 to 3 s after load are absorbed into the baseline and not saved | [Autosave](https://clayjs.com/docs/guide/saving/#autosave) |
2234
+ | A failed save is not retried until the next edit | [Autosave](https://clayjs.com/docs/guide/saving/#autosave) |
2235
+ | `ok: false` also means "nothing to save" and "queued" | [The save API](https://clayjs.com/docs/guide/saving/#the-save-api) |
2236
+ | `clay.getHTML()` runs hooks and closes the undo batch | [The save API](https://clayjs.com/docs/guide/saving/#the-save-api) |
2237
+ | A 412 conflict stops autosave until resolved | [Conflicts on save (412)](https://clayjs.com/docs/guide/saving/#conflicts-on-save-412) |
2238
+ | Impure hooks keep the page permanently dirty | [Hooks](https://clayjs.com/docs/guide/saving/#hooks) |
2239
+ | A throwing `onSnapshot` or `addDocumentTransform` callback blocks every save | [Hooks](https://clayjs.com/docs/guide/saving/#hooks) |
2240
+ | `onaftersave` writing to the live DOM loops autosave | [Hooks](https://clayjs.com/docs/guide/saving/#hooks) |
2241
+ | Without autosave, collaborators see nothing until a save | [What sync is](https://clayjs.com/docs/guide/live-sync/#what-sync-is) |
2242
+ | Listeners attached to records are lost when a merge recreates them | [What a merge does](https://clayjs.com/docs/guide/live-sync/#what-a-merge-does) |
2243
+ | A peer's frame re-runs inline scripts | [What a merge does](https://clayjs.com/docs/guide/live-sync/#what-a-merge-does), [Starting the app once](https://clayjs.com/docs/guide/architecture/#starting-the-app-once) |
2244
+ | A `no-dirty` region replaced by a peer raises a false conflict notice (1.8.2) | [Regions and JSON under sync](https://clayjs.com/docs/guide/live-sync/#regions-and-json-under-sync) |
2245
+ | Same-key JSON edits under `merge`: incoming wins, yours goes to the notice | [Regions and JSON under sync](https://clayjs.com/docs/guide/live-sync/#regions-and-json-under-sync) |
2246
+ | Rewriting the record at boot makes incoming frames hold | [Focus, typing and long-running editors](https://clayjs.com/docs/guide/live-sync/#focus-typing-and-long-running-editors) |
2247
+ | Parsed JSON goes stale after a merge | [Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code) |
2248
+ | A script that copies a record into a styling attribute goes stale in the file, and a merge brings the stale copy back | [Rules for DOM state](https://clayjs.com/docs/guide/model/#rules-for-dom-state), [sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup) |
2249
+ | An `api` rule that reads a painted value can read a stale one until a tab repaints and saves | [sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup) |
2250
+ | Full sapjs markup under the sapjs core halts with `E50` | [sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup) |
2251
+ | There is no presence API | [Presence](https://clayjs.com/docs/guide/live-sync/#presence) |
2252
+ | `data-richclay` card mode deletes images on load | [richclay: rich text (`editable`)](https://clayjs.com/docs/guide/components/#richclay-rich-text-editable) |
2253
+ | The richclay toolbar is unusable inside `showModal()` and popovers | [richclay: rich text (`editable`)](https://clayjs.com/docs/guide/components/#richclay-rich-text-editable) |
2254
+ | Rich text has its own undo; page undo never records it | [richclay: rich text (`editable`)](https://clayjs.com/docs/guide/components/#richclay-rich-text-editable), [undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo) |
2255
+ | Squire undo can roll back a peer's edit in the same region | [richclay: rich text (`editable`)](https://clayjs.com/docs/guide/components/#richclay-rich-text-editable) |
2256
+ | Global `registerButton` after load does not reach mounted editors | [richclay: rich text (`editable`)](https://clayjs.com/docs/guide/components/#richclay-rich-text-editable) |
2257
+ | Screenshot paste does nothing without a `pasteImage` listener | [richclay: rich text (`editable`)](https://clayjs.com/docs/guide/components/#richclay-rich-text-editable) |
2258
+ | Sortable handle mode is fixed at bind time | [sortable: drag to reorder](https://clayjs.com/docs/guide/components/#sortable-drag-to-reorder) |
2259
+ | `onsorting` cannot veto a move | [sortable: drag to reorder](https://clayjs.com/docs/guide/components/#sortable-drag-to-reorder) |
2260
+ | Sortable residue saves | [sortable: drag to reorder](https://clayjs.com/docs/guide/components/#sortable-drag-to-reorder), [Hooks](https://clayjs.com/docs/guide/saving/#hooks) |
2261
+ | The undo plugin takes Cmd+Z in every text field | [undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo) |
2262
+ | Undoing an add removes the record even after a peer edited it | [undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo) |
2263
+ | Pausing undo across an `await` loses the earlier mutations | [undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo) |
2264
+ | Sap reads live control values: undo without re-syncing inputs saves the undone number | [`persist`: form controls](https://clayjs.com/docs/guide/record/#persist-form-controls), [sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup) |
2265
+ | `clay.upload` is not a picker; embed only on `unsupported` | [upload and quickcrop](https://clayjs.com/docs/guide/components/#upload-and-quickcrop) |
2266
+ | `clay.confirm` rejects with `undefined` on cancel | [clay-ui: toasts and dialogs](https://clayjs.com/docs/guide/components/#clay-ui-toasts-and-dialogs) |
2267
+ | A live element passed to `clay.modal.html` is deleted | [clay-ui: toasts and dialogs](https://clayjs.com/docs/guide/components/#clay-ui-toasts-and-dialogs) |
2268
+ | Mutation hooks that write what they watch loop forever | [clay-events, clay-dom and All](https://clayjs.com/docs/guide/components/#clay-events-clay-dom-and-all) |
2269
+ | `onclone` does not fire for template fragments | [clay-events, clay-dom and All](https://clayjs.com/docs/guide/components/#clay-events-clay-dom-and-all) |
2270
+ | `el.nearest` reads a neighbour record's field | [clay-events, clay-dom and All](https://clayjs.com/docs/guide/components/#clay-events-clay-dom-and-all) |
2271
+ | `hide()`, `show()`, `css()` write saved inline styles | [clay-events, clay-dom and All](https://clayjs.com/docs/guide/components/#clay-events-clay-dom-and-all) |
2272
+ | A file whose painted values do not match sap's state writes at load | [sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup) |
2273
+ | Dialog prompts are HTML: record text in `clay.confirm` runs a collaborator's markup | [clay-ui: toasts and dialogs](https://clayjs.com/docs/guide/components/#clay-ui-toasts-and-dialogs) |
2274
+ | `editmode:resource type="inert/javascript"` activates as `javascript` and never runs | [Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers) |
2275
+ | Sap's template row shows before sap loads | [sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup) |
2276
+ | CMS `cms-template` with `hidden` makes every new item invisible | [cms: content-managed pages](https://clayjs.com/docs/guide/components/#cms-content-managed-pages) |
2277
+ | Records stored twice drift | [The record container](https://clayjs.com/docs/guide/architecture/#the-record-container) |
2278
+ | `applyData` pairs rows by content and order, not by id | [Import and export](https://clayjs.com/docs/guide/architecture/#import-and-export) |
2279
+ | Activity logged from a mutation observer also logs peers and undo | [Commands](https://clayjs.com/docs/guide/architecture/#commands) |
2280
+ | Saves slow down past about 1,000 records and stall past 3,000 | [Document size and record limits](https://clayjs.com/docs/guide/architecture/#document-size-and-record-limits) |
2281
+ | Updating a runtime style tag without `no-dirty` can raise the leave warning | [Styles a script writes](https://clayjs.com/docs/guide/architecture/#styles-a-script-writes) |
2282
+ | A version restore on hyperclay.com does not reach open editors | [What each host gives](https://clayjs.com/docs/guide/hosts/#what-each-host-gives) |
2283
+ | Hidden is not private | [Privacy](https://clayjs.com/docs/guide/hosts/#privacy) |
2284
+ | Typed values vanish from a visitor's draft: the control has no `persist` | [The contract](https://clayjs.com/docs/guide/free-documents/#the-contract) |