hyperclayjs 1.15.0 → 1.17.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.
package/LICENSE CHANGED
@@ -26,10 +26,10 @@ SOFTWARE.
26
26
 
27
27
  This project includes the following third-party libraries:
28
28
 
29
- ### Idiomorph
30
- - Copyright (c) Big Sky Software
29
+ ### HyperMorph
30
+ - Copyright (c) Hyperclay
31
31
  - License: 0BSD (Zero-Clause BSD)
32
- - https://github.com/bigskysoftware/idiomorph
32
+ - https://github.com/hyperclay/hyper-morph
33
33
 
34
34
  ### Sortable.js
35
35
  - Copyright (c) All contributors to Sortable
package/README.md CHANGED
@@ -57,17 +57,17 @@ import 'hyperclayjs/presets/standard.js';
57
57
 
58
58
  | Module | Size | Description |
59
59
  |--------|------|-------------|
60
- | autosave | 0.9KB | Auto-save on DOM changes |
60
+ | autosave | 1.4KB | Auto-save on DOM changes |
61
61
  | edit-mode | 1.8KB | Toggle edit mode on hyperclay on/off |
62
62
  | edit-mode-helpers | 7.5KB | Admin-only functionality: [edit-mode-input], [edit-mode-resource], [edit-mode-onclick] |
63
63
  | option-visibility | 7.8KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
64
64
  | persist | 2.4KB | Persist input/select/textarea values to the DOM with [persist] attribute |
65
- | save-core | 6.8KB | Basic save function only - hyperclay.savePage() |
66
- | save-system | 9.6KB | CMD+S, [trigger-save] button, savestatus attribute |
65
+ | save-core | 7.4KB | Basic save function only - hyperclay.savePage() |
66
+ | save-system | 12.1KB | CMD+S, [trigger-save] button, savestatus attribute |
67
67
  | save-toast | 0.9KB | Toast notifications for save events |
68
- | snapshot | 7.5KB | Source of truth for page state - captures DOM snapshots for save and sync |
69
- | tailwind-inject | 0.4KB | Injects tailwind CSS link with cache-bust on save |
70
- | unsaved-warning | 0.8KB | Warn before leaving page with unsaved changes |
68
+ | snapshot | 10.2KB | Source of truth for page state - captures DOM snapshots for save and sync |
69
+ | tailwind-inject | 1.4KB | Injects tailwind CSS link with cache-bust on save |
70
+ | unsaved-warning | 1.3KB | Warn before leaving page with unsaved changes |
71
71
 
72
72
  ### Custom Attributes (HTML enhancements)
73
73
 
@@ -76,7 +76,7 @@ import 'hyperclayjs/presets/standard.js';
76
76
  | ajax-elements | 2.6KB | [ajax-form], [ajax-button] for async form submissions |
77
77
  | dom-helpers | 6.2KB | el.nearest, el.val, el.text, el.exec, el.cycle |
78
78
  | event-attrs | 4.6KB | [onclickaway], [onclickchildren], [onclone], [onpagemutation], [onrender] |
79
- | input-helpers | 1.2KB | [prevent-enter], [autosize] for textareas |
79
+ | input-helpers | 3.9KB | [prevent-enter], [autosize] for textareas |
80
80
  | onaftersave | 1KB | [onaftersave] attribute - run JS when save status changes |
81
81
  | sortable | 3.4KB | Drag-drop sorting with [sortable], lazy-loads ~118KB Sortable.js in edit mode |
82
82
 
@@ -86,7 +86,7 @@ import 'hyperclayjs/presets/standard.js';
86
86
  |--------|------|-------------|
87
87
  | dialogs | 7.7KB | ask(), consent(), tell(), snippet() dialog functions |
88
88
  | the-modal | 21KB | Full modal window creation system - window.theModal |
89
- | toast | 7.9KB | Success/error message notifications, toast(msg, msgType) |
89
+ | toast | 8.3KB | Success/error message notifications, toast(msg, msgType) |
90
90
 
91
91
  ### Utilities (Core utilities (often auto-included))
92
92
 
@@ -95,7 +95,7 @@ import 'hyperclayjs/presets/standard.js';
95
95
  | cache-bust | 0.6KB | Cache-bust href/src attributes |
