hyperclayjs 1.14.0 → 1.14.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.
package/README.md CHANGED
@@ -60,10 +60,10 @@ import 'hyperclayjs/presets/standard.js';
60
60
  | autosave | 0.9KB | 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
- | option-visibility | 5.3KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
63
+ | option-visibility | 5.5KB | 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
65
  | save-core | 6.8KB | Basic save function only - hyperclay.savePage() |
66
- | save-system | 7.1KB | CMD+S, [trigger-save] button, savestatus attribute |
66
+ | save-system | 9.6KB | CMD+S, [trigger-save] button, savestatus attribute |
67
67
  | save-toast | 0.9KB | Toast notifications for save events |
68
68
  | snapshot | 7.5KB | Source of truth for page state - captures DOM snapshots for save and sync |
69
69
  | tailwind-inject | 0.4KB | Injects tailwind CSS link with cache-bust on save |
@@ -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 | 2.4KB | Dynamic stylesheet injection |
109
+ | style-injection | 4KB | Dynamic stylesheet injection |
110
110
 
111
111
  ### String Utilities (String manipulation helpers)
112
112
 
@@ -132,17 +132,17 @@ import 'hyperclayjs/presets/standard.js';
132
132
 
133
133
  ## Presets
134
134
 
135
- ### Minimal (~38.1KB)
135
+ ### Minimal (~40.6KB)
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 (~57.4KB)
140
+ ### Standard (~60.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 (~178KB)
145
+ ### Everything (~182.3KB)
146
146
  All available features
147
147
 
148
148
  Includes all available modules across all categories.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperclayjs",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "Modular JavaScript library for building interactive HTML applications with Hyperclay",
5
5
  "type": "module",
6
6
  "main": "src/hyperclay.js",
