@panphora/clayjs 1.21.0 → 1.23.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 (192) 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 +8871 -8629
  11. package/dist/m/attrs/{save-freeze-MTFVVYV5.js → save-freeze-JZHKRQLR.js} +1 -1
  12. package/dist/m/{c-OABVRYOT.js → c-24PMUJIV.js} +1 -1
  13. package/dist/m/{c-EOCZ2YVL.js → c-2AYVYEI4.js} +1 -1
  14. package/dist/m/{c-UXEVJ63W.js → c-2L6O5PDS.js} +1 -1
  15. package/dist/m/{c-VBLFAQD2.js → c-2RYOGUQM.js} +1 -1
  16. package/dist/m/{c-LDUNOR57.js → c-2Z22FCFK.js} +13 -25
  17. package/dist/m/{c-NLZ5AN4L.js → c-2ZVOEU4M.js} +1 -1
  18. package/dist/m/{c-BJPZCWAV.js → c-37NAONZA.js} +1 -1
  19. package/dist/m/{c-ZE46QDIH.js → c-3TLG3OLG.js} +1 -1
  20. package/dist/m/{c-CSNE5EIF.js → c-4PO3YGDC.js} +1 -1
  21. package/dist/m/{c-OJYM642Q.js → c-5FKRZNJV.js} +1 -1
  22. package/dist/m/{c-SO63UGPQ.js → c-5OJ7YF2N.js} +1 -1
  23. package/dist/m/{c-CAWNFER6.js → c-5TIZ2I64.js} +1 -1
  24. package/dist/m/{c-7SXYQCN4.js → c-5WMXEKFH.js} +1 -1
  25. package/dist/m/{c-XQSXKIKM.js → c-6RDOKLN7.js} +1 -1
  26. package/dist/m/{c-BRLCTNMC.js → c-6TYGD5AB.js} +1 -1
  27. package/dist/m/c-A4IP6MBC.js +1 -0
  28. package/dist/m/{c-I2IFKJX2.js → c-ALDVFPWL.js} +1 -1
  29. package/dist/m/{c-LV5MABYD.js → c-AYVGD6A6.js} +1 -1
  30. package/dist/m/{c-LQU7TXIH.js → c-B6O7AJYG.js} +1 -1
  31. package/dist/m/{c-SBSMJIPC.js → c-BCGWJYXW.js} +1 -1
  32. package/dist/m/{c-RT3C74GY.js → c-BGKAT2FL.js} +1 -1
  33. package/dist/m/{c-N7Y52HJH.js → c-BXYSDKAN.js} +1 -1
  34. package/dist/m/c-CRPIDGXC.js +78 -0
  35. package/dist/m/c-CUMXGHVD.js +1 -0
  36. package/dist/m/{c-67ILSNPR.js → c-DTMZ3ZHI.js} +1 -1
  37. package/dist/m/{c-EZ7B5AZT.js → c-ELYIIYRS.js} +1 -1
  38. package/dist/m/{c-VAK7SG37.js → c-EUGJVHDD.js} +1 -1
  39. package/dist/m/{c-67LGZ52V.js → c-FCSZ3DC5.js} +1 -1
  40. package/dist/m/{c-JUE7B4OX.js → c-FQGHR32O.js} +1 -1
  41. package/dist/m/{c-ZJLU56DV.js → c-G4PWUDD5.js} +1 -1
  42. package/dist/m/{c-CJFDLD43.js → c-G6COVIZB.js} +1 -1
  43. package/dist/m/c-GBTVCJXC.js +1 -0
  44. package/dist/m/c-GC2HICFJ.js +1 -0
  45. package/dist/m/{c-44B5ODN7.js → c-HCNWJE4A.js} +2 -2
  46. package/dist/m/{c-BJ7YQ5QZ.js → c-IHUWUKIU.js} +1 -1
  47. package/dist/m/{c-LIBE4HPD.js → c-JCOQVZYS.js} +1 -1
  48. package/dist/m/{c-RG6XUZFA.js → c-JGHEACGK.js} +1 -1
  49. package/dist/m/{c-3GJ4XARY.js → c-JGLX6TBK.js} +1 -1
  50. package/dist/m/c-JNRKHYWO.js +1 -0
  51. package/dist/m/{c-PIVIYLTY.js → c-KDXIJEEF.js} +1 -1
  52. package/dist/m/{c-NFR673TL.js → c-L4KMT7FO.js} +2 -2
  53. package/dist/m/{c-E4IBSAQM.js → c-LCYPMULG.js} +1 -1
  54. package/dist/m/{c-SJPD46ZO.js → c-LIGQYZ7M.js} +4 -4
  55. package/dist/m/{c-U5X57T4L.js → c-LKIWEPAV.js} +1 -1
  56. package/dist/m/{c-EDSPKYMA.js → c-LUXAB2XI.js} +1 -1
  57. package/dist/m/{c-YSB6JKV2.js → c-LWIDDWAR.js} +2 -2
  58. package/dist/m/c-MPSKIU2I.js +1 -0
  59. package/dist/m/{c-DHXPDLW6.js → c-NK33MMYF.js} +1 -1
  60. package/dist/m/{c-GPGYKER7.js → c-NQLWOHLH.js} +1 -1
  61. package/dist/m/{c-LPUJXRXW.js → c-NTHUTABN.js} +1 -1
  62. package/dist/m/{c-Y33LUUIQ.js → c-NW54NHKE.js} +1 -1
  63. package/dist/m/{c-3AXJDL2P.js → c-OG6GFSUX.js} +1 -1
  64. package/dist/m/{c-MTQHWQC3.js → c-OKEGUICD.js} +1 -1
  65. package/dist/m/c-OPONJ2UK.js +128 -0
  66. package/dist/m/{c-3YH6JSIL.js → c-OWQWOGYI.js} +1 -1
  67. package/dist/m/{c-FDAXZ2PM.js → c-P6Q2ZTFQ.js} +1 -1
  68. package/dist/m/c-PKCCHJ5X.js +1 -0
  69. package/dist/m/c-PWJSJEJU.js +1 -0
  70. package/dist/m/{c-2ZZBC45O.js → c-PXLTUYZC.js} +1 -1
  71. package/dist/m/c-QEN6HZCA.js +1 -0
  72. package/dist/m/{c-JOH753EJ.js → c-QGAPG7OM.js} +1 -1
  73. package/dist/m/{c-W23KVGUO.js → c-QIR5CAKM.js} +1 -1
  74. package/dist/m/{c-GKEATME6.js → c-QM5LXRDT.js} +1 -1
  75. package/dist/m/c-QUGCKMWF.js +1 -0
  76. package/dist/m/{c-FNVQAACK.js → c-QYYUVLVG.js} +1 -1
  77. package/dist/m/c-RO73MKKR.js +1 -0
  78. package/dist/m/c-SBCBXKOZ.js +26 -0
  79. package/dist/m/{c-J3BXFTZS.js → c-SGRIYOAJ.js} +1 -1
  80. package/dist/m/c-SKF5HL4I.js +4 -0
  81. package/dist/m/{c-W3K23S72.js → c-SRPC3VBE.js} +1 -1
  82. package/dist/m/{c-K76AUL4I.js → c-T5NK2TVY.js} +1 -1
  83. package/dist/m/{c-EFD6QNTE.js → c-TANBH7FI.js} +1 -1
  84. package/dist/m/{c-5734MF2P.js → c-THGQQDYY.js} +1 -1
  85. package/dist/m/{c-YQUJ74R3.js → c-TSBVMWAH.js} +1 -1
  86. package/dist/m/{c-VY6XRBOW.js → c-UD5JZTDL.js} +1 -1
  87. package/dist/m/{c-M4FSPNQ2.js → c-UDGEFWNU.js} +1 -1
  88. package/dist/m/{c-ZSCBEB7I.js → c-UHUNXWKN.js} +1 -1
  89. package/dist/m/{c-BTLFLLML.js → c-VIRFF5HG.js} +1 -1
  90. package/dist/m/{c-2VB555FG.js → c-VYJ7O4RI.js} +1 -1
  91. package/dist/m/{c-TUGVINJR.js → c-W5E5P2TZ.js} +1 -1
  92. package/dist/m/{c-EOCKLMKX.js → c-W7IPVCAA.js} +1 -1
  93. package/dist/m/{c-5FEU5MMY.js → c-WJ5DRBHT.js} +1 -1
  94. package/dist/m/{c-V2J5C5MT.js → c-WQZTI5P2.js} +1 -1
  95. package/dist/m/c-X5WC4PX5.js +1 -0
  96. package/dist/m/{c-GVPNDIKJ.js → c-XCFQEWW6.js} +2 -2
  97. package/dist/m/c-XLQ3BDMM.js +1 -0
  98. package/dist/m/{c-XD5MOROJ.js → c-XX75FFNL.js} +1 -1
  99. package/dist/m/{c-JHJR5BQX.js → c-YK2L436K.js} +1 -1
  100. package/dist/m/{c-MEWUGR4D.js → c-YYMZWQLR.js} +1 -1
  101. package/dist/m/core/{admin-attrs-ZG7R5V3E.js → admin-attrs-U7IJNYBG.js} +1 -1
  102. package/dist/m/core/{autosave-VPORYJSE.js → autosave-F5YQCKVL.js} +1 -1
  103. package/dist/m/core/{conflict-notice-JMAZLVRX.js → conflict-notice-42H4ASLA.js} +1 -1
  104. package/dist/m/core/{persist-3OPWJKZS.js → persist-CJXIL7D4.js} +1 -1
  105. package/dist/m/core/{save-AOJBDSOK.js → save-ESPSQPLV.js} +1 -1
  106. package/dist/m/core/{save-core-IOCMXO5L.js → save-core-6SQHOR6W.js} +1 -1
  107. package/dist/m/core/snapshot-FJP5IJL3.js +1 -0
  108. package/dist/m/core/{unsaved-warning-U5VUJFOE.js → unsaved-warning-HNQVTA2D.js} +1 -1
  109. package/dist/m/internals/{index-HDRANDWE.js → index-NRPNDTSA.js} +1 -1
  110. package/dist/m/loader-FBDE2UAB.js +1 -0
  111. package/dist/m/manifest-1D2F5CA5.js +1 -0
  112. package/dist/m/manifest-edit-F7CC5CFD.js +1 -0
  113. package/dist/m/manifest.json +1139 -1069
  114. package/dist/m/page/{editor-EY2WAAGB.js → editor-OSKOW6RS.js} +2 -2
  115. package/dist/m/plugins/ai-edit-VV4QTINH.js +6 -0
  116. package/dist/m/plugins/cms-H7DTMUQX.js +2235 -0
  117. package/dist/m/plugins/{indicator-4VDXWYTD.js → indicator-WVPGMOZ7.js} +1 -1
  118. package/dist/m/plugins/{live-5EZN2EYP.js → live-DCUBKXY4.js} +1 -1
  119. package/dist/m/plugins/menu-Q4MFFQ6E.js +1 -0
  120. package/dist/m/plugins/{page-LVW5DWA4.js → page-OAWUJQEL.js} +2 -2
  121. package/dist/m/plugins/{page-view-5KQ76IJB.js → page-view-DZ26ZDDQ.js} +1 -1
  122. package/dist/m/plugins/{people-C7TVBBMR.js → people-5XRNRP5U.js} +1 -1
  123. package/dist/m/plugins/{quickcrop-BQ5YYFCH.js → quickcrop-WWF5I422.js} +2 -2
  124. package/dist/m/plugins/{richclay-KLGETK77.js → richclay-PGOOJ4OK.js} +2 -2
  125. package/dist/m/plugins/{sortable-WJZZBM7R.js → sortable-266HY6TR.js} +1 -1
  126. package/dist/m/plugins/{source-CCEUM3OR.js → source-6BKMK4PO.js} +1 -1
  127. package/dist/m/plugins/{upgrade-NW556WJD.js → upgrade-BNZVFHMZ.js} +1 -1
  128. package/dist/m/plugins/visitor-drafts-2AUYKAIJ.js +20 -0
  129. package/dist/m/plugins/{visitor-drafts-owner-MZP45XSG.js → visitor-drafts-owner-Z5B2OQBR.js} +1 -1
  130. package/dist/m/plugins/visitor-menu-4LQMKLPP.js +1 -0
  131. package/dist/m/plugins/{wire-VRYPY6EJ.js → wire-W2EV36KZ.js} +1 -1
  132. package/dist/m/{reader-core-2GXY7SHY.js → reader-core-POFQCKRV.js} +2 -2
  133. package/dist/m/sync/{live-sync-SKPMFYOO.js → live-sync-XGUPHI3I.js} +1 -1
  134. package/dist/m/ui/{index-QNIPDHXQ.js → index-VGYAKAKI.js} +1 -1
  135. package/dist/offline-manifest.json +666 -606
  136. package/docs/guide.md +38 -27
  137. package/package.json +2 -2
  138. package/src/core/save-replace.js +5 -2
  139. package/src/core/serialize-steps.js +10 -2
  140. package/src/core/snapshot.js +37 -1
  141. package/src/core/upgrade-run.js +16 -2
  142. package/src/loader-logic.js +10 -3
  143. package/src/plugins/ai-edit.js +6 -17
  144. package/src/plugins/cms.js +3 -6
  145. package/src/plugins/menu/dom-items.js +45 -0
  146. package/src/plugins/menu/looks.js +32 -0
  147. package/src/plugins/menu/shell-style.js +143 -0
  148. package/src/plugins/menu/shell.js +398 -0
  149. package/src/plugins/menu/style.js +15 -116
  150. package/src/plugins/menu/variants.js +56 -10
  151. package/src/plugins/menu/visitor.js +78 -0
  152. package/src/plugins/menu.js +21 -313
  153. package/src/plugins/visitor-drafts/inert.js +1 -0
  154. package/src/plugins/visitor-drafts/intro-style.js +6 -0
  155. package/src/plugins/visitor-drafts/intro.js +77 -25
  156. package/src/plugins/visitor-drafts/owner-chip.js +18 -23
  157. package/src/plugins/visitor-drafts/prompt.js +2 -2
  158. package/src/plugins/visitor-drafts/store.js +58 -5
  159. package/src/plugins/visitor-drafts.js +1 -0
  160. package/src/plugins/visitor-menu.js +13 -0
  161. package/src/sync/live-sync.js +2 -1
  162. package/src/sync/page-relay.js +2 -2
  163. package/src/ui/bevel.js +1 -1
  164. package/src/ui/page-colour.js +17 -9
  165. package/src/ui/skins/cms.js +1 -1
  166. package/src/ui/skins/quickcrop.js +1 -1
  167. package/src/ui/skins/richclay.js +1 -1
  168. package/src/vendor/hypercms.vendor.js +16 -69
  169. package/dist/m/c-AL4RBJF2.js +0 -1
  170. package/dist/m/c-DDDOKPI6.js +0 -3
  171. package/dist/m/c-EGTIAL6P.js +0 -1
  172. package/dist/m/c-FSWRGBVQ.js +0 -1
  173. package/dist/m/c-HDWZEJHM.js +0 -183
  174. package/dist/m/c-HMYWIHFG.js +0 -1
  175. package/dist/m/c-JH5FF4NL.js +0 -20
  176. package/dist/m/c-M6FNQCTQ.js +0 -1
  177. package/dist/m/c-MHI3GYKF.js +0 -1
  178. package/dist/m/c-MIL4B2JQ.js +0 -1
  179. package/dist/m/c-S5IEIZCN.js +0 -1
  180. package/dist/m/c-VSPAFOKU.js +0 -1
  181. package/dist/m/c-WFMRX5RZ.js +0 -1
  182. package/dist/m/core/snapshot-O7II2AGG.js +0 -1
  183. package/dist/m/loader-ZVPK4ZN3.js +0 -1
  184. package/dist/m/manifest-5498AFD3.js +0 -1
  185. package/dist/m/manifest-edit-62A5A00F.js +0 -1
  186. package/dist/m/plugins/ai-edit-J5VLSW5I.js +0 -6
  187. package/dist/m/plugins/cms-LRAOZHT7.js +0 -2293
  188. package/dist/m/plugins/menu-DM2JHPSR.js +0 -1
  189. package/dist/m/plugins/visitor-drafts-2AWNT23K.js +0 -20
  190. package/src/ui/skins/cms-toggle.js +0 -15
  191. /package/dist/m/plugins/{quickcrop-BQ5YYFCH.js.LEGAL.txt → quickcrop-WWF5I422.js.LEGAL.txt} +0 -0
  192. /package/dist/m/plugins/{richclay-KLGETK77.js.LEGAL.txt → richclay-PGOOJ4OK.js.LEGAL.txt} +0 -0
