@panphora/clayjs 0.6.0 → 0.7.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.
@@ -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,
@@ -0,0 +1,185 @@
1
+ /**
2
+ * upload.js — store a file on the host instead of embedding it (spec §9).
3
+ *
4
+ * The problem this exists for: a document that cannot upload has to put the image
5
+ * INSIDE itself as a data: URL. A two megabyte photo then costs 2.7 MB of base64
6
+ * on this save, on every future save, and in every stored version. Embedding is
7
+ * the right answer for a file nobody is serving, and the wrong answer everywhere
8
+ * else, and until now clayjs had no way to tell the difference.
9
+ *
10
+ * const result = await clay.upload(file, { onProgress, signal });
11
+ * // { ok, msg, msgType, code, uploads }
12
+ *
13
+ * The result NEVER rejects, matching the save lane. One shape from every exit,
14
+ * because two channels for three outcomes (stored, cannot store here, failed) is
15
+ * the exact pattern save-core.js documents as the source of its own past bugs: a
16
+ * "cannot store here" thrown as an error reads as a failure, and a caller that
17
+ * catches it embeds when it should have stopped, or stops when it should have
18
+ * embedded.
19
+ *
20
+ * Codes a caller branches on:
21
+ * unsupported this host does not store files; embedding is correct
22
+ * payment-required the host stores files, but not for this account
23
+ * too-large over the host's cap, refused here or by the host
24
+ * unsupported-type the host will not store this kind of file
25
+ * timeout no answer; the upload may or may not have landed
26
+ */
27
+
28
+ import { hostMeta } from "../core/host-meta.js";
29
+ import { saveToken } from "../core/host-attrs.js";
30
+
31
+ const UPLOAD_PATH = "/_/upload";
32
+ const UPLOAD_TIMEOUT_MS = 120000;
33
+
34
+ function result(ok, msg, msgType, code, uploads = []) {
35
+ return { ok, msg, msgType, code, uploads };
36
+ }
37
+
38
+ function emit(name, detail) {
39
+ if (typeof document === "undefined") return;
40
+ document.dispatchEvent(new CustomEvent(`clay:upload-${name}`, { detail }));
41
+ }
42
+
43
+ /**
44
+ * Store one file on the host.
45
+ *
46
+ * @param {File|Blob} file
47
+ * @param {Object} [options]
48
+ * @param {function} [options.onProgress] - ({loaded, total, percent})
49
+ * @param {AbortSignal} [options.signal]
50
+ * @returns {Promise<{ok: boolean, msg: string, msgType: string, code: ?string, uploads: Array}>}
51
+ */
52
+ export async function upload(file, { onProgress, signal } = {}) {
53
+ if (!file) return result(false, "No file to upload", "error", "bad-request");
54
+
55
+ const meta = await hostMeta();
56
+ if (!meta.extensions.includes("upload")) {
57
+ // Not an error. This is a document open from a plain file server, or a bare
58
+ // core host, and embedding is what it should do.
59
+ return result(false, "This host does not store uploaded files", "skipped", "unsupported");
60
+ }
61
+
62
+ // The one local pre-check the spec asks for, and the only reason `maxBytes` is
63
+ // published: refusing a 40 MB photo here costs nothing, and sending it to be
64
+ // refused costs the person the whole upload first. Deliberately the ONLY thing
65
+ // decided locally. `document.upload.allowed` is not read as a veto: it can be
66
+ // stale (a plan upgraded in another tab, a share link granted since load), and
67
+ // the host answers the real reason with its own code on the request itself.
68
+ const cap = meta.document?.upload?.maxBytes;
69
+ if (typeof cap === "number" && cap > 0 && file.size > cap) {
70
+ return result(false, `That file is larger than this host accepts (${cap} bytes)`, "error", "too-large");
71
+ }
72
+
73
+ return send(file, { onProgress, signal });
74
+ }
75
+
76
+ function send(file, { onProgress, signal }) {
77
+ return new Promise((resolve) => {
78
+ const token = saveToken();
79
+ const path = token ? `${UPLOAD_PATH}/${token}` : UPLOAD_PATH;
80
+ // Absolute against the real origin, never left relative: a <base href> in the
81
+ // authored document would otherwise redirect this request, and the
82
+ // per-document token in its path, to an origin the document chose.
83
+ const url = new URL(path, window.location.origin).href;
84
+
85
+ // XMLHttpRequest for one reason: fetch cannot report upload progress. A photo
86
+ // over a phone connection is the case this whole capability is for, and a
87
+ // picker that sits there saying nothing for twenty seconds reads as broken.
88
+ const xhr = new XMLHttpRequest();
89
+ let settled = false;
90
+
91
+ const finish = (value) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ clearTimeout(timeoutId);
95
+ if (signal) signal.removeEventListener("abort", onAbort);
96
+ resolve(value);
97
+ };
98
+
99
+ const fail = (msg, msgType, code) => {
100
+ emit("error", { msg, code });
101
+ finish(result(false, msg, msgType, code));
102
+ };
103
+
104
+ const timeoutId = setTimeout(() => {
105
+ xhr.abort();
106
+ // A timeout is not evidence the upload failed. The bytes may well have
107
+ // landed, so this says "we do not know" rather than asserting a failure,
108
+ // and the content-hash name makes the retry idempotent if the caller resends.
109
+ fail("Upload timed out", "unknown", "timeout");
110
+ }, UPLOAD_TIMEOUT_MS);
111
+
112
+ const onAbort = () => {
113
+ xhr.abort();
114
+ finish(result(false, "Upload cancelled", "skipped", "aborted"));
115
+ };
116
+ if (signal) {
117
+ if (signal.aborted) return onAbort();
118
+ signal.addEventListener("abort", onAbort);
119
+ }
120
+
121
+ xhr.upload.addEventListener("progress", (event) => {
122
+ if (!event.lengthComputable) return;
123
+ const detail = {
124
+ loaded: event.loaded,
125
+ total: event.total,
126
+ percent: Math.round((event.loaded / event.total) * 100)
127
+ };
128
+ emit("progress", detail);
129
+ if (onProgress) {
130
+ try { onProgress(detail); } catch (_) { /* a caller's drawing must not kill the upload */ }
131
+ }
132
+ });
133
+
134
+ xhr.addEventListener("load", () => {
135
+ let body = {};
136
+ try { body = JSON.parse(xhr.responseText || "{}"); } catch (_) { body = {}; }
137
+
138
+ if (xhr.status >= 200 && xhr.status < 300) {
139
+ const uploads = Array.isArray(body.uploads) ? body.uploads : [];
140
+ if (!uploads.length || typeof uploads[0]?.url !== "string") {
141
+ return fail("The host accepted the file but did not say where it put it", "error", "bad-response");
142
+ }
143
+ emit("done", { uploads });
144
+ return finish(result(true, body.msg ?? "Uploaded", body.msgType || "success", body.code ?? null, uploads));
145
+ }
146
+
147
+ // The host's own code when it sent one, so a caller branches on the reason
148
+ // rather than pattern-matching a message. Falling back to the status keeps
149
+ // a host that answers a bare 413 readable.
150
+ const code = body.code || STATUS_CODES[xhr.status] || "error";
151
+ return fail(body.msg || `Upload failed (${xhr.status})`, "error", code);
152
+ });
153
+
154
+ xhr.addEventListener("error", () => fail("Upload failed", "error", "network"));
155
+ xhr.addEventListener("abort", () => finish(result(false, "Upload cancelled", "skipped", "aborted")));
156
+
157
+ xhr.open("POST", url, true);
158
+ // The exact equivalent of the save lane's `credentials: 'omit'` in the case
159
+ // where the two differ: a sandboxed document is cross-origin to its own host,
160
+ // and asking for credentials there needs Access-Control-Allow-Credentials
161
+ // back, which a token-minting host must never send.
162
+ xhr.withCredentials = false;
163
+ xhr.setRequestHeader("Document-URL", window.location.href);
164
+
165
+ // FormData, so the file streams as bytes. No FileReader and no base64: a
166
+ // whole-file string costs a third more bytes and holds the entire file in
167
+ // memory twice.
168
+ const form = new FormData();
169
+ form.append("file", file, file.name || "upload");
170
+
171
+ emit("start", { name: file.name || null, size: file.size ?? null });
172
+ xhr.send(form);
173
+ });
174
+ }
175
+
176
+ const STATUS_CODES = {
177
+ 402: "payment-required",
178
+ 413: "too-large",
179
+ 415: "unsupported-type",
180
+ 401: "unauthorized",
181
+ 403: "forbidden",
182
+ 404: "not-found"
183
+ };
184
+
185
+ export default upload;
@@ -10,7 +10,7 @@
10
10
  * │
