hyperclayjs 1.23.0 → 1.24.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/README.md CHANGED
@@ -59,7 +59,7 @@ import 'hyperclayjs/presets/standard.js';
59
59
  |--------|------|-------------|
60
60
  | autosave | 1.4KB | Auto-save on DOM changes |
61
61
  | edit-mode | 1.8KB | Toggle edit mode on hyperclay on/off |
62
- | edit-mode-helpers | 8.3KB | Admin-only functionality: [edit-mode-input], [edit-mode-resource], [edit-mode-onclick] |
62
+ | edit-mode-helpers | 6.8KB | Admin-only functionality: [viewmode:disabled], [editmode:resource], [editmode:onclick] |
63
63
  | option-visibility | 7.1KB | 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 | 8.9KB | Basic save function only - hyperclay.savePage() |
@@ -79,6 +79,7 @@ import 'hyperclayjs/presets/standard.js';
79
79
  | input-helpers | 3.9KB | [prevent-enter], [autosize] for textareas |
80
80
  | movable | 2.5KB | Free-positioning drag with [movable] and [movable-handle], edit mode only |
81
81
  | onaftersave | 1KB | [onaftersave] attribute - run JS when save status changes |
82
+ | save-freeze | 1.9KB | [save-freeze] attribute - freeze element innerHTML for saves, live DOM changes freely |
82
83
  | sortable | 3.4KB | Drag-drop sorting with [sortable], lazy-loads ~118KB Sortable.js in edit mode |
83
84
 
84
85
  ### UI Components (User interface elements)
@@ -86,8 +87,8 @@ import 'hyperclayjs/presets/standard.js';
86
87
  | Module | Size | Description |
87
88
  |--------|------|-------------|
88
89
  | dialogs | 7.7KB | ask(), consent(), tell(), snippet() dialog functions |
89
- | the-modal | 21.5KB | Full modal window creation system - window.theModal |
90
- | toast | 15.9KB | Success/error message notifications, toast(msg, msgType) |
90
+ | the-modal | 22.4KB | Full modal window creation system - window.theModal |
91
+ | toast | 15.8KB | Success/error message notifications, toast(msg, msgType) |
91
92
 
92
93
  ### Utilities (Core utilities (often auto-included))
93
94
 
@@ -96,7 +97,7 @@ import 'hyperclayjs/presets/standard.js';
96
97
  | cache-bust | 0.6KB | Cache-bust href/src attributes |
97
98
  | cookie | 1.4KB | Cookie management (often auto-included) |
98
99
  | debounce | 0.4KB | Function debouncing |
99
- | mutation | 13.5KB | DOM mutation observation (often auto-included) |
100
+ | mutation | 13.8KB | DOM mutation observation (often auto-included) |
100
101
  | nearest | 3.4KB | Find nearest elements (often auto-included) |
101
102
  | throttle | 0.8KB | Function throttling |
102
103
 
@@ -122,7 +123,7 @@ import 'hyperclayjs/presets/standard.js';
122
123
  | Module | Size | Description |
123
124
  |--------|------|-------------|
124
125
  | file-upload | 10.7KB | File upload with progress |
125
- | live-sync | 11.5KB | Real-time DOM sync across browsers |
126
+ | live-sync | 11.7KB | Real-time DOM sync across browsers |
126
127
  | send-message | 1.3KB | Message sending utility |
127
128
 
128
129
  ### Vendor Libraries (Third-party libraries)
@@ -133,17 +134,17 @@ import 'hyperclayjs/presets/standard.js';
133
134
 
134
135
  ## Presets
135
136
 
136
- ### Minimal (~58.6KB)
137
+ ### Minimal (~57KB)
137
138
  Essential features for basic editing
138
139
 
139
140
  **Modules:** `save-core`, `snapshot`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
140
141
 
141
- ### Standard (~80.8KB)
142
+ ### Standard (~79.2KB)
142
143
  Standard feature set for most use cases
143
144
 
144
145
  **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`
145
146
 
146
- ### Everything (~217.9KB)
147
+ ### Everything (~219.6KB)
147
148
  All available features
148
149
 
149
150
  Includes all available modules across all categories.
@@ -316,9 +317,9 @@ tell("Welcome to Hyperclay!");
316
317
 
317
318
  ```html