package/docs/guide.md CHANGED
@@ -315,7 +315,7 @@ Plugins add editing, sync, and UI features to `clay.js`. Most are off by default
315
315
  | `sync` | off | yes | live sync, `clay.conflicts`, `clay.morph`, presence and the editing marker |
316
316
  | `live` | off | yes | `clay.live(name)`: a `clay="live"` JSON tag as a live object |
317
317
  | `sortable` | off | no | the `sortable` attribute, `window.Sortable`, `clay:sorted` |
318
- | `undo` | off | no | `clay.undo` (whole-page undo, takes Cmd+Z for the window) |
318
+ | `undo` | off | no | `clay.undo` (whole-page undo over DOM mutations; leaves Cmd+Z to text fields) |
319
319
  | `indicator` | off | no | the save status chip, drawn by a module fetched at the first input or save state |
320
320
  | `upload` | off | no | `clay.upload` |
321
321
  | `quickcrop` | off | yes | `clay.quickcrop` |
@@ -326,6 +326,7 @@ Plugins add editing, sync, and UI features to `clay.js`. Most are off by default
326
326
  | `ai-edit` | off | no | select text, ask an agent to rewrite it; implies `wire` |
327
327
  | `demo` | off | yes | fakes saving into `localStorage` for public demos |
328
328
  | `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/)) |
329
+ | `visitor-menu` | off | no (draft mode only) | `clay.menu` and `clay.variants` in draft mode: **Download a copy**, [DOM menu items](https://clayjs.com/docs/plugins/menu/#menu-items-from-the-page) and [looks](https://clayjs.com/docs/plugins/menu/#looks), with no owner or host actions ([The visitor menu](https://clayjs.com/docs/plugins/menu/#the-visitor-menu)) |
329
330
  | `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)) |
