@panphora/clayjs 1.11.0 → 1.11.2

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.
@@ -587,7 +587,16 @@ export function render(clone, map, m, provenance, opts = {}) {
587
587
  // `from >= 0` is not defensive padding: `src.slice(-1, n)` is the LAST BYTE of the
588
588
  // source, so an unset offset reaching here does not produce nothing, it produces one
589
589
  // wrong byte and looks like a successful render.
590
- const keep = (from, to) => { if (from >= 0 && to > from) pieces.push({ from, to }); };
590
+ // Adjacent copied ranges merge into one piece, except across a mark that records
591
+ // a piece index (tailMark, afterBody): a piece sealed under a mark is never extended,
592
+ // so the next range starts a new piece at the index the mark points to.
593
+ let sealedAt = 0;
594
+ const keep = (from, to) => {
595
+ if (!(from >= 0 && to > from)) return;
596
+ const last = pieces.length > sealedAt ? pieces[pieces.length - 1] : null;
597
+ if (last && last.text === undefined && last.to === from) last.to = to;
598
+ else pieces.push({ from, to });
599
+ };
591
600
  const text = (t) => { if (t) pieces.push({ text: t }); };
592
601
  // Elements in `opts.print`, and everything inside them, are printed rather than
593
602
  // copied: the answer to a region whose copy did not verify.
@@ -622,7 +631,7 @@ export function render(clone, map, m, provenance, opts = {}) {
622
631
  // Called once <body>'s end tag, if any, has been emitted. The owner is still last when
623
632
  // nothing but end tags was emitted after its text.
624
633
  function endOfBody() {
625
- afterBody = pieces.length;
634
+ afterBody = pieces.length; sealedAt = pieces.length;
626
635
  tailClean = !!tailMark && tailMark.whole
627
636
  && /^(?:<\/[^>]*>)*$/.test(pieces.slice(tailMark.at).map(piece).join(''));
628
637
  }
@@ -660,7 +669,7 @@ export function render(clone, map, m, provenance, opts = {}) {
660
669
  // Test switch: copy one named text node's bytes twice, a corruption only the
661
670
  // copy path makes, so printing the element around it is the fix.
662
671
  if (opts.doubleCopied !== undefined && data === opts.doubleCopied) keep(loc.from, loc.to);
663
- if (loc.outsideTail) tailMark = { at: pieces.length, tail: loc.outsideTail, whole: true };
672
+ if (loc.outsideTail) { tailMark = { at: pieces.length, tail: loc.outsideTail, whole: true }; sealedAt = pieces.length; }
664
673
  return;
665
674
  }
666
675
  let owner = null;
@@ -670,7 +679,7 @@ export function render(clone, map, m, provenance, opts = {}) {
670
679
  owner = { tail: whole ? loc.outsideTail : '', whole };
671
680
  }
672
681
  text(RAW_TEXT.has(parentTag) ? data : escText(data));
673
- if (owner) tailMark = { at: pieces.length, ...owner };
682
+ if (owner) { tailMark = { at: pieces.length, ...owner }; sealedAt = pieces.length; }
674
683
  }
675
684
  function emitElement(n, loc) {
676
685
  const tag = n.localName;
@@ -719,7 +728,20 @@ export function render(clone, map, m, provenance, opts = {}) {
719
728
  // clone still has with the same value copies its bytes; a changed one keeps the
720
729
  // author's quoting; a removed one takes its leading whitespace with it; a new one is
721
730
  // appended just before the `>`.
731
+ // The open tag copies as one range when the clone's attributes are the source's, in
732
+ // the source's order, with the same values: the attribute walk below would copy the
733
+ // same bytes piece by piece.
734
+ function sameAttrs(n, loc) {
735
+ const attrs = n.attributes;
736
+ if (attrs.length !== loc.attrs.length) return false;
737
+ for (let i = 0; i < attrs.length; i++) {
738
+ const a = loc.attrs[i];
739
+ if (a.from < 0 || attrs[i].value !== a.value || attrs[i].name.toLowerCase() !== a.key) return false;
740
+ }
741
+ return true;
742
+ }
722
743
  function emitOpenTag(n, loc) {
744
+ if (sameAttrs(n, loc)) { keep(loc.openFrom, loc.openTo); return; }
723
745
  const live = new Map();
724
746
  for (const a of n.attributes) live.set(a.name.toLowerCase(), a);
725
747
  const seen = new Set();
@@ -832,7 +854,7 @@ export function render(clone, map, m, provenance, opts = {}) {
832
854
  if (!whole.has(i)) return emitNode(c, parentTag);
833
855
  const s = S[owned[i]];
834
856
  keep(s.from, s.to);
835
- if (s.outsideTail) tailMark = { at: pieces.length, tail: s.outsideTail, whole: true };
857
+ if (s.outsideTail) { tailMark = { at: pieces.length, tail: s.outsideTail, whole: true }; sealedAt = pieces.length; }
836
858
  };
837
859
  const openOf = (c, i) => {
838
860
  if (!whole.has(i)) return openChain(c);
@@ -903,24 +925,31 @@ function lis(vals) {
903
925
  export function verify(rendered, today, liveDocument, clone = null, sourceErrors = null) {
904
926
  const P = new DOMParser();
905
927
  const A = P.parseFromString(rendered, 'text/html');
906
- const B = P.parseFromString(today, 'text/html');
907
928
  const outsideA = vOutside(A), outsideLive = vOutside(liveDocument);
908
929
  if (outsideA.join('\n') !== outsideLive.join('\n')) return { ok: false, diff: 'document: outside <html> ' + JSON.stringify(outsideA) + ' vs live ' + JSON.stringify(outsideLive) };
909
- const diff = vDiff(A.documentElement, B.documentElement, 'html');
910
- const worse = diff ? null : vParseErrors(rendered, sourceErrors);
911
- if (!diff) return worse ? { ok: false, diff: worse } : { ok: true, diff: null, oracle: 'today' };
930
+ // The clone oracle first: it is the stronger claim and needs no second parse. The
931
+ // accept set is unchanged (equal to the clone, or equal to today's bytes reparsed);
932
+ // only the order the two are tried in differs, and the oracle named is still the
933
+ // one that passed.
934
+ let at = null;
935
+ let cloneDiff = null;
912
936
  if (clone) {
913
- // `at` is the index path, in the clone, of the node where the render first differs,
914
- // so a caller can print that element and try again instead of discarding the whole
915
- // render. It is valid in the clone because every earlier sibling at every level on
916
- // the way down compared equal.
917
- const at = [];
918
- if (!vDiff(A.documentElement, clone, 'html', at)) {
937
+ at = [];
938
+ cloneDiff = vDiff(A.documentElement, clone, 'html', at);
939
+ if (!cloneDiff) {
919
940
  const w = vParseErrors(rendered, sourceErrors);
920
- return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone', todayDiff: diff };
941
+ return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone' };
921
942
  }
922
- return { ok: false, diff, at };
923
943
  }
944
+ const B = P.parseFromString(today, 'text/html');
945
+ const diff = vDiff(A.documentElement, B.documentElement, 'html');
946
+ const worse = diff ? null : vParseErrors(rendered, sourceErrors);
947
+ if (!diff) return worse ? { ok: false, diff: worse } : { ok: true, diff: null, oracle: 'today' };
948
+ // `at` is the index path, in the clone, of the node where the render first differs,
949
+ // so a caller can print that element and try again instead of discarding the whole
950
+ // render. It is valid in the clone because every earlier sibling at every level on
951
+ // the way down compared equal.
952
+ if (clone) return { ok: false, diff, at };
924
953
  return { ok: false, diff };
925
954
  }
926
955
 
@@ -1004,14 +1033,27 @@ function vNoscript(a, b, path) {
1004
1033
  }
1005
1034
  function vKids(el) {
1006
1035
  const list = el.localName === 'template' && el.content ? el.content.childNodes : el.childNodes;
1007
- return Array.from(list).filter((n) => n.nodeType === 1 || n.nodeType === 3 || n.nodeType === 8);
1036
+ const out = [];
1037
+ for (let i = 0; i < list.length; i++) { const n = list[i]; if (n.nodeType === 1 || n.nodeType === 3 || n.nodeType === 8) out.push(n); }
1038
+ return out;
1008
1039
  }
1009
1040
  const vAttrs = (el) => Array.from(el.attributes, (a) => (a.namespaceURI || '') + ' ' + a.name + '=' + JSON.stringify(a.value)).sort().join('; ');
1041
+ // Equal attribute lists in the same order need no sorted strings; any other case is
1042
+ // decided by the strings, as before.
1043
+ function sameAttrsInOrder(a, b) {
1044
+ const A = a.attributes, B = b.attributes;
1045
+ if (A.length !== B.length) return false;
1046
+ for (let i = 0; i < A.length; i++) {
1047
+ const x = A[i], y = B[i];
1048
+ if (x.name !== y.name || x.value !== y.value || x.namespaceURI !== y.namespaceURI) return false;
1049
+ }
1050
+ return true;
1051
+ }
1010
1052
  function vDiff(a, b, path, at = []) {
1011
1053
  if (a.nodeType !== b.nodeType) return path + ': node type ' + a.nodeType + ' vs ' + b.nodeType;
1012
1054
  if (a.nodeType === 3 || a.nodeType === 8) return a.data === b.data ? null : path + ': text ' + JSON.stringify(a.data.slice(0, 60)) + ' vs ' + JSON.stringify(b.data.slice(0, 60));
1013
1055
  if (a.localName !== b.localName || a.namespaceURI !== b.namespaceURI) return path + ': tag ' + a.localName + ' vs ' + b.localName;
1014
- if (vAttrs(a) !== vAttrs(b)) return path + ': attrs ' + vAttrs(a) + ' vs ' + vAttrs(b);
1056
+ if (!sameAttrsInOrder(a, b) && vAttrs(a) !== vAttrs(b)) return path + ': attrs ' + vAttrs(a) + ' vs ' + vAttrs(b);
1015
1057
  if (a.localName === 'noscript' && a.namespaceURI === XHTML) return vNoscript(a, b, path);
1016
1058
  const ka = vKids(a), kb = vKids(b);
1017
1059
  if (ka.length !== kb.length) return path + ': ' + ka.length + ' vs ' + kb.length + ' children';
@@ -1,4 +1,4 @@
1
- import { capabilitySelector, hasCapability } from './region-capabilities.js'
1
+ import { capabilitySelector, hasCapability, CAPABILITIES, expandsTo, hasPolicyToken } from './region-capabilities.js'
2
2
 
3
3
  const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
4
4
 
@@ -7,10 +7,9 @@ function copyControlState(source, copy) {
7
7
  const type = (source.getAttribute?.('type') || '').toLowerCase()
8
8
  if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
9
9
  if (source.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) copy.checked = source.checked
10
+ // Each surviving option carries its own selection, so a <select> whose excluded
11
+ // options were removed from the copy keeps the rest in step with the page.
10
12
  if (source.tagName === 'OPTION') copy.selected = source.selected
11
- if (source.tagName === 'SELECT') {
12
- for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
13
- }
14
13
  if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
15
14
  }
16
15
 
@@ -25,46 +24,77 @@ function matchesWithin(node, selector, boundary, inherit) {
25
24
  return false
26
25
  }
27
26
 
28
- function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
29
- if (source.nodeType === 1 && (
30
- (inherit
31
- ? hasCapability(source, capability)
32
- : matchesWithin(source, capabilityMatch, boundary, false)) ||
33
- (exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
34
- )) return null
35
- const copy = targetDocument.importNode(source, false)
27
+ // One native deep clone, then a lockstep walk of the two isomorphic trees that
28
+ // records provenance, collects controls, and marks excluded subtrees for removal.
29
+ // The browser's clone is far cheaper than importing node by node. The root answers
30
+ // for its ancestors once; below it the walk is top-down, so with inheritance each
31
+ // element answers only for its own markers: an excluded ancestor is never entered.
32
+ function excluded(source, capability, capabilityMatch, exclude, boundary, inherit) {
33
+ return (inherit
34
+ ? hasCapability(source, capability)
35
+ : matchesWithin(source, capabilityMatch, boundary, false)) ||
36
+ !!(exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
37
+ }
38
+
39
+ // The test closestWithCapability makes at each level, for this element alone.
40
+ function hasOwnCapability(element, capability) {
41
+ const definition = CAPABILITIES[capability]
42
+ return definition.tokens.some(token => expandsTo(element, token)) ||
43
+ definition.bundles.some(bundle => hasPolicyToken(element, bundle))
44
+ }
45
+
46
+ const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'])
47
+
48
+ function pairTree(source, copy, maps, drop) {
49
+ if (source.nodeType === 1 && drop(source)) { maps.dropped.push(copy); return }
36
50
  maps.cloneToLive.set(copy, source)
37
51
  maps.liveToClone.set(source, copy)
38
- const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
39
- const copyChildren = copy.nodeType === 1 && copy.tagName === 'TEMPLATE' ? copy.content : copy
40
- if (sourceChildren !== source) {
41
- maps.cloneToLive.set(copyChildren, sourceChildren)
42
- maps.liveToClone.set(sourceChildren, copyChildren)
43
- }
44
- for (const child of sourceChildren.childNodes || []) {
45
- const childCopy = importTree(child, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps)
46
- if (childCopy) {
47
- copyChildren.appendChild(childCopy)
48
- if (child.nodeType === 1) copyControlState(child, childCopy)
52
+ let sourceChildren = source
53
+ let copyChildren = copy
54
+ if (source.nodeType === 1) {
55
+ if (CONTROL_TAGS.has(source.tagName)) maps.controls.push(source, copy)
56
+ if (source.tagName === 'TEMPLATE') {
57
+ // The deep clone also copied the template's own child nodes, which are not its
58
+ // content and were never part of the view.
59
+ while (copy.firstChild) copy.removeChild(copy.firstChild)
60
+ sourceChildren = source.content
61
+ copyChildren = copy.content
62
+ maps.cloneToLive.set(copyChildren, sourceChildren)
63
+ maps.liveToClone.set(sourceChildren, copyChildren)
49
64
  }
65
+ } else if (source.nodeType !== 11) return
66
+ let c = copyChildren.firstChild
67
+ for (let s = sourceChildren.firstChild; s && c; s = s.nextSibling) {
68
+ pairTree(s, c, maps, drop)
69
+ c = c.nextSibling
50
70
  }
51
- if (source.nodeType === 1) copyControlState(source, copy)
52
- return copy
53
71
  }
54
72
 
55
- export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
73
+ export function createContentView(context, { capability = 'data', exclude = null, inherit = true, provenance = null } = {}) {
56
74
  if (!context) throw new TypeError('createContentView requires a DOM context')
57
75
  const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
58
76
  if (!sourceDocument?.implementation?.createHTMLDocument) {
59
77
  throw new TypeError('createContentView requires an HTML DOM implementation')
60
78
  }
61
79
  const inertDocument = sourceDocument.implementation.createHTMLDocument('')
62
- const cloneToLive = new WeakMap()
80
+ const cloneToLive = provenance || new WeakMap()
63
81
  const liveToClone = new WeakMap()
64
82
  const sourceRoot = context.nodeType === 9 ? context.documentElement : context
65
83
  if (exclude) inertDocument.documentElement.matches(exclude)
66
84
  const capabilityMatch = capabilitySelector(capability)
67
- const root = importTree(sourceRoot, inertDocument, capability, capabilityMatch, exclude, sourceRoot, inherit, { cloneToLive, liveToClone })
85
+ const drop = inherit
86
+ ? (el) => hasOwnCapability(el, capability) || !!(exclude && el.matches(exclude))
87
+ : (el) => excluded(el, capability, capabilityMatch, exclude, sourceRoot, false)
88
+ let root = null
89
+ if (sourceRoot.nodeType !== 1 || !excluded(sourceRoot, capability, capabilityMatch, exclude, sourceRoot, inherit)) {
90
+ root = inertDocument.importNode(sourceRoot, true)
91
+ const maps = { cloneToLive, liveToClone, dropped: [], controls: [] }
92
+ pairTree(sourceRoot, root, maps, drop)
93
+ for (const node of maps.dropped) node.remove()
94
+ // Control state is copied once the copy holds only what the view keeps, so a
95
+ // <select> sees exactly the options the old node-by-node import gave it.
96
+ for (let i = 0; i < maps.controls.length; i += 2) copyControlState(maps.controls[i], maps.controls[i + 1])
97
+ }
68
98
  if (context.nodeType === 9 && root) {
69
99
  inertDocument.replaceChild(root, inertDocument.documentElement)
70
100
  liveToClone.set(context, inertDocument)
@@ -0,0 +1,88 @@
1
+ import { isPolicyAttribute } from "./region-capabilities.js";
2
+
3
+ // Tab-local state on saved elements. The ephemeral attribute holds words a
4
+ // script sets for this tab (selected, dragging), and an ephemeral:NAME marker
5
+ // makes the element's own NAME attribute tab-local too. A dialog's open is
6
+ // tab-local with no marker: showModal() sets it, and a dialog saved open is
7
+ // always a bug. Like route-active, none of it is saved, synced, undone or
8
+ // counted as a change. The marker itself is ordinary saved content.
9
+
10
+ export const EPHEMERAL_ATTR = "ephemeral";
11
+ const MARKER = "ephemeral:";
12
+ // Identity, region attributes (a marker must not switch a region off) and form
13
+ // state (persist owns those) can never be ephemeral.
14
+ const NEVER = new Set([
15
+ "class", "id", "data-id", "value", "checked", "selected",
16
+ "save-remove", "save-ignore", "save-freeze", "mutations-ignore", "snapshot-remove",
17
+ ]);
18
+ const never = (name) => NEVER.has(name) || isPolicyAttribute(name);
19
+
20
+ export function isEphemeralAttr(name, el) {
21
+ if (name === EPHEMERAL_ATTR) return true;
22
+ if (!name || !el || el.nodeType !== 1 || never(name) || name.startsWith(MARKER)) return false;
23
+ if (name === "open" && el.localName === "dialog") return true;
24
+ return el.hasAttribute(MARKER + name);
25
+ }
26
+
27
+ /** True when `el` carries an ephemeral:NAME marker for `name`. */
28
+ export function isMarkedAttr(name, el) {
29
+ return !!el && el.nodeType === 1 && el.hasAttribute(MARKER + name);
30
+ }
31
+
32
+ function stripElement(el) {
33
+ if (el.localName === "dialog" && el.hasAttribute("open")) el.removeAttribute("open");
34
+ if (el.hasAttribute(EPHEMERAL_ATTR)) el.removeAttribute(EPHEMERAL_ATTR);
35
+ const attrs = el.attributes;
36
+ let names = null;
37
+ for (let i = 0; i < attrs.length; i++) {
38
+ const name = attrs[i].name;
39
+ if (!name.startsWith(MARKER)) continue;
40
+ const target = name.slice(MARKER.length);
41
+ if (target && !never(target)) (names || (names = [])).push(target);
42
+ }
43
+ if (names) for (const name of names) el.removeAttribute(name);
44
+ }
45
+
46
+ /** Remove ephemeral state from a snapshot clone, in place: the root, every descendant, and template contents. */
47
+ export function stripEphemeral(root) {
48
+ if (!root || root.nodeType !== 1) return;
49
+ stripElement(root);
50
+ for (const el of root.querySelectorAll("*")) {
51
+ stripElement(el);
52
+ if (el.localName === "template" && el.content) {
53
+ for (const child of el.content.children) stripEphemeral(child);
54
+ }
55
+ }
56
+ if (root.localName === "template" && root.content) {
57
+ for (const child of root.content.children) stripEphemeral(child);
58
+ }
59
+ }
60
+
61
+ function words(el) {
62
+ return (el.getAttribute(EPHEMERAL_ATTR) || "").split(/\s+/).filter(Boolean);
63
+ }
64
+
65
+ function write(el, list) {
66
+ if (list.length) el.setAttribute(EPHEMERAL_ATTR, list.join(" "));
67
+ else el.removeAttribute(EPHEMERAL_ATTR);
68
+ }
69
+
70
+ export const ephemeral = {
71
+ has(el, word) {
72
+ return words(el).includes(word);
73
+ },
74
+ add(el, word) {
75
+ const list = words(el);
76
+ if (!list.includes(word)) write(el, [...list, word]);
77
+ },
78
+ remove(el, word) {
79
+ const list = words(el);
80
+ if (list.includes(word)) write(el, list.filter((w) => w !== word));
81
+ },
82
+ toggle(el, word, force) {
83
+ const on = force === undefined ? !ephemeral.has(el, word) : !!force;
84
+ if (on) ephemeral.add(el, word);
85
+ else ephemeral.remove(el, word);
86
+ return on;
87
+ },
88
+ };
@@ -55,12 +55,19 @@ export function stripExtensionNoise(root) {
55
55
  // querySelectorAll('*') never returns the root itself, so an extension stamp on
56
56
  // <html> used to ride into every saved file. The root is the one element an
57
57
  // extension can mark on a page with no other match.
58
- for (const el of [root, ...root.querySelectorAll('*')]) {
59
- if (!el.attributes) continue
60
- for (const attr of [...el.attributes]) {
61
- if (EXTENSION_ATTR_PATTERN.test(attr.name.toLowerCase())) el.removeAttribute(attr.name)
58
+ // The attribute list is only copied when a marker is found on it.
59
+ const strip = (el) => {
60
+ const attrs = el.attributes
61
+ if (!attrs) return
62
+ let found = null
63
+ for (let i = 0; i < attrs.length; i++) {
64
+ const name = attrs[i].name
65
+ if (EXTENSION_ATTR_PATTERN.test(name.toLowerCase())) (found || (found = [])).push(name)
62
66
  }
67
+ if (found) for (const name of found) el.removeAttribute(name)
63
68
  }
69
+ strip(root)
70
+ for (const el of root.querySelectorAll('*')) strip(el)
64
71
  } catch (e) {
65
72
  if (typeof console !== 'undefined') console.warn('[clay] stripExtensionNoise skipped:', e)
66
73
  }
@@ -70,6 +70,7 @@ import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
70
70
  import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
71
71
  import { isPolicyAttribute } from './region-capabilities.js';
72
72
  import { ROOT_LIBRARY_ATTRS, ROUTE_ROOT_PREFIX, ROUTE_ACTIVE_ATTR } from './root-attrs.js';
73
+ import { isEphemeralAttr } from './ephemeral.js';
73
74
 
74
75
  import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
75
76
 
@@ -83,13 +84,15 @@ const dummyElem = document.createElement("div");
83
84
  // instead of body. The raw lane is deliberately unfiltered ("raw means raw"), so
84
85
  // without this every save would push a savestatus flip into undo's record stream
85
86
  // and Cmd+Z after a save would undo that instead of the user's edit.
86
- // Router state (route-* on the root, route-active anywhere) is dropped here for the same reason.
87
+ // Router state (route-* on the root, route-active anywhere) and ephemeral state
88
+ // (lib/ephemeral.js) are dropped here for the same reason.
87
89
  const ROOT_LIBRARY_ATTR_SET = new Set(ROOT_LIBRARY_ATTRS);
88
90
 
89
91
  function isLibraryRootAttr(record) {
90
92
  if (record.type !== 'attributes') return false;
91
93
  const name = record.attributeName;
92
94
  if (name === ROUTE_ACTIVE_ATTR) return true;
95
+ if (isEphemeralAttr(name, record.target)) return true;
93
96
  return record.target === document.documentElement
94
97
  && (ROOT_LIBRARY_ATTR_SET.has(name) || name.startsWith(ROUTE_ROOT_PREFIX));
95
98
  }
@@ -8,6 +8,8 @@
8
8
  * peer's copy of them must never be applied.
9
9
  */
10
10
 
11
+ import { isEphemeralAttr, isMarkedAttr } from "./ephemeral.js";
12
+
11
13
  // The save token, in the order a reader tries the two spellings. Spec §9 bounds it to
12
14
  // per-file and per-tab and makes the host strip it before writing, so it never reaches
13
15
  // disk. But §9 bounds only the save path, and §10 fans a snapshot out to other editors'
@@ -128,15 +130,36 @@ export const ROUTE_ACTIVE_ATTR = "route-active";
128
130
 
129
131
  /**
130
132
  * True when `name` on `el` belongs to this tab rather than to the document:
131
- * every tab-local root attribute, route-* on a root, and route-active anywhere.
133
+ * every tab-local root attribute, route-* on a root, route-active anywhere, and
134
+ * ephemeral state (lib/ephemeral.js) anywhere.
132
135
  * Works on the live root and on a snapshot clone's or parsed frame's root.
133
136
  */
134
137
  export function isTabLocalAttr(name, el) {
138
+ if (isEphemeralAttr(name, el)) return true;
135
139
  if (name === ROUTE_ACTIVE_ATTR) return true;
136
140
  if (!el || el.localName !== "html" || el.parentElement) return false;
137
141
  return TAB_LOCAL_ROOT_ATTRS.has(name) || name.startsWith(ROUTE_ROOT_PREFIX);
138
142
  }
139
143
 
144
+ /**
145
+ * hyper-morph's ignoreAttribute for tab-local state. While a merge is applied,
146
+ * `merged` is the element the live one is becoming, so a frame that adds or
147
+ * removes an ephemeral:NAME marker together with NAME is judged by the marker
148
+ * as it ends up, not by attribute order.
149
+ */
150
+ export function mergeIgnoresAttr(el, name, merged) {
151
+ return isTabLocalAttr(name, merged || el);
152
+ }
153
+
154
+ /**
155
+ * hyper-morph's beforeAttributeUpdated veto: a merge may neither write nor
156
+ * remove a tab-local attribute. A marker-protected attribute is left to
157
+ * mergeIgnoresAttr, which reads the marker from the merged element.
158
+ */
159
+ export function mergeVetoesAttr(name, el) {
160
+ return isTabLocalAttr(name, el) && !isMarkedAttr(name, el) ? false : undefined;
161
+ }
162
+
140
163
  /** Remove router state from a snapshot clone: route-* on its root, route-active anywhere. */
141
164
  export function stripRouteAttrs(root) {
142
165
  for (const attr of [...root.attributes]) {
package/src/loader.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { resolveModules, MODULES } from "./loader-logic.js";
2
2
  import onDomReady from "./lib/dom-ready.js";
3
3
  import { initTheme } from "./ui/theme.js";
4
+ import { ephemeral } from "./lib/ephemeral.js";
4
5
 
5
6
  function domReady() {
6
7
  return new Promise((resolve) => onDomReady(resolve));
@@ -91,6 +92,7 @@ function assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPol
91
92
 
92
93
  clay.readData = pageData.readData;
93
94
  clay.writeData = pageData.writeData;
95
+ clay.ephemeral = ephemeral;
94
96
 
95
97
  if (save) {
96
98
  const saveFn = save.savePage || save.default;
@@ -5,15 +5,30 @@
5
5
 
6
6
  import { undo } from '../vendor/hyper-undo.vendor.js'
7
7
  import { isEditMode } from '../core/is-edit-mode.js'
8
+ import { isTabLocalAttr } from '../lib/root-attrs.js'
9
+
10
+ // Tab-local attributes (ephemeral state, route-active, root library state) are
11
+ // never undone. The shared mutation hub already drops them for the main
12
+ // instance; a scope made with create() runs its own observer, so it is told too.
13
+ const withTabLocal = (opts = {}) => {
14
+ const own = opts.ignoreAttribute
15
+ return {
16
+ ...opts,
17
+ ignoreAttribute: (name, target) => isTabLocalAttr(name, target) || (own ? own(name, target) : false),
18
+ }
19
+ }
20
+
21
+ const createScope = undo.create.bind(undo)
22
+ undo.create = (opts) => createScope(withTabLocal(opts))
8
23
 
9
24
  function init() {
10
25
  if (!isEditMode) return
11
26
  // Defer until the body exists so the default scope (document.body) is valid.
12
27
  if (typeof document === 'undefined') return
13
28
  if (document.readyState === 'loading') {
14
- document.addEventListener('DOMContentLoaded', () => undo.start({ bindKeys: true }))
29
+ document.addEventListener('DOMContentLoaded', () => undo.start(withTabLocal({ bindKeys: true })))
15
30
  } else {
16
- undo.start({ bindKeys: true })
31
+ undo.start(withTabLocal({ bindKeys: true }))
17
32
  }
18
33
  }
19
34
 
@@ -40,11 +40,11 @@ import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
40
40
  const { createIdentityStore } = HyperMorph;
41
41
  import Mutation from "../lib/mutation.js";
42
42
  import { NO_DIRTY_SELECTOR, LIVE_SELECTOR } from "../lib/region-policy.js";
43
- import { EXTENSION_NODE_SELECTOR } from '../lib/extension-noise.js';
43
+ import { EXTENSION_NODE_SELECTOR, EXTENSION_ATTR_PATTERN } from '../lib/extension-noise.js';
44
44
  import { isEditMode } from "../core/is-edit-mode.js";
45
45
  import { mergeTagRecognizers } from "./merge-tags.js";
46
46
  import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode, captureForMerge, captureForSaveAndComparison } from '../core/snapshot.js';
47
- import { isTabLocalAttr } from '../lib/root-attrs.js';
47
+ import { mergeIgnoresAttr, mergeVetoesAttr } from '../lib/root-attrs.js';
48
48
  import { autosaveActive } from '../lib/autosave-state.js';
49
49
  import { enableContentEditable } from '../core/admin-contenteditable.js';
50
50
  import { enableOnClick } from '../core/admin-onclick.js';
@@ -101,9 +101,12 @@ const REMOTE_WINS_SELECTOR = [
101
101
  '[clay~="no-watch"]', '[no-watch]', '[mutations-ignore]',
102
102
  ].join(',');
103
103
 
104
- // Host tokens, library state and router state, on every side
104
+ // Host tokens, library state, router state and ephemeral state, on every side
105
105
  // of a merge: the live root, a snapshot clone's root, a parsed frame's root.
106
- const isRootLocalAttr = (el, name) => isTabLocalAttr(name, el);
106
+ // Extension marker attributes too: snapshots and frames never carry them, so a
107
+ // disk frame would otherwise strip them from the live inputs they belong to.
108
+ const isRootLocalAttr = (el, name, merged) =>
109
+ mergeIgnoresAttr(el, name, merged) || EXTENSION_ATTR_PATTERN.test(name.toLowerCase());
107
110
 
108
111
  // Identity an author wrote into the page, the fallback behind synthetic ids.
109
112
  //
@@ -1655,8 +1658,7 @@ class LiveSync {
1655
1658
  const store = this.identity;
1656
1659
  // A frame may neither write these onto our root nor, by not carrying them,
1657
1660
  // take ours away. Returning false is hyper-morph's veto for both directions.
1658
- const beforeAttributeUpdated = (name, element) =>
1659
- isTabLocalAttr(name, element) ? false : undefined;
1661
+ const beforeAttributeUpdated = mergeVetoesAttr;
1660
1662
  const authored = uniqueAuthoredIdentity(new WeakMap());
1661
1663
  const sideIdentity = (map) =>
1662
1664
  isIdMap(map) ? { first: authored, map, then: authored } : authored;
@@ -0,0 +1,12 @@
1
+ // Tailwind v4 plugin for ClayJS ephemeral state:
2
+ // @plugin "@panphora/clayjs/tailwind";
3
+ // ephemeral-selected:ring-2 -> &[ephemeral~="selected"]. matchVariant only
4
+ // accepts bare names that `values` lists, so `values` answers for any simple
5
+ // word; the bracketed form ephemeral-[selected]: works regardless.
6
+ const anyWord = new Proxy({}, {
7
+ get: (_, key) => (typeof key === "string" && /^[a-z0-9_-]+$/i.test(key) ? key : undefined),
8
+ });
9
+
10
+ export default function ephemeralVariant({ matchVariant }) {
11
+ matchVariant("ephemeral", (value) => `&[ephemeral~="${value}"]`, { values: anyWord });
12
+ }