318
319
  <!-- Only visible/editable in edit mode -->
319
- <div contenteditable edit-mode-contenteditable>Admin can edit this</div>
320
- <input type="text" edit-mode-input>
321
- <script edit-mode-resource>console.log('Admin only');</script>
320
+ <div contenteditable editmode:contenteditable>Admin can edit this</div>
321
+ <input type="text" viewmode:disabled>
322
+ <script editmode:resource>console.log('Admin only');</script>
322
323
  ```
323
324
 
324
325
  ## Module Creation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperclayjs",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "Modular JavaScript library for building interactive malleable HTML files with Hyperclay",
5
5
  "type": "module",
6
6
  "main": "src/hyperclay.js",
@@ -321,7 +321,9 @@ class LiveSync {
321
321
 
322
322
  this._log('applyUpdate - morph complete, resuming mutations');
323
323
  Mutation.resume();
324
- this.isPaused = false;
324
+ // Defer past microtask boundary — MutationObserver callbacks and any async
325
+ // morph side-effects fire before this, so isPaused catches stray snapshots
326
+ setTimeout(() => { this.isPaused = false; }, 0);
325
327
  }
326
328
 
327
329
  /**
@@ -2,7 +2,7 @@ import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
2
2
  import onDomReady from "../dom-utilities/onDomReady.js";
3
3
  import {beforeSave} from "./savePage.js";
4
4
 
5
- const SELECTOR = '[edit-mode-contenteditable], [editmode\\:contenteditable]';
5
+ const SELECTOR = '[editmode\\:contenteditable]';
6
6
 
7
7
  export function disableContentEditableBeforeSave () {
8
8
  beforeSave(docElem => {
@@ -2,20 +2,13 @@ import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
2
2
  import onDomReady from "../dom-utilities/onDomReady.js";
3
3
  import { beforeSave } from "./savePage.js";
4
4
 
5
- const SELECTOR_DISABLED = '[edit-mode-input], [viewmode\\:disabled]';
5
+ const SELECTOR_DISABLED = '[viewmode\\:disabled]';
6
6
  const SELECTOR_READONLY = '[viewmode\\:readonly]';
7
- const SELECTOR_ALL = '[edit-mode-input], [viewmode\\:disabled], [viewmode\\:readonly]';
8
7
 
9
8
  export function disableAdminInputsBeforeSave() {
10
9
  beforeSave(docElem => {
11
10
  docElem.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
12
- if (input.hasAttribute('viewmode:disabled')) {
13
- input.setAttribute('disabled', '');
14
- } else if (supportsReadonly(input)) {
15
- input.setAttribute('readonly', '');
16
- } else {
17
- input.setAttribute('disabled', '');
18
- }
11
+ input.setAttribute('disabled', '');
19
12
  });
20
13
  docElem.querySelectorAll(SELECTOR_READONLY).forEach(input => {
21
14
  input.setAttribute('readonly', '');
@@ -31,16 +24,9 @@ export function enableAdminInputsOnPageLoad() {
31
24
  });
32
25
  }
33
26
 
34
- // Runtime toggle functions
35
27
  export function enableAdminInputs() {
36
28
  document.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
37
- if (input.hasAttribute('viewmode:disabled')) {
38
- input.removeAttribute('disabled');
39
- } else if (supportsReadonly(input)) {
40
- input.removeAttribute('readonly');
41
- } else {
42
- input.removeAttribute('disabled');
43
- }
29
+ input.removeAttribute('disabled');
44
30
  });
45
31
  document.querySelectorAll(SELECTOR_READONLY).forEach(input => {
46
32
  input.removeAttribute('readonly');
@@ -49,43 +35,18 @@ export function enableAdminInputs() {
49
35
 
50
36
  export function disableAdminInputs() {
51
37
  document.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
52
- if (input.hasAttribute('viewmode:disabled')) {
53
- input.setAttribute('disabled', '');
54
- } else if (supportsReadonly(input)) {
55
- input.setAttribute('readonly', '');
56
- } else {
57
- input.setAttribute('disabled', '');
58
- }
38
+ input.setAttribute('disabled', '');
59
39
  });
60
40
  document.querySelectorAll(SELECTOR_READONLY).forEach(input => {
61
41
  input.setAttribute('readonly', '');
62
42
  });
63
43
  }
64
44
 
65
- // Input types that support the readonly attribute
66
- const readonlyTypes = ['text', 'search', 'url', 'tel', 'email', 'password', 'date', 'month', 'week', 'time', 'datetime-local', 'number'];
67
-
68
- function supportsReadonly(element) {
69
- const tagName = element.tagName?.toUpperCase();
70
-
71
- if (tagName === 'TEXTAREA') return true;
72
- if (tagName === 'SELECT' || tagName === 'BUTTON' || tagName === 'FIELDSET') return false;
73
-
74
- if (tagName === 'INPUT') {
75
- const type = element.type || 'text';
76
- return readonlyTypes.includes(type);
77
- }
78
-
79
- return false;
80
- }
81
-
82
- // Auto-initialize
83
45
  export function init() {
84
46
  disableAdminInputsBeforeSave();
85
47
  enableAdminInputsOnPageLoad();
86
48
  }
87
49
 
88
- // Export to window
89
50
  window.hyperclay = window.hyperclay || {};
90
51
  window.hyperclay.enableAdminInputs = enableAdminInputs;
91
52
  window.hyperclay.disableAdminInputs = disableAdminInputs;
@@ -2,7 +2,7 @@ import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
2
2
  import onDomReady from "../dom-utilities/onDomReady.js";
3
3
  import {beforeSave} from "./savePage.js";
4
4
 
5
- const SELECTOR = '[edit-mode-onclick], [editmode\\:onclick]';
5
+ const SELECTOR = '[editmode\\:onclick]';
6
6
 
7
7
  export function disableOnClickBeforeSave () {
8
8
  beforeSave(docElem => {
@@ -2,8 +2,8 @@ import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
2
2
  import onDomReady from "../dom-utilities/onDomReady.js";
3
3
  import {beforeSave} from "./savePage.js";
4
4
 
5
- const SELECTOR = '[edit-mode-resource]:is(style, link, script), [editmode\\:resource]:is(style, link, script)';
6
- const SELECTOR_INERT = '[edit-mode-resource]:is(style, link, script)[type^="inert/"], [editmode\\:resource]:is(style, link, script)[type^="inert/"]';
5
+ const SELECTOR = '[editmode\\:resource]:is(style, link, script)';
6
+ const SELECTOR_INERT = '[editmode\\:resource]:is(style, link, script)[type^="inert/"]';
7
7
 
8
8
  export function disableAdminResourcesBeforeSave () {
9
9
  beforeSave(docElem => {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * [save-freeze] Custom Attribute
3
+ *
4
+ * Freezes an element's innerHTML for save purposes.
5
+ * The live DOM can change freely, but the saved HTML always
6
+ * contains the original content captured when the element first appeared.
7
+ *
8
+ * Usage:
9
+ * <div save-freeze>Content that JS will modify at runtime</div>
10
+ *
11
+ * The original innerHTML is captured:
12
+ * - On page load, for all existing [save-freeze] elements
13
+ * - On DOM insertion, for dynamically added [save-freeze] elements
14
+ *
15
+ * At save time, the clone's innerHTML is replaced with the stored original.
16
+ * Changes inside [save-freeze] elements do not trigger autosave dirty checks.
17
+ */
18
+
19
+ import { onPrepareForSave } from "../core/snapshot.js";
20
+ import { isEditMode } from "../core/isAdminOfCurrentResource.js";
21
+
22
+ const originals = new WeakMap();
23
+
24
+ function capture(el) {
25
+ if (!originals.has(el)) {
26
+ originals.set(el, el.innerHTML);
27
+ }
28
+ }
29
+
30
+ function captureAll() {
31
+ for (const el of document.querySelectorAll('[save-freeze]')) {
32
+ capture(el);
33
+ }
34
+ }
35
+
36
+ function init() {
37
+ if (!isEditMode) return;
38
+
39
+ captureAll();
40
+
41
+ const observer = new MutationObserver(mutations => {
42
+ for (const m of mutations) {
43
+ for (const node of m.addedNodes) {
44
+ if (node.nodeType !== 1) continue;
45
+ if (node.hasAttribute?.('save-freeze')) capture(node);
46
+ for (const el of node.querySelectorAll?.('[save-freeze]') || []) {
47
+ capture(el);
48
+ }
49
+ }
50
+ }
51
+ });
52
+ observer.observe(document.documentElement, { childList: true, subtree: true });
53
+
54
+ onPrepareForSave(clone => {
55
+ const liveElements = document.querySelectorAll('[save-freeze]');
56
+ const cloneElements = clone.querySelectorAll('[save-freeze]');
57
+
58
+ for (let i = 0; i < cloneElements.length; i++) {
59
+ const liveEl = liveElements[i];
60
+ if (liveEl && originals.has(liveEl)) {
61
+ cloneElements[i].innerHTML = originals.get(liveEl);
62
+ }
63
+ }
64
+ });
65
+ }
66
+
67
+ init();
68
+
69
+ export default init;
package/src/hyperclay.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * DO NOT EDIT THIS FILE DIRECTLY — it is generated from build/hyperclay.template.js
3
3
  *
4
- * HyperclayJS v1.23.0 - Minimal Browser-Native Loader
4
+ * HyperclayJS v1.24.0 - Minimal Browser-Native Loader
5
5
  *
6
6
  * Modules auto-init when imported (no separate init call needed).
7
7
  * Include `export-to-window` feature to export to window.hyperclay.
@@ -44,6 +44,7 @@ const MODULE_PATHS = {
44
44
  "dom-helpers": "./custom-attributes/domHelpers.js",
45
45
  "input-helpers": "./custom-attributes/inputHelpers.js",
46
46
  "onaftersave": "./custom-attributes/onaftersave.js",
47
+ "save-freeze": "./custom-attributes/saveFreeze.js",
47
48
  "dialogs": "./ui/prompts.js",
48
49
  "toast": "./ui/toast.js",
49
50
  "toast-hyperclay": "./ui/toast-hyperclay.js",
@@ -124,6 +125,7 @@ const PRESETS = {
124
125
  "dom-helpers",
125
126
  "input-helpers",
126
127
  "onaftersave",
128
+ "save-freeze",
127
129
  "dialogs",
128
130
  "toast",
129
131
  "the-modal",
@@ -168,6 +170,7 @@ const PRESETS = {
168
170
  "dom-helpers",
169
171
  "input-helpers",
170
172
  "onaftersave",
173
+ "save-freeze",
171
174
  "dialogs",
172
175
  "toast",
173
176
  "the-modal",
@@ -208,6 +211,7 @@ const EDIT_MODE_ONLY = new Set([
208
211
  "sortable",
209
212
  "movable",
210
213
  "onaftersave",
214
+ "save-freeze",
211
215
  "cache-bust",
212
216
  "hyper-morph",
213
217
  "file-upload",
@@ -608,6 +608,24 @@ const themodal = (() => {
608
608
  const themodalMain = {
609
609
  isShowing: false,
610
610
  open() {
611
+ // Clean up stale modal if DOM was removed externally (e.g. live-sync morph)
612
+ if (this.isShowing && !document.querySelector('.micromodal-parent')) {
613
+ this._cleanupListeners?.();
614
+ html = "";
615
+ yes = "";
616
+ no = "";
617
+ zIndex = "100";
618
+ closeHtml = "";
619
+ enableClickOutsideCloses = true;
620
+ disableScroll = true;
621
+ disableFocus = false;
622
+ onYes = [];
623
+ onNo = [];
624
+ onOpen = [];
625
+ this.isShowing = false;
626
+ document.body.style.overflow = '';
627
+ }
628
+
611
629
  document.body.insertAdjacentHTML("afterbegin", "<div save-remove snapshot-remove class='micromodal-parent'>" + modalCss + modalHtml + "</div>");
612
630
 
613
631
  const modalOverlayElem = document.querySelector(".micromodal__overlay");
@@ -682,6 +700,13 @@ const themodal = (() => {
682
700
  document.addEventListener("click", handleClick);
683
701
  document.addEventListener("submit", handleSubmit);
684
702
 
703
+ // Store cleanup so stale listeners can be removed if DOM is yanked externally
704
+ this._cleanupListeners = () => {
705
+ document.removeEventListener("mousedown", handleMousedown);
706
+ document.removeEventListener("click", handleClick);
707
+ document.removeEventListener("submit", handleSubmit);
708
+ };
709
+
685
710
  function setButtonsVisibility () {
686
711
  modalButtonsElem.classList.toggle("micromodal__hide", !yes && !no);
687
712
  modalYesElem.classList.toggle("micromodal__hide", !yes);
@@ -718,6 +743,7 @@ const themodal = (() => {
718
743
  document.removeEventListener("mousedown", handleMousedown);
719
744
  document.removeEventListener("click", handleClick);
720
745
  document.removeEventListener("submit", handleSubmit);
746
+ this._cleanupListeners = null;
721
747
  }
722
748
  });
723
749
 
package/src/ui/toast.js CHANGED
@@ -235,9 +235,6 @@ export const hyperclayStyles = `
235
235
  }