330
331
  | `page` | off | reader half only (`page-view`) | the page editor, `clay.page`; implies `live`, `sync` and `upload`, and drops `richclay` unless named |
331
332
  | `unsent` | off | no | `unsent="<name>"`: unsent text in chrome survives a redraw and a reload, `clay.unsent` |
@@ -337,15 +338,20 @@ Plugins add editing, sync, and UI features to `clay.js`. Most are off by default
337
338
  - 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)).
338
339
  - `exclude` wins over implications. `exclude=wire` also drops `ai-edit`.
339
340
  - Load order is fixed regardless of how you list them.
340
- - 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.
341
- - Nothing implies `undo`, on purpose: name it when the page should have whole-page undo. It leaves Cmd+Z to text fields ([undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo)).
341
+ - Use `exclude=richclay` when the document 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.
342
+ - Nothing implies `undo`, on purpose: name it only when the document has discrete DOM commands that should undo as whole-page steps. It leaves Cmd+Z to text fields ([undo: whole-page undo](https://clayjs.com/docs/guide/components/#undo-whole-page-undo)).
342
343
  - In view mode, `page` loads only its reader half and nothing it implies. A Document on `plugins=page` resolves to `page-view`, with no live data service, no generic `sync`, and no `upload`. Name `sync` as well (`plugins=page,sync`) when the reader should take its saved files from sync's lane instead of its own.
343
344
  - `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.
344
345
  - `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.
346
+ - `visitor-menu` loads only in draft mode (`draftOnly` and `editOnly`). A reader in view mode and an owner editing the original skip it and download zero bytes ([The visitor menu](https://clayjs.com/docs/plugins/menu/#the-visitor-menu)).
345
347
 
346
348
  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).
347
349
 
348
- **A good default:** `?plugins=sync,undo,indicator,menu&exclude=richclay`. It gives a page of plain text live sync, whole-page undo, a save status chip and an Edit switch. Then add by task: `sortable` for drag-to-reorder lists, and `upload` for images or attachments. For formatted text, drop `exclude=richclay` and use `editable`. A document opens in view even for its owner, which is why `menu` is in the default. Declare `<html opens-in="edit">` on a tool page that must edit as soon as it opens.
350
+ **Defaults by document kind:**
351
+
352
+ - **Owned document:** `?plugins=sync,indicator,menu&exclude=richclay`. It gives a plain-text document live sync, a save status chip, and an Edit switch (`source` is on by default in edit mode, and `derive` turns on when `[sap]` is present). A document opens in view even for its owner, which is why `menu` is in the default; declare `<html opens-in="edit">` on a tool document that must edit as soon as it opens.
353
+ - **Free document:** `?plugins=visitor-drafts,visitor-menu,menu&exclude=richclay`. In draft mode, ClayJS drops `menu`, `sync`, `upload`, `people`, `source`, and the other host plugins while `visitor-drafts` and `visitor-menu` run; when the owner edits the original, `visitor-menu` is skipped and `menu` and `source` run; for a view-mode reader, neither menu renders.
354
+ - **Add by task on either kind:** `sortable` for `[sortable]` lists, `upload` for attachments, `people` for collaborator names, `bind-text` for a bound `<textarea>`, `unsent` for `[unsent]` inputs, `indicator` when a free document has no `savestatus` UI of its own, `sync` when the owner of a free document co-edits across tabs, and `undo` only when the document runs discrete DOM commands (adding, removing, or moving records) that should undo as whole-page steps. Leave `undo` off when editing happens inside text inputs, textareas, or `contenteditable`, which already use the browser's native undo; otherwise `undo` also groups idle DOM mutations into page steps on Cmd+Z outside the field. For formatted rich text, drop `exclude=richclay` and use `editable`.
349
355
 
350
356
  ### 2.3 Satellites
351
357
 
@@ -1979,7 +1985,7 @@ Rules for sapjs in a malleable file:
1979
1985
 
1980
1986
  ### 7.12 cms: content-managed pages
1981
1987
 
1982
- `?plugins=cms` turns a page into a content-managed page. A JSON rules tag maps field names to selectors, and an "Edit content" button opens a sidebar form (or edits text in place) that writes straight into the DOM.
1988
+ `?plugins=cms` turns a page into a content-managed page. It brings ClayJS's menu plugin automatically; the menu is a small dock in a corner of the page, shown only to someone who can edit. A JSON rules tag maps field names to selectors, and **Open the CMS (Edit content)** in the menu opens a sidebar form (or edits text in place) that writes straight into the DOM.
1983
1989
 
1984
1990
  ```html
1985
1991
  <script data-rules-name="cms" data-rules-version="1" type="application/json">
@@ -1989,7 +1995,7 @@ Rules for sapjs in a malleable file:
1989
1995
 
1990
1996
  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.
1991
1997
 
1992
- 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.
1998
+ 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. **Open the CMS (Edit content)** in the menu 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.
1993
1999
 
1994
2000
  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.
1995
2001
 
@@ -2518,17 +2524,17 @@ The owner is a visitor here too. Signed in, they get the same private draft unle
2518
2524
 
2519
2525
  ### 12.2 The contract
2520
2526
 
2521
- 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:
2527
+ A document 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 the data contract, and its test proves the round trip:
2522
2528
 
2523
2529
  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)).
2524
- 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.
2525
- 3. `plugins=visitor-drafts` on the loader tag, plus whatever else the app needs.
2530
+ 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 document'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.
2531
+ 3. `plugins=visitor-drafts,visitor-menu` on the loader tag (plus `menu` when the owner also edits the hosted or local file, `exclude=richclay` when there is no rich text, and whatever else the document needs; see [The visitor menu](https://clayjs.com/docs/plugins/menu/#the-visitor-menu)).
2526
2532
  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.
2527
2533
  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)).
2528
- 6. `<meta name="application-name" content="...">`. The prompt, the intro and the download name the app from it, falling back to the title before the first `:` or `|`.
2529
- 7. A feedback link the visitor can use: a `mailto:` address in the page, not in the plugin.
2530
- 8. Optional: a `<template visitor-intro>` ([The download prompt and the intro area](https://clayjs.com/docs/guide/free-documents/#the-download-prompt-and-the-intro-area)). No template, no intro; the prompt still shows.
2531
- 9. A round trip test in your own repo: write a draft, load the page again with that storage, and check that the values and the computed counts come back.
2534
+ 6. `<meta name="application-name" content="...">`. The prompt, the intro and the download name the document from it, falling back to the title before the first `:` or `|`.
2535
+ 7. An appreciation or feedback link in the document, not in the plugin: a `mailto:` `<a clay-menu-item data-id="...">` element with its `cms-template` seed row in a hidden container outside `clay="freeze"`, covered by the `api` rules tag ([Menu items from the page](https://clayjs.com/docs/plugins/menu/#menu-items-from-the-page)).
2536
+ 8. Optional: a `<template visitor-intro>` or `<template visitor-intro="manual">` ([The download prompt and the intro area](https://clayjs.com/docs/guide/free-documents/#the-download-prompt-and-the-intro-area)). No template, no intro; the prompt still shows.
2537
+ 9. A round trip test in your own repo: write a draft, load the document again with that storage, and check that the values and the computed counts come back.
2532
2538
 
2533
2539
  Bump the slot (`-v2`) only when an old draft cannot fit the new rules. A draft that no longer fits is parked, not thrown away ([Restoring and redrawing](https://clayjs.com/docs/guide/free-documents/#restoring-and-redrawing)).
2534
2540
 
@@ -2556,10 +2562,11 @@ The rung is where a free document lives, so the details matter:
2556
2562
 
2557
2563
  `clay.isDraftMode` is true, and everything else follows from it:
2558
2564
 
2559
- - **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.
2565
+ - **The plugins that talk to the host are dropped**, even when the document 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.
2566
+ - **`menu` stays dropped for visitors, and `visitor-menu` runs instead.** `menu` carries the Edit switch, SEO settings, presence, look deletion (`keep()`), and `/_/meta` capability checks, all of which belong to the owner. When the loader tag names `visitor-menu`, draft mode loads `visitor-menu` in its place and mounts `clay.menu` with **Download a copy**, [DOM menu items](https://clayjs.com/docs/plugins/menu/#menu-items-from-the-page), and [looks](https://clayjs.com/docs/plugins/menu/#looks), with no owner code and no host requests ([The visitor menu](https://clayjs.com/docs/plugins/menu/#the-visitor-menu)).
2560
2567
  - **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.
2561
2568
  - **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.
2562
- - **`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)).
2569
+ - **`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 document 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)).
2563
2570
  - **`clay.visitorDrafts`** is the plugin's whole surface:
2564
2571
 
2565
2572
  | Member | What it is |
@@ -2569,12 +2576,13 @@ The rung is where a free document lives, so the details matter:
2569
2576
  | `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) |
