@panphora/clayjs 0.6.1 → 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.
@@ -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
 
@@ -1,4 +1,4 @@
1
- /*! quickcrop v1.0.0 | MIT | https://github.com/panphora/quickcrop
1
+ /*! quickcrop v1.1.0 | MIT-0 | https://github.com/panphora/quickcrop
2
2
  *
3
3
  * const result = await quickcrop(file, { aspect: 1 });
4
4
  * // result: { blob, dataURL, width, height } on confirm, null on cancel
package/NOTICE DELETED
@@ -1,19 +0,0 @@
1
- clayjs
2
- Copyright (c) 2026 David Miranda
3
- Licensed under the MIT License (see LICENSE).
4
-
5
- This product bundles and adapts third-party software:
6
-
7
- - hyperclayjs — MIT License, Copyright (c) 2025 Hyperclay.
8
- Portions of clayjs (the save lifecycle, mutation hub, region model, UI,
9
- event attributes, option visibility, DOM helpers, and utilities under src/)
10
- are hand-ported from hyperclayjs.
11
-
12
- - MicroModal — MIT License, Copyright (c) 2017 Indrashish Ghosh.
13
- Embedded in src/ui/modal.js.
14
-
15
- - sapjs — MIT License, Copyright (c) 2026 panphora.
16
- Distributed unmodified as the generated sap.js (see its header).
17
-
18
- - hyper-html-api — Zero-Clause BSD (0BSD).
19
- Distributed as the generated clay-data.js (see its header).