11
11
  * ▼
12
12
  * ┌─────────────────────────────────────────────────────────┐
13
- * │ 2. SEND POST html to /_/live-sync/save │
13
+ * │ 2. SEND POST snapshot to the relay address │
14
14
  * │ (debounced, skip if unchanged) │
15
15
  * └─────────────────────────────────────────────────────────┘
16
16
  * │
@@ -40,11 +40,61 @@ import Mutation from "../lib/mutation.js";
40
40
  import { isSnapshotRemoved } from "../lib/region-policy.js";
41
41
  import { isEditMode } from "../core/is-edit-mode.js";
42
42
  import { mergeTagRecognizers } from "./merge-tags.js";
43
- import { serializeForSync, captureForComparison, captureSnapshot } from '../core/snapshot.js';
43
+ import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from '../core/snapshot.js';
44
44
  import { isTabLocalRootAttr } from '../lib/root-attrs.js';
45
45
  import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
46
+ import { hostMeta } from '../core/host-meta.js';
46
47
  import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
47
- import { savePageThrottled, setLastSavedContents, setUnsavedChanges } from '../core/save.js';
48
+
49
+ // The page just took a frame verified clean against its baseline, so it now IS
50
+ // the file on disk. Both saved baselines move together from one capture: leaving
51
+ // the dirty baseline behind would make the close warning fire on the frame's own
52
+ // content. Never flushes undo (this runs per incoming frame, not per save) and
53
+ // never emits snapshot-ready (it must not feed the send pipeline).
54
+ function pairedBaseline() {
55
+ const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
56
+ return [forComparison, forDirty];
57
+ }
58
+ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges } from '../core/save.js';
59
+
60
+ /**
61
+ * The two live-sync wires, and the rule for choosing between them.
62
+ *
63
+ * Spec §10 puts both halves on `/_/sync`. Not every host serves that yet, and the
64
+ * ones that do not cannot be upgraded on our schedule: hyperclay-local ships behind
65
+ * Apple notarization, while this library reaches every document within one 10-minute
66
+ * cache TTL. So the client is the half that has to know both.
67
+ *
68
+ * The choice is DISCOVERED, never guessed. Spec §5 makes `/_/meta` the only way to
69
+ * learn what a host can do, and §10 says a client that has not discovered `sync`
70
+ * never opens either half of the route. Probing is also a bad instrument here: an
71
+ * EventSource reports a 404, an auth refusal and an offline browser through one
72
+ * error path, so a failed connection cannot tell you which address was wrong.
73
+ *
74
+ * One profile is chosen once and used for BOTH directions for the life of the page.
75
+ * Letting send and receive decide independently is exactly the bug this replaces: a
76
+ * client that posted to the spec address while streaming from the legacy one.
77
+ */
78
+ const WIRE_PROFILES = {
79
+ spec: {
80
+ name: 'spec',
81
+ relayPath: '/_/sync',
82
+ documentHeader: 'Document-URL',
83
+ snapshotKey: 'snapshot',
84
+ streamPath: (href, lane, resumeId) =>
85
+ `/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
86
+ },
87
+ // What every published clayjs speaks, and what hyperclayjs and the inline script in
88
+ // every Collection dashboard hardcode. Hosts keep these addresses permanently.
89
+ legacy: {
90
+ name: 'legacy',
91
+ relayPath: '/_/live-sync/save',
92
+ documentHeader: 'Page-URL',
93
+ snapshotKey: 'html',
94
+ streamPath: (href, lane, resumeId) =>
95
+ `/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
96
+ },
97
+ };
48
98
 
