@panphora/clayjs 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/dist/all.js +1 -1
  2. package/dist/clay-dom.js +1 -1
  3. package/dist/clay-events.js +1 -1
  4. package/dist/clay-internals.js +1 -1
  5. package/dist/clay-options.js +1 -1
  6. package/dist/clay-router.js +1 -1
  7. package/dist/clay-ui.js +1 -1
  8. package/dist/clay-utils.js +1 -1
  9. package/dist/clay.js +1 -1
  10. package/dist/clay.standalone.js +72474 -62090
  11. package/dist/m/attrs/{save-freeze-MYNVXREN.js → save-freeze-LWGF6XUN.js} +1 -1
  12. package/dist/m/c-2GRUM77Q.js +30 -0
  13. package/dist/m/c-2KIEGNC4.js +1 -0
  14. package/dist/m/{c-AMSNPOCX.js → c-2KL3BD52.js} +1 -1
  15. package/dist/m/c-2ZZBC45O.js +1 -0
  16. package/dist/m/c-372N6H3B.js +1 -0
  17. package/dist/m/{c-4WKFKEKF.js → c-3G2DGZUP.js} +1 -1
  18. package/dist/m/c-3LDGIBMN.js +1 -0
  19. package/dist/m/c-3O7OBDCM.js +1 -0
  20. package/dist/m/c-4ASQPRQL.js +1 -0
  21. package/dist/m/c-4TXNLX4K.js +6 -0
  22. package/dist/m/c-4YU3BR4D.js +6 -0
  23. package/dist/m/c-5734MF2P.js +1 -0
  24. package/dist/m/c-57XDY6HC.js +1 -0
  25. package/dist/m/{c-KAU6E43D.js → c-5B4YBZXC.js} +1 -1
  26. package/dist/m/c-5ONTYIUI.js +1 -0
  27. package/dist/m/c-5OOVFCJM.js +1 -0
  28. package/dist/m/{c-3XIRD4NU.js → c-5T6NZHNM.js} +1 -1
  29. package/dist/m/{c-74MGYWZF.js → c-5ZQV7KC4.js} +1 -1
  30. package/dist/m/{c-7OLLJPWP.js → c-6DB2OLRJ.js} +1 -1
  31. package/dist/m/c-6DNRCPNM.js +1 -0
  32. package/dist/m/c-6UGWFMQC.js +2 -0
  33. package/dist/m/c-7K4SCQB7.js +1 -0
  34. package/dist/m/c-7MG5RU63.js +1 -0
  35. package/dist/m/c-7PRJCA3E.js +1 -0
  36. package/dist/m/c-A6L3FMUS.js +1 -0
  37. package/dist/m/c-AMU3BPPR.js +4 -0
  38. package/dist/m/{c-Y5HBBIQ4.js → c-AMYEFA2E.js} +1 -1
  39. package/dist/m/c-AQYCG7QW.js +1 -0
  40. package/dist/m/c-ATKAOH23.js +1 -0
  41. package/dist/m/c-B5CS55WR.js +1 -0
  42. package/dist/m/c-B5UJQT7W.js +1 -0
  43. package/dist/m/c-B7J4K2AE.js +3 -0
  44. package/dist/m/c-BHZYSHW4.js +1 -0
  45. package/dist/m/{c-VHM5XQHK.js → c-BIJALSRD.js} +1 -1
  46. package/dist/m/c-BJ7YQ5QZ.js +1 -0
  47. package/dist/m/{c-KIPQJX4B.js → c-BRLCTNMC.js} +1 -1
  48. package/dist/m/{c-VE6ZMYMC.js → c-BUAYFEV5.js} +1 -1
  49. package/dist/m/c-C6IPRG34.js +1 -0
  50. package/dist/m/{c-KU3ZFADW.js → c-CAS3NND4.js} +1 -1
  51. package/dist/m/{c-PS5ARVWR.js → c-CCQ25GFY.js} +1 -1
  52. package/dist/m/c-CJFDLD43.js +1 -0
  53. package/dist/m/c-COPTL5WB.js +1 -0
  54. package/dist/m/c-D3E4WJ4M.js +166 -0
  55. package/dist/m/c-D5IKEKXW.js +7 -0
  56. package/dist/m/c-DHBRA6DL.js +1 -0
  57. package/dist/m/c-DKLS74XB.js +72 -0
  58. package/dist/m/c-DVYGFTSK.js +3 -0
  59. package/dist/m/c-E3ZXARWA.js +1 -0
  60. package/dist/m/c-EK63G3CG.js +1 -0
  61. package/dist/m/c-ERQSNR6K.js +13 -0
  62. package/dist/m/c-EVYAISP3.js +1 -0
  63. package/dist/m/c-EXKHPOVC.js +3 -0
  64. package/dist/m/{c-QJ4OM3G6.js → c-FWKHM4E7.js} +1 -1
  65. package/dist/m/{c-4Q2XTQ2N.js → c-GBKB5WDL.js} +1 -1
  66. package/dist/m/c-GNDL3TKB.js +1 -0
  67. package/dist/m/c-GRWMD6O5.js +1 -0
  68. package/dist/m/c-GRXHHFPO.js +1 -0
  69. package/dist/m/c-HG2C77BM.js +4 -0
  70. package/dist/m/c-HHGJBU4D.js +1 -0
  71. package/dist/m/c-HMV6P4RX.js +1 -0
  72. package/dist/m/c-HMYWIHFG.js +1 -0
  73. package/dist/m/c-HT2DKBV2.js +1 -0
  74. package/dist/m/c-I2SCCUEW.js +1 -0
  75. package/dist/m/{c-TJ6WOJL3.js → c-ICJYHABF.js} +2 -2
  76. package/dist/m/c-IMZCWYHR.js +7 -0
  77. package/dist/m/c-IVMD3FEM.js +1 -0
  78. package/dist/m/{c-WDC5GVKU.js → c-IVTRVENZ.js} +1 -1
  79. package/dist/m/c-JKZJIUIP.js +1 -0
  80. package/dist/m/{c-UDUUJPFZ.js → c-JNLX3RJB.js} +1 -1
  81. package/dist/m/c-K3ADYNLU.js +1 -0
  82. package/dist/m/c-KGHVFRR3.js +1 -0
  83. package/dist/m/{c-5QWQ7A5L.js → c-KJJPV77R.js} +1 -1
  84. package/dist/m/{c-CZUSOB7V.js → c-KNPDOMDY.js} +1 -1
  85. package/dist/m/{c-EZU56NQV.js → c-KP64LT2A.js} +2 -2
  86. package/dist/m/c-KQ7E3PYY.js +1 -0
  87. package/dist/m/c-L3F5ZLIV.js +1 -0
  88. package/dist/m/{c-3RUU4TPX.js → c-L42OLXWM.js} +3 -2
  89. package/dist/m/c-L5VFATT6.js +1 -0
  90. package/dist/m/c-L7JL5TL3.js +3 -0
  91. package/dist/m/c-LKSL4AUJ.js +1 -0
  92. package/dist/m/c-M6FNQCTQ.js +1 -0
  93. package/dist/m/c-MCOOHCSG.js +1 -0
  94. package/dist/m/c-MHI3GYKF.js +1 -0
  95. package/dist/m/c-MWXFLW4J.js +1 -0
  96. package/dist/m/{c-5RJFTLSF.js → c-NEL7HQW3.js} +1 -1
  97. package/dist/m/{c-DCXOXBF3.js → c-NQJRVJ56.js} +1 -1
  98. package/dist/m/c-NWX3MFT4.js +1 -0
  99. package/dist/m/{c-HPURYVZS.js → c-OJYM642Q.js} +1 -1
  100. package/dist/m/c-OKXI52N7.js +1 -0
  101. package/dist/m/{c-R3L7ZJGB.js → c-OPLUZR3A.js} +1 -1
  102. package/dist/m/{c-GSFIGKOP.js → c-OTMKN2OO.js} +1 -1
  103. package/dist/m/{c-6NC5I2TV.js → c-OYRZ7XG2.js} +1 -1
  104. package/dist/m/{c-EVCQ3BKA.js → c-P3OFYUMA.js} +1 -1
  105. package/dist/m/c-PLN76GF3.js +6 -0
  106. package/dist/m/c-PLP4JKFC.js +1 -0
  107. package/dist/m/c-Q26MME6N.js +1 -0
  108. package/dist/m/{c-S7H2DRCB.js → c-Q3XOWZEV.js} +1 -1
  109. package/dist/m/{c-FRLHSY7E.js → c-QIKT6SKM.js} +1 -1
  110. package/dist/m/c-QIXT3Q7C.js +28 -0
  111. package/dist/m/c-QO4HNGCQ.js +1 -0
  112. package/dist/m/{c-JU3NK25H.js → c-R27DWQKX.js} +1 -1
  113. package/dist/m/{c-RSMOODDU.js → c-R2XUODVY.js} +1 -1
  114. package/dist/m/{c-QNKREOL6.js → c-R7LBFMF5.js} +1 -1
  115. package/dist/m/c-RND3G2S2.js +1 -0
  116. package/dist/m/{c-Q622NS2Y.js → c-S2U6NJU4.js} +1 -1
  117. package/dist/m/c-SPZPGD3R.js +1 -0
  118. package/dist/m/c-SW4YEG2D.js +8 -0
  119. package/dist/m/c-T637T7SN.js +1 -0
  120. package/dist/m/c-TJAXGIRV.js +1 -0
  121. package/dist/m/{c-RYUXUUBX.js → c-TNEM3RNM.js} +1 -1
  122. package/dist/m/c-TTB5EK4S.js +32 -0
  123. package/dist/m/c-U7SHNWDH.js +1 -0
  124. package/dist/m/c-UDHIVLYH.js +20 -0
  125. package/dist/m/{c-BBVGTQG7.js → c-UGNV5MFB.js} +1 -1
  126. package/dist/m/{c-QICP2YR5.js → c-UORXLSD3.js} +1 -1
  127. package/dist/m/c-UPD33ZPE.js +1 -0
  128. package/dist/m/c-URSMSFCB.js +1 -0
  129. package/dist/m/c-UY4QX42B.js +1 -0
  130. package/dist/m/c-V44KWI2L.js +4 -0
  131. package/dist/m/c-VILENZTD.js +1 -0
  132. package/dist/m/{c-HDDEEL77.js → c-VJYEVRYK.js} +1 -1
  133. package/dist/m/{c-SMBZG5X4.js → c-VR2BJ4AN.js} +1 -1
  134. package/dist/m/c-W4JCVLAB.js +1 -0
  135. package/dist/m/c-W6DQSHXI.js +135 -0
  136. package/dist/m/c-WBGWGOM7.js +1 -0
  137. package/dist/m/{c-4L3BTFRK.js → c-WCXLG3MV.js} +1 -1
  138. package/dist/m/c-WGW764WX.js +1 -0
  139. package/dist/m/c-WMRJKQ2V.js +5 -0
  140. package/dist/m/c-WNTYKCDG.js +1 -0
  141. package/dist/m/c-WQWIRWO3.js +72 -0
  142. package/dist/m/c-X5LNHET5.js +1 -0
  143. package/dist/m/{c-I2ZATQCD.js → c-X5TSXZYJ.js} +2 -2
  144. package/dist/m/c-XGH4GS2T.js +1 -0
  145. package/dist/m/c-Y6F2FORL.js +1 -0
  146. package/dist/m/c-YBKHUAK7.js +10 -0
  147. package/dist/m/c-YBSS55KU.js +20 -0
  148. package/dist/m/c-YCLU4UNT.js +2 -0
  149. package/dist/m/c-YVZ2L7CE.js +1 -0
  150. package/dist/m/c-ZK4GIZH2.js +1 -0
  151. package/dist/m/c-ZRXJD4G6.js +1 -0
  152. package/dist/m/c-ZWBI4HYK.js +1 -0
  153. package/dist/m/core/{admin-attrs-QHOKPJ7W.js → admin-attrs-YY7T5FGU.js} +1 -1
  154. package/dist/m/core/autosave-S6OMNDCK.js +1 -0
  155. package/dist/m/core/conflict-notice-IWHMHH43.js +1 -0
  156. package/dist/m/core/{host-permission-FNXVEFNE.js → host-permission-6EVJGNPL.js} +1 -1
  157. package/dist/m/core/is-edit-mode-GCX6767E.js +1 -0
  158. package/dist/m/core/{persist-JFYRHMRJ.js → persist-UKXV5KLA.js} +1 -1
  159. package/dist/m/core/recovery-DRFZRJED.js +1 -0
  160. package/dist/m/core/save-LYY7K6U7.js +1 -0
  161. package/dist/m/core/save-core-MKRPSEEO.js +1 -0
  162. package/dist/m/core/snapshot-NU3YM6GR.js +1 -0
  163. package/dist/m/core/unsaved-warning-7QTKEN7Z.js +1 -0
  164. package/dist/m/internals/{index-PVXGPUDA.js → index-TGLQINN3.js} +1 -1
  165. package/dist/m/loader-3BBNZZGM.js +1 -0
  166. package/dist/m/manifest-F449FF98.js +1 -0
  167. package/dist/m/manifest-edit-4396A009.js +1 -0
  168. package/dist/m/manifest.json +2111 -1573
  169. package/dist/m/page/{editor-NNJA4OKD.js → editor-6LRB3GE4.js} +123 -21
  170. package/dist/m/plugins/ai-edit-UTV7YO2I.js +6 -0
  171. package/dist/m/plugins/{cms-HP3LSTPN.js → cms-LRAOZHT7.js} +9 -9
  172. package/dist/m/plugins/{demo-RM5IR6AO.js → demo-OYO5GCAO.js} +1 -1
  173. package/dist/m/plugins/{derive-UAAA4V4S.js → derive-FRE5JJQE.js} +1 -1
  174. package/dist/m/plugins/indicator-4VDXWYTD.js +1 -0
  175. package/dist/m/plugins/live-AD4H6XSL.js +1 -0
  176. package/dist/m/plugins/menu-M5WW7DBF.js +1 -0
  177. package/dist/m/plugins/page-MD3T2ZX3.js +2 -0
  178. package/dist/m/plugins/page-view-PAWVZNFY.js +1 -0
  179. package/dist/m/plugins/{people-SGPJKANK.js → people-JCHVPF7P.js} +1 -1
  180. package/dist/m/plugins/quickcrop-BQ5YYFCH.js +26 -0
  181. package/dist/m/plugins/{richclay-NRQDSWX6.js → richclay-KLGETK77.js} +2 -2
  182. package/dist/m/plugins/sortable-4S6SQC3D.js +1 -0
  183. package/dist/m/plugins/source-TJXOIOYY.js +1 -0
  184. package/dist/m/plugins/undo-NLPGCJQY.js +1 -0
  185. package/dist/m/plugins/{unsent-HJFLQJ2O.js → unsent-OA4RGBUT.js} +1 -1
  186. package/dist/m/plugins/upgrade-HSUNDNJD.js +1 -0
  187. package/dist/m/plugins/{upload-WLYKK6TL.js → upload-NHGJXZK2.js} +1 -1
  188. package/dist/m/plugins/visitor-drafts-BW43DGY3.js +20 -0
  189. package/dist/m/plugins/visitor-drafts-owner-7ODQG7JX.js +1 -0
  190. package/dist/m/plugins/{wire-4N6GYAF6.js → wire-VBWXEXU2.js} +1 -1
  191. package/dist/m/reader-core-4W2KYEWQ.js +77 -0
  192. package/dist/m/route/route-62CHXL5B.js +1 -0
  193. package/dist/m/sync/live-sync-MO5DH33W.js +1 -0
  194. package/dist/m/ui/index-IVWN2QC4.js +1 -0
  195. package/dist/m/vendor/sap-core.vendor-RN4ZVY4X.js +6 -0
  196. package/dist/offline-manifest.json +1367 -930
  197. package/dist/page/themes/light-1.css +8 -7
  198. package/dist/server/core.cjs +1440 -288
  199. package/dist/server/offline-runtime.cjs +63 -63
  200. package/dist/server/offline-runtime.mjs +63 -63
  201. package/dist/server/page-compaction-worker.cjs +3710 -0
  202. package/dist/server/rooms.mjs +5 -0
  203. package/docs/guide.md +653 -101
  204. package/entries/sap.js +5 -5
  205. package/package.json +1 -1
  206. package/src/core/admin-contenteditable.js +5 -2
  207. package/src/core/admin-inputs.js +1 -0
  208. package/src/core/admin-onclick.js +2 -1
  209. package/src/core/conflict-notice-ui.js +4 -7
  210. package/src/core/download.js +2 -2
  211. package/src/core/host-meta.js +38 -0
  212. package/src/core/is-edit-mode.js +17 -1
  213. package/src/core/mode-key.js +125 -19
  214. package/src/core/mode-support.js +5 -0
  215. package/src/core/mode-transition.js +20 -6
  216. package/src/core/preserve-authored.js +4 -4
  217. package/src/core/recovery.js +121 -33
  218. package/src/core/save-accepted.js +33 -0
  219. package/src/core/save-core.js +106 -31
  220. package/src/core/save-job.js +108 -0
  221. package/src/core/save-prepare.js +53 -0
  222. package/src/core/save-size-notice.js +3 -1
  223. package/src/core/save-slices.js +15 -0
  224. package/src/core/save.js +557 -127
  225. package/src/core/serialize-steps.js +109 -0
  226. package/src/core/snapshot.js +170 -39
  227. package/src/core/source-map.js +83 -38
  228. package/src/core/stale-host-notice.js +1 -0
  229. package/src/core/upgrade-run.js +59 -8
  230. package/src/dom/active-element.js +5 -0
  231. package/src/lib/assets.js +1 -0
  232. package/src/lib/slice.js +65 -0
  233. package/src/lib/uimotion.js +774 -0
  234. package/src/lib/unsaved-state.js +2 -2
  235. package/src/live/fast-lane.js +10 -3
  236. package/src/live/local-lane.js +202 -11
  237. package/src/live/ops.js +16 -0
  238. package/src/live/page-compaction.js +162 -0
  239. package/src/live/server-lane.js +1 -0
  240. package/src/live/store.js +11 -3
  241. package/src/loader-logic.js +3 -1
  242. package/src/loader.js +14 -4
  243. package/src/page/adopt-boot.js +2 -1
  244. package/src/page/awareness.js +33 -2
  245. package/src/page/blocks.js +89 -27
  246. package/src/page/boot-pack.js +1 -1
  247. package/src/page/chart-tables.js +21 -3
  248. package/src/page/chart.js +406 -63
  249. package/src/page/checklist.js +10 -135
  250. package/src/page/checkpoint-proof.js +7 -1
  251. package/src/page/chrome.js +250 -51
  252. package/src/page/clipboard-shape.js +126 -0
  253. package/src/page/drag.js +33 -3
  254. package/src/page/editor.js +136 -25
  255. package/src/page/export-assets.js +357 -0
  256. package/src/page/export-html.js +209 -0
  257. package/src/page/export-markdown.js +361 -0
  258. package/src/page/features/checklist.js +428 -0
  259. package/src/page/features/export-html.js +189 -0
  260. package/src/page/features/export-markdown.js +169 -0
  261. package/src/page/features/import-markdown.js +349 -0
  262. package/src/page/features/media.js +61 -5
  263. package/src/page/features/registry.js +18 -0
  264. package/src/page/features/table.js +373 -83
  265. package/src/page/features/title-cover.js +331 -0
  266. package/src/page/features/title-icon.js +213 -0
  267. package/src/page/features/toc.js +87 -0
  268. package/src/page/format-command.js +25 -1
  269. package/src/page/format-gate.js +68 -18
  270. package/src/page/format-host.js +17 -0
  271. package/src/page/format-native-commands.js +21 -5
  272. package/src/page/image-actions.js +64 -0
  273. package/src/page/import-markdown.js +262 -0
  274. package/src/page/items.js +135 -17
  275. package/src/page/markdown.js +6 -2
  276. package/src/page/media.js +35 -119
  277. package/src/page/placeholder.js +14 -1
  278. package/src/page/projection.js +98 -10
  279. package/src/page/recover.js +10 -12
  280. package/src/page/recovery-ui.js +464 -0
  281. package/src/page/rich/chart.js +63 -7
  282. package/src/page/save-projection.js +28 -0
  283. package/src/page/save-states.js +13 -3
  284. package/src/page/schema.js +19 -3
  285. package/src/page/table-paste.js +3 -1
  286. package/src/page/table-style.js +4 -4
  287. package/src/page/table.js +44 -2
  288. package/src/page/themes/light-1.css +8 -7
  289. package/src/page/themes/light-1.source.css +8 -7
  290. package/src/page/toc-link.js +31 -0
  291. package/src/page/toc.js +5 -0
  292. package/src/page/view-hooks.js +38 -12
  293. package/src/page/view.js +13 -0
  294. package/src/page/ybind/binding.js +88 -5
  295. package/src/page/ybind/bridge.js +187 -20
  296. package/src/page/ybind/containment.js +162 -0
  297. package/src/page/ybind/doc.js +17 -9
  298. package/src/page/ybind/file-debt.js +63 -0
  299. package/src/page/ybind/layout.js +7 -2
  300. package/src/page/ybind/marks.js +1 -1
  301. package/src/page/ybind/moves.js +5 -4
  302. package/src/page/ybind/origins.js +106 -4
  303. package/src/page/ybind/parts.js +8 -1
  304. package/src/page/ybind/provider.js +113 -17
  305. package/src/page/ybind/recovery-proof.js +32 -9
  306. package/src/page/ybind/recovery-replay.js +558 -15
  307. package/src/page/ybind/recovery.js +174 -45
  308. package/src/page/ybind/table.js +67 -11
  309. package/src/page/ybind/walk-delta.js +258 -0
  310. package/src/page/ybind/walk.js +54 -29
  311. package/src/page/ybind/writer.js +1 -1
  312. package/src/plugins/ai-edit.js +13 -9
  313. package/src/plugins/indicator-chip.js +91 -0
  314. package/src/plugins/indicator.js +13 -80
  315. package/src/plugins/live.js +89 -14
  316. package/src/plugins/menu/actions.js +196 -0
  317. package/src/plugins/menu/people.js +279 -0
  318. package/src/plugins/menu/placement.js +121 -0
  319. package/src/plugins/menu/preferences.js +9 -0
  320. package/src/plugins/menu/seo.js +316 -397
  321. package/src/plugins/menu/style.js +171 -0
  322. package/src/plugins/menu/variants.js +296 -0
  323. package/src/plugins/menu.js +186 -183
  324. package/src/plugins/page-view.js +3 -2
  325. package/src/plugins/page.js +249 -44
  326. package/src/plugins/sortable.js +8 -1
  327. package/src/plugins/source.js +6 -0
  328. package/src/plugins/upgrade.js +51 -17
  329. package/src/plugins/upload-ui.js +12 -17
  330. package/src/plugins/visitor-drafts/bottom-ui.js +12 -4
  331. package/src/plugins/visitor-drafts/intro-style.js +7 -2
  332. package/src/plugins/visitor-drafts/intro.js +5 -3
  333. package/src/plugins/visitor-drafts/owner-chip.js +8 -12
  334. package/src/plugins/visitor-drafts/prompt.js +11 -9
  335. package/src/plugins/visitor-drafts/source.js +4 -3
  336. package/src/plugins/visitor-drafts/store.js +138 -9
  337. package/src/plugins/visitor-drafts/ui-memory.js +2 -2
  338. package/src/plugins/visitor-drafts-owner.js +4 -6
  339. package/src/plugins/visitor-drafts.js +5 -3
  340. package/src/recovery/store.js +1 -1
  341. package/src/route/route.js +4 -0
  342. package/src/server/rooms/index.js +5 -1
  343. package/src/server/rooms/live-data-rooms.js +302 -50
  344. package/src/server/rooms/page-compaction-worker.js +12 -0
  345. package/src/server/rooms/page-compactor.js +92 -0
  346. package/src/server/rooms/page-room-conformance.js +461 -0
  347. package/src/source/parse-steps.js +94 -0
  348. package/src/source/session.js +233 -57
  349. package/src/sync/editing-sender.js +12 -0
  350. package/src/sync/live-sync-edit.js +22 -2
  351. package/src/sync/live-sync.js +150 -23
  352. package/src/sync/page-relay.js +52 -0
  353. package/src/sync/presence-store.js +44 -4
  354. package/src/sync/presence.js +6 -3
  355. package/src/sync/saved-stream.js +11 -6
  356. package/src/ui/bevel-controls.js +1 -0
  357. package/src/ui/bevel-dialog.js +5 -2
  358. package/src/ui/editor-ui.js +77 -0
  359. package/src/ui/page-colour.js +281 -0
  360. package/src/ui/toast.js +16 -7
  361. package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
  362. package/src/vendor/hyper-undo.vendor.js +1 -1
  363. package/src/vendor/hypercms.vendor.js +9 -9
  364. package/src/vendor/parse5.vendor.js +2 -2
  365. package/src/vendor/sap-core.vendor.js +5 -5
  366. package/dist/m/c-2SPVQSHC.js +0 -1
  367. package/dist/m/c-336SGU5W.js +0 -1
  368. package/dist/m/c-3H6BZDNJ.js +0 -6
  369. package/dist/m/c-4DFEJ2PO.js +0 -3
  370. package/dist/m/c-56QRZTZH.js +0 -1
  371. package/dist/m/c-5J4E7O4Y.js +0 -4
  372. package/dist/m/c-5LKDWQTL.js +0 -19
  373. package/dist/m/c-5V3MOVJT.js +0 -1
  374. package/dist/m/c-6AZOGAPE.js +0 -1
  375. package/dist/m/c-6CRUUXOX.js +0 -5
  376. package/dist/m/c-6HMB24EV.js +0 -1
  377. package/dist/m/c-6KEVEQ7L.js +0 -1
  378. package/dist/m/c-A3E3NIMC.js +0 -1
  379. package/dist/m/c-BQ6LOBEE.js +0 -1
  380. package/dist/m/c-BTDI4Y23.js +0 -1
  381. package/dist/m/c-CJ3A7ZKE.js +0 -1
  382. package/dist/m/c-CQHPGKX2.js +0 -1
  383. package/dist/m/c-DFS4XZJV.js +0 -2
  384. package/dist/m/c-DNENWY4O.js +0 -1
  385. package/dist/m/c-E4LIR5X5.js +0 -1
  386. package/dist/m/c-FDNHHZY5.js +0 -1
  387. package/dist/m/c-FROJUZMV.js +0 -1
  388. package/dist/m/c-G2JPKCTP.js +0 -1
  389. package/dist/m/c-GB6TU7WV.js +0 -1
  390. package/dist/m/c-GFJJEBTU.js +0 -1
  391. package/dist/m/c-GJAEGGXY.js +0 -1
  392. package/dist/m/c-GOWBDM6J.js +0 -1
  393. package/dist/m/c-HFKUVACH.js +0 -1
  394. package/dist/m/c-HHDFCNHV.js +0 -1
  395. package/dist/m/c-IEM4NTCS.js +0 -1
  396. package/dist/m/c-JDCX26UP.js +0 -25
  397. package/dist/m/c-KGVGROZT.js +0 -1
  398. package/dist/m/c-KQ6TYJAP.js +0 -135
  399. package/dist/m/c-LFPHCXY3.js +0 -1
  400. package/dist/m/c-LRPUO2MX.js +0 -1
  401. package/dist/m/c-LWLBHLVC.js +0 -3
  402. package/dist/m/c-M2L47S2X.js +0 -7
  403. package/dist/m/c-MFP2ZWD4.js +0 -1
  404. package/dist/m/c-MKSQ4Y57.js +0 -1
  405. package/dist/m/c-NUFFJVVJ.js +0 -1
  406. package/dist/m/c-NUHSPT5P.js +0 -1
  407. package/dist/m/c-O72HVYSA.js +0 -1
  408. package/dist/m/c-OIBUTDXI.js +0 -1
  409. package/dist/m/c-OW5QIGG3.js +0 -1
  410. package/dist/m/c-PGRCK73F.js +0 -1
  411. package/dist/m/c-Q7NQD65V.js +0 -1
  412. package/dist/m/c-QH63LQOS.js +0 -1
  413. package/dist/m/c-R7IWVINQ.js +0 -1
  414. package/dist/m/c-RM7J7BV2.js +0 -1
  415. package/dist/m/c-S4ZEH46B.js +0 -9
  416. package/dist/m/c-SDAXDM2D.js +0 -1
  417. package/dist/m/c-SMGU7RT3.js +0 -235
  418. package/dist/m/c-SPIORRYS.js +0 -1
  419. package/dist/m/c-SWLRT66R.js +0 -1
  420. package/dist/m/c-TCJTVAFN.js +0 -1
  421. package/dist/m/c-TQEMVJCS.js +0 -1
  422. package/dist/m/c-TYLXX4NF.js +0 -21
  423. package/dist/m/c-U62OB4BT.js +0 -1
  424. package/dist/m/c-U6YWUAR3.js +0 -8
  425. package/dist/m/c-UDCSU5G5.js +0 -2
  426. package/dist/m/c-VI5RMSVY.js +0 -1
  427. package/dist/m/c-VRZN56XY.js +0 -1
  428. package/dist/m/c-W5MCHEGA.js +0 -1
  429. package/dist/m/c-WNYBLIOZ.js +0 -1
  430. package/dist/m/c-WWXIDH4O.js +0 -28
  431. package/dist/m/c-XCNTK76U.js +0 -1
  432. package/dist/m/c-XMS6DPBR.js +0 -1
  433. package/dist/m/c-ZUCOKOHT.js +0 -1
  434. package/dist/m/c-ZVPVXTJF.js +0 -4
  435. package/dist/m/c-ZVRKXKM7.js +0 -1
  436. package/dist/m/core/autosave-HRYGUWV6.js +0 -1
  437. package/dist/m/core/conflict-notice-6MROJN2L.js +0 -1
  438. package/dist/m/core/is-edit-mode-KTWA3O5N.js +0 -1
  439. package/dist/m/core/recovery-FLTCC2OC.js +0 -1
  440. package/dist/m/core/save-HQZ4S667.js +0 -1
  441. package/dist/m/core/save-core-KGQWFUNH.js +0 -1
  442. package/dist/m/core/snapshot-EGQZNT45.js +0 -1
  443. package/dist/m/core/unsaved-warning-IDJLD66H.js +0 -1
  444. package/dist/m/loader-S4I7GKXR.js +0 -1
  445. package/dist/m/manifest-4C419D67.js +0 -1
  446. package/dist/m/manifest-edit-B077F288.js +0 -1
  447. package/dist/m/plugins/ai-edit-QN4PMMNL.js +0 -6
  448. package/dist/m/plugins/indicator-URDBBOQO.js +0 -1
  449. package/dist/m/plugins/live-HFWD4OIH.js +0 -1
  450. package/dist/m/plugins/menu-3HIEQSI6.js +0 -138
  451. package/dist/m/plugins/page-JOKB6XPR.js +0 -2
  452. package/dist/m/plugins/page-view-NXOJHNW6.js +0 -1
  453. package/dist/m/plugins/quickcrop-QNY3PKLD.js +0 -26
  454. package/dist/m/plugins/sortable-GWT4PWDV.js +0 -1
  455. package/dist/m/plugins/source-4ZRSAIYM.js +0 -1
  456. package/dist/m/plugins/undo-VDA5P7FH.js +0 -1
  457. package/dist/m/plugins/upgrade-W7FG5VFG.js +0 -1
  458. package/dist/m/plugins/visitor-drafts-LIDY7NH6.js +0 -21
  459. package/dist/m/plugins/visitor-drafts-owner-ZXCKBZE7.js +0 -1
  460. package/dist/m/reader-core-DXYCURFF.js +0 -68
  461. package/dist/m/route/route-KOCZSEGN.js +0 -1
  462. package/dist/m/sync/live-sync-J7VMYTKS.js +0 -1
  463. package/dist/m/ui/index-3JLVI27O.js +0 -1
  464. package/dist/m/vendor/sap-core.vendor-A7BMUYZT.js +0 -6
  465. /package/dist/m/{c-FILLTJA7.js → c-QTTJYJMA.js} +0 -0
  466. /package/dist/m/plugins/{quickcrop-QNY3PKLD.js.LEGAL.txt → quickcrop-BQ5YYFCH.js.LEGAL.txt} +0 -0
  467. /package/dist/m/plugins/{richclay-NRQDSWX6.js.LEGAL.txt → richclay-KLGETK77.js.LEGAL.txt} +0 -0