96
96
  | cookie | 1.4KB | Cookie management (often auto-included) |
97
97
  | debounce | 0.4KB | Function debouncing |
98
- | mutation | 13KB | DOM mutation observation (often auto-included) |
98
+ | mutation | 13.5KB | DOM mutation observation (often auto-included) |
99
99
  | nearest | 3.4KB | Find nearest elements (often auto-included) |
100
100
  | throttle | 0.8KB | Function throttling |
101
101
 
@@ -106,7 +106,7 @@ import 'hyperclayjs/presets/standard.js';
106
106
  | all-js | 14.4KB | Full DOM manipulation library |
107
107
  | dom-ready | 0.4KB | DOM ready callback |
108
108
  | form-data | 2KB | Extract form data as an object |
109
- | style-injection | 4KB | Dynamic stylesheet injection |
109
+ | style-injection | 4.2KB | Dynamic stylesheet injection |
110
110
 
111
111
  ### String Utilities (String manipulation helpers)
112
112
 
@@ -121,28 +121,28 @@ import 'hyperclayjs/presets/standard.js';
121
121
  | Module | Size | Description |
122
122
  |--------|------|-------------|
123
123
  | file-upload | 10.7KB | File upload with progress |
124
- | live-sync | 12.7KB | Real-time DOM sync across browsers |
124
+ | live-sync | 10.4KB | Real-time DOM sync across browsers |
125
125
  | send-message | 1.3KB | Message sending utility |
126
126
 
127
127
  ### Vendor Libraries (Third-party libraries)
128
128
 
129
129
  | Module | Size | Description |
130
130
  |--------|------|-------------|
131
- | idiomorph | 8.3KB | Efficient DOM morphing library |
131
+ | hyper-morph | 17.2KB | DOM morphing with content-based element matching |
132
132
 
133
133
  ## Presets
134
134
 
135
- ### Minimal (~40.6KB)
135
+ ### Minimal (~46.8KB)
136
136
  Essential features for basic editing
137
137
 
138
138
  **Modules:** `save-core`, `snapshot`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
139
139
 
140
- ### Standard (~62.4KB)
140
+ ### Standard (~69.1KB)
141
141
  Standard feature set for most use cases
142
142
 