2570
2577
  | `hasDraft()` | is there a stored draft for this slot |
2571
2578
  | `restored()` | `{ restored, hadDraft, parked }` for this load, or `null` when the plugin is not active |
2572
- | `saveNow()` | write the page to the slot now; `true` when it landed, `false` when it did not |
2579
+ | `saveNow()` | write the document to the slot now; `true` when it landed, `false` when it did not |
2573
2580
  | `reset()` | delete this slot and reload the page; nothing outside draft mode |
2574
2581
  | `onRestore(fn)` | call `fn(detail)` at once when the restore already ran, otherwise on `clay:drafts-restored` |
2575
2582
  | `editOriginal()` | navigate to this page with `?editmode=true` |
2576
2583
  | `backToDraft()` | navigate back with `editmode` removed |
2577
2584
  | `downloadUrl()` | the download link the prompt is using, with `src` |
2585
+ | `download()` | flush the draft store and trigger the prompt's download |
2578
2586
  | `source` | the `?src=` this slot remembers, or `null` ([The download prompt and the intro area](https://clayjs.com/docs/guide/free-documents/#the-download-prompt-and-the-intro-area)) |
2579
2587
 
2580
2588
  - **Three events** on `document`: `clay:drafts-restored` with `{ restored, hadDraft, parked }`, `clay:drafts-saved` with `{ data }`, and `clay:drafts-store-failed` with `{ error }`.
@@ -2599,13 +2607,16 @@ Both are runtime chrome: `clay="editor-ui"` and `sap-ignore`, built by the plugi
2599
2607
 
2600
2608
  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.
2601
2609
 
2602
- **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.
2610
+ **The intro** is authored per document as `<template visitor-intro>` (the default) or `<template visitor-intro="manual">`. The template holds the author's whole strip: its markup and any `<style>` it needs. The plugin clones it into `<section class="clay-intro">` at the very top of `document.body`, before the document's own content.
2611
+
2612
+ - **Default (`<template visitor-intro>`):** adds a quiet `x` button (`Hide this introduction`) in the corner and dismisses for good when the visitor clicks `x` or scrolls the strip past the top of the viewport. UI memory records `"closed"` or `"scrolled"`, and no menu item is added.
2613
+ - **Manual (`<template visitor-intro="manual">`):** adds a **Hide intro** button in the corner instead of `x`, and never collapses or dismisses on scroll. Clicking **Hide intro** (or `[visitor-intro-dismiss]`) records `"closed"` in UI memory, removes the section, focuses the document, and adds **Show intro** (`id: "clay.intro"`) to `clay.menu` (waiting for `clay:menu-ready` when the menu mounts later, and also adding **Show intro** on loads where the intro starts hidden because a draft already exists or `done` is `"closed"`). Choosing **Show intro** clears `intro.done`, prepends the same `<section>` back onto `document.body` without re-cloning the template, removes **Show intro** from the menu, and scrolls to the top.
2603
2614
 
2604
- 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.
2615
+ A `<script type="module">` inside the template runs when the intro first 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.
2605
2616
 
2606
2617
  ```html
2607
- <template visitor-intro>
2608
- <p>One paragraph on what the app does and who it is for.</p>
2618
+ <template visitor-intro="manual">
2619
+ <p>One paragraph on what the document does and who it is for.</p>
2609
2620
  </template>
2610
2621
  ```
2611
2622
 
@@ -2615,11 +2626,11 @@ When there is a release video, add it as the first element of the template, and
2615
2626
  <video src="/_/uploads/assets/kanban/release.mp4" poster="/_/uploads/assets/kanban/release-poster.jpg" controls playsinline preload="metadata"></video>
2616
2627
  ```
2617
2628
 
2618
- An element inside the template with `[visitor-intro-start]` scrolls to the app, and one with `[visitor-intro-dismiss]` closes the intro the same way the `x` does. A deep link (a URL with a `#...`) skips the intro so a shared link opens where it points, a draft skips it too, and `?intro=1` forces it.
2629
+ An element inside the template with `[visitor-intro-start]` scrolls smoothly to the document below the intro, and one with `[visitor-intro-dismiss]` closes the intro the same way the corner button does. A deep link (a URL with a `#...`) skips the intro on load so a shared link opens where it points, an existing draft skips it too, and `?intro=1` forces it open.
2619
2630
 
2620
- **The prompt** needs nothing from the app but its name: a small tab at the bottom right ("This app is free to keep") that opens into a card holding the download link and "Don't show again", with a minimize button in its corner. The tab's X and the card's minimize button leave a small "Keep" button with the download icon that reopens the download card. That choice survives reload. It opens itself once after the visitor has touched three fields. "Don't show again" stops those reminders for that slot but keeps the icon, including for previously dismissed notices. A storage failure opens it in its error state, and the next good save clears it. In that error state the download link does not go to the host, whose download page would read the stored copy that just failed to be written: the plugin builds the file from the live page in the browser instead, named after the page file with `.htmlclay` at the end.
2631
+ **The prompt** needs nothing from the document but its name: a small tab at the bottom right ("This app is free to keep") that opens into a card holding the download link and "Don't show again", with a minimize button in its corner. The tab's X and the card's minimize button leave a small "Keep" button with the download icon that reopens the download card. That choice survives reload. It opens itself once after the visitor has touched three fields. "Don't show again" stops those reminders for that slot but keeps the icon, including for previously dismissed notices. A storage failure opens it in its error state, and the next good save clears it. In that error state the download link does not go to the host, whose download page would read the stored copy that just failed to be written: the plugin builds the file from the live page in the browser instead, named after the page file with `.htmlclay` at the end.
2621
2632
 
2622
- **The download link** is `/_/download<path>?as=htmlclay`, with the page's path (`/_/download?as=htmlclay` for `/`) and `&src=<src>` when this slot has one. `clay.visitorDrafts.downloadUrl()` returns it. **`?src=` is first touch**: the first valid value the page sees for this slot is kept and rides into the download link and every signup. Valid values match `/^[a-z0-9][a-z0-9-]{0,39}$/`.
2633
+ **The download link** is `/_/download<path>?as=htmlclay`, with the document's path (`/_/download?as=htmlclay` for `/`) and `&src=<src>` when this slot has one. `clay.visitorDrafts.downloadUrl()` returns it. **`?src=` is first touch**: the first valid value the page sees for this slot is kept and rides into the download link and every signup. Valid values match `/^[a-z0-9][a-z0-9-]{0,39}$/`.
2623
2634
 
2624
2635
  **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()`.
2625
2636
 
@@ -2739,7 +2750,7 @@ The `:legacy` marker is what stops a reset from coming back: once ClayJS has cop
2739
2750
 
2740
2751
  1. Loads with `clay.js` as a classic script at the end of `<body>`, every tag on the same version prefix.
2741
2752
  2. `<html autosave>` unless it is a deliberate manual-save writer.
2742
- 3. `sync` is in the plugin list, and `exclude=richclay` if there is no rich text.
2753
+ 3. On an owned collaborative document, `sync` is in `plugins=`; on a [free document](https://clayjs.com/docs/guide/free-documents/), `visitor-drafts,visitor-menu,menu` is in `plugins=`. Add `exclude=richclay` if there is no rich text, and add task plugins (`sortable`, `upload`, `undo`, `indicator`) only when the document uses them.
2743
2754
  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).
2744
2755
  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"`.
2745
2756
  6. Records are authored in the saved form (`inert-contenteditable`, `disabled`, `readonly`); templates in the active form.
@@ -2748,9 +2759,9 @@ The `:legacy` marker is what stops a reset from coming back: once ClayJS has cop
2748
2759
  9. All chrome is cloned from a `<template>` into `clay="editor-ui"` after `clay.ready`; nothing in the file is chrome.
2749
2760
  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.
2750
2761
  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.
2751
- 12. Commands are wrapped in `clay.undo.commit`; derived views are drawn in `clay.effect`, with no effect warnings in the console.
2762
+ 12. When `undo` is loaded, commands are wrapped in `clay.undo?.commit(label, fn)` (falling back to `fn()` when `undo` is absent); derived views are drawn in `clay.effect`, with no effect warnings in the console.
2752
2763
  13. Views whose record a peer deleted are closed, in the effect or a `clay:sync-applied` handler.
2753
- 14. The app script starts with `clay.start`; all listeners are delegated.
2764
+ 14. The document script starts with `clay.start`; all listeners are delegated.
2754
2765
  15. Nothing writes the record at boot.
2755
2766
  16. Edit-only calls are guarded for view mode, and edit actions check `clay.isEditMode`.
2756
2767
  17. `clay.confirm` calls have a rejection handler.
@@ -2759,7 +2770,7 @@ The `:legacy` marker is what stops a reset from coming back: once ClayJS has cop
2759
2770
  20. Images use `clay.upload`, embedding only on `unsupported`.
2760
2771
  21. The file opens in view mode with JavaScript disabled and shows correct content and no edit controls.
2761
2772
  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.
2762
- 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.
2773
+ 23. A tool document 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.
2763
2774
 
2764
2775
  ### 13.2 Testing a document that changes modes
2765
2776
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable documents. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
@@ -96,7 +96,7 @@
96
96
  "@fontsource-variable/work-sans": "5.3.0",
97
97
  "@fontsource/ibm-plex-mono": "5.3.0",
98
98
  "@fontsource/ibm-plex-sans": "5.3.0",
99
- "@panphora/bevel": "0.1.0",
99
+ "@panphora/bevel": "0.3.2",
100
100
  "@web/test-runner": "^0.20.2",
101
101
  "@web/test-runner-commands": "^0.9.0",
102
102
  "@web/test-runner-playwright": "^0.11.1",
@@ -24,12 +24,15 @@ function failed() {
24
24
  *
25
25
  * @param {string} html - the whole document, as it should be stored
26
26
  * @param {Object} [options]
27
- * @param {?string} [options.etag] - the stamp to replace, defaulting to this tab's
27
+ * @param {?string} [options.etag] - the stamp to replace. Left out, it is this tab's
28
+ * stamp at the moment of writing; given, even as null, it is exactly that stamp, so a
29
+ * caller that captured the document earlier never writes against a later version.
28
30
  * @returns {Promise<{ok: true, etag: ?string}|{ok: false, code: "conflict"|"forbidden"|"unsupported"|"failed"}>}
29
31
  */
30
32
  export async function replace(html, { etag } = {}) {
31
33
  if (typeof html !== "string" || html === "") return failed();
32
34
 
35
+ const pinned = etag !== undefined;
33
36
  const given = typeof etag === "string" && etag ? etag : null;
34
37
  // Discovery is what says whether the capability exists, and it is also where a
35
38
  // tab that has never saved learns the stamp it is about to replace.
@@ -38,7 +41,7 @@ export async function replace(html, { etag } = {}) {
38
41
 
39
42
  // With no stamp the write would be unconditional, which is exactly what this lane
40
43
  // promises never to send. A reload gives the tab the stamp it is missing.
41
- const stamp = given || lastSeenEtag();
44
+ const stamp = pinned ? given : lastSeenEtag();
42
45
  if (!stamp) return { ok: false, code: "conflict" };
43
46
  const controller = new AbortController();
44
47
  const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
@@ -103,7 +103,15 @@ export function* serializeForSyncSteps(clone) {
103
103
  const shell = bareRoot.outerHTML;
104
104
  const endTag = `</${bareRoot.localName}>`;
105
105
  const out = ["<!DOCTYPE html>", shell.slice(0, shell.length - endTag.length)];
106
- yield* serializeChildrenSteps(clone, clonePreventingOnclone(clone, false), out, { bytes: 0 });
106
+ const blocks = noscriptBlocks(clone);
107
+ const raw = blocks.length ? swapNoscriptMarkers(blocks) : [];
108
+ try {
109
+ yield* serializeChildrenSteps(clone, clonePreventingOnclone(clone, false), out, { bytes: 0 });
110
+ } finally {
111
+ for (const [el, kids] of blocks) el.replaceChildren(...kids);
112
+ }
107
113
  out.push(endTag);
108
- return out.join('');
114
+ let html = out.join('');
115
+ for (const [marker, text] of raw) html = html.split(marker).join(text);
116
+ return html;
109
117
  }
@@ -294,6 +294,30 @@ export function noscriptBlocks(clone) {
294
294
  return blocks;
295
295
  }
296
296
 
297
+ /**
298
+ * Each <noscript> of a document parsed with scripting off, given back the one text node a
299
+ * scripting parser keeps. DOMParser parses with scripting off, so a received <noscript>
300
+ * holds its markup as elements and its escaped text decoded: merged as elements, a fallback
301
+ * stylesheet would go live, and written raw, decoded text would become markup. Its
302
+ * innerHTML in that document is the source again, escaping included.
303
+ */
304
+ export function noscriptsAsText(doc) {
305
+ const found = [];
306
+ const collect = (node) => {
307
+ for (const child of node.childNodes) {
308
+ if (child.nodeType !== 1) continue;
309
+ if (child.localName === 'noscript' && child.namespaceURI === XHTML_NS) {
310
+ if (child.firstChild) found.push(child);
311
+ continue;
312
+ }
313
+ collect(child.localName === 'template' && child.content ? child.content : child);
314
+ }
315
+ };
316
+ collect(doc.documentElement ?? doc);
317
+ for (const el of found) el.replaceChildren(el.ownerDocument.createTextNode(el.innerHTML));
318
+ return doc;
319
+ }
320
+
297
321
  /** Swap each block's text for a marker, and answer the text each marker stands for. */
298
322
  export function swapNoscriptMarkers(blocks) {
299
323
  const nonce = Math.random().toString(36).slice(2);
@@ -941,11 +965,23 @@ export function serializeForSync(clone) {
941
965
  // truncate the broadcast.
942
966
  const shell = bareRoot.outerHTML;
943
967
  const endTag = `</${bareRoot.localName}>`;
968
+ // A <noscript>'s text is written raw, as serializeClone writes it: escaped, a head
969
+ // <noscript> parses as text on the receiving side, which closes the head and puts
970
+ // the fallback markup at the top of <body>, and that tab then saves it.
971
+ const blocks = noscriptBlocks(clone);
972
+ const raw = blocks.length ? swapNoscriptMarkers(blocks) : [];
973
+ let inner;
974
+ try {
975
+ inner = clone.innerHTML;
976
+ } finally {
977
+ for (const [el, kids] of blocks) el.replaceChildren(...kids);
978
+ }
979
+ for (const [marker, text] of raw) inner = inner.split(marker).join(text);
944
980
  // The doctype, so this artifact is a complete document like every other one this
945
981
  // module produces. Spec section 2 asks for that, and it costs nothing on the wire:
946
982
  // a receiver parses the string and morphs documentElement against documentElement,
947
983
  // so the prologue is consumed by the parser and never reaches the morph.
948
- return "<!DOCTYPE html>" + shell.slice(0, shell.length - endTag.length) + clone.innerHTML + endTag;
984
+ return "<!DOCTYPE html>" + shell.slice(0, shell.length - endTag.length) + inner + endTag;
949
985
  }
950
986
 
951
987
  /** A private deep copy of a capture's clone, for work that outlives the capture event. */
@@ -1,5 +1,5 @@
1
1
  import { replace as replaceDocument } from "./save-replace.js";
2
- import { lastSeenEtag } from "./etag.js";
2
+ import { lastSeenEtag, seedEtag } from "./etag.js";
3
3
  import { claimDocumentOperation, releaseDocumentOperation } from "./save-core.js";
4
4
  import { isNewerVersion } from "./version.js";
5
5
  import { upgrade, readMeta } from "../vendor/hyper-html-api-upgrade.vendor.js";
@@ -281,6 +281,16 @@ async function applyUnderSlot(update, { doc, loc, fetchFn, clay, reload, replace
281
281
  }
282
282
  }
283
283
 
284
+ // The stamp is read before the capture, so it can only be older than the bytes the
285
+ // upgrade starts from: a save that lands while the capture runs or the release
286
+ // downloads makes the write below fail instead of being overwritten by it.
287
+ // A tab that has never saved learns its stamp from discovery here, before the capture,
288
+ // not inside the write after the download, when it would describe a later version.
289
+ if (etag === undefined && !lastSeenEtag()) {
290
+ try { await seedEtag(); } catch {}
291
+ }
292
+ const stamp = etag !== undefined ? etag : lastSeenEtag();
293
+
284
294
  // An editing tab's live article holds the editor, not the page: the upgrade starts from
285
295
  // the document as a save writes it.
286
296
  let source = doc;
@@ -290,6 +300,10 @@ async function applyUnderSlot(update, { doc, loc, fetchFn, clay, reload, replace
290
300
  } catch {
291
301
  return { ok: false, code: "failed", message: SAVE_FAILED };
292
302
  }
303
+ } else {
304
+ // A view-mode tab's live document can change under the download (a disk frame from
305
+ // another tab), so it is copied now, at the same moment as the stamp.
306
+ source = new DOMParser().parseFromString("<!DOCTYPE html>\n" + doc.documentElement.outerHTML, "text/html");
293
307
  }
294
308
  const prepared = await prepare(update, { doc: source, loc, fetchFn });
295
309
  if (!prepared.ok) return prepared;
@@ -301,7 +315,7 @@ async function applyUnderSlot(update, { doc, loc, fetchFn, clay, reload, replace
301
315
  : replaceDocument;
302
316
  let result;
303
317
  try {
304
- result = await save(prepared.html, { etag: etag !== undefined ? etag : lastSeenEtag() });
318
+ result = await save(prepared.html, { etag: stamp });
305
319
  } catch {
306
320
  return { ok: false, code: "failed", message: SAVE_FAILED };
307
321
  }
@@ -82,6 +82,9 @@ export const PLUGIN_PATHS = {
82
82
  // owner editing the original, gets the owner module: the chip when it applies and
83
83
  // the inert `clay.visitorDrafts`, without the drafts UI's 24 KB.
84
84
  "visitor-drafts": { path: "plugins/visitor-drafts.js", ownerPath: "plugins/visitor-drafts-owner.js", editOnly: false, default: false },
85
+ // `draft: true` loads it only in a visitor's draft. The key is short on purpose: every
86
+ // reader downloads this file, and the reader's Brotli ceiling counts it.
87
+ "visitor-menu": { path: "plugins/visitor-menu.js", editOnly: true, draft: true },
85
88
  // An editor textarea outside the saved records, bound to one record's text
86
89
  // element. Edit mode only: the page that asks for it is the page whose owner
87
90
  // writes in it.
@@ -147,6 +150,7 @@ export const MODULES = {
147
150
  "plugins/demo.js": () => import("./plugins/demo.js"),
148
151
  "plugins/visitor-drafts.js": () => import("./plugins/visitor-drafts.js"),
149
152
  "plugins/visitor-drafts-owner.js": () => import("./plugins/visitor-drafts-owner.js"),
153
+ "plugins/visitor-menu.js": () => import("./plugins/visitor-menu.js"),
150
154
  "plugins/bind-text.js": () => import("./plugins/bind-text.js"),
151
155
  "plugins/source.js": () => import("./plugins/source.js"),
152
156
  "plugins/derive.js": () => import("./plugins/derive.js"),
@@ -155,7 +159,7 @@ export const MODULES = {
155
159
 
156
160
  // `source` is last on purpose: its install captures a save clone, so it wants every
157
161
  // plugin that registers a document transform to have registered it first.
158
- const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "unsent", "quickcrop", "upload", "people", "menu", "upgrade", "cms", "page-view", "sync", "live", "page", "message", "like", "wire", "ai-edit", "demo", "derive", "bind-text", "source"];
162
+ const PLUGIN_ORDER = ["visitor-drafts", "visitor-menu", "richclay", "indicator", "sortable", "undo", "unsent", "quickcrop", "upload", "people", "menu", "upgrade", "cms", "page-view", "sync", "live", "page", "message", "like", "wire", "ai-edit", "demo", "derive", "bind-text", "source"];
159
163
 
160
164
  // A plugin that cannot do its whole job alone. hypercms reads the cropper through
161
165
  // a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
@@ -182,7 +186,10 @@ const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "un
182
186
  // image and file blocks send through `clay.upload`: without the plugin every drop
183
187
  // ends in "File uploads are not available", while with it a host that stores no
184
188
  // files answers `unsupported` and the editor offers to embed the file instead.
185
- const IMPLIES = { cms: ["quickcrop", "upload"], "ai-edit": ["wire"], page: ["live", "sync", "upload"] };
189
+ // The menu is how a person opens the CMS: hypercms adds no button of its own. It loads
190
+ // in view too, because that is where a person starts, and it shows itself only to
191
+ // someone who can edit. A page with its own control uses exclude=menu.
192
+ const IMPLIES = { cms: ["quickcrop", "upload", "menu"], "ai-edit": ["wire"], page: ["live", "sync", "upload"] };
186
193
 
187
194
  // A Document's editor owns its article's rich text, so richclay (for [editable]) has
188
195
  // nothing to do there; plugins=page,richclay still loads it.
@@ -274,7 +281,7 @@ export function resolveModules(params, isEditMode, { isDraftMode = false, hasSap
274
281
  for (const name of PLUGIN_ORDER) {
275
282
  if (!enabled.has(name)) continue;
276
283
  const spec = PLUGIN_PATHS[name];
277
- if (spec.editOnly && !isEditMode) continue;
284
+ if ((spec.editOnly && !isEditMode) || (spec.draft && !isDraftMode)) continue;
278
285
  (LATE.has(name) ? late : plugins).push(spec.ownerPath && !isDraftMode ? spec.ownerPath : spec.path);
279
286
  }
280
287
  return { core, plugins, late };
@@ -670,17 +670,13 @@ function buildChrome() {
670
670
 
671
671
  window.addEventListener('scroll', () => { positionChrome(); hideChip(); }, { passive: true, signal });
672
672
  window.addEventListener('resize', () => { placeBubble(); if (panel && !panel.hidden) fitTextarea(); positionChrome(); }, { passive: true, signal });
673
- // The CMS toggle can arrive after this chrome, and a docked sidebar slides it left.
673
+ // A docked CMS sidebar shifts the page under this chrome.
674
674
  const replace = () => { placeBubble(); positionChrome(); };
675
675
  document.addEventListener('hcms:open', () => { replace(); setTimeout(replace, 350); }, { signal });
676
676
  document.addEventListener('hcms:close', () => { replace(); setTimeout(replace, 350); }, { signal });
677
- const cmsToggle = new MutationObserver((records) => {
678
- syncBubble();
679
- const touched = (nodes) => [...nodes].some((n) => n.nodeType === 1 && n.hasAttribute('data-hcms-toggle-host'));
680
- if (records.some((r) => touched(r.addedNodes) || touched(r.removedNodes))) replace();
681
- });
682
- cmsToggle.observe(document.body, { childList: true });
683
- observers.push(cmsToggle);
677
+ const bodyChildren = new MutationObserver(() => syncBubble());
678
+ bodyChildren.observe(document.body, { childList: true });
679
+ observers.push(bodyChildren);
684
680
 
685
681
  // A target removed from the page ends its edit, so the save hold and the paused
686
682
  // observers never outlive it. A running bar is no exception: it is torn down with
@@ -748,17 +744,10 @@ function refreshHelperState() {
748
744
  }).catch(() => {});
749
745
  }
750
746
 
751
- // The bubble sits in the bottom-right corner, or just left of the CMS's "Edit content"
752
- // toggle when the page has one, bottoms aligned, so the two never overlap.
747
+ // The bubble sits in the bottom-right corner.
753
748
  function placeBubble() {
754
749
  if (!docBubble) return;
755
- const host = document.querySelector('[data-hcms-toggle-host]');
756
- const rect = host && host.getBoundingClientRect();
757
- const beside = !!rect && rect.width > 0 && rect.height > 0;
758
- docBubble.pin({
759
- right: beside ? `${Math.round((document.documentElement.clientWidth || window.innerWidth) - rect.left + 8)}px` : '16px',
760
- bottom: beside ? `${Math.round((document.documentElement.clientHeight || window.innerHeight) - rect.bottom)}px` : '16px',
761
- });
750
+ docBubble.pin({ right: '16px', bottom: '16px' });
762
751
  paintLook(docBubble);
763
752
  }
764
753
 
@@ -1,16 +1,13 @@
1
- // The CMS shell, toggle, inline layer and ghost rows, with their --mirk-* tokens pinned
2
- // to Bevel and the Bevel system mono face. The CMS keeps its own stylesheet and its
3
- // own light/dark switching. The "Edit content" toggle also gets Bevel's button face, ink and hover (src/ui/skins/cms-toggle.js).
1
+ // The CMS shell, inline layer and ghost rows, with their --mirk-* tokens pinned to Bevel
2
+ // and the Bevel system mono face. The CMS keeps its own stylesheet and its own light/dark
3
+ // switching.
4
4
  import { installSkin } from "../ui/vendor-skin.js";
5
5
  import { CSS } from "../ui/skins/cms.js";
6
- import { CSS as TOGGLE_CSS, ROOTS as TOGGLE_ROOTS } from "../ui/skins/cms-toggle.js";
7
6
  import { cms as hypercms } from "../vendor/hypercms.vendor.js";
8
7
 
9
8
  export { cms, default } from "../vendor/hypercms.vendor.js";
10
9
 
11
10
  installSkin("cms", CSS);
12
- // The toggle takes the page's scheme, so Bevel's face and ink resolve the way the page reads.
13
- installSkin("cms-toggle", TOGGLE_CSS, { roots: TOGGLE_ROOTS });
14
11
 
15
12
  const fault = (message, code) => Object.assign(new Error(`clay:cms: ${message}`), { code });
16
13
 
@@ -0,0 +1,45 @@
1
+ const SELECTOR = "[clay-menu-item]:not([cms-template])";
2
+
3
+ const canSave = () => window.clay?.mode === "edit" || window.clay?.mode === "draft";
4
+
5
+ async function removeAndSave(el) {
6
+ await window.clay?.ready;
7
+ if (!canSave() || !el.isConnected) return false;
8
+ const undo = window.clay?.undo;
9
+ if (undo && typeof undo.commit === "function") undo.commit("Remove this item", () => el.remove());
10
+ else el.remove();
11
+ window.clay?.save?.soon?.();
12
+ return true;
13
+ }
14
+
15
+ export function attachDomItems({ addItem, removeItem, beforeOpen }) {
16
+ const shown = new Set();
17
+ function sync() {
18
+ const seen = new Set();
19
+ for (const el of document.querySelectorAll(SELECTOR)) {
20
+ const key = el.getAttribute("data-id") || el.id;
21
+ if (!key) {
22
+ console.warn("[clay] A clay-menu-item needs a data-id or an id.", el);
23
+ continue;
24
+ }
25
+ const id = `dom.${key}`;
26
+ seen.add(id);
27
+ const dismiss = el.getAttribute("clay-menu-dismiss");
28
+ addItem({
29
+ id,
30
+ label: el.textContent.trim(),
31
+ href: el.getAttribute("href") || "",
32
+ section: el.getAttribute("clay-menu-section") || undefined,
33
+ quiet: el.hasAttribute("clay-menu-quiet"),
34
+ run: el.hasAttribute("href") ? null : () => el.click(),
35
+ dismiss: dismiss && canSave() ? { label: dismiss, run: () => removeAndSave(el) } : undefined,
36
+ });
37
+ }
38
+ for (const id of shown) if (!seen.has(id)) removeItem(id);
39
+ shown.clear();
40
+ for (const id of seen) shown.add(id);
41
+ }
42
+ beforeOpen.push(sync);
43
+ for (const name of ["clay:drafts-restored", "clay:sync-applied", "clay:modechange"]) document.addEventListener(name, sync);
44
+ sync();
45
+ }
@@ -0,0 +1,32 @@
1
+ export const OFF = "not all";
2
+ export const looks = () => [...document.querySelectorAll("head style[clay-variant]")];
3
+ export const nameOf = (style) => style.getAttribute("clay-variant");
4
+ export const labelOf = (style) => style.getAttribute("clay-variant-label") || `Variant ${nameOf(style)}`;
5
+
6
+ export function wantedLook() {
7
+ const name = document.documentElement.getAttribute("look");
8
+ return name && looks().some((style) => nameOf(style) === name) ? name : null;
9
+ }
10
+
11
+ export function activeLook() {
12
+ const all = looks();
13
+ const wanted = wantedLook();
14
+ return all.find((style) => nameOf(style) === wanted) || all.find((style) => style.getAttribute("media") !== OFF) || all[0] || null;
15
+ }
16
+
17
+ export function paintLook(name = wantedLook()) {
18
+ document.querySelector("style[clay-look-hint]")?.remove();
19
+ if (!name) return;
20
+ for (const style of looks()) {
21
+ if (nameOf(style) === name) style.removeAttribute("media");
22
+ else if (style.getAttribute("media") !== OFF) style.setAttribute("media", OFF);
23
+ }
24
+ }
25
+
26
+ export const carriesLook = () => /"html@look"/.test(document.querySelector('script[data-rules-name~="api"]')?.textContent ?? "");
27
+
28
+ export function recordLook(name) {
29
+ if (!carriesLook()) return;
30
+ if (name) document.documentElement.setAttribute("look", name);
31
+ else document.documentElement.removeAttribute("look");
32
+ }