@panphora/clayjs 0.6.1 → 0.7.1

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.
@@ -1,22 +1,26 @@
1
1
  // cacheBust.js
2
2
  // Cache-bust an element's href or src attribute by adding/updating a version query param
3
3
 
4
- import { addRegionToken } from './region-policy.js';
4
+ import Mutation from './mutation.js';
5
+ import { urlAttrFor, writeRuntimeUrl } from './authored-url.js';
5
6
 
6
7
  function cacheBust(el) {
7
- const attr = el.hasAttribute('href') ? 'href' : 'src';
8
- const currentValue = el.getAttribute(attr);
9
- const url = new URL(currentValue, location.href);
8
+ const attr = urlAttrFor(el);
9
+ if (!attr) return;
10
+
11
+ const url = new URL(el.getAttribute(attr), location.href);
10
12
  url.searchParams.set('v', Date.now());
11
- el.setAttribute(attr, url.href);
12
13
 
13
- // This runs from [onaftersave], i.e. after the save baseline was taken, so the
14
- // rewrite would otherwise read as a user edit and leave the page dirty forever.
15
- // Marking the element instead of re-reading the whole live DOM afterwards is what
16
- // lets the baseline stay equal to the bytes actually sent: a post-save re-read
17
- // cannot tell this rewrite apart from something the user typed mid-save, and used
18
- // to record the latter as saved without sending it.
19
- addRegionToken(el, 'no-trigger-autosave');
14
+ // Paused because this is clay writing, not the person editing: it must not
15
+ // schedule an autosave. Keeping it out of the SAVED FILE is a separate job,
16
+ // done by remembering the authored URL and restoring it on every snapshot
17
+ // clone (see authored-url.js) rather than by marking the region.
18
+ Mutation.pause();
19
+ try {
20
+ writeRuntimeUrl(el, attr, url.href);
21
+ } finally {
22
+ Mutation.resume();
23
+ }
20
24
  }
21
25
 
22
26
  export default cacheBust;
@@ -8,7 +8,7 @@
8
8
  * a false "clean" full-morphs over a real unsaved edit.
9
9
  *
10
10
  * Two feeds, because neither alone sees everything:
11
- * - the mutation hub (require: 'autosave'), which sees DOM changes but not
11
+ * - the mutation hub (require: 'dirty'), which sees DOM changes but not
12
12
  * value-property writes on form controls (typing fires no MutationRecord
13
13
  * for .value), and
14
14
  * - capture-phase input/change listeners on ALL form controls, not just
@@ -27,7 +27,7 @@
27
27
 
28
28
  import Mutation from './mutation.js';
29
29
  import { isEditMode } from '../core/is-edit-mode.js';