@@ -118,7 +118,11 @@ const optionVisibility = {
118
118
  try {
119
119
  const attributes = this.findOptionAttributes();
120
120
  const css = this.generateCSS(attributes);
121
- insertStyles(STYLE_NAME, css);
121
+ // mutations-ignore: This style tag is regenerated on load. Without this,
122
+ // the mutation observer would detect it as a change, delaying the settled signal.
123
+ insertStyles(STYLE_NAME, css, (style) => {
124
+ style.setAttribute('mutations-ignore', '');
125
+ });
122
126
  this.log(`Generated ${attributes.length} rules`);
123
127
  } catch (error) {
124
128
  console.error('[OptionVisibility:Layer] Error generating rules:', error);
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import throttle from "../utilities/throttle.js";
14
+ import Mutation from "../utilities/mutation.js";
14
15
  import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
15
16
  import {
16
17
  savePage as savePageCore,
@@ -19,6 +20,11 @@ import {
19
20
  beforeSave
20
21
  } from "./savePageCore.js";
21
22
 
23
+ // Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
24
+ beforeSave(clone => {
25
+ clone.setAttribute('savestatus', 'saved');
26
+ });
27
+
22
28
  // ============================================
23
29
  // SAVE STATE MANAGEMENT
24
30
  // ============================================
@@ -94,17 +100,6 @@ export function setUnsavedChanges(val) { unsavedChanges = val; }
94
100
  export function getLastSavedContents() { return lastSavedContents; }
95
101
  export function setLastSavedContents(val) { lastSavedContents = val; }
96
102
 
97
- // Initialize lastSavedContents on page load to match what's on disk
98
- // This prevents unnecessary save attempts when content hasn't changed
99
- document.addEventListener('DOMContentLoaded', () => {
100
- if (isEditMode) {
101
- // Capture initial state immediately for comparison
102
- lastSavedContents = getPageContents();
103
- // Set initial save status to 'saved'
104
- document.documentElement.setAttribute('savestatus', 'saved');
105
- }
106
- });
107
-
108
103
  /**
109
104
  * Save the current page with change detection and state management
110
105
  *
@@ -185,14 +180,87 @@ const throttledSave = throttle(savePage, 1200);
185
180
  // Baseline for autosave comparison
186
181
  let baselineContents = '';
187
182
 
188
- // Capture baseline after setup mutations settle
189
- document.addEventListener('DOMContentLoaded', () => {
190
- if (isEditMode) {
191
- setTimeout(() => {
192
- baselineContents = getPageContents();
193
- }, 1500);
194
- }
195
- });
183
+ // ============================================
184
+ // BASELINE CAPTURE (Settled Signal)
185
+ // ============================================
186
+ //
187
+ // WHY SETTLED SIGNAL:
188
+ // Modules run on load and mutate the DOM (add styles, modify attributes).
189
+ // A fixed delay (e.g., 1500ms) is arbitrary and either too short (misses slow
190
+ // mutations) or too long (delays baseline). Instead, we wait for mutations to
191
+ // stop, meaning all modules have finished their setup work.
192
+ //
193
+ // WHY IMMEDIATE + CONDITIONAL UPDATE:
194
+ // We set baseline immediately as a safety net. If the user edits or saves
195
+ // before settle completes, we don't overwrite their work. The settled snapshot
196
+ // only replaces baseline if nothing changed (lastSavedContents === immediateContents).
197
+
198
+ const SETTLE_MS = 500; // Wait for no mutations for this long
199
+ const MAX_SETTLE_MS = 3000; // Max time to wait before forcing capture
200
+
201
+ function initBaselineCapture() {
202
+ if (!isEditMode) return;
203
+
204
+ let userEdited = false;
205
+ let settled = false;
206
+ let unsubscribeMutation = null;
207
+
208
+ // Take immediate snapshot and set as baseline right away
209
+ // This ensures saves during settle window work correctly
210
+ const immediateContents = getPageContents();
211
+ lastSavedContents = immediateContents;
212
+ baselineContents = immediateContents;
213
+
214
+ // Track user edits to avoid overwriting real changes
215
+ const userEditEvents = ['input', 'change', 'paste'];
216
+ const markUserEdited = (e) => {
217
+ const target = e.target;
218
+ const isEditable = target.isContentEditable ||
219
+ target.tagName === 'INPUT' ||
220
+ target.tagName === 'TEXTAREA' ||
221
+ target.tagName === 'SELECT';
222
+ if (isEditable) userEdited = true;
223
+ };
224
+ userEditEvents.forEach(evt => document.addEventListener(evt, markUserEdited, true));
225
+
226
+ // Called when mutations settle OR max timeout reached
227
+ const captureBaseline = () => {
228
+ if (settled) return;
229
+ settled = true;
230
+
231
+ // Cleanup listeners
232
+ if (unsubscribeMutation) unsubscribeMutation();
233
+ userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
234
+
235
+ // Only update if no user edits AND no saves occurred during settle
236
+ // (if a save happened, lastSavedContents would differ from immediateContents)
237
+ if (!userEdited && lastSavedContents === immediateContents) {
238
+ const contents = getPageContents();
239
+ lastSavedContents = contents;
240
+ baselineContents = contents;
241
+ }
242
+
243
+ document.documentElement.setAttribute('savestatus', 'saved');
244
+ };
245
+
246
+ // Start settle observer - fires when no mutations for SETTLE_MS
247
+ unsubscribeMutation = Mutation.onAnyChange(
248
+ { debounce: SETTLE_MS, omitChangeDetails: true },
249
+ captureBaseline
250
+ );
251
+
252
+ // Max timeout fallback
253
+ setTimeout(() => {
254
+ if (!settled) captureBaseline();
255
+ }, MAX_SETTLE_MS);
256
+ }
257
+
258
+ // Run when DOM is ready
259
+ if (document.readyState === 'loading') {
260
+ document.addEventListener('DOMContentLoaded', initBaselineCapture);
261
+ } else {
262
+ initBaselineCapture();
263
+ }
196
264
 
197
265
  /**
198
266
  * Save the page with throttling, for use with auto-save
@@ -2,10 +2,20 @@
2
2
  * Insert styles into the document (inline CSS or external stylesheet).
3
3
  *
4
4
  * With a persistent DOM (i.e. hyperclay), we need a way to update styles.
5
- * This function always inserts the new styles first, then removes any
6
- * duplicates. This ensures:
7
- * - No flickering: new styles are applied before old ones are removed
8
- * - Always upgrades: we default to the new styles/approach
5
+ * This function reuses existing elements when possible:
6
+ * - Inline styles: matches by data-name, reuses if content matches
7
+ * - External stylesheets: matches by normalized full URL path
8
+ *
9
+ * This ensures:
10
+ * - No DOM churn: existing elements are reused when content/path matches
11
+ * - No duplicates: removes any duplicate style/link elements
12
+ * - Callback always runs: attributes can be updated on existing elements
13
+ *
14
+ * WHY REUSE IN-PLACE:
15
+ * In a persistent DOM (hyperclay), removing and re-adding elements changes their
16
+ * position and surrounding whitespace. This causes snapshot diffs even when content
17
+ * is identical, triggering false "unsaved changes" warnings. Reusing existing
18
+ * elements preserves DOM structure for stable snapshots.
9
19
  *
10
20
  * Usage:
11
21
  * insertStyles('/path/to/file.css') // External stylesheet
@@ -18,15 +28,33 @@ function insertStyles(nameOrHref, cssOrCallback, callback) {
18
28
  // Inline style: insertStyles('my-styles', '.foo { ... }', optionalCallback)
19
29
  const name = nameOrHref;
20
30
  const css = cssOrCallback;
21
- const oldStyles = document.querySelectorAll(`style[data-name="${name}"]`);
31
+ const existingStyles = [...document.querySelectorAll(`style[data-name="${name}"]`)];
32
+
33
+ // If exact match exists, just update attributes via callback and return it
34
+ const exactMatch = existingStyles.find(el => el.textContent === css);
35
+ if (exactMatch) {
36
+ if (callback) callback(exactMatch);
37
+ // Remove any duplicates
38
+ existingStyles.filter(el => el !== exactMatch).forEach(el => el.remove());
39
+ return exactMatch;
40
+ }
22
41
 
23
- const style = document.createElement('style');
24
- style.dataset.name = name;
25
- style.textContent = css;
26
- if (callback) callback(style);
27
- document.head.appendChild(style);
42
+ // Update first existing style in-place, or create new one
43
+ let style;
44
+ if (existingStyles.length > 0) {
45
+ style = existingStyles[0];
46
+ style.textContent = css;
47
+ if (callback) callback(style);
48
+ // Remove duplicates
49
+ existingStyles.slice(1).forEach(el => el.remove());
50
+ } else {
51
+ style = document.createElement('style');
52
+ style.dataset.name = name;
53
+ style.textContent = css;
54
+ if (callback) callback(style);
55
+ document.head.appendChild(style);
56
+ }
28
57
 
29
- oldStyles.forEach(el => el.remove());
30
58
  return style;
31
59
  }
32
60
 
@@ -34,25 +62,35 @@ function insertStyles(nameOrHref, cssOrCallback, callback) {
34
62
  const href = nameOrHref;
35
63
  const cb = typeof cssOrCallback === 'function' ? cssOrCallback : undefined;
36
64
 
37
- let identifier;
38
- try {
39
- const url = new URL(href, window.location.href);
40
- identifier = url.pathname.split('/').pop();
41
- } catch (e) {
42
- identifier = href;
43
- }
65
+ // Normalize href to full URL for comparison
66
+ const normalizedHref = new URL(href, window.location.href).href;
67
+
68
+ // Find all links with matching normalized path
69
+ const existingLinks = [...document.querySelectorAll('link[rel="stylesheet"]')]
70
+ .filter(el => {
71
+ try {
72
+ return new URL(el.getAttribute('href'), window.location.href).href === normalizedHref;
73
+ } catch {
74
+ return false;
75
+ }
76
+ });
44
77
 
45
- const oldLinks = document.querySelectorAll(
46
- `link[href="${href}"], link[href*="${identifier}"]`
47
- );
78
+ // If match exists, just update attributes via callback and return it
79
+ if (existingLinks.length > 0) {
80
+ const link = existingLinks[0];
81
+ if (cb) cb(link);
82
+ // Remove any duplicates
83
+ existingLinks.slice(1).forEach(el => el.remove());
84
+ return link;
85
+ }
48
86
 
87
+ // Create new link element
49
88
  const link = document.createElement('link');
50
89
  link.rel = 'stylesheet';
51
90
  link.href = href;
52
91
  if (cb) cb(link);
53
92
  document.head.appendChild(link);
54
93
 
55
- oldLinks.forEach(el => el.remove());
56
94
  return link;
57
95
  }
58
96
 
package/src/hyperclay.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * HyperclayJS v1.14.0 - Minimal Browser-Native Loader
2
+ * HyperclayJS v1.14.1 - Minimal Browser-Native Loader
3
3
  *
4
4
  * Modules auto-init when imported (no separate init call needed).
5
5
  * Include `export-to-window` feature to export to window.hyperclay.