package/docs/guide.md CHANGED
@@ -4,7 +4,7 @@ This is the complete reference for building malleable documents with ClayJS: one
4
4
 
5
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
- 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:
7
+ Every behavior stated here was read from ClayJS 1.17.0 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
 
9
9
  | File | What it shows |
10
10
  |---|---|
@@ -117,7 +117,7 @@ Templates are blueprints. `item-tpl` holds a new row in its active form (`conten
117
117
  <script src="https://clayjs.com/v1/clay-ui.js"></script>
118
118
  ```
119
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/))
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/)) The file opens in view mode, even for its owner. Press Cmd+Shift+E (Ctrl+Shift+E elsewhere) to edit, or add `menu` to the plugin list for an Edit switch ([How the mode is decided](https://clayjs.com/docs/guide/edit-mode/#how-the-mode-is-decided)).
121
121
 
122
122
  #### Starting the app
123
123
 
@@ -260,7 +260,7 @@ Follow these rules to keep record, chrome, and derived state separate.
260
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
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
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.
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, `clay.storage`, 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
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
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
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.
@@ -308,7 +308,7 @@ Plugins add editing, sync, and UI features to `clay.js`. Most are off by default
308
308
  | `live` | off | yes | `clay.live(name)`: a `clay="live"` JSON tag as a live object |
309
309
  | `sortable` | off | no | the `sortable` attribute, `window.Sortable`, `clay:sorted` |
310
310
  | `undo` | off | no | `clay.undo` (whole-page undo, takes Cmd+Z for the window) |
311
- | `indicator` | off | no | the save status chip |
311
+ | `indicator` | off | no | the save status chip, drawn by a module fetched at the first input or save state |
312
312
  | `upload` | off | no | `clay.upload` |
313
313
  | `quickcrop` | off | yes | `clay.quickcrop` |
314
314
  | `cms` | off | yes | `clay.cms`; implies `quickcrop` and `upload` |
@@ -318,8 +318,14 @@ Plugins add editing, sync, and UI features to `clay.js`. Most are off by default
318
318
  | `demo` | off | yes | fakes saving into `localStorage` for public demos |
319
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
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)) |
321
-
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.
321
+ | `page` | off | reader half only (`page-view`) | the page editor, `clay.page`; implies `live`, `sync` and `upload`, and drops `richclay` unless named |
322
+ | `unsent` | off | no | `unsent="<name>"`: unsent text in chrome survives a redraw and a reload, `clay.unsent` |
323
+ | `bind-text` | off | no | `clay.bindText`: an editor textarea outside the records, bound to one record's text |
324
+ | `people` | off | yes | who is editing and who wrote what |
325
+ | `menu` | off | yes | an Edit switch and claygoo in the top right for anyone who can edit, with the SEO settings |
326
+ | `upgrade` | off; added for a copy of a hyperclay.com starter template that this person can edit | yes | the template upgrade notice |
327
+
328
+ - Edit-only plugins do not load in a tab that booted in view, even when named. Their members are absent there. Test `clay.isEditMode` (or `clay.modeLifecycle.canWrite()` before a write), never `'save' in clay` or `clay.RichClay` alone, because a live exit leaves those members attached. On a `<html clay-mode-switch="live">` document, entering edit imports them while the tab still shows view, and they stay loaded after the exit ([Leaving edit](https://clayjs.com/docs/guide/edit-mode/#leaving-edit)).
323
329
  - `exclude` wins over implications. `exclude=wire` also drops `ai-edit`.
324
330
  - Load order is fixed regardless of how you list them.
325
331
  - 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.
@@ -328,7 +334,9 @@ Plugins add editing, sync, and UI features to `clay.js`. Most are off by default
328
334
  - `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
335
  - `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.
330
336
 
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.
337
+ Measure what each mode you ship downloads, and keep a byte budget and a test that fails when one grows: [Keep a byte budget per boot, and fail a test when it grows](https://clayjs.com/docs/practices/#keep-a-byte-budget-per-boot-and-fail-a-test-when-it-grows).
338
+
339
+ **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. A document opens in view even for its owner, so add `menu` for the Edit switch, or declare `<html opens-in="edit">` on a tool page that must edit as soon as it opens.
332
340
 
333
341
  ### 2.3 Satellites
334
342
 
@@ -348,13 +356,13 @@ Satellites are separate classic script tags. Each publishes a readiness promise
348
356
 
349
357
  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
358
 
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.
359
+ `clay.standalone.js` bundles the loader, every plugin, and every satellite into one file of about 5 MB 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
360
 
353
361
  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
362
 
355
363
  ### 2.4 `clay.ready`
356
364
 
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)).
365
+ `clay.ready` is a promise that resolves with `window.clay` once boot is done and every plugin that loads at boot has started; the late plugins ([Plugins that load late](#plugins-that-load-late)) arrive after it. `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)).
358
366
 
359
367
  `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.
360
368
 
@@ -379,6 +387,17 @@ Boot moves through `loading` (the saved article is read and validated), `joining
379
387
 
380
388
  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.
381
389
 
390
+ **The two waits overlap.** The editor's code downloads while authority is prepared, so the boot waits for the longer of the two plus adoption, never for their sum. Neither branch touches the page: only the coordinator mounts, adopts, and then enables input.
391
+
392
+ ```js
393
+ const settled = await settleBranches(inst, [
394
+ branch(steps.loadEditorAssets(inst, source), inst, "editor-assets-failed", "document:assets-ready"),
395
+ branch(steps.prepareAuthority(inst, source), inst, "authority-failed", "document:authority-ready"),
396
+ ]);
397
+ ```
398
+
399
+ The first branch to fail answers the wait at once, and the branch still on the wire is aborted instead of being left to subscribe anything. Input opens only after adoption, so the faster start creates no temporary document and no second undo history.
400
+
382
401
  You can set configuration before the tag, and the loader merges into it: `window.clay = { saveToast: true }`.
383
402
 
384
403
  ### 2.5 Theming ClayJS's own UI
@@ -406,7 +425,7 @@ Your stylesheet cannot reach these elements: every declaration is inline and `!i
406
425
 
407
426
  ### 2.6 Plugins that load late
408
427
 
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.
428
+ `source`, `sortable`, `menu` and `upgrade` 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
429
 
411
430
  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
431
 
@@ -425,7 +444,7 @@ Do not read `window.Sortable` in a `clay.ready` callback: the plugin may not hav
425
444
 
426
445
  ### 2.7 Libraries on demand
427
446
 
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.
447
+ `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`, `highlight`, and `uimotion`, plus one grammar per `highlight/<language>` for `bash`, `css`, `javascript`, `json`, `python`, and `xml`. `clay.assets.names()` lists them.
429
448
 
430
449
  ```js
431
450
  const { Marked } = await clay.assets.load("marked");
@@ -434,7 +453,15 @@ const { default: javascript } = await clay.assets.load("highlight/javascript");
434
453
  hljs.registerLanguage("javascript", javascript);
435
454
  ```
436
455
 
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.
456
+ `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. `uimotion` resolves the engine the free apps' intros animate with: `scene(stage, options)` builds a looping scene, `mount(stage, build, options)` plays it while the stage is on screen and stops it when the stage leaves the page, `rewind(scene, keep)` returns a scene to its start, and `ease` holds the curves. 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.
457
+
458
+ ### 2.8 Editor commands load on first use
459
+
460
+ The page editor's optional commands are separate modules under `src/page/features/`, listed in `COMMAND_FEATURES` with one literal import each: `mentions`, `dates`, `emoji`, `titleIcon`, `titleCover`, `exportHtml`, `importMarkdown` and `exportMarkdown`. The build splits each into its own files, and the editor fetches one the first time a person invokes that command. A plain document that never uses them fetches none: opening a page, reading it and typing in it asks for no picker, no parser and no exporter. The emoji list arrives with the first picker that needs it (`page/emoji-data.js`), and `marked` arrives with the first Markdown import.
461
+
462
+ An installed command shares the page lease's disposal. Leaving edit, or a refused exit that closes admission, disposes what the commands installed and aborts whatever was still arriving, and the next entry installs fresh against the module the browser already holds. A command asked for while its module is still on the way runs only if the edit is still open, the document revision has not moved and the selection is where it was; otherwise it refuses with `stale-target` and writes nothing. A failed fetch is reported and asked for again on the next request.
463
+
464
+ The load report and the offline manifest both carry them. `node scripts/measure-load.mjs` counts what a command fetches in its `firstUse` phase, printed as the `phase first use` row, so the editor's ready bytes never include a module nobody asked for, and `dist/offline-manifest.json` lists their files, so a host that serves a release offline still loads one on first use.
438
465
 
439
466
  ---
440
467
 
@@ -442,28 +469,266 @@ hljs.registerLanguage("javascript", javascript);
442
469
 
443
470
  ### 3.1 How the mode is decided
444
471
 
445
- `clay.isEditMode` is decided once at load by the first rule that matches:
472
+ Three separate facts:
446
473
 
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).
449
- 3. `window.clayEditMode`, set by a script before boot.
450
- 4. A `savetoken` attribute the host put on `<html>` (HTML Clay 1.9.0 and newer).
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)).
452
- 6. The `isAdminOfCurrentResource` cookie (hyperclay.com, Hyperclay Local).
474
+ - `clay.canEdit` is permission: whether this person may edit the document at all.
475
+ - `clay.mode` is `"view"`, `"edit"` or `"draft"`, and it is a live getter.
476
+ - `clay.modeLifecycle.state.destination` is where edits land: `"host"`, `"draft"`, or `"demo"` when the demo plugin saves to `localStorage`.
477
+
478
+ Permission alone does not open edit. An owner on hyperclay.com opens in view, with `clay.canEdit === true`. The host's own answer about the document, `document.writable` in `/_/meta`, is the last word on `canEdit`, and a change to it fires `clay:capabilitychange`.
479
+
480
+ The mode this tab starts in is decided once, in this order:
481
+
482
+ 1. **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)).
483
+ 2. **The reload receipt.** A deliberate switch writes `sessionStorage` entry `clay:doc:<pathname>:mode-receipt` just before it reloads. Boot reads it once and removes it, accepts it only within 30 seconds and only for a reload of the same path, and then starts the tab in the mode it names. A tab that can edit also leaves a receipt when the page is hidden, and boot accepts it only for a browser reload, so reloading an editing tab comes back in edit. On a page that declares `<html opens-in="edit">`, that hide receipt never keeps a tab in view, so adding the attribute takes effect on the next reload; a deliberate switch to view still does. A receipt keeps a reload in the same tab, not a lasting preference.
484
+ 3. **`?editmode=`.** Only the literal `true` asks for edit.
485
+ 4. **`window.clayEditMode === true`** or `<html opens-in="edit">`.
486
+ 5. **Otherwise** the tab starts in view.
487
+
488
+ Nothing else opens edit, including a page whose own script makes text editable. An editor library such as OverType or CodeMirror, a `persist` field, and `contenteditable` set by your code all accept typing in view mode, and none of that typing is saved, because the save path runs only in edit. A tool page that must be editable the moment it opens, such as a journal or a notes pad, declares `<html opens-in="edit">`. Every other document needs a way into edit: the `menu` plugin, or a control that calls `clay.setMode("edit")`.
489
+
490
+ When someone who can edit types into a document in view anyway, ClayJS shows a notice on the first edit to a field that would save in edit mode: a `persist` control, or `contenteditable` content outside `editor-ui`, `no-save` and `freeze`. A field that edit mode would unlock (`editmode:contenteditable`, `viewmode:readonly`, `viewmode:disabled`) raises it on the first attempt to type into it or press it. The notice reads "In view mode, so edits won't be saved." and stays until the tab enters edit, with a **Switch to edit** button that calls `clay.setMode("edit")`. A document without `clay-mode-switch="live"` reloads to switch, so the characters typed before the notice appeared are lost. A visitor who cannot edit never sees it, and a page with its own handled single-key shortcuts does not set it off. It also writes one warning to the console.
491
+
492
+ `<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).
493
+
494
+ `clay.canEdit` follows its own ladder: `?editmode=` first, then `viewonly` and a stale token, which take edit off the table, then `window.clayEditMode`, then a save token or the owner cookie.
495
+
496
+ `clay.isOwner` reflects the cookie alone. On HTML Clay, an editable page has `isOwner === false`. Use `clay.canEdit` for whether a person may edit, and `clay.mode` for whether this tab is editing.
497
+
498
+ ### 3.2 Switching modes: clay.setMode
499
+
500
+ `await clay.setMode("edit")` and `await clay.setMode("view")` switch this tab. `setMode` takes one argument, and anything else throws a `TypeError`.
501
+
502
+ Three answers:
503
+
504
+ - `{ ok: true, mode, changed }`: the switch committed. `changed` is `false` when the tab was already in that mode.
505
+ - `{ ok: true, mode, changed: true, reloading: true }`: a reload is on its way.
506
+ - `{ ok: false, mode, code }`: it refused, and `mode` is still the committed one.
507
+
508
+ The refusal codes:
509
+
510
+ - `busy`: another switch to the other mode is already running.
511
+ - `not-allowed`: this person may not edit, or the page is in draft mode.
512
+ - `permission-denied`: the host answered that this caller may not write the document.
513
+ - `cancelled`: the page moved to another document while the host answered.
514
+ - `host-failed`: the workspace shell threw instead of answering.
515
+ - `host-refused`: the workspace shell answered without success.
516
+ - `save-failed`: the save before leaving edit failed or did not settle as saved.
517
+ - `pending-input`: text or a composition is still uncommitted in the page.
518
+ - `conflict`: the file has an unresolved save conflict.
519
+ - `save-unknown`: the outcome of the last save could not be established.
520
+ - `stale-document`: the file moved on while the session was being prepared.
521
+ - `timeout`: the 15 second deadline passed.
522
+ - `load-failed`: a module the entry needed failed to import.
523
+ - `unsupported-plugin`: a queued entry was not a participant.
453
524
 
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"`).
525
+ An undeclared document saves, writes the receipt and reloads. A document with `<html clay-mode-switch="live">` and supported participants switches live. A workspace frame hands the change to its shell, whose reload owns it.
455
526
 
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`.
527
+ Draft mode always answers `not-allowed`: a draft stays local.
457
528
 
458
- `clay.toggleEditMode()` flips `?editmode=` and reloads.
529
+ Keep the control on the committed value while a switch is pending, and show the code when it refuses:
530
+
531
+ ```js
532
+ const toggle = document.querySelector("[clay-mode-toggle]");
533
+ const status = document.querySelector("[data-mode-status]");
534
+ let busy = false;
535
+
536
+ function paint() {
537
+ toggle.setAttribute("aria-checked", clay.mode === "edit" ? "true" : "false");
538
+ }
539
+ paint();
540
+ document.addEventListener("clay:modechange", paint);
541
+
542
+ toggle.addEventListener("click", async () => {
543
+ if (busy) return;
544
+ busy = true;
545
+ toggle.disabled = true;
546
+ status.textContent = "";
547
+ const result = await clay.setMode(clay.mode === "edit" ? "view" : "edit");
548
+ // A reload is coming: stay busy until the page goes.
549
+ if (result.reloading) return;
550
+ busy = false;
551
+ toggle.disabled = false;
552
+ if (result.ok === false) status.textContent = `Stayed in ${clay.mode}: ${result.code}`;
553
+ });
554
+ ```
555
+
556
+ Never switch by writing the root `editmode` attribute. That attribute reports the committed mode; it does not change it.
557
+
558
+ `clay.toggleEditMode()` is the one-line form. In draft mode it opens `?editmode=true`, and otherwise it calls `setMode`.
559
+
560
+ Every page answers Cmd+Shift+E (Ctrl+Shift+E elsewhere) with `setMode` for a person who can edit. A page with no switch of its own shows that person a one-time note pointing to the shortcut. Mark your own control `clay-mode-toggle` to replace the note, or load the `menu` plugin for a ready-made Edit switch in the top right ([menu](https://clayjs.com/docs/plugins/menu/)).
561
+
562
+ ### 3.3 Switching without a reload: clay-mode-switch="live"
563
+
564
+ `<html clay-mode-switch="live">` is the author's promise that every edit resource can be taken back. Without it, `setMode` takes the guarded reload: it saves, writes the receipt and reloads.
565
+
566
+ A document registers participants with `clay.modeLifecycle.register(...)`, or before the loader runs through the `window.clay.modeParticipants` array, which survives the boot and is drained by the first entry.
567
+
568
+ A participant is `{ id, order, prepare }`. `order` decides prepare order and defaults to `0`. `prepare(context)` returns a lease:
569
+
570
+ - `ready`: a promise the entry awaits before it goes on, or nothing.
571
+ - `enable`: runs synchronously, once every preparation has succeeded.
572
+ - `quiesce`: stops admission without losing buffers.
573
+ - `resume`: takes the very same lease back after a refused exit.
574
+ - `dispose`: runs only after a committed exit.
575
+
576
+ `context` is `{ generation, destination, signal, represented, reader, initial, isCurrent() }`.
577
+
578
+ `clay.modeLifecycle.canWrite()` is true while this tab is in edit or draft and no transition is running. `clay.modeLifecycle.state` reports `phase` as `idle`, `entering`, `leaving` or `blocked`, beside `mode`, `destination`, `generation` and `reason`. A committed change fires `clay:modechange`, and a refusal is the returned code plus `state.reason`.
579
+
580
+ An id that starts with `clay:` is reserved for the built-in sessions.
581
+
582
+ One 15 second deadline covers the whole transition: every import a participant prepares from, the preparation itself and the commit.
583
+
584
+ `prepare` must not await `clay.ready`. The loader awaits `activateInitialMode()` before it resolves `ready`, and `activateInitialMode()` awaits every `prepare`, so a `prepare` that awaits `ready` waits on itself.
585
+
586
+ An asynchronous callback checks `context.isCurrent()` and `clay.modeLifecycle.canWrite()` before it changes a record.
587
+
588
+ A participant registered before the loader runs, with a lease that has all five members:
589
+
590
+ ```js
591
+ window.clay.modeParticipants = [
592
+ {
593
+ id: "board",
594
+ order: 10,
595
+ prepare: async (context) => {
596
+ const editor = await openEditor(context.signal);
597
+ return {
598
+ ready: null,
599
+ enable: () => editor.adopt(),
600
+ quiesce: async () => editor.flush(),
601
+ resume: () => editor.resume(),
602
+ dispose: () => editor.teardown(),
603
+ };
604
+ },
605
+ },
606
+ ];
607
+ ```
459
608
 
460
- ### 3.2 The root attributes
609
+ ### 3.4 Leaving edit
461
610
 
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.
611
+ The exit waits for the writes it is allowed to wait for, and refuses with a code when it cannot finish:
612
+
613
+ - an unsent composer or an unresolved IME composition answers `pending-input`.
614
+ - an unresolved conflict answers `conflict`.
615
+ - a save whose outcome is unknown answers `save-unknown`.
616
+ - a failed save answers `save-failed`.
617
+ - a pending upload is flushed first, and one that is still running answers `pending-input`.
618
+
619
+ A refused exit resumes the very same leases and leaves the page in edit, with `clay.modeLifecycle.state.reason` naming why. Nothing blur-submits: the exit never commits text a person did not commit.
620
+
621
+ Entering edit writes nothing. The session adopts the file it shows as its baseline (`save.adoptEntryBaseline`), so a clean enter and exit leaves the file byte for byte the same. The recorded case is `enter-leave-zero-writes` in the clayjs corpus.
622
+
623
+ ### 3.5 Attributes across a mode change
624
+
625
+ The four controlled families, `editmode:contenteditable`, `editmode:onclick`, `viewmode:disabled` and `viewmode:readonly`, are handed back with their authored values after a cycle.
626
+
627
+ Frozen regions keep their saved contents.
628
+
629
+ `editmode` on `<html>` follows each commit, and it is stripped from every save.
630
+
631
+ A committed live switch drops `?editmode=` from the URL.
632
+
633
+ `opens-in` is read once at boot and never rewritten.
634
+
635
+ ### 3.6 A complete example
636
+
637
+ One persisted note, a participant registered before the loader, an explicit initial edit, and a composer that holds a comment until it is sent or discarded. The participant's script goes above the loader in the same document.
638
+
639
+ ```html example=mode-cycle-page
640
+ <!DOCTYPE html>
641
+ <html clay-mode-switch="live">
642
+ <head>
643
+ <meta charset="utf-8">
644
+ <title>Notes</title>
645
+ </head>
646
+ <body>
647
+ <article data-id="note">
648
+ <div id="content" editmode:contenteditable inert-contenteditable="plaintext-only">A note kept in the file.</div>
649
+ <div class="composer" clay="editor-ui">
650
+ <input id="comment" unsent="comment" placeholder="Add a comment">
651
+ <button id="send" type="button">Send</button>
652
+ <button id="discard" type="button">Discard</button>
653
+ <span id="count" aria-live="polite"></span>
654
+ </div>
655
+ </article>
656
+ <script src="https://clayjs.com/v1/clay.js?plugins=unsent&exclude=richclay"></script>
657
+ </body>
658
+ </html>
659
+ ```
660
+
661
+ The record is a controlled element, so a save writes the inert spelling back and no reader receives an editable note. The lease hands that surface to the session and takes it back on the way out, and a write that lands in a later turn checks the session is still current and still admits writes:
662
+
663
+ ```js example=mode-cycle-participant
664
+ window.clay = window.clay ?? {};
665
+ window.clay.modeParticipants = window.clay.modeParticipants ?? [];
666
+
667
+ window.clay.modeParticipants.push({
668
+ id: "notes",
669
+ order: 10,
670
+ prepare: async (context) => {
671
+ const content = document.getElementById("content");
672
+ const count = document.getElementById("count");
673
+ const editable = "plaintext-only";
674
+
675
+ const paint = () => {
676
+ setTimeout(() => {
677
+ if (!context.isCurrent() || !clay.modeLifecycle.canWrite()) return;
678
+ count.textContent = `${content.textContent.trim().length} characters`;
679
+ }, 0);
680
+ };
681
+
682
+ return {
683
+ ready: null,
684
+ enable() {
685
+ content.setAttribute("contenteditable", editable);
686
+ content.removeAttribute("inert-contenteditable");
687
+ content.addEventListener("input", paint);
688
+ },
689
+ quiesce: async () => {},
690
+ resume() {
691
+ content.setAttribute("contenteditable", editable);
692
+ content.removeAttribute("inert-contenteditable");
693
+ },
694
+ dispose() {
695
+ content.removeEventListener("input", paint);
696
+ content.removeAttribute("contenteditable");
697
+ content.setAttribute("inert-contenteditable", editable);
698
+ },
699
+ };
700
+ },
701
+ });
702
+ ```
703
+
704
+ ```js example=mode-cycle-submit
705
+ const comment = document.getElementById("comment");
706
+
707
+ document.getElementById("send").addEventListener("click", async () => {
708
+ const content = document.getElementById("content");
709
+ const line = document.createElement("p");
710
+ line.textContent = comment.value;
711
+ content.append(line);
712
+ clay.unsent.clear(comment);
713
+ comment.value = "";
714
+ await clay.save();
715
+ });
716
+
717
+ document.getElementById("discard").addEventListener("click", () => {
718
+ clay.unsent.clear(comment);
719
+ comment.value = "";
720
+ });
721
+ ```
722
+
723
+ Leaving edit is refused while the composer holds unsent text, and it succeeds once Send or Discard clears that text.
724
+
725
+ ### 3.7 The root attributes
726
+
727
+ At load, ClayJS sets `editmode="true|false"` and `pageowner="true|false"` on `<html>` in both modes, `canedit="true"` while this person may edit (kept current on `clay:capabilitychange`), and `draftmode="true"` in draft mode. In edit mode, it also sets `savestatus` (`saving`, `saved`, `error`, `offline`, `conflict`) once the page settles, and `unsaved="true"` while there is work a save has not written. **All of them are removed on every save**, along with the host's `savetoken` and `documentetag`. The saved file's `<html>` carries none of them.
463
728
 
464
729
  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.
465
730
 
466
- ### 3.3 Showing and hiding edit controls: clay-options
731
+ ### 3.8 Showing and hiding edit controls: clay-options
467
732
 
468
733
  Load the `clay-options.js` satellite and mark edit-only controls with `show-when:editmode="true"`:
469
734
 
@@ -493,9 +758,9 @@ Visibility attributes:
493
758
  - 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
759
  - 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.
495
760
 
496
- ### 3.4 Mode attributes: one file for editors and viewers
761
+ ### 3.9 Mode attributes: one file for editors and viewers
497
762
 
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.
763
+ 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. A view boot never loads the activation code, so a viewer sees what the file holds.
499
764
 
500
765
  | Attribute | In the saved file | In edit mode |
501
766
  |---|---|---|
@@ -525,9 +790,9 @@ There is no general `editmode:<anything>`. Only these four attributes exist.
525
790
 
526
791
  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
792
 
528
- ### 3.5 What works in view mode
793
+ ### 3.10 What works in view mode
529
794
 
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).
795
+ `window.clay` keeps `ready`, `setMode`, `mode`, `canEdit`, `modeLifecycle`, `isEditMode`, `isDraftMode`, `isOwner`, `toggleEditMode`, `Mutation`, `region`, `readData`, `writeData`, `assets`, `ephemeral`, `storage`, `host`, `trash`, `derived`, `effect`, `style`, `morph`, and `hasUnsavedChanges` (always `false`) in both modes. 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). On a template copy whose tab can edit, the `upgrade` plugin loads in view and `clay.save` holds only `replace`, so test `clay.isEditMode`, never `'save' in clay`.
531
796
 
532
797
  - 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
798
  - 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.
@@ -535,9 +800,9 @@ In view mode, `window.clay` keeps `ready`, `isEditMode`, `isOwner`, `toggleEditM
535
800
  - 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