30
- import { STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
30
+ import { STRIP_FROM_DIRTY_CHECK, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
31
31
 
32
32
  let changes = 0;
33
33
  let clearedAt = 0;
@@ -36,13 +36,18 @@ let started = false;
36
36
 
37
37
  const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
38
38
 
39
- // Regions the comparison never sees. The hub feed already skips them, through
40
- // `require: 'autosave'`, and the input feed has to skip them for the same
41
- // reason: their content is stripped from the comparison clone, so an edit inside
39
+ // Regions the loss oracle never sees. The hub feed already skips them, through
40
+ // `require: 'dirty'`, and the input feed has to skip them for the same reason:
41
+ // their content is stripped from the merge's compare clone, so an edit inside
42
42
  // one can never produce a dirty root, and counting it marks the page dirty with
43
43
  // nothing for the oracle to find — permanently, since only a save clears the
44
44
  // counter and churn in these regions triggers none.
45
45
  //
46
+ // The gate and the oracle must key off the SAME domain. This is the dirty
47
+ // domain, so no-trigger-autosave is deliberately absent: the oracle can now
48
+ // report an unsaved batching edit, so the gate has to arm for one. Disposable
49
+ // churn leaves via no-dirty, which is in both.
50
+ //
46
51
  // This is not a relaxation of "never under-report". A control here is absent
47
52
  // from the clone by definition, so there is nothing about it to under-report.
48
53
  // It matters because a mounted tool (redpen's answer field, any panel that
@@ -50,9 +55,22 @@ const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
50
55
  // it used to freeze the live-sync save baseline for the rest of the session,
51
56
  // after which every incoming disk change was diffed against a stale base, and
52
57
  // the previous change was spliced back over the newer one and written to disk.
53
- const GATE_IGNORE = `${STRIP_FROM_COMPARISON}, ${SNAPSHOT_REMOVE_SELECTOR}`;
58
+ const GATE_IGNORE = `${STRIP_FROM_DIRTY_CHECK}, ${SNAPSHOT_REMOVE_SELECTOR}`;
54
59
  const probeCache = new WeakMap();
55
60
 
61
+ // The [persist] probe has to honour GATE_IGNORE too. It scans by attribute, not
62
+ // through the mutation hub, so without this a programmatic .value write inside a
63
+ // no-dirty region pins the gate dirty forever even though every DOM mutation
64
+ // there is ignored — the one hole that would survive the domain switch.
65
+ function gatedPersistControls() {
66
+ const out = [];
67
+ for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
68
+ if (el.closest(GATE_IGNORE)) continue;
69
+ out.push(el);
70
+ }
71
+ return out;
72
+ }
73
+
56
74
  function onUserInput(event) {
57
75
  // Deliberately NOT gated on `paused`: a morph never dispatches input or
58
76
  // change events, so anything arriving here is the user — including typing
@@ -68,7 +86,7 @@ export function startDirtyGate() {
68
86
  if (started) return;
69
87
  started = true;
70
88
  Mutation.onAnyChange(
71
- { omitChangeDetails: true, require: 'autosave' },
89
+ { omitChangeDetails: true, require: 'dirty' },
72
90
  () => {
73
91
  if (!paused) changes++;
74
92
  }
@@ -115,7 +133,7 @@ function serializedStateMatches(el) {
115
133
  }
116
134
 
117
135
  export function persistProbeDirty() {
118
- for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
136
+ for (const el of gatedPersistControls()) {
119
137
  const sig = controlSignature(el);
120
138
  if (probeCache.get(el) === sig) continue;
121
139
  if (serializedStateMatches(el)) {
@@ -129,7 +147,7 @@ export function persistProbeDirty() {
129
147
 
130
148
  /** Call after the oracle verified the whole page clean against its baseline. */
131
149
  export function probeMarkClean() {
132
- for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
150
+ for (const el of gatedPersistControls()) {
133
151
  probeCache.set(el, controlSignature(el));
134
152
  }
135
153
  }
@@ -145,7 +163,7 @@ export function pageMaybeDirty() {
145
163
  */
146
164
  export function gateCaptureToken() {
147
165
  const probe = [];
148
- for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
166
+ for (const el of gatedPersistControls()) {
149
167
  probe.push([el, controlSignature(el)]);
150
168
  }
151
169
  return { gen: changes, probe };
@@ -604,12 +604,14 @@ const localMutation = {
604
604
  });
605
605
 
606
606
  // Data-guard provenance: if a trusted gesture drove this turn (or one
607
- // happened within the recency window) and any change is autosave-relevant
608
- // (matches the save's region scope), mark the pending save user-driven.
607
+ // happened within the recency window) and any change is dirty-relevant
608
+ // (i.e. it reaches the saved bytes), mark the pending save user-driven.
609
+ // The DIRTY domain, not the autosave one: an edit inside a batching region
610
+ // is still the person's work, and the save that carries it is still human.
609
611
  // Runs synchronously in the MO callback; skipped during paused-morph drains.
610
612
  if (!onlyNonPausable && isUserDrivenNow()) {
611
613
  for (const change of changes) {
612
- if (!skipForPolicy(this._policyForChange(change), 'autosave')) {
614
+ if (!skipForPolicy(this._policyForChange(change), 'dirty')) {
613
615
  markUserDriven();
614
616
  break;
615
617
  }
@@ -7,7 +7,10 @@
7
7
  * resolved for back-compat. They apply to an element and its descendants:
8
8
  *
9
9
  * no-save — not written to the saved file (stripped). Live at runtime.
10
- * no-trigger-autosave — saved, but editing it doesn't trigger an autosave / mark dirty.
10
+ * no-trigger-autosave — saved, and still counts as unsaved work, but editing it does
11
+ * not start an autosave. Durable work the person batches by hand.
12
+ * no-dirty — saved, but its content is disposable: no autosave, no close
13
+ * warning, and an incoming sync frame may replace it.
11
14
  * no-undo — edits here are not recorded in the undo stack.
12
15
  * no-watch — invisible to the whole mutation system (high-churn regions). Still saved.
13
16
  * freeze — saved as authored (runtime changes not persisted). Live at runtime.
@@ -17,9 +20,15 @@
17
20
  *
18
21
  * mutations-ignore -> no-watch
19
22
  * save-remove -> no-save no-undo
20
- * save-ignore -> no-trigger-autosave no-undo
23
+ * save-ignore -> no-dirty no-undo
21
24
  * save-freeze -> freeze no-undo
22
25
  *
26
+ * save-ignore maps to no-dirty, NOT to no-trigger-autosave. hyper-morph has always
27
+ * defined it as local-instance chrome ("explicit leave me alone") and sync-ignores
28
+ * it, and every real use of it in the wild is a generated stylesheet <link>. Calling
29
+ * it a spelling of no-trigger-autosave gave clayjs a durable region that hyper-morph
30
+ * refuses to sync, which is a contradiction no merge domain can express.
31
+ *
23
32
  * Separately, a snapshot-layer marker controls whether an element appears in any
24
33
  * snapshot at all (save file, live-sync broadcast, and dirty-comparison):
25
34
  *
@@ -31,7 +40,7 @@
31
40
  *
32
41
  * resolveRegionPolicy() walks an element's self-or-ancestor chain once and
33
42
  * returns the four independent axes the rest of the framework keys off:
34
- * { watched, autosaveTriggered, undoable, persist, extension }
43
+ * { watched, autosaveTriggered, dirtyTracked, undoable, persist, extension }
35
44
  */
36
45
 
37
46
  import { EXTENSION_NODE_SELECTOR } from './extension-noise.js';
@@ -39,10 +48,12 @@ import { EXTENSION_NODE_SELECTOR } from './extension-noise.js';
39
48
  export const PERSIST = { FULL: 'full', FROZEN: 'frozen', NONE: 'none' };
40
49
 
41
50
  // The canonical region tokens (spelled in the `clay` attribute or as bare attrs).
42
- export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-undo', 'no-watch', 'freeze'];
51
+ export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'freeze'];
43
52
 
44
- // Canonical tokens spellable inside the space-separated `clay` attribute.
45
- const CLAY_TOKENS = ["no-save", "no-snapshot", "no-trigger-autosave", "no-watch", "no-undo", "freeze"];
53
+ // Every canonical token spellable inside the space-separated `clay` attribute.
54
+ // Exported: this list used to exist as a private CLAY_TOKENS that nothing read,
55
+ // while callers hardcoded their own copies.
56
+ export const TOKENS = ["no-save", "no-snapshot", "no-trigger-autosave", "no-dirty", "no-watch", "no-undo", "freeze"];
46
57
 
47
58
  // True when a region marker is present, whether spelled as a `clay` token
48
59
  // (whitespace-token semantics, matching [clay~=token]) or a legacy bare attribute.
@@ -75,7 +86,32 @@ export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
75
86
  // mutations-ignore footgun (their content stays in the saved file, but is no
76
87
  // longer counted as a change).
77
88
  export const STRIP_FROM_COMPARISON =
78
- '[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
89
+ '[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
90
+
91
+ // Every spelling of no-trigger-autosave: the ONE difference between the autosave
92
+ // domain and the dirty domain. A document containing none of these has identical
93
+ // domains, which is what lets captureForSaveAndComparison short-circuit.
94
+ // Derived here rather than written out at the call site so a page using the
95
+ // legacy `save-ignore` spelling cannot get a dirty domain the policy disagrees
96
+ // with — the split-brain where savePage() skips while the close warning fires.
97
+ export const NO_TRIGGER_AUTOSAVE_SELECTOR =
98
+ '[clay~="no-trigger-autosave"], [no-trigger-autosave]';
99
+
100
+ // Disposable regions: saved, but their content is explicitly not work. Stripped
101
+ // from BOTH comparison domains, skipped by the merge on both sides, and never
102
+ // counted by the close warning. This is the declared signal that lets the merge
103
+ // stop guessing whether churn inside a batching region was a person or a renderer.
104
+ export const NO_DIRTY_SELECTOR =
105
+ '[clay~="no-dirty"], [no-dirty], [save-ignore]';
106
+
107
+ // forDirtyCheck strips everything forComparison does EXCEPT no-trigger-autosave.
108
+ // An edit inside such a region is a real edit: the person can save it by hand and
109
+ // must be warned about it on close. It just doesn't start an autosave by itself.
110
+ // no-watch stays stripped — a region invisible to the mutation system cannot be
111
+ // dirty-tracked either, and counting it would re-freeze the live-sync baseline
112
+ // the way the mounted-tool bug did (see dirty-gate.js).
113
+ export const STRIP_FROM_DIRTY_CHECK =
114
+ '[clay~="no-save"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
79
115
 
80
116
  // Snapshot-layer marker: removed from EVERY snapshot (save, live-sync broadcast,
81
117
  // dirty-comparison) in snapshot.js. `no-snapshot` is the consistent alias for the
@@ -83,8 +119,13 @@ export const STRIP_FROM_COMPARISON =
83
119
  // live-sync receiver keeps its own local copy instead of deleting it.
84
120
  export const SNAPSHOT_REMOVE_SELECTOR = '[clay~="no-snapshot"], [snapshot-remove], [no-snapshot]';
85
121
 
86
- export function isSnapshotRemoved(el) {
87
- return hasRegionToken(el, 'snapshot-remove') || hasRegionToken(el, 'no-snapshot');
122
+ // Ancestor-aware, because the strip removes a marked element together with its
123
+ // whole subtree: a child of a no-snapshot region is just as absent from every
124
+ // snapshot as the region itself, and answering false for it was a lie callers
125
+ // acted on.
126
+ export function isSnapshotRemoved(node) {
127
+ const element = startElement(node);
128
+ return !!(element && element.closest && element.closest(SNAPSHOT_REMOVE_SELECTOR));
88
129
  }
89
130
 
90
131
  const PERSIST_RANK = { full: 0, frozen: 1, none: 2 };
@@ -98,19 +139,20 @@ function startElement(node) {
98
139
  * Walk an element's self-or-ancestor chain once and resolve its region axes.
99
140
  *
100
141
  * @param {Node} node
101
- * @returns {{watched:boolean, autosaveTriggered:boolean, undoable:boolean, persist:string, extension:boolean}}
142
+ * @returns {{watched:boolean, autosaveTriggered:boolean, dirtyTracked:boolean, undoable:boolean, persist:string, extension:boolean}}
102
143
  */
103
144
  export function resolveRegionPolicy(node) {
104
145
  let element = startElement(node);
105
146
 
106
147
  // Browser-extension injected content is never page content, for any consumer.
107
148
  if (element && element.closest && element.closest(EXTENSION_NODE_SELECTOR)) {
108
- return { watched: false, autosaveTriggered: false, undoable: false, persist: PERSIST.FULL, extension: true };
149
+ return { watched: false, autosaveTriggered: false, dirtyTracked: false, undoable: false, persist: PERSIST.FULL, extension: true };
109
150
  }
110
151
 
111
152
  let watched = true;
112
153
  let undoable = true;
113
154
  let autosaveOff = false;
155
+ let dirtyOff = false;
114
156
  let persistRank = 0;
115
157
 
116
158
  while (element && element.nodeType === 1) {
@@ -118,13 +160,14 @@ export function resolveRegionPolicy(node) {
118
160
  // new naked attributes
119
161
  if (hasRegionToken(element, 'no-watch')) watched = false;
120
162
  if (hasRegionToken(element, 'no-trigger-autosave')) autosaveOff = true;
163
+ if (hasRegionToken(element, 'no-dirty')) { autosaveOff = true; dirtyOff = true; }
121
164
  if (hasRegionToken(element, 'no-undo')) undoable = false;
122
165
  if (hasRegionToken(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
123
166
  if (hasRegionToken(element, 'freeze')) persistRank = Math.max(persistRank, PERSIST_RANK.frozen);
124
167
  // legacy markers -> bundles
125
168
  if (hasRegionToken(element, 'mutations-ignore')) watched = false;
126
169
  if (hasRegionToken(element, 'save-remove')) { persistRank = Math.max(persistRank, PERSIST_RANK.none); undoable = false; }
127
- if (hasRegionToken(element, 'save-ignore')) { autosaveOff = true; undoable = false; }
170
+ if (hasRegionToken(element, 'save-ignore')) { autosaveOff = true; dirtyOff = true; undoable = false; }
128
171
  if (hasRegionToken(element, 'save-freeze')) { persistRank = Math.max(persistRank, PERSIST_RANK.frozen); undoable = false; }
129
172
  }
130
173
  element = element.parentElement;
@@ -133,12 +176,18 @@ export function resolveRegionPolicy(node) {
133
176
  // Implication rules (no-save wins over freeze automatically via Math.max above):
134
177
  // no-watch ⟹ no autosave + no undo (can't track what isn't watched)
135
178
  // no-save / freeze ⟹ no autosave (nothing live to persist)
136
- if (!watched) { autosaveOff = true; undoable = false; }
137
- if (persistRank > 0) autosaveOff = true;
179
+ // no-watch ⟹ not dirty-tracked either (can't track what isn't watched)
180
+ // no-save / freeze ⟹ not dirty-tracked (nothing live to persist)
181
+ // no-trigger-autosave deliberately does NOT clear dirtyTracked: that is the
182
+ // whole distinction between the two axes. no-dirty is the token that clears
183
+ // both, for content that is saved but is not work.
184
+ if (!watched) { autosaveOff = true; undoable = false; dirtyOff = true; }
185
+ if (persistRank > 0) { autosaveOff = true; dirtyOff = true; }
138
186
 
139
187
  return {
140
188
  watched,
141
189
  autosaveTriggered: !autosaveOff,
190
+ dirtyTracked: !dirtyOff,
142
191
  undoable,
143
192
  persist: RANK_PERSIST[persistRank],
144
193
  extension: false,
@@ -176,6 +225,7 @@ export function strictestPolicy(a, b) {
176
225
  return {
177
226
  watched: a.watched && b.watched,
178
227
  autosaveTriggered: a.autosaveTriggered && b.autosaveTriggered,
228
+ dirtyTracked: a.dirtyTracked && b.dirtyTracked,
179
229
  undoable: a.undoable && b.undoable,
180
230
  persist: PERSIST_RANK[a.persist] >= PERSIST_RANK[b.persist] ? a.persist : b.persist,
181
231
  extension: a.extension || b.extension,
@@ -191,7 +241,8 @@ const SKIP_TOKEN_PREDICATES = {
191
241
  'freeze': p => p.persist === PERSIST.FROZEN,
192
242
  'save-freeze': p => p.persist === PERSIST.FROZEN,
193
243
  'no-trigger-autosave': p => !p.autosaveTriggered,
194
- 'save-ignore': p => !p.autosaveTriggered,
244
+ 'no-dirty': p => !p.dirtyTracked,
245
+ 'save-ignore': p => !p.dirtyTracked,
195
246
  'no-undo': p => !p.undoable,
196
247
  };
197
248
 
@@ -199,7 +250,7 @@ const SKIP_TOKEN_PREDICATES = {
199
250
  * Should a consumer skip a change in this region?
200
251
  *
201
252
  * @param {object} policy resolved region policy
202
- * @param {string} [require] axis the consumer needs: 'observed' | 'autosave' | 'undo'
253
+ * @param {string} [require] axis the consumer needs: 'observed' | 'autosave' | 'dirty' | 'undo'
203
254
  * @param {string[]} [skip] literal attribute escape-hatch (any match -> skip)
204
255
  * @returns {boolean}
205
256
  */
@@ -211,6 +262,7 @@ export function skipForPolicy(policy, require, skip) {
211
262
  switch (require) {
212
263
  case 'observed': return !policy.watched;
213
264
  case 'autosave': return !policy.autosaveTriggered;
265
+ case 'dirty': return !policy.dirtyTracked;
214
266
  case 'undo': return !policy.undoable;
215
267
  default:
216
268
  // No require declared: preserve the legacy four-marker skip so unmodified
@@ -222,14 +274,42 @@ export function skipForPolicy(policy, require, skip) {
222
274
  // The canonical region API the vendored hyper-undo (a separate bundle that can't
223
275
  // import this module) delegates "is this undoable?" to via clay.region, so the two
224
276
  // can no longer drift. The loader assembles this onto clay in assembleCore.
225
- export const windowRegionShape = {
277
+ // ONE object, served to both clay.region and clay.internals.region.
278
+ //
279
+ // They had drifted into two shapes with different key spellings for the same
280
+ // selectors, and both are documented, so this is an additive union rather than a
281
+ // choice between them: every name either surface published still resolves.
282
+ export const regionShape = {
226
283
  resolveRegionPolicy,
227
284
  isInert,
285
+ isSnapshotRemoved,
228
286
  skipForPolicy,
229
287
  strictestPolicy,
288
+ addRegionToken,
230
289
  PERSIST,
290
+ TOKENS,
231
291
  REGION_ATTRS,
292
+
293
+ // Flat UPPERCASE names: what clay.region has always published.
232
294
  STRIP_FROM_SAVE,
233
- FREEZE_SELECTOR,
234
295
  STRIP_FROM_COMPARISON,
296
+ STRIP_FROM_DIRTY_CHECK,
297
+ NO_TRIGGER_AUTOSAVE_SELECTOR,
298
+ NO_DIRTY_SELECTOR,
299
+ SNAPSHOT_REMOVE_SELECTOR,
300
+ FREEZE_SELECTOR,
301
+
302
+ // Nested camelCase: what clay.internals.region has always published.
303
+ selectors: {
304
+ stripFromSave: STRIP_FROM_SAVE,
305
+ stripFromComparison: STRIP_FROM_COMPARISON,
306
+ stripFromDirtyCheck: STRIP_FROM_DIRTY_CHECK,
307
+ noTriggerAutosave: NO_TRIGGER_AUTOSAVE_SELECTOR,
308
+ noDirty: NO_DIRTY_SELECTOR,
309
+ snapshotRemove: SNAPSHOT_REMOVE_SELECTOR,
310
+ freeze: FREEZE_SELECTOR,
311
+ },
235
312
  };
313
+
314
+ // The name the loader imported before the two shapes merged.
315
+ export const windowRegionShape = regionShape;
@@ -18,10 +18,6 @@
18
18
  // content cannot strip this tab's copy, and a peer's copy is never applied.
19
19
  export const HOST_TOKEN_ATTRS = ["savetoken", "htmlclaytoken", "htmlclayid"];
20
20
 
21
- // Which save envelope this host's lane takes: a fact about the response, not
22
- // about the document.
23
- export const SAVE_TRANSPORT_ATTR = "clay-save-transport";
24
-
25
21
  // This library's own root state, and this tab's UI truth.
26
22
  export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
27
23
 
@@ -36,7 +32,6 @@ export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
36
32
  // can no longer save at all.
37
33
  export const TAB_LOCAL_ROOT_ATTRS = new Set([
38
34
  ...HOST_TOKEN_ATTRS,
39
- SAVE_TRANSPORT_ATTR,
40
35
  ...ROOT_LIBRARY_ATTRS,
41
36
  ]);
42
37
 
@@ -45,6 +45,7 @@ const RECENT_GESTURE_MS = 500;
45
45
  let gestureTaskActive = false;
46
46
  let lastTrustedGestureTs = -Infinity;
47
47
  let userDrivenSinceLastSave = false;
48
+ let explicitSaveIntent = false;
48
49
  let installed = false;
49
50
 
50
51
  // Monotonic clock for the recency window — never moves backward, so a system
@@ -69,7 +70,9 @@ function onGesture(e) {
69
70
 
70
71
  /**
71
72
  * Install the capture-phase gesture listeners (idempotent). Call once in edit
72
- * mode (autosave.js and data-loss-panel both do this).
73
+ * mode. save.js does this for every editable page: gesture provenance is not an
74
+ * autosave feature, and gating it on <html autosave> left every manual-save page
75
+ * reporting its human edits as background writes.
73
76
  */
74
77
  export function initUserGesture() {
75
78
  if (installed || typeof document === 'undefined') return;
@@ -108,11 +111,40 @@ export function consumeUserDriven() {
108
111
  return v;
109
112
  }
110
113
 
114
+ /**
115
+ * Record that a person asked for THIS save (Cmd+S, a [trigger-save] button).
116
+ *
117
+ * Deliberately NOT markUserDriven(). That bit accumulates until a save actually
118
+ * ships, which is right for "an edit happened in a human turn" but wrong here: a
119
+ * person pressing Save on a clean page produces no save at all, so the bit stays
120
+ * armed and rides whatever BACKGROUND write happens next, reporting it as human.
121
+ * That is precisely the write the recovery guard exists to catch.
122
+ *
123
+ * This one is scoped to a single save attempt. The send consumes it; an attempt
124
+ * that ends without sending clears it.
125
+ */
126
+ export function markExplicitSave() {
127
+ explicitSaveIntent = true;
128
+ }
129
+
130
+ /** Read-and-reset, at the actual send. */
131
+ export function consumeExplicitSave() {
132
+ const v = explicitSaveIntent;
133
+ explicitSaveIntent = false;
134
+ return v;
135
+ }
136
+
137
+ /** The save attempt ended without sending anything. */
138
+ export function clearExplicitSave() {
139
+ explicitSaveIntent = false;
140
+ }
141
+
111
142
  /** Test seam: force-reset state. */
112
143
  export function _resetUserGesture() {
113
144
  gestureTaskActive = false;
114
145
  lastTrustedGestureTs = -Infinity;
115
146
  userDrivenSinceLastSave = false;
147
+ explicitSaveIntent = false;
116
148
  }
117
149
 
118
150
  /**
@@ -38,10 +38,17 @@ const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop",
38
38
  // dead with no error and no log. quickcrop loads BEFORE cms in the order above,
39
39
  // because the loader attaches each plugin's member as it lands and cms reads what
40
40
  // earlier plugins attached during its own evaluation.
41
- // `upload` is deliberately NOT implied by cms yet. Adding it flips how an
42
- // existing page behaves, from embedding an image to storing it, and that is
43
- // isolated into its own one-line release so it can be reverted alone.
44
- const IMPLIES = { cms: ["quickcrop"] };
41
+ // `upload` rides the same reasoning one step further. Without it the cms has no
42
+ // uploader to look up, so it embeds every picked image in the document as a data:
43
+ // URL: a two megabyte photo costs 2.7 MB of base64 on that save, on every future
44
+ // save, and in every stored version. With it the cms asks the host first and
45
+ // embeds only when the host does not store files, which is still the right answer
46
+ // on a plain file server.
47
+ //
48
+ // This is the only line in the capability that changes how an already-published
49
+ // page behaves, which is why it shipped alone, one release after the plugin it
50
+ // enables. Reverting it is reverting this line.
51
+ const IMPLIES = { cms: ["quickcrop", "upload"] };
45
52
 
46
53
  function parseCsv(params, key, enabled, apply) {
47
54
  const raw = params.get(key);
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { morph } from "../vendor/hyper-morph.vendor.js";
23
23
  import { STRIP_FROM_SAVE } from "../lib/region-policy.js";
24
+ import { isEditMode } from "../core/is-edit-mode.js";
24
25
 
25
26
  const KEY = "clay:demo:" +
26
27
  (document.documentElement.getAttribute("demo-key") || window.location.pathname);
@@ -50,14 +51,7 @@ document.addEventListener("clay:snapshot-ready", (event) => {
50
51
  });
51
52
 
52
53
  function postedHtml(raw) {
53
- const text = typeof raw === "string" ? raw : "";
54
- try {
55
- const envelope = JSON.parse(text);
56
- if (envelope && typeof envelope === "object") {
57
- return envelope.snapshotHtml || envelope.content || text;
58
- }
59
- } catch {}
60
- return text;
54
+ return typeof raw === "string" ? raw : "";
61
55
  }
62
56
 
63
57
  const realFetch = window.fetch.bind(window);
@@ -111,6 +105,18 @@ async function restore() {
111
105
  const saved = localStorage.getItem(KEY);
112
106
  if (!saved) return;
113
107
  const doc = new DOMParser().parseFromString(saved, "text/html");
108
+
109
+ // The body was stored from an edit-mode page, so every [editable] in it carries
110
+ // the contenteditable richclay adds at runtime. Morphing that into a view-mode
111
+ // load would hand a visitor a fully editable document. Drop the runtime
112
+ // attribute, never the element: [contenteditable] cannot join CHROME_SELECTOR,
113
+ // whose matches are removed outright, and doing that deletes real content.
114
+ if (!isEditMode) {
115
+ for (const el of doc.querySelectorAll("[editable][contenteditable]")) {
116
+ el.removeAttribute("contenteditable");
117
+ }
118
+ }
119
+
114
120
  await morph(document.body, doc.body, {
115
121
  morphStyle: "outerHTML",
116
122
  ignoreActiveValue: true,