49
99
  class LiveSync {
50
100
  constructor() {
@@ -65,6 +115,16 @@ class LiveSync {
65
115
  this.debounceTimer = null;
66
116
  this._sendInFlight = false;
67
117
  this._queuedSend = null;
118
+
119
+ // The chosen wire, and the in-flight discovery that chooses it. Memoized for the
120
+ // life of the page: a host does not change its capabilities under a loaded
121
+ // document, and hostMeta() memoizes the request itself.
122
+ this._profile = null;
123
+ this._profilePromise = null;
124
+ this._ready = null;
125
+ // Bumped by every start(), so a discovery that resolves after a stop/restart
126
+ // cannot open a stream for the run that has already been torn down.
127
+ this._startGen = 0;
68
128
  this.isPaused = false;
69
129
  this.isDestroyed = false;
70
130
  this.debug = false;
@@ -129,6 +189,12 @@ class LiveSync {
129
189
  this._holdRetryPeer = null;
130
190
  this._holdRetryExt = null;
131
191
 
192
+ // Whether each lane is currently holding. A hold is a safe outcome but a
193
+ // silent one: without an event the page simply stops updating and nothing
194
+ // can say why. These make the transitions observable in both directions.
195
+ this._heldLive = false;
196
+ this._heldExt = false;
197
+
132
198
  // Identity tracking for content-based morphing across live-sync updates.
133
199
  // Synthetic IDs (`<clientId>:<counter>`) live here only — never written to
134
200
  // the DOM, never serialized into saved HTML. The WeakMap holds them
@@ -214,7 +280,16 @@ class LiveSync {
214
280
  this.resumeId = this.generateResumeId();
215
281
 
216
282
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
217
- this.connect();
283
+ // One discovery request stands between here and the stream. It is memoized and
284
+ // bounded, and it does not gate ordinary saves — /_/save is a separate lane that
285
+ // never moved. Snapshots produced during the window are queued, not dropped.
286
+ const gen = ++this._startGen;
287
+ // Exposed so a caller (and the tests) can await the point where the stream is
288
+ // actually open, rather than counting microtasks behind the discovery request.
289
+ this._ready = this._resolveProfile().then(() => {
290
+ if (this.isDestroyed || this._startGen !== gen) return;
291
+ this.connect();
292
+ });
218
293
  // View-mode tabs are receive-only: saves are edit-gated upstream, so a
219
294
  // snapshot listener would never fire — skip registering it.
220
295
  if (this.lane === 'live') {
@@ -405,6 +480,48 @@ class LiveSync {
405
480
  return pathname;
406
481
  }
407
482
 
483
+ /**
484
+ * Choose the wire profile from what the host advertises.
485
+ *
486
+ * Absence of `sync` selects the legacy wire rather than disabling live sync. That is
487
+ * deliberate transition debt: §10 says an undiscovered capability is not opened at
488
+ * all, but both deployed first-party hosts ran live sync for a long time without
489
+ * advertising anything, and refusing to sync with them would be a regression against
490
+ * every published version. Once the unannounced hosts are gone, absence should mean
491
+ * off. A malformed answer, a 404 or a timeout all land here too, and all mean legacy.
492
+ */
493
+ _resolveProfile() {
494
+ if (!this._profilePromise) {
495
+ this._profilePromise = hostMeta()
496
+ .then((meta) => (meta?.extensions?.includes('sync') ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy))
497
+ .catch(() => WIRE_PROFILES.legacy)
498
+ .then((profile) => {
499
+ this._profile = profile;
500
+ this._log(`Wire profile: ${profile.name}`);
501
+ return profile;
502
+ });
503
+ }
504
+ return this._profilePromise;
505
+ }
506
+
507
+ /**
508
+ * Report a hold transition on one lane, once per episode in each direction,
509
+ * so a listener can raise a "sync paused until you save" notice and take it
510
+ * back down again. Never fires twice for the same state.
511
+ *
512
+ * @param {string} lane 'live' or 'external'
513
+ * @param {boolean} isHeld
514
+ * @param {Element} [el] the local element that could not be merged
515
+ */
516
+ _setHeld(lane, isHeld, el) {
517
+ const key = lane === 'live' ? '_heldLive' : '_heldExt';
518
+ if (this[key] === isHeld) return;
519
+ this[key] = isHeld;
520
+ document.dispatchEvent(new CustomEvent(isHeld ? 'clay:sync-held' : 'clay:sync-resumed', {
521
+ detail: { lane, el: el || null },
522
+ }));
523
+ }
524
+
408
525
  /**
409
526
  * Connect to the SSE endpoint
410
527
  * Uses native EventSource reconnection behavior
@@ -412,8 +529,10 @@ class LiveSync {
412
529
  connect() {
413
530
  if (this.isDestroyed) return;
414
531
 
415
- const pageUrl = encodeURIComponent(window.location.href);
416
- const path = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}&resume-id=${this.resumeId}`;
532
+ // Whichever wire discovery selected. start() does not call connect() until the
533
+ // profile is known, so this is never null on the normal path.
534
+ const profile = this._profile || WIRE_PROFILES.legacy;
535
+ const path = profile.streamPath(window.location.href, this.lane, this.resumeId);
417
536
  // Resolved against the real origin: a <base href> in the authored document
418
537
  // would otherwise point the sync stream at an origin the document chose.
419
538
  this.sse = new EventSource(new URL(path, window.location.origin).href);
@@ -566,6 +685,14 @@ class LiveSync {
566
685
  }
567
686
 
568
687
  _enqueueSend(html, identityMap) {
688
+ // Same one-deep queue the in-flight case uses, for the same reason: peers only
689
+ // ever need the newest state, so a fresher snapshot replaces the waiting one.
690
+ // Sending before the profile is known would have to guess an address.
691
+ if (!this._profile) {
692
+ this._queuedSend = { html, identityMap };
693
+ this._resolveProfile().then(() => this._flushQueuedSend());
694
+ return;
695
+ }
569
696
  if (this._sendInFlight) {
570
697
  this._queuedSend = { html, identityMap };
571
698
  return;
@@ -573,6 +700,14 @@ class LiveSync {
573
700
  this._postUpdate(html, identityMap);
574
701
  }
575
702
 
703
+ _flushQueuedSend() {
704
+ if (this.isDestroyed || this._sendInFlight) return;
705
+ const queued = this._queuedSend;
706
+ if (!queued) return;
707
+ this._queuedSend = null;
708
+ this._postUpdate(queued.html, queued.identityMap);
709
+ }
710
+
576
711
  _postUpdate(html, identityMap) {
577
712
  // Skip if unchanged
578
713
  if (html === this.lastHtml) {
@@ -587,11 +722,15 @@ class LiveSync {
587
722
 
588
723
  // Absolute against the real origin, so a <base href> in the page cannot
589
724
  // redirect the whole document to an origin the author picked.
590
- fetch(new URL('/_/live-sync/save', window.location.origin).href, {
725
+ const profile = this._profile || WIRE_PROFILES.legacy;
726
+ fetch(new URL(profile.relayPath, window.location.origin).href, {
591
727
  method: 'POST',
592
- headers: { 'Content-Type': 'application/json', 'Page-URL': window.location.href },
728
+ headers: {
729
+ 'Content-Type': 'application/json',
730
+ [profile.documentHeader]: window.location.href,
731
+ },
593
732
  body: JSON.stringify({
594
- html: html,
733
+ [profile.snapshotKey]: html,
595
734
  sender: this.clientId,
596
735
  identityMap: identityMap
597
736
  })
@@ -931,6 +1070,7 @@ class LiveSync {
931
1070
  // blocking edit is undone; the slot-empty check and the drain's
932
1071
  // staleness checks drop it once superseded.
933
1072
  console.log('[LiveSync] Holding incoming update: unsaved local section cannot be safely merged', protection.held?.el || '');
1073
+ this._setHeld('live', true, protection.held?.el);
934
1074
  const epochAtHold = this._saveEpoch;
935
1075
  const seenAtHold = this.lastSeenSeq;
936
1076
  clearTimeout(this._holdRetryPeer);
@@ -951,6 +1091,8 @@ class LiveSync {
951
1091
  retainedRoots = protection.entries.length;
952
1092
  }
953
1093
 
1094
+ this._setHeld('live', false);
1095
+
954
1096
  // Morph entire document. We MUST await — HyperMorph.morph returns a
955
1097
  // Promise when `scripts: { handle: true }` needs to wait for external
956
1098
  // scripts to load. If we don't await, Mutation.resume() fires before
@@ -1016,7 +1158,7 @@ class LiveSync {
1016
1158
  // (including typing that arrived during the morph's async wait, which
1017
1159
  // must never be recorded as saved).
1018
1160
  if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1019
- setLastSavedContents(captureForComparison({ flushUndo: false }));
1161
+ setLastSavedBaselines(...pairedBaseline());
1020
1162
  }
1021
1163
 
1022
1164
  // Announce that a remote morph just landed, so document-level listeners
@@ -1102,6 +1244,7 @@ class LiveSync {
1102
1244
  // disk instead of a stale re-apply, and the seq check drops it
1103
1245
  // once a newer external change supersedes it.
1104
1246
  console.log('[LiveSync] Holding external change: unsaved local section cannot be safely merged', protection.held?.el || '');
1247
+ this._setHeld('external', true, protection.held?.el);
1105
1248
  const epochAtHold = this._saveEpoch;
1106
1249
  clearTimeout(this._holdRetryExt);
1107
1250
  this._holdRetryExt = setTimeout(() => {
@@ -1115,6 +1258,8 @@ class LiveSync {
1115
1258
  retainedRoots = protection.entries.length;
1116
1259
  }
1117
1260
 
1261
+ this._setHeld('external', false);
1262
+
1118
1263
  activateIncomingDoc(newDoc.documentElement);
1119
1264
 
1120
1265
  const liveWeakMap = this.liveWeakMap;
@@ -1148,7 +1293,7 @@ class LiveSync {
1148
1293
  // beforeunload stays quiet. The dirty re-check matters: typing that
1149
1294
  // arrived during the morph's async wait would otherwise be captured
1150
1295
  // into the baseline and recorded as saved without reaching disk.
1151
- setLastSavedContents(captureForComparison({ flushUndo: false }));
1296
+ setLastSavedBaselines(...pairedBaseline());
1152
1297
  setUnsavedChanges(false);
1153
1298
 
1154
1299
  // Cross-lane baseline: the PEER diff base (lastHtml) would otherwise
@@ -1241,5 +1386,5 @@ if (typeof window !== 'undefined') {
1241
1386
  // Export for the clayjs module system. The class itself is exported so
1242
1387
  // tests can create fresh instances without driving the singleton's
1243
1388
  // EventSource/snapshot wiring.
1244
- export { liveSync, LiveSync, morph };
1389
+ export { liveSync, LiveSync, morph, WIRE_PROFILES };
1245
1390
  export default liveSync;
@@ -7,11 +7,16 @@
7
7
  * diff always compares trees from ONE serialization domain.
8
8
  *
9
9
  * Disk frames (htmlclay external changes; save domain on the wire):
10
- * diff comparison clone vs parse(lastSavedContents) [same domain]
10
+ * diff loss-domain clone vs parse(lastSavedDirty) [same domain]
11
11
  * splice save-clone subtrees into the incoming disk doc [same domain]
12
12
  * The two clones come from ONE snapshot via captureForMerge(), whose
13
- * pairMap bridges them. Save-domain subtrees still carry the
14
- * no-trigger-autosave / freeze / no-watch children the comparison strips.
13
+ * pairMap bridges them. Save-domain subtrees still carry the freeze /
14
+ * no-watch children the loss-domain clone strips.
15
+ *
16
+ * The loss domain, not the autosave domain: the question here is "would
17
+ * this frame destroy work?", which is the close warning's question, so it
18
+ * keeps no-trigger-autosave content. Disposable churn is declared out of
19
+ * it with no-dirty rather than inferred from bytes or gesture timing.
15
20
  *
16
21
  * Peer frames (live-lane relays; snapshot domain on the wire):
17
22
  * diff snapshot clone vs parse(lastHtml) [same domain]
@@ -31,15 +36,16 @@ import { captureSnapshot, captureForMerge } from '../core/snapshot.js';
31
36
  // save.js is edit-only in the loader waves but safe to reach from here: its
32
37
  // module body guards every init on isEditMode, and the disk lane that needs
33
38
  // this state only ever runs in edit-mode tabs.
34
- import { getLastSavedContents } from '../core/save.js';
39
+ import { getLastSavedDirty } from '../core/save.js';
35
40
  import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
36
41
  import {
37
42
  isSnapshotRemoved,
38
43
  STRIP_FROM_SAVE,
39
44
  FREEZE_SELECTOR,
40
45
  SNAPSHOT_REMOVE_SELECTOR,
46
+ NO_DIRTY_SELECTOR,
41
47
  } from '../lib/region-policy.js';
42
- import { probeMarkClean } from '../lib/dirty-gate.js';
48
+ import { probeMarkClean, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
43
49
  import { isEditMode } from '../core/is-edit-mode.js';
44
50
  import { enableContentEditable } from '../core/admin-contenteditable.js';
45
51
  import { enableOnClick } from '../core/admin-onclick.js';
@@ -53,7 +59,7 @@ const PEER_SKIP_SELECTOR = [
53
59
  STRIP_FROM_SAVE,
54
60
  FREEZE_SELECTOR,
55
61
  SNAPSHOT_REMOVE_SELECTOR,
56
- '[save-ignore]',
62
+ NO_DIRTY_SELECTOR,
57
63
  ].join(', ');
58
64
 
59
65
  function peerSkip(el) {
@@ -148,6 +154,7 @@ export function protectPeerDoc({ newDoc, parsedWeakMap, baseHtml, baseIdentityMa
148
154
  return { ok: false, entries: [], held: null };
149
155
  }
150
156
 
157
+ const gateToken = gateCaptureToken();
151
158
  const localClone = captureSnapshot({ flushUndo: false });
152
159
  const baseDoc = parsePeerBase(baseHtml);
153
160
  if (!baseDoc.documentElement) return { ok: false, entries: [], held: null };
@@ -172,7 +179,12 @@ export function protectPeerDoc({ newDoc, parsedWeakMap, baseHtml, baseIdentityMa
172
179
  });
173
180
 
174
181
  if (!entries.length) {
182
+ // The oracle just proved the page clean against its baseline. probeMarkClean
183
+ // only caches form signatures; without the generation-checked counter clear
184
+ // an edit that was typed and then undone leaves the gate armed forever, and
185
+ // every later frame pays the full capture-and-diff for nothing.
175
186
  probeMarkClean();
187
+ gateClearIfUnchanged(gateToken);
176
188
  return { ok: true, entries };
177
189
  }
178
190
 
@@ -203,9 +215,10 @@ export function protectPeerDoc({ newDoc, parsedWeakMap, baseHtml, baseIdentityMa
203
215
  * @returns {{ ok: boolean, entries: Array, held?: object }}
204
216
  */
205
217
  export function protectDiskDoc({ newDoc }) {
206
- const base = getLastSavedContents();
218
+ const base = getLastSavedDirty();
207
219
  if (!base) return { ok: false, entries: [], held: null };
208
220
 
221
+ const gateToken = gateCaptureToken();
209
222
  const { saveClone, compareClone, pairMap } = captureForMerge();
210
223
  const baseDoc = parseDiskBase(base);
211
224
  if (!baseDoc.documentElement) return { ok: false, entries: [], held: null };
@@ -216,6 +229,7 @@ export function protectDiskDoc({ newDoc }) {
216
229
 
217
230
  if (!entries.length) {
218
231
  probeMarkClean();
232
+ gateClearIfUnchanged(gateToken);
219
233
  return { ok: true, entries };
220
234
  }
221
235