236
236
  `;
237
237
 
238
- // Track which theme styles have been injected
239
- const injectedThemes = new Set();
240
-
241
238
  // Global toast configuration (can be overridden by toast-hyperclay module)
242
239
  let toastConfig = {
243
240
  styles: modernStyles,
@@ -253,7 +250,7 @@ export function setToastTheme(config) {
253
250
 
254
251
  // Helper function to inject styles for a theme (additive, not replacing)
255
252
  export function injectToastStyles(styles, theme) {
256
- if (injectedThemes.has(theme)) return;
253
+ if (document.querySelector(`style.toast-styles-${theme}`)) return;
257
254
 
258
255
  const styleSheet = document.createElement('style');
259
256
  styleSheet.className = `toast-styles-${theme}`;
@@ -261,8 +258,6 @@ export function injectToastStyles(styles, theme) {
261
258
  styleSheet.setAttribute('snapshot-remove', '');
262
259
  styleSheet.textContent = styles;
263
260
  document.head.appendChild(styleSheet);
264
-
265
- injectedThemes.add(theme);
266
261
  }
267
262
 
268
263
  // Core toast function (used by both toast and toastHyperclay)
@@ -357,8 +352,6 @@ const persistentToastStyles = `
357
352
  }
358
353
  `;
359
354
 
360
- let persistentStylesInjected = false;
361
-
362
355
  // Track active persistent toasts by message
363
356
  const activePersistentToasts = new Map();
364
357
 
@@ -367,14 +360,13 @@ function toastPersistent(message, messageType = "warning") {
367
360
  injectToastStyles(toastConfig.styles, toastConfig.theme);
368
361
 
369
362
  // Inject persistent-specific styles once
370
- if (!persistentStylesInjected) {
363
+ if (!document.querySelector('style.toast-styles-persistent')) {
371
364
  const styleSheet = document.createElement('style');
372
365
  styleSheet.className = 'toast-styles-persistent';
373
366
  styleSheet.setAttribute('save-remove', '');
374
367
  styleSheet.setAttribute('snapshot-remove', '');
375
368
  styleSheet.textContent = persistentToastStyles;
376
369
  document.head.appendChild(styleSheet);
377
- persistentStylesInjected = true;
378
370
  }
379
371
 
380
372
  const templates = toastConfig.templates;
@@ -84,6 +84,11 @@ const Mutation = {
84
84
  * Resume mutation observation after a pause.
85
85
  */
86
86
  resume() {
87
+ // Drain pending mutation records — observer stays connected during pause,
88
+ // so morph mutations are recorded and would fire async after _paused=false
89
+ if (this._observer) {
90
+ this._observer.takeRecords();
91
+ }
87
92
  this._paused = false;
88
93
  this._log('Resumed');
89
94
  },
@@ -199,7 +204,8 @@ const Mutation = {
199
204
  while (element && element.nodeType === 1) {
200
205
  if (element.hasAttribute?.('mutations-ignore') ||
201
206
  element.hasAttribute?.('save-remove') ||
202
- element.hasAttribute?.('save-ignore')) {
207
+ element.hasAttribute?.('save-ignore') ||
208
+ element.hasAttribute?.('save-freeze')) {
203
209
  return true;
204
210
  }
205
211
  element = element.parentElement;