801
  - Clicking inside `[trigger-save]` in view mode dispatches `clay:view-save-attempt`, so you can explain why nothing saved.
537
802
 
538
- ### 3.6 When the page editor is ready
803
+ ### 3.11 When the page editor is ready
539
804
 
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 }`.
805
+ The mode this tab starts in 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
806
 
542
807
  `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
808
 
@@ -557,7 +822,7 @@ if (boot?.phase !== "ready") document.documentElement.setAttribute("read-only",
557
822
 
558
823
  `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
824
 
560
- ### 3.7 The page editor on a phone
825
+ ### 3.12 The page editor on a phone
561
826
 
562
827
  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
828
 
@@ -565,13 +830,13 @@ More holds the rest: Strikethrough, Code, bulleted, numbered, and checklist list
565
830
 
566
831
  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
832
 
568
- ### 3.8 Keyboard navigation in the page editor
833
+ ### 3.13 Keyboard navigation in the page editor
569
834
 
570
835
  Escape closes an open menu or the link popover first, returning focus to the control that opened it or to the editor.
571
836
 
572
837
  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
838
 
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.
839
+ 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. A table with no widths fills the page; the first width you set pins the other columns at their drawn widths, in the same undo step. Double-clicking a handle resets the table to the page width, and pointer dragging is unchanged. On a phone, the column's Width menu (Wider, Narrower, Fit to content, and Reset widths once the table has widths) does the same job.
575
840
 
576
841
  ---
577
842
 
@@ -583,7 +848,7 @@ A save writes the current DOM with these changes:
583
848
 
584
849
  - Subtrees marked `no-save`, `no-snapshot`, or `editor-ui` are removed.
585
850
  - `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.
851
+ - 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`, `canedit`, `draftmode`, `savestatus`, `unsaved`, `savetoken`, and `documentetag` are removed.
587
852
  - `persist` controls write their live values ([`persist`: form controls](https://clayjs.com/docs/guide/record/#persist-form-controls)).
588
853
  - Runtime residue from ClayJS editors (richclay attributes, toolbars) and browser extensions (password managers, Grammarly, Dark Reader) is stripped.
589
854
  - Your hooks run ([Hooks](https://clayjs.com/docs/guide/saving/#hooks)).
@@ -638,6 +903,7 @@ Put `persist` on every `<input>`, `<textarea>`, and `<select>` whose value belon
638
903
  ```
639
904
 
640
905
  - 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.
906
+ - A `<textarea>` merges as one value, so do not use `<textarea persist>` for long text several people edit. Keep the text in a record element (`<pre><code>`) and bind an editor outside the records to it with `clay.bindText` ([OverType: a markdown editor](https://clayjs.com/docs/guide/components/#overtype-a-markdown-editor)).
641
907
  - A snapshot also copies every `persist` control's live state, so a value set from script with no event is saved too.
642
908
  - Password, hidden, and file inputs are never persisted. An authored `value` on them stays in the file.
643
909
  - `persist` goes on the control itself, never on a wrapper.
@@ -686,6 +952,7 @@ Live sync and undo pair elements across versions by **`data-id`, then `id`**, us
686
952
  - **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.
687
953
  - **Never stamp ids on open.** Opening an old file must not rewrite it. Add an id when an item is created or first changed.
688
954
  - **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)`.
955
+ - **A `data-id` is also what presence names.** A record target `{ kind: "record", id }` carries it, so a stable id is what lets a peer's badge find the row again after a reload or a merge ([Presence](https://clayjs.com/docs/guide/live-sync/#presence)).
689
956
  - **Use ids to find records in commands:** `` board.querySelector(`.card[data-id="${CSS.escape(id)}"]`) ``. Never find a record by position.
690
957
 
691
958
  ### 4.6 Assets
@@ -714,6 +981,33 @@ clay.ephemeral.toggle(card, "selected"); // add, remove, toggle(el, word, forc
714
981
 
715
982
  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)).
716
983
 
984
+ ### 4.8 A template is an ordinary record with a tag
985
+
986
+ A reusable template is a record like any other, marked with a tag. It stays editable, searchable, exported, synced and in a visitor's draft, and it needs no second store and no new format. The app reads the tag off the body it already has:
987
+
988
+ ```js
989
+ const templates = open => Entries.live().filter(a => a !== open && summary({ body: Entries.body(a), $el: a }).tags.includes('template'));
990
+ ```
991
+
992
+ The tag is matched outside code spans and fences, so a template entry that shows a `#template` example does not become a template itself. Using one copies its text into the page, and the new record is written by the same save as everything else.
993
+
994
+ ### 4.9 Keep a helper that reads the text beside the editor, in an `editor-ui` row
995
+
996
+ A helper that reacts to the text being written belongs in a row a script builds beside the editor, marked `clay="editor-ui"`, and refreshed on a debounce. Nothing it draws is saved, printed or synced, and no keystroke pays for it beyond the debounce:
997
+
998
+ ```js
999
+ el = document.createElement('div');
1000
+ el.className = 'starters';
1001
+ el.setAttribute('clay', 'editor-ui');
1002
+ $('#writer').before(el);
1003
+
1004
+ function start(on) {
1005
+ on($('#writer'), 'input', () => { clearTimeout(timer); timer = setTimeout(refresh, 300); });
1006
+ }
1007
+ ```
1008
+
1009
+ The row is built on demand and refreshed on that debounce. Everything it draws lives in the row, so it is never saved, printed or synced.
1010
+
717
1011
  ---
718
1012
 
719
1013
  ## 5. Saving
@@ -722,6 +1016,8 @@ Use `ephemeral` for state on one element. State that comes from the URL hash and
722
1016
 
723
1017
  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.
724
1018
 
1019
+ Leaving edit saves first. If that save fails, or text is still unsent, or a conflict is open, the switch is refused with a code and the page stays in edit ([Leaving edit](https://clayjs.com/docs/guide/edit-mode/#leaving-edit)).
1020
+
725
1021
  #### 5.1.1 The saved title
726
1022
 
727
1023
  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.
@@ -734,7 +1030,7 @@ Title changes only update the document's content. ClayJS never derives or change
734
1030
 
735
1031
  `<html autosave>` present at load turns on autosave. Adding or removing it later changes nothing.
736
1032
 
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.
1033
+ - A watched DOM change or a `persist` input starts a 1500 ms debounce. Continuous changes save at most 10 s after the first one. After each save, the next automatic save waits at least 1.2 s, and at least four times as long as that save took to prepare, up to 30 s, so a page whose saves are expensive saves less often instead of saving between keystrokes.
738
1034
  - 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
1035
  - Template content edits never trigger autosave.
740
1036
  - 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:
@@ -745,12 +1041,63 @@ Title changes only update the document's content. ClayJS never derives or change
745
1041
  });
746
1042
  ```
747
1043
 
1044
+ A save is admitted before the page is captured. The spacing is decided first, and only an admitted attempt takes a snapshot, so nothing captures the page for a save that is still only an intention:
1045
+
1046
+ ```js
1047
+ const cooldown = Math.min(MAX_COOLDOWN_MS, Math.max(MIN_GAP_MS, COST_MULTIPLIER * costMs));
1048
+ const quietAt = Math.max(last, finished) + IDLE_MS;
1049
+ const continuousAt = Math.max(first + CONTINUOUS_MS, finished + cooldown);
1050
+ return Math.max(finished + MIN_GAP_MS, Math.min(quietAt, continuousAt));
1051
+ ```
1052
+
1053
+ The quiet window and the continuous ceiling are both computed from the clock, and the cooldown after a save scales with what that save cost.
1054
+
748
1055
  **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:
749
1056
 
750
1057
  - Do not seed defaults, migrate a schema, or stamp ids at startup. Author defaults in the file.
751
1058
  - 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()`.
752
1059
 
753
- ### 5.3 Manual-save pages and batching
1060
+ ### 5.3 Two baselines from one capture
1061
+
1062
+ A page keeps two saved baselines from one save: the bytes a comparison uses, and the bytes the dirty check uses. Both domains are taken from one capture, because a comparison that borrowed the dirty domain would lose a batching region's edit, and a dirty baseline that borrowed the comparison domain would call a page clean while a `clay="no-trigger-autosave"` region still held new work.
1063
+
1064
+ ```js
1065
+ const { forComparison, forDirty } = captureForComparisonAndDirty();
1066
+ setLastSavedBaselines(forComparison, forDirty);
1067
+ ```
1068
+
1069
+ Both baselines advance from the same capture taken before the request, never from the live DOM, so an edit made while the save was on the wire stays unsaved. A caller with one string is a page with no batching region, and passes it for both: `setLastSavedContents(val)`.
1070
+
1071
+ ### 5.4 Reuse a verified parse only for the accepted capture
1072
+
1073
+ Verifying the bytes a save will send already parses them, and a second parse of the accepted bytes pairs the whole page again. Keep the parse the verification paid for, and adopt its model only when the candidate is still this document:
1074
+
1075
+ ```js
1076
+ function withCandidate(verdict, errors, rendered, liveDocument) {
1077
+ if (!errors.doc) return verdict;
1078
+ const candidate = modelFromParsed(rendered, errors.doc, errors.parseErrors);
1079
+ if (checkSource(candidate, liveDocument)) return verdict;
1080
+ verdict.model = candidate;
1081
+ return verdict;
1082
+ }
1083
+ ```
1084
+
1085
+ `checkSource` compares what a tree comparison cannot see: the doctype and the document-level comments outside `<html>`. A model that fails it is not this document, whatever the render verified as, so the save keeps its fallback path and adopts nothing. The model is only kept for the capture the acknowledgement is about, never for newer typing.
1086
+
1087
+ ### 5.5 Measure typing across several accepted saves
1088
+
1089
+ Input to view timing alone misses the work that blocks the next input from arriving. Type continuously across several accepted saves, hold each response so typing continues while the request is on the wire, and report the input delivery gaps between key events beside the input to frame times:
1090
+
1091
+ ```js
1092
+ // node scripts/measure-save-pipeline.mjs --entries 5000 --typing-ms 60000 --pause-ms 3000 --response-delay-ms 2000
1093
+ inputGapsMs: stats(gaps),
1094
+ inputToFrameMs: stats(meter.inputs.map((input) => input.raf)),
1095
+ inputToViewsMs: stats(meter.inputs.map((input) => input.views)),
1096
+ ```
1097
+
1098
+ The gaps inside a save window are the ones that matter. A save that only shifts input to the next frame has still blocked the input that never arrived, and only a run across several accepted saves shows it.
1099
+
1100
+ ### 5.6 Manual-save pages and batching
754
1101
 
755
1102
  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:
756
1103
 
@@ -768,7 +1115,7 @@ To batch saves in part of an autosaving page, wrap that region in `clay="no-trig
768
1115
 
769
1116
  `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.
770
1117
 
771
- ### 5.4 The save API
1118
+ ### 5.7 The save API
772
1119
 
773
1120
  `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.
774
1121
 
@@ -783,6 +1130,7 @@ To batch saves in part of an autosaving page, wrap that region in `clay="no-trig
783
1130
 
784
1131
  **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`:
785
1132
 
1133
+ - `clay.save.soon()` saves a discrete action (a moved card, a toggled checkbox) within about 250 ms instead of after the autosave wait. Calls within a second share one save, and each resolves with that save's result. A save you asked for with Cmd+S or `soon()` that an incoming merge cancels mid preparation is captured again once, after the merge settles (150 ms of quiet, at most 2 s); an autosave cancelled the same way is not retried. Leave typing on autosave.
786
1134
  - `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
1135
  - `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
1136
  - `clay.save.overwrite()` re-reads the host's current version stamp and force-saves over it. This is "keep mine" after a conflict.
@@ -791,7 +1139,42 @@ To batch saves in part of an autosaving page, wrap that region in `clay="no-trig
791
1139
  - `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.
792
1140
  - `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.
793
1141
 
794
- ### 5.5 Conflicts on save (412)
1142
+ ### 5.8 Size limits
1143
+
1144
+ The limit comes from the host's `/_/meta` answer, `document.maxBytes`: the most bytes of uncompressed UTF-8 HTML one save body may have, inclusive. It counts only as a positive safe integer. A missing, zero, negative, fractional or non-numeric value means the limit is unknown, and `clay.save.limits()` resolves `{ maxBytes: null }`. Draft mode answers the same without asking the host at all. See [Hosts, assets and privacy](https://clayjs.com/docs/guide/hosts/#what-each-host-gives).
1145
+
1146
+ A save is measured on the exact UTF-8 bytes it is about to send, after hooks, transforms and cleanup, and refused before anything is sent when it is over. The result is `{ ok: false, code: "save-too-large", sent: false, size }`, where `size` holds `{ bytes, maxBytes, state: "over" }`. Nothing is written: no accepted listener runs, no baseline moves and no recovery copy is cleared. A host that answers 413 maps to the same code.
1147
+
1148
+ The host stays authoritative. A client that predates this check, a first save sent before discovery answers, and a limit that changed after it was read are all decided by the host's own refusal, which leaves the writing recoverable.
1149
+
1150
+ `clay.save.inspect(html)` measures bytes that are not this page, such as a file you are about to import, without capturing, sending or moving a baseline:
1151
+
1152
+ ```js
1153
+ const size = await clay.save.inspect(candidateHTML);
1154
+ if (size.state === "over") return say("That is larger than this host can save.");
1155
+ ```
1156
+
1157
+ A real save that measures `near` or `over` fires `clay:save-size` and loads a small notice into the editor UI. It reports the last measurement, never a live size:
1158
+
1159
+ - Near: `This file is getting large. At the last save attempt: 1.6 MB of 2.0 MB. Download a copy, then reduce its size; saving still works.`
1160
+ - Over: `This host can't save a file this size. At the last save attempt: 2.1 MB of 2.0 MB. Download a copy to keep your work.`
1161
+
1162
+ **Download my copy** appears only when the measurement is over. The notice is hidden by a later save measurement below the threshold, and when the page leaves edit mode. It is loaded only when a measurement is near or over, so most pages never fetch it.
1163
+
1164
+ To make a copy of the page without some of its records, remove them from the clone with `clay.getHTML({ transform })`:
1165
+
1166
+ ```js
1167
+ const html = clay.getHTML({
1168
+ transform: (root) => {
1169
+ for (const el of root.querySelectorAll("#entries > article.entry[data-drop]")) el.remove();
1170
+ },
1171
+ flushUndo: false
1172
+ });
1173
+ ```
1174
+
1175
+ The transform must be synchronous and its return value is ignored. The live page, the baselines and the network are untouched. See [Importing and exporting records](https://clayjs.com/docs/guide/architecture/#importing-and-exporting-records).
1176
+
1177
+ ### 5.9 Conflicts on save (412)
795
1178
 
796
1179
  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.
797
1180
 
@@ -801,7 +1184,7 @@ On hosts that announce `conditional` (all three hosts in [What the host does](ht
801
1184
  - 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.
802
1185
  - If you hide the built-in notice, you must still offer overwrite and reload.
803
1186
 
804
- ### 5.6 Hooks
1187
+ ### 5.10 Hooks
805
1188
 
806
1189
  Hooks shape the saved bytes without touching the live page. Except for `onaftersave`, they run on a clone.
807
1190
 
@@ -841,7 +1224,7 @@ clay.onSnapshot?.((root) => {
841
1224
 
842
1225
  Only strip `draggable="false"` if your own markup never authors it.
843
1226
 
844
- ### 5.7 Save status
1227
+ ### 5.11 Save status
845
1228
 
846
1229
  ClayJS reflects save state on `<html>`, so you can style a status indicator with CSS alone:
847
1230
 
@@ -860,7 +1243,7 @@ html[unsaved="true"]:not([savestatus="error"]):not([savestatus="offline"]) .chip
860
1243
 
861
1244
  In draft mode, nothing is saved on purpose, and `html[draftmode="true"]` indicates why the page is not marked saved.
862
1245
 
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.
1246
+ Put `#status` in chrome, or load the `indicator` plugin for a ready-made chip. The chip's drawing arrives on demand, at the first input or the first save state, so it costs nothing at edit ready. 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.
864
1247
 
865
1248
  Events, all on `document`:
866
1249
 
@@ -883,7 +1266,7 @@ Events, all on `document`:
883
1266
 
884
1267
  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
1268
 
886
- ### 5.8 Recovery copies
1269
+ ### 5.12 Recovery copies
887
1270
 
888
1271
  `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
1272
 
@@ -960,7 +1343,7 @@ When an incoming frame (B) meets unsaved edits in this tab (A), ClayJS merges th
960
1343
 
961
1344
  More engine rules:
962
1345
 
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.
1346
+ - Text merges word by word inside a block, and formatting merges per character. Inline `<style>` text merges word by word, while a `<textarea>` merges as one value. Scripts and comments merge as whole values.
964
1347
  - 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
1348
  - 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)).
966
1349
  - Window scroll is restored. Inner scroll containers are not, except the focused element's own.
@@ -1061,9 +1444,98 @@ The built-in notice reads "Another edit replaced N changes" and offers **Revert
1061
1444
 
1062
1445
  ### 6.7 Presence
1063
1446
 
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.
1447
+ `?plugins=sync` loads presence. `clay.presence` is the facade (`clayjs/src/sync/presence-store.js`), installed on `window.clay.presence` when the store loads. It has three page-facing methods:
1448
+
1449
+ ```js
1450
+ const snapshot = clay.presence.getSnapshot(); // the same object until something changes
1451
+ const stop = clay.presence.subscribe(render); // the current snapshot at once, then every change
1452
+ clay.presence.setTarget({ kind: "record", id: "card-1", field: "title" });
1453
+ ```
1454
+
1455
+ `subscribe(listener)` returns an unsubscribe function. It emits the current snapshot at once, even before the host answers, so a page never waits for a first frame. `setTarget(null)` clears this tab's target and publishes nobody anywhere; it does not fall back to the generic editing marker.
1456
+
1457
+ **The snapshot.** `{ supported, recordAwareness, pageAwareness, people, anonymous }`. Each person is `{ id, name, you, canEdit, editing, sessions }`, and each session is `{ id, you, n, editing }`:
1458
+
1459
+ ```js
1460
+ {
1461
+ supported: true, recordAwareness: true, pageAwareness: true,
1462
+ people: [{
1463
+ id: "p_ada", name: "Ada Lovelace", you: false, canEdit: true,
1464
+ editing: { kind: "record", id: "card-1", field: "title" },
1465
+ sessions: [{ id: "s1", you: false, n: 12, editing: { kind: "page", v: 1, epoch, generation, selection } }],
1466
+ }],
1467
+ anonymous: 0,
1468
+ }
1469
+ ```
1470
+
1471
+ Before discovery answers, the snapshot is unsupported with no people. The host decides who appears: each recipient gets a roster filtered by its own access, and nothing in the page can widen it (`src/sync/live-sync.js`, the `presence` event).
1065
1472
 
1066
- ### 6.8 Agents
1473
+ **Targets.** A target is `null`, a legacy dotted path string, a record target `{ kind: "record", id, field }` with `field` optional, or a page target `{ kind: "page", v: 1, epoch, generation, selection }`. A page selection is `text` (`anchor` and `head`, each `{ block, rel }`), `blocks` (`{ ids }`), or `cell` (`{ block, row, col }`).
1474
+
1475
+ The bounds come from `src/sync/presence-target.js`:
1476
+
1477
+ - A record id of 1 to 128 printable ASCII characters.
1478
+ - A field of `title`, `description`, `labels`, `members`, `dates`, `checklist`, `comment`, or `attachments`.
1479
+ - 8 KiB per target.
1480
+ - 256 bytes per page id.
1481
+ - 1 KiB per decoded relative position.
1482
+ - At most 128 block ids.
1483
+
1484
+ An invalid target is refused, never repaired. Updates are coalesced to one every 250 ms, a stable target renews every 5 seconds, and it expires 15 seconds after the host receives it.
1485
+
1486
+ **A record target from a focused control.** Publish a target only while the control that writes the record has focus, and publish `null` the moment it stops: on blur, when the open record changes, when the page is hidden, when the record goes to Trash, and when the tab leaves edit mode or enters draft mode. After a blur, check again on the next microtask, so focus moving inside the same control does not flash `null`. A record with one writing control sends the fieldless shape, `{ kind: "record", id }`, where `id` is the record's `data-id`. A target never carries a title, text or date. The Journal template does this in `RecordPresence` (`templates/00-journal/journal.html`):
1487
+
1488
+ ```js
1489
+ function reconcile() {
1490
+ const id = capable && clay.isEditMode && !clay.isDraftMode && !document.hidden && writerHasFocus()
1491
+ ? openEntryId() : null;
1492
+ if (id === sentId) return;
1493
+ sentId = id;
1494
+ clay.presence.setTarget(id ? { kind: "record", id } : null);
1495
+ }
1496
+ ```
1497
+
1498
+ `capable` is the snapshot's `recordAwareness`, so a host without `record-awareness-v1` is sent nothing. Renewal and expiry belong to ClayJS: a page sets a target once and keeps no timer of its own.
1499
+
1500
+ **Showing who is on a record.** Build a map from record id to people out of each snapshot. Skip `you: true`, keep only targets with `kind: "record"` whose id is a live record on this page, and count a person once however many tabs they have. Write names with `textContent`. A slot a script creates carries `clay="no-save no-watch no-snapshot"`. A slot inside a repeated row's `<template>` carries only `clay="no-save no-watch"`: a save removes `no-snapshot` nodes from template content too, which would delete the slot from the file, and the rows sapjs renders from the template are marked `editor-ui`, which already keeps them out of snapshots. Repaint only when the names change, so a renewal announces nothing. A badge is advisory: it never blocks editing and never reaches a record.
1501
+
1502
+ **Hosts.** The client picks exactly one transport from `/_/meta` (`connect` in `presence-store.js`):
1503
+
1504
+ - hyperclay.com relays presence through the host. The target goes to `POST /_/presence`, and the roster comes back as a named `presence` event on the document's stream, with one person per account and one session per tab.
1505
+ - HTML Clay announces `awareness: { transport: "broadcast", scope }`. Its presence travels between tabs of the same browser on a channel named for the document scope (`src/sync/local-presence.js`). Each tab is a fresh random session, not an account, shown under the label "This computer". Two documents that share a file name never meet.
1506
+ - Hyperclay Local likewise shows tabs on this computer, not authenticated people.
1507
+
1508
+ In view mode the reader draws the roster too. It takes the roster from the stream it already follows, as a named `presence` event, so it opens no second connection. The roster UI is a first-use download that comes with the first presence frame, and a reader that receives no frame downloads none of it. A reader publishes no target and joins no writable room, because its lease has no write half: a target a page sets in view mode sends nothing. Its roster goes away when its stream closes.
1509
+
1510
+ Each part needs its own extension: `presence` for the roster, `presence-editing` for the generic editing marker, `record-awareness-v1` for record targets, and `page-awareness-v1` for remote carets.
1511
+
1512
+ **Runtime only.** The roster UI is marked `clay="no-save no-watch no-snapshot"` (`RUNTIME_ONLY` in `src/ui/bevel-controls.js`, applied in `src/sync/presence.js`). It never reaches a save, a snapshot, or the page's own change detection, and a roster change is not an edit. Remote carets are editor decorations, not document content ([Remote carets](https://clayjs.com/docs/plugins/page/#remote-carets)). All three tokens are needed: `no-save` alone only keeps a name out of the file, and the snapshot clone that peers receive is emitted before the save-only regions are stripped, so `no-snapshot` is what keeps a name out of that clone, while `no-watch` is what keeps a roster change from counting as an edit (`src/sync/presence.js:15-28`).
1513
+
1514
+ Presence is not for app data. For state that must be shared and is not presence, use a live store ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)).
1515
+
1516
+ ### 6.8 The page format floor
1517
+
1518
+ A document records the lowest page format a writer must understand, in document metadata outside the page value:
1519
+
1520
+ ```html
1521
+ <meta name="clay-page-format" content="1">
1522
+ ```
1523
+
1524
+ No marker means floor 0, and a floor only ever rises: a later save cannot remove the marker or lower it. The value is protocol capability, not permission. The host still applies every access check it applied before, and the marker cannot be used to discover a private document.
1525
+
1526
+ **Every page write declares its capability.** A save and a live write carry `X-Clay-Page-Format: 1`, the format the runtime knows rather than the format this document uses, so even an ordinary document's save declares it.
1527
+
1528
+ **A host refuses a write below the floor.** Nothing is mutated. The answer is HTTP `409` with a body carrying the code `page-format-upgrade-required`, the same string as its `reason` on the save lanes, and `minimum`, the floor the document requires. That covers the ordinary save, the live data lanes, a checkpoint, a version restore and the relay, on all three hosts, and a refused live request ends that tab's stream so it receives no newer content. The stored file, its version history and its page state stay as they were, and the refused tab keeps its own unsaved work.
1529
+
1530
+ **A writer raises the floor on its first new format, through one real save.** The gate reads the floor the document already has, asks the host, and adds the marker to an otherwise equivalent capture. Activation is idempotent. It is refused while any admitted live page editor declares a capability below the floor it would set, and that check happens under the host's per-document write lock, so an editor that registers between the check and the save is refused rather than missed. A refused activation leaves ordinary writing available and every unsaved store as it was, and says **This document uses a newer page format; update the editor in the other tabs to keep writing**. The command that asked applies only if the same editor installation and document revision still exist once activation succeeds; otherwise the answer is retryable and the content is untouched.
1531
+
1532
+ **A host announces `page-format-v1` only when it can hold the floor**, meaning its enforcement and its tolerant checkpoint renderer are installed. On a host that does not announce it, a new format command is refused as `page-format-unsupported` and writes nothing.
1533
+
1534
+ **Who reads what.** ClayJS 1.16.0 and later keep floor 1 content, so those tabs are the cohort that can stay open while a floor 1 document is written. The published 1.15.x tabs cannot be changed: once a document's floor rises they are refused writes with `page-format-upgrade-required` instead of saving a version that would drop the new content, and their own unsaved work stays in the tab. A floor 0 document keeps working on all of them.
1535
+
1536
+ Opening a document that is already at floor 1 asks for nothing: only a command that writes a newer format raises a floor, and a document that never uses one never records one.
1537
+
1538
+ ### 6.9 Agents
1067
1539
 
1068
1540
  An agent edits a malleable file the way a person in another tab does:
1069
1541
 
@@ -1072,7 +1544,7 @@ An agent edits a malleable file the way a person in another tab does:
1072
1544
  - **`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
1545
  - **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.
1074
1546
 
1075
- ### 6.9 Live data: `clay="live"`
1547
+ ### 6.10 Live data: `clay="live"`
1076
1548
 
1077
1549
  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.
1078
1550
 
@@ -1235,6 +1707,7 @@ Traps:
1235
1707
  | `> ` | toggle |
1236
1708
  | ` ``` ` | code block, when the whole paragraph is the fence |
1237
1709
  | `---` | divider, when the whole paragraph is the dashes |
1710
+ | `" ` | quote, when the document's format gate allows it; otherwise the space is typed as itself |
1238
1711
 
1239
1712
  | Typed around text | Becomes |
1240
1713
  |---|---|
@@ -1248,7 +1721,7 @@ An opening delimiter must follow the start of the line, whitespace, or opening p
1248
1721
 
1249
1722
  **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.
1250
1723
 
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.
1724
+ None of this loads for a reader. The input rules, menus, and title adapter are editor code, and a Document that never enters edit fetches none of them. A live entry fetches them while the tab still shows view ([switching without a reload](https://clayjs.com/docs/guide/edit-mode/#switching-without-a-reload-clay-mode-switch-live)).
1252
1725
 
1253
1726
  ### 7.2 sortable: drag to reorder
1254
1727
 
@@ -1273,6 +1746,7 @@ None of this loads for a reader. The input rules, menus, and title adapter are e
1273
1746
  - `onsorted="code"` runs on drop with `this` and `evt`.
1274
1747
  - Containers added later bind after about 200 ms.
1275
1748
  - `window.Sortable` is SortableJS 1.15.6. `Sortable.get(el).option(name, value)` changes an instance.
1749
+ - `clay.sortable.move(item, toContainer, beforeItem = null)` makes the reorder a drag makes, without dragging and without waiting for SortableJS. It fires the same `clay:sorted`, records the same undo step, and answers `{ ok: true, moved }` or `{ ok: false, code }` with `not-editable`, `detached`, `item`, `group` or `anchor`. Resolve `beforeItem` by `data-id` at the moment of the move, and use it for keyboard and touch moves.
1276
1750
 
1277
1751
  Traps:
1278
1752
 
@@ -1297,12 +1771,12 @@ Traps:
1297
1771
 
1298
1772
  - `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
1773
  - 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.
1774
+ - Every step is checked against the document as it stands before either stack moves, so a step applies whole or changes nothing. Undoing "Add card" checks that the card is still the node the step added and still holds what the step recorded; if a peer edited it, the undo is refused with `content-changed` and the card stays as the peer left it. A moved neighbour refuses with `structure-changed`, and a reused `data-id` with `duplicate-id`. A refused step stays on top and fires `clay:undo-refused`; replay it once the conflict is gone, or drop it with `clay.undo.discard(entryId)`. [undo](https://clayjs.com/docs/plugins/undo/) lists every code.
1301
1775
  - Also exposed: `clay.undo.on("undo" | "redo" | "commit" | "clear", fn)`, `canUndo`, `canRedo`, and `history`.
1302
1776
 
1303
1777
  Traps:
1304
1778
 
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.
1779
+ - **It leaves Cmd+Z to text fields.** Inside an `input`, `textarea`, `select` or `contenteditable` subtree, the browser's own undo runs. A plain control's typed value is not content until `persist` or a command writes it, so page undo has nothing to replay for it.
1306
1780
  - History is per tab, capped at 100, and not saved.
1307
1781
  - 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.
1308
1782
 
@@ -1335,7 +1809,7 @@ async function imageSource(file) {
1335
1809
  - Events: `clay:upload-start`, `clay:upload-progress`, `clay:upload-done`, and `clay:upload-error`.
1336
1810
  - Host limits: HTML Clay and Hyperclay Local allow 25 MB per file; hyperclay.com allows 10, 20, or 100 MB by plan.
1337
1811
  - 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.
1338
- - Uploaded assets on hyperclay.com are publicly reachable by URL even when the document is private.
1812
+ - On hyperclay.com, an upload made from a document follows that document's read access once `document.upload.access` is `document`; before the account's protection migration it stays reachable by URL. To refuse private media on a host that cannot protect it, start the upload with `clay.upload.start(file, { requiredAccess: "document-or-local" })` ([Privacy](https://clayjs.com/docs/guide/hosts/#privacy)).
1339
1813
 
1340
1814
  `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
1815
 
@@ -1381,7 +1855,7 @@ For fixed content, such as code samples in a docs page, mark the block `clay="fr
1381
1855
 
1382
1856
  ### 7.9 OverType: a markdown editor
1383
1857
 
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`):
1858
+ For a notes or journal app where Markdown is the format, OverType is a textarea under a styled preview. Load it with `await clay.assets.load("overtype")`, which takes it from the release that served `clay.js` ([Libraries on demand](https://clayjs.com/docs/guide/loading/#libraries-on-demand)). **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`):
1385
1859
 
1386
1860
  ```html
1387
1861
  <article data-id="a"><pre><code class="entry-body"># Morning
@@ -1475,7 +1949,6 @@ Rules for sapjs in a malleable file:
1475
1949
  - **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
1950
  - **Put `persist` on bound inputs inside records** so typed values reach the attributes a save captures.
1477
1951
  - **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.
1479
1952
  - **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.
1480
1953
  - 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.
1481
1954
 
@@ -1491,7 +1964,7 @@ Rules for sapjs in a malleable file:
1491
1964
 
1492
1965
  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.
1493
1966
 
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.
1967
+ 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. The CMS edits only in edit mode: while the page is in view, `clay.cms.open()` answers `{ ok: false, code: "not-allowed" }`, so a button of your own calls `clay.setMode("edit")` first.
1495
1968
 
1496
1969
  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.
1497
1970
 
@@ -1638,7 +2111,7 @@ Open on a click that is not on an editable field, handle, input, or link. Close
1638
2111
 
1639
2112
  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
2113
 
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.
2114
+ **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>"`, with the `unsent` plugin loaded (`?plugins=unsent`). ClayJS keeps its value per record and name in this tab, outside the file. The record is named by the nearest `unsent-key` attribute, and by the nearest `[data-id]` when there is none, so a composer for a record that does not exist yet can name its destination (`unsent-key="capture:board"`). While a draft is held, leaving edit is refused with `pending-input`. Close the detail, take a sync redraw, or reload, and the text is back with the close warning on until it is sent.
1642
2115
 
1643
2116
  ```html
1644
2117
  <div class="detail" clay="editor-ui">
@@ -1757,7 +2230,27 @@ Sap.batch("Import CSV", () => {
1757
2230
 
1758
2231
  A rules tag with `data-rules-name="api"` gives agents and the host's data API the same shape your export uses.
1759
2232
 
1760
- ### 8.10 Starting the app once
2233
+ ### 8.10 Importing and exporting records
2234
+
2235
+ The Journal keeps every record in one container, so a backup, an import, a selected export and a print are four ways of copying or writing that same set. Each one is a named function in `journal.html`.
2236
+
2237
+ - **Back up the exact fields, blanks and Trash included.** `Backup.records` reads every record with all of its fields, and `Backup.envelope` wraps them as `{ format, version, exportedAt, entries }`. An empty body and a trashed record are still records, so a backup restores what the file holds rather than what the view shows. Markdown is not an exact data backup.
2238
+ - **Parse, preview and revalidate before committing.** `Backup.parse` reads a file inertly and refuses unknown fields, missing fields, duplicate ids and bad dates with a code. `Backup.plan` sorts the rows into fresh, identical and conflicting ones, `planRestore` offers `keepBoth` for a row that changed here, and `Backup.verify` reads a candidate back field for field. A refusal at any of those points changes nothing.
2239
+ - **Commit once, synchronously.** `confirmImport` re-reads the limit, compares the current capture with the preview's baseline, replans and re-verifies, then appends the prepared records in one synchronous step followed by one `clay.save.soon()`. There is no await between the last check and that step, so a refusal leaves the page, the route, the selection and the dirty state as they were.
2240
+ - **Measure the candidate against the host's limit.** `candidateWith` builds the bytes a save would send with the new records already in place, and `measureHTML` asks `clay.save.inspect` about them. An over-limit import is refused before a single record enters the document.
2241
+ - **Export only the bytes selected.** `selectedCopy` removes every unselected article from the copy inside `clay.getHTML({ transform })`, so the records left out are gone from the file rather than hidden by CSS. `verifySelectedCopy` reads the result back and checks the exact id set.
2242
+ - **Move a year out only after the copy you kept checks out.** `YearArchive.archive` downloads the year as a `.htmlclay` copy and an exact JSON backup and removes nothing; starting a download proves nothing about a second copy. **Finish moving this year** asks for the file the person kept, reads it inertly, and requires exactly the reviewed records, field for field. A different file, a changed record or a record that joined the year since the review refuses with nothing removed. Before removing, `ArchiveMoves.stage` keeps the reviewed records in the `journal.archive-moves` recovery channel and awaits its durable receipt; a browser that cannot keep it refuses and offers the copy only. The removal itself is one synchronous step after a last re-check, followed by one `clay.save.soon()`. The move is cleared only by a saved file whose bytes lack every reviewed id, so a save that never lands leaves **Archive move awaiting save** after a reload. A year whose entries link pictures kept in the journal's own files can be copied but not moved, because a copy cannot carry them yet.
2243
+ - **Print the canonical records.** `printSelected` flags the chosen live articles with the ephemeral `print-pick` word and print CSS narrows the printout to them, so a printout is the records themselves, not the filtered list or an open editor. The flag is never saved, and another tab's sync never carries it.
2244
+
2245
+ ```js
2246
+ const attempt = candidateWith(added);
2247
+ if (!Backup.verify(attempt.html, attempt.expected).ok) return;
2248
+ const size = await measureHTML(attempt.html, limit);
2249
+ if (size.state === "over") return;
2250
+ store.append(fragment);
2251
+ ```
2252
+
2253
+ ### 8.11 Starting the app once
1761
2254
 
1762
2255
  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:
1763
2256
 
@@ -1769,7 +2262,7 @@ clay.start(() => {
1769
2262
 
1770
2263
  A page with more than one app script names each one so they do not share a slot: `clay.start("board", fn)`.
1771
2264
 
1772
- ### 8.11 Document size and record limits
2265
+ ### 8.12 Document size and record limits
1773
2266
 
1774
2267
  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):
1775
2268
 
@@ -1780,9 +2273,11 @@ Every save captures and serializes the whole document, every change check captur
1780
2273
  | 3,000 | 1.3 MB | ~120 ms | ~300 ms | ~190 ms |
1781
2274
  | 6,000 | 2.6 MB | ~250 ms | ~650 ms | ~400 ms |
1782
2275
 
2276
+ Typing also costs one Sap pass, and a pass visits every record. Sap skips rereading unchanged record values, but it still builds fresh objects for every record, so the pass grows with the record count: at 5,000 records in jsdom, a body edit's pass takes about 27 ms ([Cost at size](https://clayjs.com/docs/plugins/sap/#cost-at-size)).
2277
+
1783
2278
  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.
1784
2279
 
1785
- ### 8.12 Styles a script writes
2280
+ ### 8.13 Styles a script writes
1786
2281
 
1787
2282
  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:
1788
2283
 
@@ -1815,16 +2310,20 @@ Use `clay="editor-ui"` instead for CSS that viewers must not receive or that rep
1815
2310
  | Who edits | the local user | the local user; teammates through device sync | owner, team editors, edit-link holders |
1816
2311
  | Live sync | tabs on this machine | tabs, plus other devices through the sync engine | every editor and viewer |
1817
2312
  | 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 |
2313
+ | Presence (`clay.presence`) | tabs of this browser, shown as "This computer" | tabs on this computer, relayed by the app | one person per account and one session per tab, filtered per recipient |
1819
2314
  | `by` on frames, editing marker | no | no | yes |
1820
2315
  | 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 |
2316
+ | Page editor merge state ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)) | kept beside the file: `document.pageState.href` returns the state stored with the exact bytes on disk | kept beside the file | kept beside the file |
1822
2317
  | Wire and ai-edit | yes | yes | ai-edit only, for owners and team editors on a paid plan |
1823
2318
  | Data API write | yes | yes | read only |
1824
2319
  | 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
2320
  | Upload access (`document.upload.access`) | `local` | `local` | `document` once the account's uploaded files are protected; no field before that |
1826
2321
  | 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 |
2322
+ | Max save (`document.maxBytes`) | 50 MB | 20 MB | 5 MB |
2323
+
2324
+ The `/_/meta` answer carries `document.maxBytes`: the maximum uncompressed UTF-8 HTML save body, inclusive. All three hosts send it, and ClayJS measures a save against it before sending, so a document that is over the limit is refused instead of lost ([Size limits](https://clayjs.com/docs/guide/saving/#size-limits)). A missing or unusable value means the limit is unknown, and the host's own refusal is the only answer.
2325
+
2326
+ On hyperclay.com only, the same block carries `document.maxBytesMeasures: "file"`. It means the limit is measured without the `<script merge="page">` tag that holds the document's live state. HTML Clay and Hyperclay Local send no such field.
1828
2327
 
1829
2328
  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
2329
 
@@ -1855,6 +2354,7 @@ Hyperclay Local and HTML Clay each bundle one exact, published ClayJS release, c
1855
2354
  - **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
2355
  - **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
2356
  - **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.
2357
+ - **The first switch to edit.** A document opened offline in view can switch to edit without the network. The switch reloads into an edit boot, and every file that boot loads (the mode controller, the plugins, the Document editor and their late imports) is in the bundled release. Both hosts serve that set from the release with its index digest, the first edit saves the canonical public URLs, and the reopened file is projected again. ClayJS checks it per release: a mode file missing from the release fails the closure check.
1858
2358
  - **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
2359
  - **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
2360
 
@@ -1870,7 +2370,7 @@ ClayJS gives you one file per document that edits and saves itself. Here is what
1870
2370
 
1871
2371
  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
2372
 
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.
2373
+ - **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, toggle headings, lists, checklists, toggles, callouts, code, images, files, typed tables, boards, charts from a table, mentions of people and dates, emoji, a page icon and cover, CSV and Markdown import, and Markdown and self-contained HTML export.
1874
2374
  - **[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
2375
 
1876
2376
  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.
@@ -1879,7 +2379,7 @@ The old limits apply elsewhere. Ordinary `editable` regions and DOM records have
1879
2379
 
1880
2380
  | What you are comparing to | What ClayJS gives | What is missing | What to build instead |
1881
2381
  |---|---|---|---|
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 |
2382
+ | **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 outside the page editor, 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
2383
  | **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
2384
  | **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
2385
  | **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 |
@@ -1891,10 +2391,11 @@ The old limits apply elsewhere. Ordinary `editable` regions and DOM records have
1891
2391
  ### 10.3 Limits that apply to every document
1892
2392
 
1893
2393
  - **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.
2394
+ - **Live cursors only in the page editor.** The page editor draws other people's carets and selections where the host announces `page-awareness-v1`. Elsewhere, `clay.presence` tells a page who is here and which record they are writing in, and the page draws its own badges ([Presence](https://clayjs.com/docs/guide/live-sync/#presence)).
1895
2395
  - **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
2396
  - **Per-file edit access.** There are no per-row permissions. Split data for different audiences into separate files.
1897
2397
  - **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.
2398
+ - **Large record sets cost every keystroke.** Each change runs a Sap pass that visits every record. Sap rereads only changed values, but the pass still grows with the record count, about 27 ms for a body edit at 5,000 records in jsdom. See [Document size and record limits](https://clayjs.com/docs/guide/architecture/#document-size-and-record-limits).
1898
2399
  - **Undo is per tab.** History is capped at 100 steps per tab, is not saved, and never reverts a peer's merged edits.
1899
2400
 
1900
2401
  > [!NOTE]
@@ -1915,12 +2416,14 @@ The old limits apply elsewhere. Ordinary `editable` regions and DOM records have
1915
2416
  - `clay:sorted` logs "Moved to *Column*" in the card when it changes column.
1916
2417
  - Templates: card, checklist item, column, chrome.
1917
2418
 
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`).
2419
+ Extend with labels (a settings element listing `<li data-label="bug" data-color="…">`, cards carrying `data-labels="bug ui"`, and `merge-tokens="data-labels"` on the board so two people adding labels both keep theirs, [Declared attribute merges](https://clayjs.com/docs/guide/live-sync/#declared-attribute-merges)), 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`).
1919
2420
 
1920
2421
  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.
1921
2422
 
1922
2423
  ### 11.2 Block writer
1923
2424
 
2425
+ For a Notion-like document, use the [page editor](https://clayjs.com/docs/plugins/page/) (`?plugins=page`) instead. This recipe is for a block app whose blocks are your own records.
2426
+
1924
2427
  [`examples/writer.html`](https://clayjs.com/examples/writer.html). Plugins `sync,sortable,undo,upload,indicator`, satellite `clay-ui`.
1925
2428
 
1926
2429
  **One `editable` per block**, not one big editor. The document is `#blocks[sortable="blocks"]`, a list of sibling `.block[data-id][data-type]` elements:
@@ -1984,16 +2487,16 @@ A **free document** is a published app anyone can use and keep. The visitor gets
1984
2487
 
1985
2488
  ### 12.1 What a visitor gets
1986
2489
 
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`.
2490
+ 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>@<path>"]`. 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>@<path>:conflict`.
1988
2491
 
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)).
2492
+ 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 never put the owner in draft mode: a document opened from `file:`, where draft mode never applies, and HTML Clay, which stamps a save token on the response. On HTML Clay the owner opens the file in view and edits the original after switching to edit. 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)).
1990
2493
 
1991
2494
  ### 12.2 The contract
1992
2495
 
1993
2496
  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:
1994
2497
 
1995
2498
  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)).
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.
2499
+ 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 drafts. ClayJS keys each draft by the slot and the document's path, so two documents on one origin never share a visitor's draft, even with the same slot.
1997
2500
  3. `plugins=visitor-drafts` on the loader tag, plus whatever else the app needs.
1998
2501
  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
2502
  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)).
@@ -2013,14 +2516,14 @@ Bump the slot (`-v2`) only when an old draft cannot fit the new rules. A draft t
2013
2516
  3. `window.clayEditMode`, set by a script before boot.
2014
2517
  4. A `savetoken` attribute the host put on `<html>`.
2015
2518
  5. **Draft mode**, on a page that opted in ([The contract](https://clayjs.com/docs/guide/free-documents/#the-contract)).
2016
- 6. The `isAdminOfCurrentResource` cookie (hyperclay.com, Hyperclay Local).
2519
+ 6. The `isAdminOfCurrentResource` cookie (hyperclay.com, Hyperclay Local). It grants permission, so `clay.canEdit` is true, and the page still opens in view: the person opens it for editing with `clay.setMode("edit")` ([How the mode is decided](https://clayjs.com/docs/guide/edit-mode/#how-the-mode-is-decided)).
2017
2520
 
2018
2521
  The rung is where a free document lives, so the details matter:
2019
2522
 
2020
2523
  - `?editmode=false` is view mode, and nothing below it changes that.
2021
2524
  - `?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.
2022
2525
  - 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.
2526
+ - `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. `window.clayEditMode = true` starts the load in edit mode against the host, and a `savetoken` grants permission, so the page opens in view and `clay.setMode("edit")` edits the original. The host 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.
2024
2527
  - 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.
2025
2528
  - `<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.
2026
2529
 
@@ -2028,7 +2531,7 @@ The rung is where a free document lives, so the details matter:
2028
2531
 
2029
2532
  `clay.isDraftMode` is true, and everything else follows from it:
2030
2533
 
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.
2534
+ - **The plugins that talk to the host are dropped**, even when the page names them: `sync`, `live`, `page`, `upload`, `quickcrop`, `cms`, `people`, `menu`, `upgrade`, `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.
2032
2535
  - **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.
2033
2536
  - **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.
2034
2537
  - **`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)).
@@ -2038,7 +2541,7 @@ The rung is where a free document lives, so the details matter:
2038
2541
  |---|---|
2039
2542
  | `active` | `true` when the plugin found a slot and the rules tag, `false` otherwise |
2040
2543
  | `slot` | the slot from `<html visitor-drafts="...">` |
2041
- | `key` | the `localStorage` key, `clay:visitor-drafts:<slot>` |
2544
+ | `key` | the `localStorage` key, `clay:visitor-drafts:<slot>@<path>`, where `<path>` is the document's path up to its `.html` or `.htmlclay` file (`/` for the root and for a host that serves one document at every path) |
2042
2545
  | `hasDraft()` | is there a stored draft for this slot |
2043
2546
  | `restored()` | `{ restored, hadDraft, parked }` for this load, or `null` when the plugin is not active |
2044
2547
  | `saveNow()` | write the page to the slot now; `true` when it landed, `false` when it did not |
@@ -2057,16 +2560,24 @@ The restore runs while ClayJS boots, before `clay.ready` resolves, and applies t
2057
2560
 
2058
2561
  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 }`.
2059
2562
 
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.
2563
+ 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>@<path>: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.
2564
+
2565
+ **Drafts saved by an older ClayJS.** A draft stored under the slot alone is copied into the document's key on its first load, with its intro and prompt memory, and the old key is left as it is. Code outside ClayJS that reads a draft should read `clay.visitorDrafts.key`; a script that must run before `clay.js` uses the same path rule.
2566
+
2567
+ ### 12.6 Two tabs
2061
2568
 
2062
- ### 12.6 The download prompt and the intro area
2569
+ Each save merges what the other tab saved. Fields merge one by one, and lists of rows with ids merge row by row: a row added or removed in one tab is kept or removed, and rows changed in different tabs both keep their change. When both tabs changed the same field, reordered a list, or moved one row to different places, this tab's value is saved and the other tab's whole record is kept under `<key>:conflict`.
2570
+
2571
+ ### 12.7 The download prompt and the intro area
2063
2572
 
2064
2573
  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.
2065
2574
 
2066
- Both remember themselves per slot in `localStorage["clay:visitor-drafts:<slot>:ui"]`, as `{ "v": 1, "prompt": { "shown": ..., "dismissed": ..., "uses": ..., "downloaded": ... }, "intro": { "done": null | "closed" | "scrolled" }, "src": ... }`. Clearing that key shows the intro and the prompt again.
2575
+ Both remember themselves per slot and path in `localStorage["clay:visitor-drafts:<slot>@<path>:ui"]`, as `{ "v": 1, "prompt": { "shown": ..., "dismissed": ..., "uses": ..., "downloaded": ... }, "intro": { "done": null | "closed" | "scrolled" }, "src": ... }`. Clearing that key shows the intro and the prompt again.
2067
2576
 
2068
2577
  **The intro** is authored per app as a `<template visitor-intro>`. The template holds the author's whole strip: its markup and any `<style>` it needs. The plugin clones it into a section at the very top of the page body, before the app's own content, and adds a quiet close (`x`) in the corner. The intro leaves for good when the visitor closes it or scrolls it past the top of the viewport; the ui memory records `"closed"` or `"scrolled"` as the reason.
2069
2578
 
2579
+ A `<script type="module">` inside the template runs when the intro mounts. Animate it with `clay.assets.load('uimotion')`, never an engine copied into the file, so a visitor the intro skips loads no motion code.
2580
+
2070
2581
  ```html
2071
2582
  <template visitor-intro>
2072
2583
  <p>One paragraph on what the app does and who it is for.</p>
@@ -2087,13 +2598,34 @@ An element inside the template with `[visitor-intro-start]` scrolls to the app,
2087
2598
 
2088
2599
  **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()`.
2089
2600
 
2090
- ### 12.7 Migrating an app's own storage
2601
+ ### 12.8 Migrating an app's own storage
2091
2602
 
2092
2603
  **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.
2093
2604
 
2094
- ### 12.8 A complete example
2605
+ **Change a key only after every reader reads both.** A reader that spells the key by hand keeps reading the old one, and an old key stops changing the moment ClayJS writes the new one. The download page and the calculator's row reset both spell the draft key themselves, so both learned to read the new key and the old one before the key changed:
2606
+
2607
+ ```js
2608
+ function readDrafts(win, slot, path) {
2609
+ if (!slot) return null;
2610
+ try {
2611
+ var legacy = 'clay:visitor-drafts:' + slot;
2612
+ var key = legacy + '@' + draftPath(path);
2613
+ var raw = win.localStorage.getItem(key);
2614
+ if (raw == null && win.localStorage.getItem(key + ':legacy') == null) raw = win.localStorage.getItem(legacy);
2615
+ var stored = raw ? JSON.parse(raw) : null;
2616
+ var data = stored && stored.data;
2617
+ return data && typeof data === 'object' && !Array.isArray(data) && Object.keys(data).length ? data : null;
2618
+ } catch (e) {
2619
+ return null;
2620
+ }
2621
+ }
2622
+ ```
2623
+
2624
+ The `:legacy` marker is what stops a reset from coming back: once ClayJS has copied the old draft, a reader that still saw the old key would restore work the visitor asked to clear. A reader that can run before `clay.js` uses the same path rule, so it finds the same key ClayJS writes.
2625
+
2626
+ ### 12.9 A complete example
2095
2627
 
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.
2628
+ [`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, with its `viewmode:` attributes. 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.
2097
2629
 
2098
2630
  ```html
2099
2631
  <!DOCTYPE html>
@@ -2183,26 +2715,41 @@ An element inside the template with `[visitor-intro-start]` scrolls to the app,
2183
2715
  1. Loads with `clay.js` as a classic script at the end of `<body>`, every tag on the same version prefix.
2184
2716
  2. `<html autosave>` unless it is a deliberate manual-save writer.
2185
2717
  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
2718
+ 4. The owner has a way into edit: the `menu` plugin, or a control of your own that calls `clay.setMode()` and carries `clay-mode-toggle`. Without either, ClayJS shows a one-time note pointing to Cmd+Shift+E (Ctrl+Shift+E elsewhere).
2719
+ 5. 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"`.
2720
+ 6. Records are authored in the saved form (`inert-contenteditable`, `disabled`, `readonly`); templates in the active form.
2721
+ 7. Every repeated record has a unique `data-id`; templates carry none; every create and duplicate mints a fresh one.
2722
+ 8. Every control whose value matters has `persist`; no filter or search box does.
2723
+ 9. All chrome is cloned from a `<template>` into `clay="editor-ui"` after `clay.ready`; nothing in the file is chrome.
2724
+ 10. Tab-local state is in the hash, `clay.storage.local` or `clay.storage.session`, or memory, painted through a runtime style; opening, filtering and searching leave `clay.hasUnsavedChanges()` false.
2725
+ 11. 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.
2726
+ 12. Commands are wrapped in `clay.undo.commit`; derived views are drawn in `clay.effect`, with no effect warnings in the console.
2727
+ 13. Views whose record a peer deleted are closed, in the effect or a `clay:sync-applied` handler.
2728
+ 14. The app script starts with `clay.start`; all listeners are delegated.
2729
+ 15. Nothing writes the record at boot.
2730
+ 16. Edit-only calls are guarded for view mode, and edit actions check `clay.isEditMode`.
2731
+ 17. `clay.confirm` calls have a rejection handler.
2732
+ 18. Save failures are visible (indicator, `savestatus` CSS or clay-ui).
2733
+ 19. Library residue (Sortable, third-party editors) is stripped in a snapshot hook.
2734
+ 20. Images use `clay.upload`, embedding only on `unsupported`.
2735
+ 21. The file opens in view mode with JavaScript disabled and shows correct content and no edit controls.
2736
+ 22. 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.
2737
+ 23. A tool page whose script opens an editor at load (a journal, a notes pad) declares `<html opens-in="edit">`. Without it the page opens in view, accepts typing and saves none of it.
2738
+
2739
+ ### 13.2 Testing a document that changes modes
2740
+
2741
+ Each check names the clayjs test that proves the library side.
2742
+
2743
+ - **It opens in view by default.** `tests/unit/mode-decision.test.js` pins the table, including an owner cookie alone opening in view with permission kept.
2744
+ - **An explicit edit enters edit.** The same file covers `?editmode=true`, `window.clayEditMode = true` and `<html opens-in="edit">`, and `tests/unit/mode-transition.test.js` covers an explicit initial edit preparing the session a live first entry does.
2745
+ - **A draft document stays a draft.** `tests/unit/mode-draft.test.js` shows a declared draft refusing both directions.
2746
+ - **A refused exit can be retried.** `tests/unit/mode-lifecycle.test.js` covers a quiesce that refuses and resumes the same leases, and `tests/unit/mode-transition.test.js` covers the next entry starting a fresh session.
2747
+ - **Pending work refuses the exit.** `tests/unit/mode-transition.test.js` covers a registered pending source, and `tests/unit/mode-save-barrier.test.js` covers the barrier's codes.
2748
+ - **A clean enter and exit writes nothing.** `tests/unit/mode-transition.test.js` asserts it, and the corpus case `enter-leave-zero-writes` records the same run.
2749
+
2750
+ `tests/mode-counterexamples/` is a set of recorded cases. Each one boots a fixture in jsdom through the real loader, applies a schedule with the transport, the chunk imports and the clock supplied by the harness, and judges the result from what the run left behind. It is not a browser run: no phone, no keyboard and no real network appears in it.
2751
+
2752
+ ### 13.3 Trap index
2206
2753
 
2207
2754
  | Trap | Section |
2208
2755
  |---|---|
@@ -2213,6 +2760,10 @@ An element inside the template with `[visitor-intro-start]` scrolls to the app,
2213
2760
  | 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
2761
  | 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
2762
  | 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) |
2763
+ | A page whose script opens an editor at load accepts typing in view mode and saves nothing | [How the mode is decided](https://clayjs.com/docs/guide/edit-mode/#how-the-mode-is-decided) |
2764
+ | Reading `clay.mode` once at boot and keeping it | [How the mode is decided](https://clayjs.com/docs/guide/edit-mode/#how-the-mode-is-decided) |
2765
+ | Using `clay.canEdit` as the guard for writing; use `clay.modeLifecycle.canWrite()` | [Switching without a reload: clay-mode-switch="live"](https://clayjs.com/docs/guide/edit-mode/#switching-without-a-reload-clay-mode-switch-live) |
2766
+ | Switching modes by writing the root `editmode` attribute | [Switching modes: clay.setMode](https://clayjs.com/docs/guide/edit-mode/#switching-modes-clay-setmode) |
2216
2767
  | `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
2768
  | `no-save` chrome still reaches peers; use `editor-ui` | [Regions: the `clay` attribute](https://clayjs.com/docs/guide/record/#regions-the-clay-attribute) |
2218
2769
  | 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) |
@@ -2243,12 +2794,13 @@ An element inside the template with `[visitor-intro-start]` scrolls to the app,
2243
2794
  | 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
2795
  | 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
2796
  | 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) |
2797
+ | Two tabs changing one shared `data-*` attribute (labels, a counter) conflict unless a container declares `merge-tokens` or `merge-max` | [Declared attribute merges](https://clayjs.com/docs/guide/live-sync/#declared-attribute-merges) |
2246
2798
  | 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
2799
  | Parsed JSON goes stale after a merge | [Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code) |
2248
2800
  | 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
2801
  | 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
2802
  | 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) |
2803
+ | Presence is runtime only: the host filters the roster, and a caret it cannot place is not drawn | [Presence](https://clayjs.com/docs/guide/live-sync/#presence) |
2252
2804
  | `data-richclay` card mode deletes images on load | [richclay: rich text (`editable`)](https://clayjs.com/docs/guide/components/#richclay-rich-text-editable) |
2253
2805
  | The richclay toolbar is unusable inside `showModal()` and popovers | [richclay: rich text (`editable`)](https://clayjs.com/docs/guide/components/#richclay-rich-text-editable) |
2254
2806
  | 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) |
@@ -2258,8 +2810,8 @@ An element inside the template with `[visitor-intro-start]` scrolls to the app,
2258
2810
  | Sortable handle mode is fixed at bind time | [sortable: drag to reorder](https://clayjs.com/docs/guide/components/#sortable-drag-to-reorder) |
2259
2811
  | `onsorting` cannot veto a move | [sortable: drag to reorder](https://clayjs.com/docs/guide/components/#sortable-drag-to-reorder) |
2260
2812
  | 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) |
2813
+ | A plain input's typed value is not content until `persist` or a command writes it, so page undo has nothing to replay for it | [undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo) |
2814
+ | A refused undo or redo stays on top of the stack until it is replayed or discarded | [undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo) |
2263
2815
  | Pausing undo across an `await` loses the earlier mutations | [undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo) |
2264
2816
  | 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
2817
  | `clay.upload` is not a picker; embed only on `unsupported` | [upload and quickcrop](https://clayjs.com/docs/guide/components/#upload-and-quickcrop) |