143
143
  **Modules:** `save-core`, `snapshot`, `save-system`, `unsaved-warning`, `edit-mode-helpers`, `persist`, `option-visibility`, `event-attrs`, `dom-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
144
144
 
145
- ### Everything (~184.6KB)
145
+ ### Everything (~201.8KB)
146
146
  All available features
147
147
 
148
148
  Includes all available modules across all categories.
@@ -157,9 +157,9 @@ Some modules with large vendor dependencies are **lazy-loaded** to optimize page
157
157
 
158
158
  **How it works:**
159
159
  - The wrapper module checks if the page is in edit mode (`isEditMode`)
160
- - If true, it injects a `<script save-ignore>` tag that loads the vendor script
160
+ - If true, it injects a `<script save-remove>` tag that loads the vendor script
161
161
  - If false, nothing is loaded - viewers don't download the heavy scripts
162
- - The `save-ignore` attribute strips the script tag when the page is saved
162
+ - The `save-remove` attribute strips the script tag when the page is saved
163
163
 
164
164
  This means:
165
165
  - **Editors** get full functionality when needed
@@ -371,7 +371,7 @@ MIT © Hyperclay
371
371
 
372
372
  This project includes the following open-source libraries:
373
373
 
374
- - **[Idiomorph](https://github.com/bigskysoftware/idiomorph)** - DOM morphing library by Big Sky Software (0BSD)
374
+ - **[HyperMorph](https://github.com/hyperclay/hyper-morph)** - DOM morphing with content-based element matching (0BSD)
375
375
  - **[Sortable.js](https://github.com/SortableJS/Sortable)** - Drag-and-drop library (MIT)
376
376
 
377
377
  ## Links
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperclayjs",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Modular JavaScript library for building interactive HTML applications with Hyperclay",
5
5
  "type": "module",
6
6
  "main": "src/hyperclay.js",
@@ -5,12 +5,12 @@
5
5
  *
6
6
  * ┌─────────────────────────────────────────────────────────┐
7
7
  * │ 1. LISTEN snapshot-ready event from save │
8
- * │ (body with form values, no strip)
8
+ * │ (full document with form values)
9
9
  * └─────────────────────────────────────────────────────────┘
10
10
  * │
11
11
  * ▼
12
12
  * ┌─────────────────────────────────────────────────────────┐
13
- * │ 2. SEND POST body to /live-sync/save │
13
+ * │ 2. SEND POST html to /live-sync/save │
14
14
  * │ (debounced, skip if unchanged) │
15
15
  * └─────────────────────────────────────────────────────────┘
16
16
  * │
@@ -22,25 +22,27 @@
22
22
  * │
23
23
  * ▼
24
24
  * ┌─────────────────────────────────────────────────────────┐
25
- * │ 4. MORPH Idiomorph to update DOM
25
+ * │ 4. MORPH HyperMorph full documentElement
26
26
  * │ (preserves focus, input values) │
27
27
  * └─────────────────────────────────────────────────────────┘
28
28
  *
29
- * DEPENDS ON: Idiomorph (for intelligent DOM morphing)
30
29
  * INTEGRATES WITH: snapshot.js (receives snapshot-ready events)
31
30
  */
32
31
 
32
+ import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
33
+ import Mutation from "../utilities/mutation.js";
34
+
33
35
  class LiveSync {
34
36
  constructor() {
35
37
  this.sse = null;
36
38
  this.currentFile = null;
37
- this.lastHeadHash = null;
38
- this.lastBodyHtml = null;
39
+ this.lastHtml = null;
39
40
  this.clientId = this.generateClientId();
40
41
  this.debounceMs = 150;
41
42
  this.debounceTimer = null;
42
43
  this.isPaused = false;
43
44
  this.isDestroyed = false;
45
+ this.debug = false;
44
46
 
45
47
  // Store handler reference for cleanup
46
48
  this._snapshotHandler = null;
@@ -52,22 +54,33 @@ class LiveSync {
52
54
  this.onError = null;
53
55
  }
54
56
 
57
+ _log(message, data = null) {
58
+ if (!this.debug) return;
59
+ const prefix = `[LiveSync ${new Date().toISOString()}]`;
60
+ if (data !== null) {
61
+ console.log(prefix, message, data);
62
+ } else {
63
+ console.log(prefix, message);
64
+ }
65
+ }
66
+
55
67
  /**
56
- * Generate or retrieve a persistent client ID
68
+ * Generate or retrieve a tab-specific client ID
69
+ * Uses sessionStorage (unique per tab) not localStorage (shared across tabs)
57
70
  */
58
71
  generateClientId() {
59
72
  let id = null;
60
73
 
61
74
  try {
62
- id = localStorage.getItem('livesync-client-id');
75
+ id = sessionStorage.getItem('livesync-client-id');
63
76
  } catch (e) {
64
- // localStorage might not be available
77
+ // sessionStorage might not be available
65
78
  }
66
79
 
67
80
  if (!id) {
68
81
  id = Math.random().toString(36).slice(2, 11) + Date.now().toString(36);
69
82
  try {
70
- localStorage.setItem('livesync-client-id', id);
83
+ sessionStorage.setItem('livesync-client-id', id);
71
84
  } catch (e) {
72
85
  // That's okay
73
86
  }
@@ -97,8 +110,7 @@ class LiveSync {
97
110
  }
98
111
 
99
112
  // Reset state for new connection
100
- this.lastHeadHash = null;
101
- this.lastBodyHtml = null;
113
+ this.lastHtml = null;
102
114
 
103
115
  console.log('[LiveSync] Starting for:', this.currentFile);
104
116
  this.connect();
@@ -185,32 +197,23 @@ class LiveSync {
185
197
  return;
186
198
  }
187
199
 
188
- const { body, headHash, sender } = data;
200
+ const { html, sender } = data;
189
201
 
190
202
  // Ignore own changes
191
- if (sender === this.clientId) return;
192
-
193
- // Guard against invalid body - never apply non-string
194
- if (typeof body !== 'string') {
195
- console.error('[LiveSync] Received invalid body (not a string), ignoring');
203
+ if (sender === this.clientId) {
204
+ this._log('Ignoring own message (sender matches clientId)');
196
205
  return;
197
206
  }
198
207
 
199
- // Check for head changes -> full reload
200
- // Only compare when BOTH hashes exist (server must send headHash)
201
- // Only set lastHeadHash when incoming hash is valid
202
- if (headHash) {
203
- if (this.lastHeadHash && headHash !== this.lastHeadHash) {
204
- console.log('[LiveSync] Head changed, reloading');
205
- location.reload();
206
- return;
207
- }
208
- this.lastHeadHash = headHash;
208
+ // Guard against invalid html
209
+ if (typeof html !== 'string') {
210
+ console.error('[LiveSync] Received invalid html, ignoring');
211
+ return;
209
212
  }
210
213
 
211
- console.log('[LiveSync] Received update from:', sender);
212
- this.applyUpdate(body);
213
- if (this.onUpdate) this.onUpdate({ body, sender });
214
+ this._log(`Received update from: ${sender} (my clientId: ${this.clientId})`);
215
+ this.applyUpdate(html);
216
+ if (this.onUpdate) this.onUpdate({ html, sender });
214
217
  };
215
218
 
216
219
  // Native EventSource auto-reconnects on transient errors
@@ -228,160 +231,90 @@ class LiveSync {
228
231
 
229
232
  /**
230
233
  * Listen for snapshot-ready events from the save system.
231
- * Receives the full cloned documentElement and extracts head/body.
234
+ * Receives the full cloned documentElement and sends it.
232
235
  */
233
236
  listenForSnapshots() {
234
- this._snapshotHandler = async (event) => {
235
- if (this.isPaused) return;
237
+ this._snapshotHandler = (event) => {
238
+ if (this.isPaused) {
239
+ this._log('snapshot-ready received but isPaused, skipping');
240
+ return;
241
+ }
236
242
 
237
243
  const { documentElement } = event.detail;
238
244
  if (!documentElement) return;
239
245
 
240
- // Extract head and body directly from cloned element
241
- const head = documentElement.querySelector('head')?.innerHTML || '';
242
- const body = documentElement.querySelector('body')?.innerHTML || '';
243
-
244
- // Compute headHash using SHA-256 (async)
245
- const headHash = await this.computeHeadHash(head);
246
-
247
- // Send update even if body is empty (allows clearing content)
248
- this.sendUpdate(body, headHash);
246
+ this._log('snapshot-ready received, preparing to send');
247
+ const html = documentElement.outerHTML;
248
+ this.sendUpdate(html);
249
249
  };
250
250
 
251
251
  document.addEventListener('hyperclay:snapshot-ready', this._snapshotHandler);
252
252
  }
253
253
 
254
254
  /**
255
- * Send body and headHash to the server (debounced)
256
- * Only updates lastBodyHtml after successful save
255
+ * Send full HTML to the server (debounced)
256
+ * Only updates lastHtml after successful save
257
257
  */
258
- sendUpdate(body, headHash) {
258
+ sendUpdate(html) {
259
259
  clearTimeout(this.debounceTimer);
260
260
 
261
261
  this.debounceTimer = setTimeout(() => {
262
262
  // Skip if unchanged
263
- if (body === this.lastBodyHtml) return;
264
-
265
- console.log('[LiveSync] Sending update');
263
+ if (html === this.lastHtml) {
264
+ this._log('Skipping send - HTML unchanged');
265
+ return;
266
+ }
266
267
 
267
- // Track local head changes
268
- this.lastHeadHash = headHash;
268
+ this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
269
269
 
270
270
  fetch('/live-sync/save', {
271
271
  method: 'POST',
272
272
  headers: { 'Content-Type': 'application/json' },
273
273
  body: JSON.stringify({
274
274
  file: this.currentFile,
275
- body: body,
276
- sender: this.clientId,
277
- headHash: headHash
275
+ html: html,
276
+ sender: this.clientId
278
277
  })
279
278
  }).then(response => {
280
279
  if (response.ok) {
281
- // Only update lastBodyHtml after successful save
282
- this.lastBodyHtml = body;
280
+ this.lastHtml = html;
283
281
  } else {
284
- // Log non-OK responses but don't suppress future sends
285
282
  console.warn('[LiveSync] Save returned status:', response.status);
286
283
  }
287
284
  }).catch(err => {
288
- // Network error - don't update lastBodyHtml so next mutation will retry
289
285
  console.error('[LiveSync] Save failed:', err);
290
286
  if (this.onError) this.onError(err);
291
287
  });
292
288
  }, this.debounceMs);
293
289
  }
294
290
 
295
- /**
296
- * Compute SHA-256 hash of head content (first 16 hex chars)
297
- * Uses SubtleCrypto API (async)
298
- * @param {string} head - Head innerHTML
299
- * @returns {Promise<string|null>} 16-char hex hash or null if unavailable
300
- */
301
- async computeHeadHash(head) {
302
- if (!head) return null;
303
-
304
- // SubtleCrypto requires secure context (HTTPS or localhost)
305
- if (!crypto?.subtle?.digest) {
306
- console.warn('[LiveSync] SHA-256 unavailable (non-secure context), skipping headHash');
307
- return null;
308
- }
309
-
310
- try {
311
- const encoder = new TextEncoder();
312
- const data = encoder.encode(head);
313
- const hashBuffer = await crypto.subtle.digest('SHA-256', data);
314
- const hashArray = Array.from(new Uint8Array(hashBuffer));
315
- return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
316
- } catch (e) {
317
- console.warn('[LiveSync] SHA-256 hash failed, skipping headHash:', e);
318
- return null;
319
- }
320
- }
321
-
322
291
  /**
323
292
  * Apply an update received from the server
324
- * Guards against non-string values
293
+ * Morphs the entire document (head and body)
325
294
  */
326
- applyUpdate(bodyHtml) {
327
- // Guard against non-string values
328
- if (typeof bodyHtml !== 'string') {
329
- console.error('[LiveSync] applyUpdate called with non-string value, ignoring');
330
- return;
331
- }
332
-
295
+ applyUpdate(html) {
296
+ this._log('applyUpdate - pausing mutations and morphing');
333
297
  this.isPaused = true;
334
- this.lastBodyHtml = bodyHtml;
298
+ this.lastHtml = html;
335
299
 
336
- try {
337
- const temp = document.createElement('div');
338
- temp.innerHTML = bodyHtml;
339
-
340
- // Use Idiomorph if available
341
- const morphFn = window.Idiomorph?.morph;
342
-
343
- if (morphFn) {
344
- morphFn(document.body, temp, {
345
- morphStyle: 'innerHTML',
346
- ignoreActiveValue: true
347
- });
348
- } else {
349
- // Fallback to innerHTML
350
- console.warn('[LiveSync] Idiomorph not available, using innerHTML fallback');
351
- document.body.innerHTML = bodyHtml;
352
- }
300
+ // Pause mutation observer so morph doesn't trigger autosave
301
+ Mutation.pause();
353
302
 
354
- this.rehydrateFormState(document.body);
355
- } finally {
356
- this.isPaused = false;
357
- }
358
- }
303
+ // Parse as full document
304
+ const parser = new DOMParser();
305
+ const newDoc = parser.parseFromString(html, 'text/html');
359
306
 
360
- /**
361
- * Sync form control attributes to properties after DOM morph
362
- */
363
- rehydrateFormState(container) {
364
- const focused = document.activeElement;
365
-
366
- // Text inputs and textareas
367
- container.querySelectorAll('input[value], textarea[value]').forEach(el => {
368
- if (el === focused) return;
369
- el.value = el.getAttribute('value') || '';
307
+ // Morph entire document (html element)
308
+ HyperMorph.morph(document.documentElement, newDoc.documentElement, {
309
+ morphStyle: 'outerHTML',
310
+ ignoreActiveValue: true,
311
+ head: { style: 'merge' },
312
+ scripts: { handle: true, matchMode: 'smart' }
370
313
  });
371
314
 
372
- // Checkboxes and radios
373
- container.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(el => {
374
- if (el === focused) return;
375
- el.checked = el.hasAttribute('checked');
376
- });
377
-
378
- // Select dropdowns
379
- container.querySelectorAll('select').forEach(select => {
380
- if (select === focused) return;
381
- select.querySelectorAll('option').forEach(opt => {
382
- opt.selected = opt.hasAttribute('selected');
383
- });
384
- });
315
+ this._log('applyUpdate - morph complete, resuming mutations');
316
+ Mutation.resume();
317
+ this.isPaused = false;
385
318
  }
386
319
 
387
320
  /**
@@ -27,9 +27,23 @@ function initSavePageOnChange() {
27
27
  });
28
28
  }
29
29
 
30
+ /**
31
+ * Initialize auto-save on input events for [persist] elements
32
+ * Form input values don't trigger DOM mutations, so we listen for input events
33
+ */
34
+ let inputSaveTimer = null;
35
+ function initSaveOnPersistInput() {
36
+ document.addEventListener('input', (e) => {
37
+ if (!e.target.closest('[persist]')) return;
38
+ clearTimeout(inputSaveTimer);
39
+ inputSaveTimer = setTimeout(savePageThrottled, 3333);
40
+ }, true);
41
+ }
42
+
30
43
  function init() {
31
44
  if (!isEditMode) return;
32
45
  initSavePageOnChange();
46
+ initSaveOnPersistInput();
33
47
  }
34
48
 
35
49
  // No window exports - savePageThrottled is exported from save-system
@@ -14,11 +14,14 @@ import throttle from "../utilities/throttle.js";
14
14
  import Mutation from "../utilities/mutation.js";
15
15
  import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
16
16
  import {
17
- savePage as savePageCore,
17
+ saveHtml,
18
18
  getPageContents,
19
19
  replacePageWith as replacePageWithCore,
20
- beforeSave
20
+ beforeSave,
21
+ isSaveInProgress
21
22
  } from "./savePageCore.js";
23
+ import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
24
+ import { logSaveCheck, logBaseline } from "../utilities/autosaveDebug.js";
22
25
 
23
26
  // Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
24
27
  beforeSave(clone => {
@@ -88,6 +91,23 @@ window.addEventListener('online', () => {
88
91
  }
89
92
  });
90
93
 
94
+ // ============================================
95
+ // POST-SAVE BASELINE RECAPTURE
96
+ // ============================================
97
+ // After a successful save, onaftersave handlers may modify the live DOM
98
+ // (e.g., cacheBust updates ?v= query params). We recapture the baseline
99
+ // after these sync handlers complete to prevent false "unsaved changes" warnings.
100
+
101
+ document.addEventListener('hyperclay:save-saved', () => {
102
+ // Use setTimeout(0) to run after all sync onaftersave handlers complete
103
+ setTimeout(() => {
104
+ // Store stripped version so comparisons are direct (no parsing needed)
105
+ const contents = captureForComparison();
106
+ lastSavedContents = contents;
107
+ logBaseline('recaptured after onaftersave', `${contents.length} chars`);
108
+ }, 0);
109
+ });
110
+
91
111
  // Re-export from core for backward compatibility
92
112
  export { beforeSave, getPageContents };
93
113
 
@@ -110,6 +130,11 @@ export function savePage(callback = () => {}) {
110
130
  return;
111
131
  }
112
132
 
133
+ // Don't start a new save if one is already in progress
134
+ if (isSaveInProgress()) {
135
+ return;
136
+ }
137
+
113
138
  // Check if offline - set DOM state immediately for UI feedback
114
139
  // but still try the fetch (navigator.onLine can be wrong)
115
140
  const wasOffline = !navigator.onLine;
@@ -117,10 +142,24 @@ export function savePage(callback = () => {}) {
117
142
  setOfflineStateQuiet();
118
143
  }
119
144
 
120
- const currentContents = getPageContents();
145
+ // Single capture: clone once, get both versions
146
+ // forComparison has [save-remove] and [save-ignore] stripped
147
+ // forSave has only [save-remove] stripped
148
+ let forSave, forComparison;
149
+ try {
150
+ ({ forSave, forComparison } = captureForSaveAndComparison());
151
+ } catch (err) {
152
+ console.error('savePage: captureForSaveAndComparison failed', err);
153
+ setSaveState('error', err.message);
154
+ if (typeof callback === 'function') {
155
+ callback({ msg: err.message, msgType: 'error' });
156
+ }
157
+ return;
158
+ }
121
159
 
122
- // Track whether there are unsaved changes
123
- unsavedChanges = (currentContents !== lastSavedContents);
160
+ // Compare directly - lastSavedContents is already stripped
161
+ unsavedChanges = (forComparison !== lastSavedContents);
162
+ logSaveCheck('savePage dirty check', !unsavedChanges);
124
163
 
125
164
  // Skip if content hasn't changed
126
165
  if (!unsavedChanges) {
@@ -130,24 +169,29 @@ export function savePage(callback = () => {}) {
130
169
  // Start debounced 'saving' state (only shows if save takes >500ms)
131
170
  setSavingState();
132
171
 
133
- savePageCore(({msg, msgType}) => {
134
- if (msgType !== 'error') {
135
- // SUCCESS
136
- lastSavedContents = currentContents;
172
+ // Use saveHtml directly with our pre-captured content (avoids double capture)
173
+ saveHtml(forSave, (err, data) => {
174
+ if (!err) {
175
+ // SUCCESS - store stripped version for future comparisons
176
+ lastSavedContents = forComparison;
137
177
  unsavedChanges = false;
138
- setSaveState('saved', msg);
178
+ setSaveState('saved', data?.msg || 'Saved');
179
+ logBaseline('updated after save', `${lastSavedContents.length} chars`);
139
180
  } else {
140
181
  // FAILED - determine if it's offline or server error
141
182
  if (!navigator.onLine) {
142
- setSaveState('offline', msg);
183
+ setSaveState('offline', err.message);
143
184
  } else {
144
- setSaveState('error', msg);
185
+ setSaveState('error', err.message);
145
186
  }
146
187
  }
147
188
 
148
- // Call user callback if provided
149
- if (typeof callback === 'function' && msg) {
150
- callback({msg, msgType});
189
+ // Call user callback if provided (preserve server's msgType)
190
+ if (typeof callback === 'function') {
191
+ callback({
192
+ msg: err?.message || data?.msg,
193
+ msgType: err ? 'error' : (data?.msgType || 'success')
194
+ });
151
195
  }
152
196
  });
153
197
  }
@@ -207,9 +251,11 @@ function initBaselineCapture() {
207
251
 
208
252
  // Take immediate snapshot and set as baseline right away
209
253
  // This ensures saves during settle window work correctly
210
- const immediateContents = getPageContents();
254
+ // Store stripped version so comparisons are direct (no parsing needed)
255
+ const immediateContents = captureForComparison();
211
256
  lastSavedContents = immediateContents;
212
257
  baselineContents = immediateContents;
258
+ logBaseline('immediate capture', `${immediateContents.length} chars`);
213
259
 
214
260
  // Track user edits to avoid overwriting real changes
215
261
  const userEditEvents = ['input', 'change', 'paste'];
@@ -235,9 +281,13 @@ function initBaselineCapture() {
235
281
  // Only update if no user edits AND no saves occurred during settle
236
282
  // (if a save happened, lastSavedContents would differ from immediateContents)
237
283
  if (!userEdited && lastSavedContents === immediateContents) {
238
- const contents = getPageContents();
284
+ // Store stripped version so comparisons are direct (no parsing needed)
285
+ const contents = captureForComparison();
239
286
  lastSavedContents = contents;
240
287
  baselineContents = contents;
288
+ logBaseline('settled capture', `${contents.length} chars`);
289
+ } else {
290
+ logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
241
291
  }
242
292
 
243
293
  document.documentElement.setAttribute('savestatus', 'saved');
@@ -271,10 +321,17 @@ if (document.readyState === 'loading') {
271
321
  export function savePageThrottled(callback = () => {}) {
272
322
  if (!isEditMode) return;
273
323
 
274
- const currentContents = getPageContents();
275
324
  // For autosave: check both that content changed from baseline AND from last save
276
325
  // This prevents saves from initial setup mutations
277
- if (currentContents !== baselineContents && currentContents !== lastSavedContents) {
326
+ // Compare directly - stored versions are already stripped
327
+ const currentForCompare = captureForComparison();
328
+ const differsFromBaseline = currentForCompare !== baselineContents;
329
+ const differsFromLastSave = currentForCompare !== lastSavedContents;
330
+
331
+ logSaveCheck('throttled vs baseline', !differsFromBaseline);
332
+ logSaveCheck('throttled vs lastSave', !differsFromLastSave);
333
+
334
+ if (differsFromBaseline && differsFromLastSave) {
278
335
  unsavedChanges = true;
279
336
  throttledSave(callback);
280
337
  }