morph-embed 0.1.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.
@@ -0,0 +1,2424 @@
1
+ /*! Morph Embed SDK v0.1.0 — https://usemorph.xyz — MIT */
2
+ const g = typeof window !== "undefined" ? window : globalThis;
3
+
4
+ /* ---- shared/dsl.js ---- */
5
+ /*
6
+ * Morph DSL — the contract between the model and the page.
7
+ *
8
+ * The model returns a Transform: { version, explanation, ops: [...] }.
9
+ * This module:
10
+ * - validateTransform() : structural validation + sanitization (PURE, no DOM)
11
+ * - applyTransform() : interprets ops against a real document (needs a DOM)
12
+ * - undoTransform() : reverses an applied transform (in-session)
13
+ *
14
+ * SECURITY: there is no code-execution op. addElement HTML is whitelist-sanitized,
15
+ * injected CSS is neutralized, and no op can run script. The interpreter never trusts
16
+ * the model — every op is validated/sanitized here, on our side.
17
+ *
18
+ * UMD: works as a Node module (tests) and as a browser global `MorphDSL` (content script).
19
+ */
20
+ (function (global, factory) {
21
+ const api = factory();
22
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
23
+ else global.MorphDSL = api;
24
+ })(typeof self !== 'undefined' ? self : this, function () {
25
+ 'use strict';
26
+
27
+ const SCHEMA_VERSION = 1;
28
+
29
+ // Op name -> required string fields (besides `op`). Optional fields handled per-op.
30
+ const OP_SPECS = {
31
+ injectCSS: { fields: ['css'] },
32
+ hide: { fields: ['selector'] },
33
+ show: { fields: ['selector'] },
34
+ remove: { fields: ['selector'] },
35
+ setStyle: { fields: ['selector'], object: 'style' },
36
+ setText: { fields: ['selector', 'text'] },
37
+ setAttr: { fields: ['selector', 'name', 'value'] },
38
+ addClass: { fields: ['selector', 'class'] },
39
+ removeClass: { fields: ['selector', 'class'] },
40
+ move: { fields: ['selector', 'target', 'position'] },
41
+ addElement: { fields: ['html', 'target', 'position'] },
42
+ renderData: { fields: ['target', 'position', 'chart'] },
43
+ };
44
+
45
+ const POSITIONS = ['before', 'after', 'prepend', 'append'];
46
+
47
+ const ALLOWED_TAGS = new Set([
48
+ 'div', 'span', 'p', 'a', 'ul', 'ol', 'li', 'img', 'b', 'i', 'strong', 'em',
49
+ 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'thead', 'tbody',
50
+ 'tr', 'td', 'th', 'section', 'article', 'header', 'footer', 'nav', 'aside',
51
+ 'main', 'small', 'code', 'pre', 'blockquote', 'figure', 'figcaption',
52
+ 'label', 'button', 'mark', 'time', 'sub', 'sup', 'caption', 'dl', 'dt', 'dd',
53
+ ]);
54
+
55
+ const ALLOWED_ATTRS = new Set([
56
+ 'class', 'id', 'style', 'href', 'src', 'alt', 'title', 'role', 'target',
57
+ 'rel', 'width', 'height', 'colspan', 'rowspan', 'datetime', 'type',
58
+ ]);
59
+
60
+ const SAFE_URL = /^(https?:|mailto:|tel:|\/|#|\.\/|\.\.\/)/i;
61
+
62
+ // Namespaces whose elements must be DROPPED with their whole subtree (none are
63
+ // in ALLOWED_TAGS, and foreign content enables mXSS / <svg><script> leakage).
64
+ const SVG_NS = 'http://www.w3.org/2000/svg';
65
+ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
66
+
67
+ // Elements whose ENTIRE subtree must be dropped (not unwrapped): their text
68
+ // content is executable/active in some context (script) or is a parsing trap
69
+ // that re-activates on reparse (noscript/template-script, style breakout,
70
+ // <title>/<textarea> mXSS). None are in ALLOWED_TAGS.
71
+ const DROP_SUBTREE_TAGS = new Set([
72
+ 'script', 'style', 'noscript', 'noembed', 'noframes', 'iframe', 'frame',
73
+ 'frameset', 'object', 'embed', 'applet', 'title', 'textarea', 'xmp',
74
+ 'plaintext', 'listing',
75
+ ]);
76
+
77
+ // URL-bearing attribute names (used by both HTML sanitizer and setAttr guard).
78
+ const URL_ATTRS = new Set(['href', 'src', 'srcset', 'xlink:href']);
79
+
80
+ // Modest length caps (DoS hygiene; generous enough not to clip real payloads).
81
+ const MAX_CSS = 200000;
82
+ const MAX_HTML = 200000;
83
+ const MAX_TEXT = 5000;
84
+ const MAX_EXPLANATION = 500;
85
+ const MAX_SELECTOR = 2000;
86
+ const MAX_LABEL = 120; // chart title cap
87
+ const MAX_CHART_POINTS = 60; // bars / points per chart
88
+ // Trusted chart geometry — rendered by US via createElementNS, never the model.
89
+ const CHART_W = 600, CHART_H = 280, CHART_PAD = 40;
90
+
91
+ // ---- pure helpers --------------------------------------------------------
92
+
93
+ function isString(v) { return typeof v === 'string'; }
94
+
95
+ // Normalize a CSS string for DANGER DETECTION only (never used as output):
96
+ // strip /*...*/ comments, decode CSS backslash escapes (\65 -> e, \6a -> j),
97
+ // and collapse whitespace so 'expr/**/ession(' and '\65 xpression' are caught.
98
+ function normalizeCssForScan(css) {
99
+ if (!isString(css)) return '';
100
+ let s = css.replace(/\/\*[\s\S]*?\*\//g, ''); // strip comments
101
+ // Decode CSS escapes: \<1-6 hex> (optional trailing ws) or \<char>
102
+ s = s.replace(/\\([0-9a-fA-F]{1,6})\s?/g, function (_, hex) {
103
+ try {
104
+ const cp = parseInt(hex, 16);
105
+ if (cp > 0 && cp <= 0x10ffff) return String.fromCodePoint(cp);
106
+ } catch (_e) { /* fall through */ }
107
+ return '';
108
+ });
109
+ s = s.replace(/\\([^\r\n0-9a-fA-F])/g, '$1'); // \<char> -> char
110
+ // strip control chars / NUL, collapse whitespace
111
+ s = s.replace(/[\x00-\x20\x7f]+/g, ' ');
112
+ return s.toLowerCase();
113
+ }
114
+
115
+ // Does the (already normalized) CSS contain a dangerous construct?
116
+ function cssHasDanger(normalized) {
117
+ return (
118
+ /@import/.test(normalized) ||
119
+ /expression\s*\(/.test(normalized) ||
120
+ /behavior\s*:/.test(normalized) ||
121
+ /-moz-binding/.test(normalized) ||
122
+ /url\(\s*(['"]?)\s*(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)/.test(normalized) ||
123
+ /(javascript|vbscript|mocha|livescript)\s*:/.test(normalized)
124
+ );
125
+ }
126
+
127
+ // Shared CSS neutralizer. `mode` is 'css' (injectCSS) or 'style' (style attr /
128
+ // setStyle value). When the normalized form trips cssHasDanger we strip the
129
+ // offending tokens from the ORIGINAL text (comment-/escape-aware) rather than
130
+ // rewriting valid styling. If we cannot prove it safe we blank it.
131
+ function neutralizeCssValue(value, mode) {
132
+ if (!isString(value)) return '';
133
+ let out = value;
134
+ // Token-level removals that are safe to apply unconditionally. Each pattern
135
+ // tolerates intervening CSS comments/whitespace.
136
+ const ws = '(?:\\s|/\\*[\\s\\S]*?\\*/)*';
137
+ // @import ...; -> drop
138
+ out = out.replace(new RegExp('@import[^;]*;?', 'gi'), '');
139
+ // expression( possibly comment-split: expr/**/ession(
140
+ out = out.replace(new RegExp('e' + ws + 'x' + ws + 'p' + ws + 'r' + ws + 'e' + ws + 's' + ws + 's' + ws + 'i' + ws + 'o' + ws + 'n' + ws + '\\(', 'gi'), 'void(');
141
+ // behavior: and -moz-binding:
142
+ out = out.replace(new RegExp('behavior' + ws + ':', 'gi'), '/* behavior */:');
143
+ out = out.replace(new RegExp('-moz-binding' + ws + ':', 'gi'), '/* binding */:');
144
+ // url( <comments/ws> <dangerous-scheme> ...) -> url(about:blank#)
145
+ out = out.replace(
146
+ /url\(\s*(['"]?)((?:\/\*[\s\S]*?\*\/|\s)*)(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)[^)]*\)/gi,
147
+ 'url($1about:blank#)'
148
+ );
149
+ // bare scheme prefixes inside the value (style attr context)
150
+ if (mode === 'style') {
151
+ out = out.replace(/(javascript|vbscript|mocha|livescript)\s*:/gi, '');
152
+ }
153
+ // <style> breakout literal (injectCSS goes into a <style> textContent)
154
+ if (mode === 'css') {
155
+ out = out.replace(/<\/style/gi, '<\\/style');
156
+ }
157
+ // Final safety net: if normalization STILL detects danger, blank it out
158
+ // rather than emit something that could execute.
159
+ if (cssHasDanger(normalizeCssForScan(out))) return '';
160
+ return out;
161
+ }
162
+
163
+ function neutralizeCss(css) {
164
+ if (!isString(css)) return '';
165
+ return neutralizeCssValue(css.slice(0, MAX_CSS), 'css');
166
+ }
167
+
168
+ function sanitizeStyleAttr(value) {
169
+ if (!isString(value)) return '';
170
+ return neutralizeCssValue(value, 'style');
171
+ }
172
+
173
+ // Normalize a URL value for SAFE_URL testing: strip control chars / tabs /
174
+ // newlines / NUL that browsers ignore inside a scheme ('java\tscript:').
175
+ function normalizeUrl(value) {
176
+ if (!isString(value)) return '';
177
+ // remove all control chars and whitespace anywhere (defeats java\tscript:,
178
+ // leading spaces, embedded newlines) for the scheme test
179
+ return value.replace(/[\x00-\x20\x7f]+/g, '');
180
+ }
181
+
182
+ function isSafeUrl(value) {
183
+ return SAFE_URL.test(normalizeUrl(value));
184
+ }
185
+
186
+ // srcset is a comma-separated list of "<url> <descriptor>" candidates; every
187
+ // candidate URL must be SAFE.
188
+ function isSafeSrcset(value) {
189
+ if (!isString(value)) return false;
190
+ const candidates = value.split(',');
191
+ for (const cand of candidates) {
192
+ const url = cand.trim().split(/\s+/)[0];
193
+ if (!url) continue;
194
+ if (!isSafeUrl(url)) return false;
195
+ }
196
+ return true;
197
+ }
198
+
199
+ // Is this attribute name a URL-bearing one (after normalization)?
200
+ function isUrlAttr(name) {
201
+ const n = String(name).toLowerCase();
202
+ return URL_ATTRS.has(n);
203
+ }
204
+
205
+ // Normalize an attribute name for the on* / namespaced checks: strip NUL and
206
+ // whitespace that browsers may tolerate around the name.
207
+ function normalizeAttrName(name) {
208
+ return String(name).replace(/[\x00-\x20\x7f]+/g, '').toLowerCase();
209
+ }
210
+
211
+ function getDOMParser() {
212
+ if (typeof DOMParser !== 'undefined') return new DOMParser();
213
+ throw new Error('Morph: no DOMParser available in this environment');
214
+ }
215
+
216
+ /**
217
+ * Whitelist-sanitize an HTML fragment. Returns a SAFE html string.
218
+ * Requires a DOMParser (browser, or jsdom in tests).
219
+ */
220
+ function sanitizeHtml(html) {
221
+ if (!isString(html) || html.trim() === '') return '';
222
+ if (html.length > MAX_HTML) html = html.slice(0, MAX_HTML);
223
+ const parser = getDOMParser();
224
+ const doc = parser.parseFromString('<body>' + html + '</body>', 'text/html');
225
+ const body = doc.body;
226
+ sanitizeNode(body, doc);
227
+ return body.innerHTML;
228
+ }
229
+
230
+ function sanitizeNode(node, doc) {
231
+ const children = Array.from(node.childNodes);
232
+ for (const child of children) {
233
+ if (child.nodeType === 8) { node.removeChild(child); continue; } // comments
234
+ if (child.nodeType !== 1) continue; // keep text
235
+
236
+ // Foreign content (SVG / MathML): DROP element AND its whole subtree.
237
+ // None are in ALLOWED_TAGS, and unwrapping enables <svg><script>/mXSS and
238
+ // xlink:href=javascript: leakage. namespaceURI is the reliable signal.
239
+ const ns = child.namespaceURI;
240
+ if (ns === SVG_NS || ns === MATHML_NS) { node.removeChild(child); continue; }
241
+
242
+ const tag = child.tagName.toLowerCase();
243
+
244
+ // Drop script-like / parsing-trap elements WITH their subtree (their text
245
+ // is active or re-activates on reparse). Never unwrap these.
246
+ if (DROP_SUBTREE_TAGS.has(tag)) { node.removeChild(child); continue; }
247
+
248
+ // <template>: its real children live in a separate .content DocumentFragment
249
+ // (off the childNodes tree). Sanitize that fragment, lift the surviving
250
+ // children up into the document, then drop the (disallowed) <template>.
251
+ if (tag === 'template' && child.content) {
252
+ sanitizeNode(child.content, doc);
253
+ while (child.content.firstChild) node.insertBefore(child.content.firstChild, child);
254
+ // also handle any (rare) light-DOM children
255
+ sanitizeNode(child, doc);
256
+ while (child.firstChild) node.insertBefore(child.firstChild, child);
257
+ node.removeChild(child);
258
+ continue;
259
+ }
260
+
261
+ if (!ALLOWED_TAGS.has(tag)) {
262
+ // unwrap: keep sanitized children, drop the disallowed element itself.
263
+ sanitizeNode(child, doc);
264
+ const moved = [];
265
+ while (child.firstChild) {
266
+ const m = child.firstChild;
267
+ node.insertBefore(m, child);
268
+ moved.push(m);
269
+ }
270
+ node.removeChild(child);
271
+ // Re-run sanitizeNode on the moved children in their new home so any
272
+ // foreign/disallowed node that only became reachable after the unwrap is
273
+ // re-evaluated (defense against reparenting-based mXSS).
274
+ for (const m of moved) {
275
+ if (m.nodeType === 1) {
276
+ const mns = m.namespaceURI;
277
+ if (mns === SVG_NS || mns === MATHML_NS) { node.removeChild(m); continue; }
278
+ const mtag = m.tagName.toLowerCase();
279
+ if (DROP_SUBTREE_TAGS.has(mtag)) { node.removeChild(m); continue; }
280
+ if (!ALLOWED_TAGS.has(mtag)) {
281
+ sanitizeNode(m, doc);
282
+ while (m.firstChild) node.insertBefore(m.firstChild, m);
283
+ node.removeChild(m);
284
+ }
285
+ }
286
+ }
287
+ continue;
288
+ }
289
+ // scrub attributes
290
+ for (const attr of Array.from(child.attributes)) {
291
+ const rawName = attr.name;
292
+ const name = rawName.toLowerCase();
293
+ // strip ALL namespaced attrs (xlink:href, xml:*, etc.) — they are never
294
+ // whitelisted and carry foreign-content URL/script vectors.
295
+ if (name.indexOf(':') !== -1) { child.removeAttribute(rawName); continue; }
296
+ // normalize for on* detection: remove NUL/whitespace browsers may tolerate
297
+ const normName = normalizeAttrName(rawName);
298
+ if (normName.startsWith('on') || !ALLOWED_ATTRS.has(name)) {
299
+ child.removeAttribute(rawName);
300
+ continue;
301
+ }
302
+ if (name === 'href' || name === 'src') {
303
+ if (!isSafeUrl(attr.value)) { child.removeAttribute(rawName); continue; }
304
+ }
305
+ if (name === 'srcset') {
306
+ // not currently whitelisted, but if it ever is, guard every candidate.
307
+ if (!isSafeSrcset(attr.value)) { child.removeAttribute(rawName); continue; }
308
+ }
309
+ if (name === 'style') {
310
+ child.setAttribute('style', sanitizeStyleAttr(attr.value));
311
+ }
312
+ if (name === 'target' && child.getAttribute('target') === '_blank') {
313
+ child.setAttribute('rel', 'noopener noreferrer');
314
+ }
315
+ }
316
+ sanitizeNode(child, doc);
317
+ }
318
+ }
319
+
320
+ // ---- validation ----------------------------------------------------------
321
+
322
+ /**
323
+ * Validate + sanitize a raw transform object from the model.
324
+ * Returns { ok, transform, errors[], skipped[] }.
325
+ * Invalid ops are DROPPED (not fatal) and reported in `skipped`.
326
+ * Pure except sanitizeHtml needs a DOMParser; callers in Node tests provide jsdom.
327
+ */
328
+ function validateTransform(raw) {
329
+ const errors = [];
330
+ const skipped = [];
331
+
332
+ if (!raw || typeof raw !== 'object') {
333
+ return { ok: false, transform: null, errors: ['transform is not an object'], skipped };
334
+ }
335
+ if (!Array.isArray(raw.ops)) {
336
+ return { ok: false, transform: null, errors: ['transform.ops must be an array'], skipped };
337
+ }
338
+
339
+ const cleanOps = [];
340
+ raw.ops.forEach((op, i) => {
341
+ const reason = opError(op);
342
+ if (reason) { skipped.push({ index: i, op: op && op.op, reason }); return; }
343
+ cleanOps.push(sanitizeOp(op));
344
+ });
345
+
346
+ const transform = {
347
+ version: SCHEMA_VERSION,
348
+ explanation: isString(raw.explanation) ? raw.explanation.slice(0, MAX_EXPLANATION) : '',
349
+ ops: cleanOps,
350
+ };
351
+ return { ok: cleanOps.length > 0, transform, errors, skipped };
352
+ }
353
+
354
+ function opError(op) {
355
+ if (!op || typeof op !== 'object') return 'op is not an object';
356
+ const spec = OP_SPECS[op.op];
357
+ if (!spec) return 'unknown op: ' + op.op;
358
+ for (const f of spec.fields) {
359
+ if (!isString(op[f])) return `missing/invalid field "${f}"`;
360
+ }
361
+ if (op.op === 'move' || op.op === 'addElement') {
362
+ if (!POSITIONS.includes(op.position)) return 'invalid position';
363
+ }
364
+ if (spec.object && (typeof op[spec.object] !== 'object' || op[spec.object] === null)) {
365
+ return `missing/invalid object "${spec.object}"`;
366
+ }
367
+ // Fail-fast guard for obviously malformed/oversized selectors. Does NOT
368
+ // reject valid CSS selectors used elsewhere ('.promoted', '#content',
369
+ // 'article.post', '*'); only catches non-string / absurdly long values.
370
+ if (spec.fields.includes('selector') && !isValidSelectorShape(op.selector)) {
371
+ return 'invalid selector';
372
+ }
373
+ if (op.op === 'move' && !isValidSelectorShape(op.target)) {
374
+ return 'invalid target selector';
375
+ }
376
+ // setAttr: reject event-handler attribute NAMES and enforce the SAME
377
+ // attribute whitelist that sanitizeHtml uses, so an attr that would be
378
+ // stripped inside addElement HTML (srcdoc, formaction, action, ...) cannot
379
+ // be smuggled in via setAttr onto live elements.
380
+ if (op.op === 'setAttr') {
381
+ const n = normalizeAttrName(op.name);
382
+ if (n.startsWith('on')) return 'event-handler attr blocked';
383
+ if (!ALLOWED_ATTRS.has(n)) return 'attr not allowed: ' + n;
384
+ }
385
+ // renderData: chart type + position must be from the fixed sets. The op has
386
+ // two forms; either way the actual numbers come from caller-attached data at
387
+ // apply time, never the model.
388
+ // - source form (SDK): names a vendor-registered source + a query DESCRIPTION;
389
+ // trusted code resolves rows out-of-band.
390
+ // - legacy form: dataKey/x/y field mappings over caller-attached records.
391
+ if (op.op === 'renderData') {
392
+ if (!POSITIONS.includes(op.position)) return 'invalid position';
393
+ if (op.chart !== 'bar' && op.chart !== 'line' && op.chart !== 'stat') return 'invalid chart type';
394
+ if (op.source !== undefined) {
395
+ if (!isString(op.source) || !op.source) return 'invalid source';
396
+ if (!op.query || typeof op.query !== 'object' || Array.isArray(op.query)) return 'invalid query';
397
+ } else {
398
+ for (const f of ['dataKey', 'x', 'y']) {
399
+ if (!isString(op[f])) return `missing/invalid field "${f}"`;
400
+ }
401
+ }
402
+ }
403
+ return null;
404
+ }
405
+
406
+ // Lightweight selector sanity check. We deliberately accept anything that is a
407
+ // reasonably-sized string and reject only clearly-bogus inputs (empty, huge,
408
+ // or containing characters that can never appear in a CSS selector). The
409
+ // authoritative validity check still happens at apply time via
410
+ // querySelectorAll (which throws -> caught per-op), so this never changes the
411
+ // "bad selector fails that op only" contract — it just fails such ops earlier.
412
+ function isValidSelectorShape(sel) {
413
+ if (!isString(sel)) return false;
414
+ if (sel.length === 0 || sel.length > MAX_SELECTOR) return false;
415
+ // NOTE: we intentionally do NOT reject angle brackets or other syntactically
416
+ // invalid selectors here. The existing contract ("bad selector fails that op
417
+ // only") requires a malformed selector like '<<<invalid>>>' to PASS validate
418
+ // and FAIL at apply time via querySelectorAll's throw. We only fail fast on
419
+ // non-string / empty / absurdly long values, which can never be valid.
420
+ return true;
421
+ }
422
+
423
+ // Bound + type-check a self-healing anchors descriptor from a (semi-trusted,
424
+ // locally-stored) recipe. Drops unknown fields and caps sizes.
425
+ function sanitizeAnchors(arr) {
426
+ const out = [];
427
+ for (const a of arr.slice(0, 10)) {
428
+ if (!a || typeof a !== 'object') continue;
429
+ const s = {};
430
+ if (typeof a.tag === 'string') s.tag = a.tag.slice(0, 40).toLowerCase();
431
+ if (typeof a.id === 'string') s.id = a.id.slice(0, 200);
432
+ if (typeof a.role === 'string') s.role = a.role.slice(0, 80);
433
+ if (typeof a.aria === 'string') s.aria = a.aria.slice(0, 200);
434
+ if (typeof a.text === 'string') s.text = a.text.slice(0, 120);
435
+ if (Array.isArray(a.classes)) s.classes = a.classes.filter((c) => typeof c === 'string').slice(0, 8).map((c) => c.slice(0, 80));
436
+ if (typeof a.nthOfType === 'number' && isFinite(a.nthOfType)) s.nthOfType = a.nthOfType;
437
+ out.push(s);
438
+ }
439
+ return out;
440
+ }
441
+
442
+ // Bound a model-supplied query DESCRIPTION (renderData source form). Whitelist
443
+ // keys only; the SDK's registry + query engine do the semantic (schema)
444
+ // validation — this just guarantees the op stays small, inert JSON with no
445
+ // surprise keys.
446
+ const QUERY_KEYS = ['filter', 'dateField', 'since', 'until', 'groupBy', 'aggregate', 'limit'];
447
+ function sanitizeQuery(q) {
448
+ const out = {};
449
+ for (const k of QUERY_KEYS) {
450
+ const v = q[k];
451
+ if (v === undefined) continue;
452
+ if (k === 'filter' && v && typeof v === 'object' && !Array.isArray(v)) {
453
+ const f = {};
454
+ for (const fk of Object.keys(v).slice(0, 10)) {
455
+ const fv = v[fk];
456
+ if (isString(fv) || typeof fv === 'number') {
457
+ f[String(fk).slice(0, 80)] = isString(fv) ? fv.slice(0, 200) : fv;
458
+ } else if (Array.isArray(fv)) {
459
+ f[String(fk).slice(0, 80)] = fv.slice(0, 20)
460
+ .filter((x) => isString(x) || typeof x === 'number')
461
+ .map((x) => (isString(x) ? x.slice(0, 200) : x));
462
+ }
463
+ }
464
+ out.filter = f;
465
+ } else if (k === 'aggregate' && v && typeof v === 'object' && !Array.isArray(v)) {
466
+ const a = {};
467
+ if (isString(v.fn)) a.fn = v.fn.slice(0, 20);
468
+ if (isString(v.field)) a.field = v.field.slice(0, 80);
469
+ out.aggregate = a;
470
+ } else if (k === 'limit') {
471
+ if (typeof v === 'number' && isFinite(v)) out.limit = v;
472
+ } else if (isString(v)) {
473
+ out[k] = v.slice(0, 80);
474
+ }
475
+ }
476
+ return out;
477
+ }
478
+
479
+ function sanitizeOp(op) {
480
+ const out = { op: op.op };
481
+ const spec = OP_SPECS[op.op];
482
+ for (const f of spec.fields) out[f] = op[f];
483
+ if (op.op === 'injectCSS') out.css = neutralizeCss(op.css);
484
+ if (op.op === 'addElement') {
485
+ out.html = sanitizeHtml(String(op.html).slice(0, MAX_HTML));
486
+ // Optional action binding (SDK): bounded inert data. Trusted code decides
487
+ // at apply time whether anything listens (opts.onAction) — never the model.
488
+ if (isString(op.action) && op.action) {
489
+ out.action = op.action.slice(0, 80);
490
+ const args = {};
491
+ if (op.args && typeof op.args === 'object' && !Array.isArray(op.args)) {
492
+ for (const k of Object.keys(op.args).slice(0, 20)) {
493
+ const v = op.args[k];
494
+ if (isString(v)) args[String(k).slice(0, 80)] = v.slice(0, 500);
495
+ else if (typeof v === 'number' && isFinite(v)) args[String(k).slice(0, 80)] = v;
496
+ }
497
+ }
498
+ out.args = args;
499
+ }
500
+ }
501
+ if (op.op === 'setStyle') {
502
+ const style = {};
503
+ for (const k of Object.keys(op.style)) {
504
+ if (isString(op.style[k]) || typeof op.style[k] === 'number') {
505
+ style[k] = sanitizeStyleAttr(String(op.style[k]));
506
+ }
507
+ }
508
+ out.style = style;
509
+ }
510
+ if (op.op === 'setText') out.text = String(op.text).slice(0, MAX_TEXT);
511
+ if (op.op === 'renderData') {
512
+ out.title = isString(op.title) ? op.title.slice(0, MAX_LABEL) : '';
513
+ if (op.source !== undefined) {
514
+ out.source = String(op.source).slice(0, 80);
515
+ out.query = sanitizeQuery(op.query);
516
+ } else {
517
+ // Legacy form: dataKey/x/y are no longer in spec.fields (they're only
518
+ // required for THIS form), so copy them explicitly.
519
+ out.dataKey = op.dataKey; out.x = op.x; out.y = op.y;
520
+ }
521
+ }
522
+ if (op.op === 'setAttr') {
523
+ // Name is already whitelisted in opError. Sanitize the VALUE for
524
+ // URL-bearing attributes and for the style attribute (apply-side never
525
+ // checks values), matching how sanitizeHtml treats the same attributes.
526
+ if (isUrlAttr(out.name)) {
527
+ const ok = out.name.toLowerCase() === 'srcset' ? isSafeSrcset(out.value) : isSafeUrl(out.value);
528
+ if (!ok) out.value = '';
529
+ }
530
+ if (normalizeAttrName(out.name) === 'style') out.value = sanitizeStyleAttr(String(out.value));
531
+ }
532
+ // Preserve a self-healing descriptor if the (saved) op carries one.
533
+ if (Array.isArray(op.anchors)) out.anchors = sanitizeAnchors(op.anchors);
534
+ return out;
535
+ }
536
+
537
+ // ---- application (needs a DOM) -------------------------------------------
538
+
539
+ const STYLE_GROUP_ATTR = 'data-morph-style';
540
+
541
+ /**
542
+ * Apply a (validated) transform to `doc`. Returns a handle:
543
+ * { undos: [fn...], applied: n, failed: n, errors: [...] }
544
+ * Each op is wrapped in try/catch so one bad op never aborts the rest.
545
+ */
546
+ function applyTransform(transform, doc, opts) {
547
+ doc = doc || (typeof document !== 'undefined' ? document : null);
548
+ if (!doc) throw new Error('Morph: applyTransform needs a document');
549
+ const groupId = (opts && opts.groupId) || 'morph';
550
+ const data = (opts && opts.data) || {};
551
+ const undos = [];
552
+ let applied = 0, failed = 0;
553
+ const errors = [];
554
+
555
+ const onAction = (opts && typeof opts.onAction === 'function') ? opts.onAction : null;
556
+
557
+ for (const op of transform.ops) {
558
+ try {
559
+ const undo = applyOp(op, doc, groupId, data, onAction);
560
+ // null == the op matched nothing (e.g. a selector whose targets haven't
561
+ // rendered yet): a MISS, not an applied op and not a failure. This keeps
562
+ // `applied` honest so callers don't set a phantom "it's applied" sentinel.
563
+ if (undo === null) continue;
564
+ if (undo) undos.push(undo);
565
+ applied++;
566
+ } catch (e) {
567
+ failed++;
568
+ errors.push({ op: op.op, message: String(e && e.message || e) });
569
+ }
570
+ }
571
+ return { undos, applied, failed, errors };
572
+ }
573
+
574
+ function undoTransform(handle) {
575
+ if (!handle || !Array.isArray(handle.undos)) return;
576
+ // reverse order so structural moves unwind cleanly
577
+ for (let i = handle.undos.length - 1; i >= 0; i--) {
578
+ try { handle.undos[i](); } catch (_) { /* best effort */ }
579
+ }
580
+ handle.undos.length = 0;
581
+ }
582
+
583
+ function selectAll(doc, selector) {
584
+ // throws on invalid selector -> caught by applyTransform
585
+ return Array.from(doc.querySelectorAll(selector));
586
+ }
587
+
588
+ // Element resolution for applyOp. An op carrying an `anchors` descriptor uses
589
+ // the self-healing resolver (survives selector rot). An op WITHOUT anchors keeps
590
+ // the exact legacy behavior: selectAll throws on an invalid selector so that op
591
+ // fails (the "bad selector fails that op only" contract) — nothing changes.
592
+ function resolveEls(doc, op) {
593
+ if (Array.isArray(op.anchors) && op.anchors.length) return resolveSelector(doc, op).els;
594
+ return selectAll(doc, op.selector);
595
+ }
596
+
597
+ function applyOp(op, doc, groupId, data, onAction) {
598
+ switch (op.op) {
599
+ case 'injectCSS': {
600
+ const style = doc.createElement('style');
601
+ style.setAttribute(STYLE_GROUP_ATTR, groupId);
602
+ style.textContent = op.css; // textContent: no breakout
603
+ (doc.head || doc.documentElement).appendChild(style);
604
+ return () => style.remove();
605
+ }
606
+ case 'hide':
607
+ case 'show': {
608
+ const val = op.op === 'hide' ? 'none' : '';
609
+ const els = resolveEls(doc, op);
610
+ if (!els.length) return null; // matched nothing — a miss, not an applied op
611
+ const prev = els.map((el) => el.style.display);
612
+ els.forEach((el) => {
613
+ if (op.op === 'show' && val === '') el.style.removeProperty('display');
614
+ else el.style.setProperty('display', val, 'important');
615
+ });
616
+ return () => els.forEach((el, i) => { el.style.display = prev[i]; });
617
+ }
618
+ case 'remove': {
619
+ const els = resolveEls(doc, op);
620
+ if (!els.length) return null; // nothing to remove yet — a miss, not applied
621
+ const markers = els.map((el) => {
622
+ const marker = doc.createComment('morph-removed');
623
+ el.parentNode && el.parentNode.replaceChild(marker, el);
624
+ return { el, marker };
625
+ });
626
+ return () => markers.forEach(({ el, marker }) => {
627
+ marker.parentNode && marker.parentNode.replaceChild(el, marker);
628
+ });
629
+ }
630
+ case 'setStyle': {
631
+ const els = resolveEls(doc, op);
632
+ if (!els.length) return null;
633
+ const keys = Object.keys(op.style);
634
+ const prev = els.map((el) => keys.map((k) => el.style.getPropertyValue(k)));
635
+ els.forEach((el) => keys.forEach((k) => el.style.setProperty(k, op.style[k], 'important')));
636
+ return () => els.forEach((el, i) => keys.forEach((k, j) => {
637
+ if (prev[i][j]) el.style.setProperty(k, prev[i][j]); else el.style.removeProperty(k);
638
+ }));
639
+ }
640
+ case 'setText': {
641
+ // Refuse to write into active-content / reparse-trap elements: setting the
642
+ // textContent of a live <style> IS the stylesheet (arbitrary CSS injection
643
+ // that bypasses neutralizeCss), and <script>/<title>/<textarea> are mXSS
644
+ // reparse traps. These are never legitimate setText targets. Skip them;
645
+ // apply to the safe elements only.
646
+ const els = resolveEls(doc, op)
647
+ .filter((el) => !DROP_SUBTREE_TAGS.has(el.tagName.toLowerCase()));
648
+ if (!els.length) return null;
649
+ const prev = els.map((el) => el.textContent);
650
+ els.forEach((el) => { el.textContent = op.text; });
651
+ return () => els.forEach((el, i) => { el.textContent = prev[i]; });
652
+ }
653
+ case 'setAttr': {
654
+ const els = resolveEls(doc, op);
655
+ if (op.name.toLowerCase().startsWith('on')) throw new Error('event-handler attr blocked');
656
+ if (!els.length) return null;
657
+ const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
658
+ els.forEach((el) => el.setAttribute(op.name, op.value));
659
+ return () => els.forEach((el, i) => {
660
+ if (prev[i] === null) el.removeAttribute(op.name); else el.setAttribute(op.name, prev[i]);
661
+ });
662
+ }
663
+ case 'addClass':
664
+ case 'removeClass': {
665
+ const els = resolveEls(doc, op);
666
+ if (!els.length) return null;
667
+ const had = els.map((el) => el.classList.contains(op.class));
668
+ els.forEach((el) => el.classList[op.op === 'addClass' ? 'add' : 'remove'](op.class));
669
+ return () => els.forEach((el, i) => {
670
+ if (op.op === 'addClass' && !had[i]) el.classList.remove(op.class);
671
+ if (op.op === 'removeClass' && had[i]) el.classList.add(op.class);
672
+ });
673
+ }
674
+ case 'move': {
675
+ const els = resolveEls(doc, op);
676
+ const target = doc.querySelector(op.target);
677
+ if (!target) throw new Error('move target not found: ' + op.target);
678
+ const origins = els.map((el) => ({ el, parent: el.parentNode, next: el.nextSibling }));
679
+ els.forEach((el) => insertRelative(el, target, op.position));
680
+ return () => origins.forEach(({ el, parent, next }) => {
681
+ if (parent) parent.insertBefore(el, next);
682
+ });
683
+ }
684
+ case 'addElement': {
685
+ const target = doc.querySelector(op.target);
686
+ if (!target) throw new Error('addElement target not found: ' + op.target);
687
+ const tpl = doc.createElement('template');
688
+ tpl.innerHTML = op.html; // already sanitized
689
+ const nodes = Array.from(tpl.content ? tpl.content.childNodes : tpl.childNodes);
690
+ const wrapper = doc.createElement('div');
691
+ wrapper.setAttribute('data-morph-added', groupId);
692
+ nodes.forEach((n) => wrapper.appendChild(n));
693
+ insertRelative(wrapper, target, op.position);
694
+ // Action binding: only when the CALLER provided an invoker (the SDK).
695
+ // The model only NAMED the action; a real user click is the sole trigger.
696
+ if (op.action && onAction) {
697
+ wrapper.querySelectorAll('button').forEach((btn) => {
698
+ btn.addEventListener('click', () => onAction(op.action, op.args || {}));
699
+ });
700
+ }
701
+ return () => wrapper.remove();
702
+ }
703
+ case 'renderData': {
704
+ const target = doc.querySelector(op.target);
705
+ if (!target) throw new Error('renderData target not found: ' + op.target);
706
+ const svg = buildChart(doc, op, data); // built by US from attached data
707
+ const wrapper = doc.createElement('div');
708
+ wrapper.setAttribute('data-morph-added', groupId);
709
+ wrapper.appendChild(svg);
710
+ insertRelative(wrapper, target, op.position);
711
+ return () => wrapper.remove();
712
+ }
713
+ default:
714
+ throw new Error('unhandled op: ' + op.op);
715
+ }
716
+ }
717
+
718
+ function insertRelative(node, target, position) {
719
+ switch (position) {
720
+ case 'before': target.parentNode.insertBefore(node, target); break;
721
+ case 'after': target.parentNode.insertBefore(node, target.nextSibling); break;
722
+ case 'prepend': target.insertBefore(node, target.firstChild); break;
723
+ case 'append': target.appendChild(node); break;
724
+ }
725
+ }
726
+
727
+ // ---- trusted chart rendering (renderData) --------------------------------
728
+ // Built entirely by US from typed records the CALLER attached (opts.data).
729
+ // The model supplies only the chart type and field names; every value is
730
+ // coerced through Number() before it touches a numeric attribute, and every
731
+ // label/title is set via textContent. No model- or data-supplied string is
732
+ // ever parsed as markup (createElementNS only, never innerHTML).
733
+ function svgEl(doc, name, attrs) {
734
+ const el = doc.createElementNS(SVG_NS, name);
735
+ for (const k of Object.keys(attrs)) el.setAttribute(k, String(attrs[k]));
736
+ return el;
737
+ }
738
+
739
+ function coerceNum(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
740
+
741
+ function formatStat(n) {
742
+ if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
743
+ if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
744
+ return Number.isInteger(n) ? String(n) : n.toFixed(2);
745
+ }
746
+
747
+ function buildChart(doc, op, data) {
748
+ let points;
749
+ if (op.source !== undefined) {
750
+ // Source form: rows were resolved by TRUSTED code (registry + query
751
+ // engine) and attached as data[source] = [{label, value}].
752
+ const rows = (data && typeof data === 'object' && Array.isArray(data[op.source]))
753
+ ? data[op.source].slice(0, MAX_CHART_POINTS) : null;
754
+ if (!rows || rows.length === 0) throw new Error('renderData: no rows for source ' + op.source);
755
+ points = rows.map((r) => ({
756
+ label: String(r && r.label != null ? r.label : '').slice(0, 40),
757
+ value: coerceNum(r && r.value),
758
+ }));
759
+ } else {
760
+ const ds = (data && typeof data === 'object') ? data[op.dataKey] : null;
761
+ const records = (ds && Array.isArray(ds.records)) ? ds.records.slice(0, MAX_CHART_POINTS) : null;
762
+ if (!records || records.length === 0) throw new Error('renderData: no records for ' + op.dataKey);
763
+ points = records.map((r) => ({
764
+ label: String((r && r[op.x] != null) ? r[op.x] : '').slice(0, 40),
765
+ value: coerceNum(r && r[op.y]),
766
+ }));
767
+ }
768
+ if (op.chart === 'stat') {
769
+ // Single big number + caption. Text nodes only — same contract as charts.
770
+ const stat = svgEl(doc, 'svg', {
771
+ width: 280, height: 120, viewBox: '0 0 280 120',
772
+ 'data-morph-chart': 'stat', 'font-family': 'sans-serif',
773
+ });
774
+ const val = svgEl(doc, 'text', { x: 20, y: 64, 'font-size': 40, 'font-weight': 'bold', fill: 'currentColor' });
775
+ val.textContent = formatStat(points[0].value); // text only — never markup
776
+ stat.appendChild(val);
777
+ const cap = svgEl(doc, 'text', { x: 20, y: 96, 'font-size': 13, fill: 'currentColor', opacity: '0.7' });
778
+ cap.textContent = op.title || points[0].label; // text only — never markup
779
+ stat.appendChild(cap);
780
+ return stat;
781
+ }
782
+ const maxV = Math.max(1, ...points.map((p) => p.value));
783
+ const n = points.length;
784
+ const innerW = CHART_W - 2 * CHART_PAD;
785
+ const top = op.title ? 34 : 12;
786
+ const bottom = CHART_H - CHART_PAD;
787
+ const plotH = bottom - top;
788
+
789
+ const svg = svgEl(doc, 'svg', {
790
+ width: CHART_W, height: CHART_H, viewBox: '0 0 ' + CHART_W + ' ' + CHART_H,
791
+ 'data-morph-chart': op.chart, 'font-family': 'sans-serif',
792
+ });
793
+ if (op.title) {
794
+ const t = svgEl(doc, 'text', { x: CHART_PAD, y: 22, 'font-size': 16, fill: 'currentColor' });
795
+ t.textContent = op.title; // text only — never markup
796
+ svg.appendChild(t);
797
+ }
798
+
799
+ if (op.chart === 'line') {
800
+ const step = n > 1 ? innerW / (n - 1) : 0;
801
+ const coords = points.map((p, i) =>
802
+ (CHART_PAD + i * step).toFixed(1) + ',' + (bottom - (p.value / maxV) * plotH).toFixed(1)
803
+ ).join(' ');
804
+ svg.appendChild(svgEl(doc, 'polyline', { points: coords, fill: 'none', stroke: 'currentColor', 'stroke-width': 2 }));
805
+ } else {
806
+ const bw = innerW / n;
807
+ points.forEach((p, i) => {
808
+ const h = (p.value / maxV) * plotH;
809
+ svg.appendChild(svgEl(doc, 'rect', {
810
+ x: (CHART_PAD + i * bw + bw * 0.1).toFixed(1), y: (bottom - h).toFixed(1),
811
+ width: (bw * 0.8).toFixed(1), height: h.toFixed(1), fill: 'currentColor',
812
+ }));
813
+ });
814
+ }
815
+
816
+ // x-axis labels (text only)
817
+ points.forEach((p, i) => {
818
+ const x = op.chart === 'line'
819
+ ? CHART_PAD + (n > 1 ? innerW / (n - 1) : 0) * i
820
+ : CHART_PAD + (innerW / n) * i + (innerW / n) / 2;
821
+ const lab = svgEl(doc, 'text', { x: x.toFixed(1), y: CHART_H - 14, 'font-size': 10, fill: 'currentColor', 'text-anchor': 'middle' });
822
+ lab.textContent = p.label; // text only — never markup
823
+ svg.appendChild(lab);
824
+ });
825
+ return svg;
826
+ }
827
+
828
+ // ---- reconcile: idempotent re-assertion (survive SPA re-renders) ---------
829
+ // Re-applies each element-targeting op ONLY to matching nodes that don't yet
830
+ // carry that op's marker (the freshly-rendered ones), and re-adds a removed
831
+ // injectCSS <style>. Safe to call repeatedly. Returns { reapplied, undos };
832
+ // reapplied counts ops that touched >=1 new node (0 == stable — drives a hard
833
+ // cap in the caller so a constantly-mutating page can't spin the CPU).
834
+ const RECON_ATTR = 'data-morph-r'; // space-separated op tokens on touched els
835
+ const RECON_CSS_ATTR = 'data-morph-rcss'; // marks an injected style tag (value = token)
836
+
837
+ function reconHas(el, token) {
838
+ return (el.getAttribute(RECON_ATTR) || '').split(/\s+/).indexOf(token) !== -1;
839
+ }
840
+ function reconAdd(el, token) {
841
+ const v = el.getAttribute(RECON_ATTR) || '';
842
+ el.setAttribute(RECON_ATTR, v ? v + ' ' + token : token);
843
+ }
844
+ function reconDel(el, token) {
845
+ const v = (el.getAttribute(RECON_ATTR) || '').split(/\s+/).filter((t) => t && t !== token);
846
+ if (v.length) el.setAttribute(RECON_ATTR, v.join(' ')); else el.removeAttribute(RECON_ATTR);
847
+ }
848
+ function unmarked(doc, selector, token) {
849
+ return selectAll(doc, selector).filter((el) => !reconHas(el, token));
850
+ }
851
+
852
+ function reconcile(transform, doc, opts) {
853
+ doc = doc || (typeof document !== 'undefined' ? document : null);
854
+ if (!doc) throw new Error('Morph: reconcile needs a document');
855
+ if (!transform || !Array.isArray(transform.ops)) return { reapplied: 0, undos: [] };
856
+ const groupId = (opts && opts.groupId) || 'morph';
857
+ const undos = [];
858
+ let reapplied = 0;
859
+ transform.ops.forEach((op, i) => {
860
+ try {
861
+ const r = reassertOp(op, doc, groupId, i);
862
+ if (r && r.touched > 0) { reapplied++; if (r.undo) undos.push(r.undo); }
863
+ } catch (_) { /* per-op safety — never break the page */ }
864
+ });
865
+ return { reapplied, undos };
866
+ }
867
+
868
+ function reassertOp(op, doc, groupId, idx) {
869
+ const token = groupId + ':' + idx;
870
+ switch (op.op) {
871
+ case 'injectCSS': {
872
+ if (doc.querySelector('style[' + RECON_CSS_ATTR + '="' + token + '"]')) return { touched: 0 };
873
+ const style = doc.createElement('style');
874
+ style.setAttribute(RECON_CSS_ATTR, token);
875
+ style.setAttribute(STYLE_GROUP_ATTR, groupId);
876
+ style.textContent = op.css; // textContent: no breakout
877
+ (doc.head || doc.documentElement).appendChild(style);
878
+ return { touched: 1, undo: () => style.remove() };
879
+ }
880
+ case 'hide':
881
+ case 'show': {
882
+ const els = unmarked(doc, op.selector, token);
883
+ if (!els.length) return { touched: 0 };
884
+ const val = op.op === 'hide' ? 'none' : '';
885
+ const prev = els.map((el) => el.style.display);
886
+ els.forEach((el) => {
887
+ if (op.op === 'show' && val === '') el.style.removeProperty('display');
888
+ else el.style.setProperty('display', val, 'important');
889
+ reconAdd(el, token);
890
+ });
891
+ return { touched: els.length, undo: () => els.forEach((el, i) => { el.style.display = prev[i]; reconDel(el, token); }) };
892
+ }
893
+ case 'setStyle': {
894
+ const els = unmarked(doc, op.selector, token);
895
+ if (!els.length) return { touched: 0 };
896
+ const keys = Object.keys(op.style);
897
+ const prev = els.map((el) => keys.map((k) => el.style.getPropertyValue(k)));
898
+ els.forEach((el) => { keys.forEach((k) => el.style.setProperty(k, op.style[k], 'important')); reconAdd(el, token); });
899
+ return {
900
+ touched: els.length,
901
+ undo: () => els.forEach((el, i) => { keys.forEach((k, j) => { if (prev[i][j]) el.style.setProperty(k, prev[i][j]); else el.style.removeProperty(k); }); reconDel(el, token); }),
902
+ };
903
+ }
904
+ case 'setText': {
905
+ const els = unmarked(doc, op.selector, token).filter((el) => !DROP_SUBTREE_TAGS.has(el.tagName.toLowerCase()));
906
+ if (!els.length) return { touched: 0 };
907
+ const prev = els.map((el) => el.textContent);
908
+ els.forEach((el) => { el.textContent = op.text; reconAdd(el, token); });
909
+ return { touched: els.length, undo: () => els.forEach((el, i) => { el.textContent = prev[i]; reconDel(el, token); }) };
910
+ }
911
+ case 'setAttr': {
912
+ if (op.name.toLowerCase().startsWith('on')) return { touched: 0 };
913
+ const els = unmarked(doc, op.selector, token);
914
+ if (!els.length) return { touched: 0 };
915
+ const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
916
+ els.forEach((el) => { el.setAttribute(op.name, op.value); reconAdd(el, token); });
917
+ return { touched: els.length, undo: () => els.forEach((el, i) => { if (prev[i] === null) el.removeAttribute(op.name); else el.setAttribute(op.name, prev[i]); reconDel(el, token); }) };
918
+ }
919
+ case 'addClass':
920
+ case 'removeClass': {
921
+ const els = unmarked(doc, op.selector, token);
922
+ if (!els.length) return { touched: 0 };
923
+ const had = els.map((el) => el.classList.contains(op.class));
924
+ els.forEach((el) => { el.classList[op.op === 'addClass' ? 'add' : 'remove'](op.class); reconAdd(el, token); });
925
+ return {
926
+ touched: els.length,
927
+ undo: () => els.forEach((el, i) => { if (op.op === 'addClass' && !had[i]) el.classList.remove(op.class); if (op.op === 'removeClass' && had[i]) el.classList.add(op.class); reconDel(el, token); }),
928
+ };
929
+ }
930
+ case 'remove': {
931
+ // Re-remove LIVE matches the page re-rendered / hydrated / lazy-loaded
932
+ // after we first removed them — the dominant revert-on-refresh case on
933
+ // SPA pages. Removal deletes the node (no per-node marker is possible),
934
+ // so re-query each tick and remove whatever currently matches.
935
+ const els = selectAll(doc, op.selector);
936
+ if (!els.length) return { touched: 0 };
937
+ const removed = els.map((el) => ({ el, parent: el.parentNode, next: el.nextSibling }));
938
+ els.forEach((el) => { if (el.parentNode) el.parentNode.removeChild(el); });
939
+ return {
940
+ touched: els.length,
941
+ undo: () => removed.forEach(({ el, parent, next }) => { try { if (parent) parent.insertBefore(el, next); } catch (_) { /* node moved */ } }),
942
+ };
943
+ }
944
+ // Other structural ops (move / addElement / renderData) add wrappers that
945
+ // survive most partial re-renders; full re-add support is a follow-up.
946
+ default:
947
+ return { touched: 0 };
948
+ }
949
+ }
950
+
951
+ // ---- self-healing selectors (the moat) -----------------------------------
952
+ // A recipe can store a multi-signal descriptor per targeted element. When the
953
+ // literal selector stops matching (class-hash rotation / redeploy),
954
+ // resolveSelector finds the best current match via a scoring cascade and
955
+ // rebinds ONLY above a confidence threshold — never silently onto the wrong
956
+ // element (a wrong rebind is worse than going stale).
957
+ const HEAL_THRESHOLD = 0.5;
958
+ const HEAL_MAX_CANDIDATES = 3000;
959
+
960
+ function normText(t) { return String(t == null ? '' : t).replace(/\s+/g, ' ').trim().toLowerCase(); }
961
+
962
+ function nthOfType(el) {
963
+ const parent = el.parentNode;
964
+ if (!parent || !parent.children) return 0;
965
+ const tag = el.tagName;
966
+ let i = 0;
967
+ for (const sib of parent.children) { if (sib.tagName === tag) { i++; if (sib === el) return i; } }
968
+ return 0;
969
+ }
970
+
971
+ // Capture a resilient descriptor of `el` (called at recipe-save time).
972
+ function captureAnchor(el) {
973
+ if (!el || el.nodeType !== 1) return null;
974
+ const a = { tag: el.tagName.toLowerCase() };
975
+ if (el.id) a.id = el.id;
976
+ const role = el.getAttribute && el.getAttribute('role');
977
+ if (role) a.role = role;
978
+ const aria = el.getAttribute && el.getAttribute('aria-label');
979
+ if (aria) a.aria = aria;
980
+ const text = normText(el.textContent).slice(0, 80);
981
+ if (text) a.text = text;
982
+ a.classes = (el.classList ? Array.from(el.classList) : []).slice(0, 6);
983
+ a.nthOfType = nthOfType(el);
984
+ return a;
985
+ }
986
+
987
+ // Score 0..1: how well `el` matches `anchor`. Only weighs signals the anchor has.
988
+ function scoreAnchor(el, anchor) {
989
+ const W = { tag: 1, id: 4, role: 3, aria: 3, text: 5, cls: 2 };
990
+ let score = 0, max = 0;
991
+ max += W.tag; if (anchor.tag && el.tagName.toLowerCase() === anchor.tag) score += W.tag;
992
+ if (anchor.id) { max += W.id; if (el.id === anchor.id) score += W.id; }
993
+ if (anchor.role) { max += W.role; if ((el.getAttribute && el.getAttribute('role')) === anchor.role) score += W.role; }
994
+ if (anchor.aria) { max += W.aria; if ((el.getAttribute && el.getAttribute('aria-label')) === anchor.aria) score += W.aria; }
995
+ if (anchor.text) {
996
+ max += W.text;
997
+ const ct = normText(el.textContent).slice(0, 200);
998
+ if (ct === anchor.text) score += W.text;
999
+ else if (ct && ct.indexOf(anchor.text) !== -1) score += W.text * 0.5;
1000
+ }
1001
+ if (anchor.classes && anchor.classes.length) {
1002
+ max += W.cls;
1003
+ const have = anchor.classes.filter((c) => el.classList && el.classList.contains(c)).length;
1004
+ score += W.cls * (have / anchor.classes.length);
1005
+ }
1006
+ return max > 0 ? score / max : 0;
1007
+ }
1008
+
1009
+ function findByAnchor(doc, anchor) {
1010
+ if (!anchor || typeof anchor !== 'object') return null;
1011
+ let candidates;
1012
+ try { candidates = Array.from(doc.querySelectorAll(anchor.tag || '*')); }
1013
+ catch (_) { try { candidates = Array.from(doc.querySelectorAll('*')); } catch (_e) { return null; } }
1014
+ if (candidates.length > HEAL_MAX_CANDIDATES) candidates = candidates.slice(0, HEAL_MAX_CANDIDATES);
1015
+ let best = null, bestScore = 0;
1016
+ for (const el of candidates) {
1017
+ const s = scoreAnchor(el, anchor);
1018
+ if (s > bestScore) { bestScore = s; best = el; }
1019
+ }
1020
+ return bestScore >= HEAL_THRESHOLD ? best : null;
1021
+ }
1022
+
1023
+ // Resolve op.selector to elements. Literal first; if nothing matches, heal via
1024
+ // op.anchors. Returns { els, healed }. Never throws.
1025
+ function resolveSelector(doc, op) {
1026
+ let els = [];
1027
+ try { els = Array.from(doc.querySelectorAll(op.selector)); } catch (_) { els = []; }
1028
+ if (els.length) return { els, healed: false };
1029
+ const anchors = Array.isArray(op.anchors) ? op.anchors : null;
1030
+ if (!anchors || !anchors.length) return { els: [], healed: false };
1031
+ const seen = new Set();
1032
+ const healed = [];
1033
+ for (const anchor of anchors) {
1034
+ const el = findByAnchor(doc, anchor);
1035
+ if (el && !seen.has(el)) { seen.add(el); healed.push(el); }
1036
+ }
1037
+ return { els: healed, healed: healed.length > 0 };
1038
+ }
1039
+
1040
+ // Enrich a transform's selector-bearing ops with anchors captured from `doc`
1041
+ // (called at recipe-save time). Ops without a selector, with no current match,
1042
+ // or that already carry anchors are left untouched. Returns a new transform.
1043
+ function withAnchors(transform, doc) {
1044
+ if (!transform || !Array.isArray(transform.ops) || !doc) return transform;
1045
+ const ops = transform.ops.map((op) => {
1046
+ if (!op || typeof op.selector !== 'string' || Array.isArray(op.anchors)) return op;
1047
+ let matches = [];
1048
+ try { matches = Array.from(doc.querySelectorAll(op.selector)).slice(0, 5); } catch (_) { matches = []; }
1049
+ if (!matches.length) return op;
1050
+ const anchors = matches.map((el) => captureAnchor(el)).filter(Boolean);
1051
+ return anchors.length ? Object.assign({}, op, { anchors }) : op;
1052
+ });
1053
+ return Object.assign({}, transform, { ops });
1054
+ }
1055
+
1056
+ // Union the ops of two transforms so a NEW reshape stacks on top of prior ones
1057
+ // instead of replacing them ("remove images" then "remove text" => both gone).
1058
+ // Pure: never mutates either input. Carries the latest transform's metadata.
1059
+ function mergeTransforms(existing, incoming) {
1060
+ const exOps = (existing && Array.isArray(existing.ops)) ? existing.ops : [];
1061
+ const inOps = (incoming && Array.isArray(incoming.ops)) ? incoming.ops : [];
1062
+ const base = (incoming && typeof incoming === 'object') ? incoming : (existing || {});
1063
+ return Object.assign({}, base, { ops: exOps.concat(inOps) });
1064
+ }
1065
+
1066
+ return {
1067
+ SCHEMA_VERSION,
1068
+ OP_SPECS,
1069
+ POSITIONS,
1070
+ validateTransform,
1071
+ sanitizeHtml,
1072
+ neutralizeCss,
1073
+ applyTransform,
1074
+ undoTransform,
1075
+ reconcile,
1076
+ captureAnchor,
1077
+ resolveSelector,
1078
+ withAnchors,
1079
+ mergeTransforms,
1080
+ };
1081
+ });
1082
+
1083
+
1084
+ /* ---- sdk/src/query.js ---- */
1085
+ /*
1086
+ * Morph SDK — trusted query engine.
1087
+ * The model emits a query DESCRIPTION; this module (trusted code) executes it
1088
+ * over records the vendor's own fetch returned, entirely client-side. Unknown
1089
+ * fields are hard errors: a hallucinated field must never yield silent zeros.
1090
+ * UMD: Node module in tests, browser global `MorphQuery` in the SDK bundle.
1091
+ */
1092
+ (function (global, factory) {
1093
+ const api = factory();
1094
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
1095
+ else global.MorphQuery = api;
1096
+ })(typeof self !== 'undefined' ? self : this, function () {
1097
+ 'use strict';
1098
+
1099
+ const MAX_RECORDS = 10000;
1100
+ const MAX_ROWS = 60;
1101
+ const AGG_FNS = new Set(['sum', 'count', 'avg', 'min', 'max']);
1102
+
1103
+ function parseDuration(s) {
1104
+ if (typeof s !== 'string') return null;
1105
+ const m = /^(\d{1,5})([mhd])$/.exec(s.trim());
1106
+ if (!m) return null;
1107
+ const n = Number(m[1]);
1108
+ return m[2] === 'm' ? n * 60000 : m[2] === 'h' ? n * 3600000 : n * 86400000;
1109
+ }
1110
+
1111
+ function need(field, schema, what) {
1112
+ if (typeof field !== 'string' || !Object.prototype.hasOwnProperty.call(schema, field)) {
1113
+ throw new Error('unknown field: ' + field + (what ? ' (' + what + ')' : ''));
1114
+ }
1115
+ }
1116
+
1117
+ function toTime(v) { const t = Date.parse(v); return Number.isFinite(t) ? t : null; }
1118
+
1119
+ // A bound is either a relative duration ('15d': since = now - d, until = now + d)
1120
+ // or an absolute date string.
1121
+ function bound(query, key, nowMs) {
1122
+ const v = query[key];
1123
+ if (v == null) return null;
1124
+ const dur = parseDuration(v);
1125
+ if (dur != null) return key === 'since' ? nowMs - dur : nowMs + dur;
1126
+ return toTime(v);
1127
+ }
1128
+
1129
+ function runQuery(records, query, schema, nowMs) {
1130
+ if (!Array.isArray(records)) throw new Error('records must be an array');
1131
+ if (!query || typeof query !== 'object') throw new Error('query must be an object');
1132
+ if (!schema || typeof schema !== 'object') throw new Error('schema must be an object');
1133
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
1134
+ let rows = records.slice(0, MAX_RECORDS);
1135
+
1136
+ if (query.filter != null) {
1137
+ if (typeof query.filter !== 'object' || Array.isArray(query.filter)) throw new Error('filter must be an object');
1138
+ for (const f of Object.keys(query.filter)) {
1139
+ need(f, schema, 'filter');
1140
+ const want = query.filter[f];
1141
+ const set = Array.isArray(want) ? want.map(String) : [String(want)];
1142
+ rows = rows.filter((r) => r && set.includes(String(r[f])));
1143
+ }
1144
+ }
1145
+
1146
+ if (query.since != null || query.until != null) {
1147
+ need(query.dateField, schema, 'dateField');
1148
+ const lo = bound(query, 'since', now);
1149
+ const hi = bound(query, 'until', now);
1150
+ rows = rows.filter((r) => {
1151
+ const t = r && toTime(r[query.dateField]);
1152
+ if (t == null) return false; // malformed date: excluded, never coerced
1153
+ if (lo != null && t < lo) return false;
1154
+ if (hi != null && t > hi) return false;
1155
+ return true;
1156
+ });
1157
+ }
1158
+
1159
+ const agg = query.aggregate;
1160
+ if (!agg || typeof agg !== 'object' || !AGG_FNS.has(agg.fn)) {
1161
+ throw new Error('aggregate {fn} is required (sum|count|avg|min|max)');
1162
+ }
1163
+ if (agg.fn !== 'count') need(agg.field, schema, 'aggregate');
1164
+
1165
+ function fold(list) {
1166
+ if (agg.fn === 'count') return list.length;
1167
+ const nums = list.map((r) => Number(r && r[agg.field])).filter(Number.isFinite);
1168
+ if (!nums.length) return 0;
1169
+ if (agg.fn === 'sum') return nums.reduce((a, b) => a + b, 0);
1170
+ if (agg.fn === 'avg') return nums.reduce((a, b) => a + b, 0) / nums.length;
1171
+ if (agg.fn === 'min') return Math.min.apply(null, nums);
1172
+ return Math.max.apply(null, nums);
1173
+ }
1174
+
1175
+ let out;
1176
+ if (query.groupBy != null) {
1177
+ need(query.groupBy, schema, 'groupBy');
1178
+ const groups = new Map();
1179
+ for (const r of rows) {
1180
+ const k = String(r && r[query.groupBy]);
1181
+ if (!groups.has(k)) groups.set(k, []);
1182
+ groups.get(k).push(r);
1183
+ }
1184
+ out = Array.from(groups, (pair) => ({ label: pair[0], value: fold(pair[1]) }));
1185
+ } else {
1186
+ out = [{ label: (agg.fn + ' ' + (agg.field || '')).trim(), value: fold(rows) }];
1187
+ }
1188
+
1189
+ const limit = Number.isFinite(query.limit) && query.limit > 0 ? Math.min(query.limit, MAX_ROWS) : MAX_ROWS;
1190
+ return out.slice(0, limit);
1191
+ }
1192
+
1193
+ return { parseDuration, runQuery, MAX_ROWS };
1194
+ });
1195
+
1196
+
1197
+ /* ---- sdk/src/registry.js ---- */
1198
+ /*
1199
+ * Morph SDK — vendor registry. Validates the vendor's init() config (throws
1200
+ * TypeError: vendors are developers, fail loud), exposes JSON-safe manifests
1201
+ * for the model, and is the ONLY path from a model-emitted op to vendor code:
1202
+ * data via resolveData (fetch + trusted query engine), behavior via
1203
+ * invokeAction (schema-checked args + confirm gate).
1204
+ * UMD: Node module in tests, browser global `MorphRegistry` in the SDK bundle.
1205
+ */
1206
+ (function (global, factory) {
1207
+ const api = factory(
1208
+ typeof module !== 'undefined' && module.exports ? require('./query.js') : global.MorphQuery
1209
+ );
1210
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
1211
+ else global.MorphRegistry = api;
1212
+ })(typeof self !== 'undefined' ? self : this, function (Query) {
1213
+ 'use strict';
1214
+
1215
+ const NAME_RE = /^[a-zA-Z][\w-]{0,59}$/;
1216
+ const FIELD_TYPES = new Set(['string', 'number', 'date', 'boolean']);
1217
+ const ARG_TYPES = new Set(['string', 'number']);
1218
+ const MAX_ENTRIES = 20;
1219
+ const MAX_FIELDS = 40;
1220
+ const FETCH_TIMEOUT_MS = 10000;
1221
+
1222
+ function checkSchema(schema, allowed, what, name) {
1223
+ if (!schema || typeof schema !== 'object') throw new TypeError('Morph: ' + what + ' "' + name + '" needs a schema object');
1224
+ const fields = Object.keys(schema);
1225
+ if (fields.length === 0 || fields.length > MAX_FIELDS) throw new TypeError('Morph: ' + what + ' "' + name + '" schema needs 1-' + MAX_FIELDS + ' fields');
1226
+ for (const f of fields) {
1227
+ if (!NAME_RE.test(f)) throw new TypeError('Morph: bad field name "' + f + '" in ' + what + ' "' + name + '"');
1228
+ if (!allowed.has(schema[f])) throw new TypeError('Morph: bad type "' + schema[f] + '" for field "' + f + '" in ' + what + ' "' + name + '"');
1229
+ }
1230
+ }
1231
+
1232
+ function createRegistry(config) {
1233
+ config = config || {};
1234
+ const sources = {};
1235
+ const actions = {};
1236
+
1237
+ const srcEntries = Object.keys(config.sources || {});
1238
+ const actEntries = Object.keys(config.actions || {});
1239
+ if (srcEntries.length > MAX_ENTRIES || actEntries.length > MAX_ENTRIES) {
1240
+ throw new TypeError('Morph: at most ' + MAX_ENTRIES + ' sources/actions');
1241
+ }
1242
+ for (const name of srcEntries) {
1243
+ const s = config.sources[name];
1244
+ if (!NAME_RE.test(name)) throw new TypeError('Morph: bad source name "' + name + '"');
1245
+ if (!s || typeof s.fetch !== 'function') throw new TypeError('Morph: source "' + name + '" needs a fetch function');
1246
+ checkSchema(s.schema, FIELD_TYPES, 'source', name);
1247
+ sources[name] = {
1248
+ fetch: s.fetch,
1249
+ schema: Object.assign({}, s.schema),
1250
+ description: typeof s.description === 'string' ? s.description.slice(0, 200) : '',
1251
+ };
1252
+ }
1253
+ for (const name of actEntries) {
1254
+ const a = config.actions[name];
1255
+ if (!NAME_RE.test(name)) throw new TypeError('Morph: bad action name "' + name + '"');
1256
+ if (!a || typeof a.run !== 'function') throw new TypeError('Morph: action "' + name + '" needs a run function');
1257
+ checkSchema(a.argsSchema, ARG_TYPES, 'action', name);
1258
+ actions[name] = {
1259
+ run: a.run,
1260
+ argsSchema: Object.assign({}, a.argsSchema),
1261
+ description: typeof a.description === 'string' ? a.description.slice(0, 200) : '',
1262
+ confirm: a.confirm !== false, // default true: safe unless the vendor opts out
1263
+ };
1264
+ }
1265
+
1266
+ function manifests() {
1267
+ const out = { sources: {}, actions: {} };
1268
+ for (const n of Object.keys(sources)) out.sources[n] = { schema: sources[n].schema, description: sources[n].description };
1269
+ for (const n of Object.keys(actions)) out.actions[n] = { argsSchema: actions[n].argsSchema, description: actions[n].description, confirm: actions[n].confirm };
1270
+ return out;
1271
+ }
1272
+
1273
+ function validateDataOp(op) {
1274
+ if (!op || typeof op.source !== 'string' || !Object.prototype.hasOwnProperty.call(sources, op.source)) {
1275
+ return { ok: false, reason: 'unknown source' };
1276
+ }
1277
+ if (!op.query || typeof op.query !== 'object' || Array.isArray(op.query)) {
1278
+ return { ok: false, reason: 'query must be an object' };
1279
+ }
1280
+ return { ok: true };
1281
+ }
1282
+
1283
+ async function resolveData(op) {
1284
+ const v = validateDataOp(op);
1285
+ if (!v.ok) throw new Error('Morph: ' + v.reason);
1286
+ const src = sources[op.source];
1287
+ let timer;
1288
+ const records = await Promise.race([
1289
+ Promise.resolve(src.fetch(op.query)),
1290
+ new Promise((resolve, reject) => {
1291
+ timer = setTimeout(() => reject(new Error('Morph: source "' + op.source + '" timed out')), FETCH_TIMEOUT_MS);
1292
+ }),
1293
+ ]).finally(() => clearTimeout(timer));
1294
+ return Query.runQuery(Array.isArray(records) ? records : [], op.query, src.schema);
1295
+ }
1296
+
1297
+ async function invokeAction(name, args, confirmFn) {
1298
+ const a = Object.prototype.hasOwnProperty.call(actions, name) ? actions[name] : null;
1299
+ if (!a) throw new Error('Morph: unknown action "' + name + '"');
1300
+ args = args && typeof args === 'object' ? args : {};
1301
+ for (const k of Object.keys(args)) {
1302
+ if (!Object.prototype.hasOwnProperty.call(a.argsSchema, k)) throw new Error('Morph: unexpected arg "' + k + '"');
1303
+ if (typeof args[k] !== a.argsSchema[k]) throw new Error('Morph: arg "' + k + '" wrong type (want ' + a.argsSchema[k] + ')');
1304
+ }
1305
+ if (a.confirm) {
1306
+ const ok = await (typeof confirmFn === 'function' ? confirmFn(name, a.description) : false);
1307
+ if (!ok) throw new Error('Morph: action declined');
1308
+ }
1309
+ return a.run(args);
1310
+ }
1311
+
1312
+ return {
1313
+ manifests, validateDataOp, resolveData, invokeAction,
1314
+ hasSources: () => Object.keys(sources).length > 0,
1315
+ hasActions: () => Object.keys(actions).length > 0,
1316
+ };
1317
+ }
1318
+
1319
+ return { createRegistry };
1320
+ });
1321
+
1322
+
1323
+ /* ---- sdk/src/storage.js ---- */
1324
+ /*
1325
+ * Morph SDK — recipe store. Persists one recipe per page path so generated
1326
+ * widgets/reshapes reapply on every visit. Default backing is localStorage
1327
+ * (per-browser); a vendor may pass a custom adapter {get,set,remove} (sync or
1328
+ * Promise) to store recipes in their own backend so a user's generated
1329
+ * features follow them across devices. Storage failures NEVER throw — a
1330
+ * broken store must never break the host page.
1331
+ * UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
1332
+ */
1333
+ (function (global, factory) {
1334
+ const api = factory();
1335
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
1336
+ else global.MorphStorage = api;
1337
+ })(typeof self !== 'undefined' ? self : this, function () {
1338
+ 'use strict';
1339
+
1340
+ const PREFIX = 'morph:sdk:recipe:';
1341
+ const SCHEMA_VERSION = 1;
1342
+ const MAX_PROMPT_LABEL = 300;
1343
+
1344
+ // Default adapter: window.localStorage, JSON values, fully guarded.
1345
+ function localAdapter() {
1346
+ function ls() {
1347
+ try {
1348
+ return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
1349
+ } catch (_) { return null; } // SecurityError in sandboxed frames
1350
+ }
1351
+ return {
1352
+ get(key) {
1353
+ const s = ls();
1354
+ if (!s) return undefined;
1355
+ const raw = s.getItem(key);
1356
+ if (raw == null) return undefined;
1357
+ try { return JSON.parse(raw); } catch (_) { return undefined; }
1358
+ },
1359
+ set(key, value) {
1360
+ const s = ls();
1361
+ if (!s) return;
1362
+ s.setItem(key, JSON.stringify(value));
1363
+ },
1364
+ remove(key) {
1365
+ const s = ls();
1366
+ if (!s) return;
1367
+ s.removeItem(key);
1368
+ },
1369
+ };
1370
+ }
1371
+
1372
+ // mergeTransforms concats; recipes must UNION. Drop structurally identical
1373
+ // ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
1374
+ function dedupeOps(transform) {
1375
+ if (!transform || !Array.isArray(transform.ops)) return transform;
1376
+ const seen = new Set();
1377
+ const ops = transform.ops.filter((op) => {
1378
+ let key;
1379
+ try { key = JSON.stringify(op); } catch (_) { return true; }
1380
+ if (seen.has(key)) return false;
1381
+ seen.add(key);
1382
+ return true;
1383
+ });
1384
+ return Object.assign({}, transform, { ops });
1385
+ }
1386
+
1387
+ // Dedupe prompt-label segments exactly like extension/src/recipes.js
1388
+ // appendTransform: never re-append a segment that is already present.
1389
+ function mergePrompt(existingPrompt, incoming) {
1390
+ if (existingPrompt && incoming) {
1391
+ const dup = existingPrompt.split(' + ')
1392
+ .some((s) => s.trim().toLowerCase() === incoming.trim().toLowerCase());
1393
+ return dup ? existingPrompt : String(existingPrompt + ' + ' + incoming).slice(0, MAX_PROMPT_LABEL);
1394
+ }
1395
+ return incoming || existingPrompt || '';
1396
+ }
1397
+
1398
+ function createStore(adapter) {
1399
+ const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
1400
+ const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
1401
+
1402
+ // Serialize read-modify-write so rapid appends can't lose ops (same
1403
+ // pattern as extension/src/recipes.js).
1404
+ let writeChain = Promise.resolve();
1405
+ function serialize(fn) {
1406
+ const run = writeChain.then(fn, fn);
1407
+ writeChain = run.then(() => {}, () => {});
1408
+ return run;
1409
+ }
1410
+
1411
+ async function loadRecipe(pathKey) {
1412
+ try {
1413
+ const v = await backing.get(keyOf(pathKey));
1414
+ return (v && typeof v === 'object') ? v : null;
1415
+ } catch (_) { return null; }
1416
+ }
1417
+
1418
+ function saveRecipe(pathKey, recipe) {
1419
+ return serialize(async () => {
1420
+ try {
1421
+ const merged = Object.assign(
1422
+ { path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
1423
+ recipe,
1424
+ { schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
1425
+ );
1426
+ await backing.set(keyOf(pathKey), merged);
1427
+ return merged;
1428
+ } catch (_) { return null; }
1429
+ });
1430
+ }
1431
+
1432
+ function appendTransform(pathKey, transform, meta, mergeFn) {
1433
+ return serialize(async () => {
1434
+ try {
1435
+ let existing = null;
1436
+ try { existing = await backing.get(keyOf(pathKey)); } catch (_) { /* treat as none */ }
1437
+ if (!existing || typeof existing !== 'object') existing = null;
1438
+ const mergedTransform = dedupeOps(
1439
+ (existing && existing.transform && typeof mergeFn === 'function')
1440
+ ? mergeFn(existing.transform, transform)
1441
+ : transform
1442
+ );
1443
+ const prompt = mergePrompt(existing && existing.prompt, (meta && meta.prompt) || '');
1444
+ const merged = Object.assign(
1445
+ { path: String(pathKey || '/'), createdAt: Date.now() },
1446
+ existing || {},
1447
+ {
1448
+ path: String(pathKey || '/'), prompt, transform: mergedTransform,
1449
+ enabled: true, schemaVersion: SCHEMA_VERSION,
1450
+ }
1451
+ );
1452
+ await backing.set(keyOf(pathKey), merged);
1453
+ return merged;
1454
+ } catch (_) { return null; }
1455
+ });
1456
+ }
1457
+
1458
+ function removeRecipe(pathKey) {
1459
+ return serialize(async () => {
1460
+ try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
1461
+ });
1462
+ }
1463
+
1464
+ return { loadRecipe, saveRecipe, appendTransform, removeRecipe };
1465
+ }
1466
+
1467
+ return { createStore, SCHEMA_VERSION };
1468
+ });
1469
+
1470
+
1471
+ /* ---- extension/src/context.js ---- */
1472
+ /*
1473
+ * Context extractor — serialize a compact, pruned view of the page so the model
1474
+ * can pick real selectors without shipping the whole DOM. Runs in the content script
1475
+ * (isolated world). Attaches to window.Morph.extractContext.
1476
+ */
1477
+ (function () {
1478
+ 'use strict';
1479
+ window.Morph = window.Morph || {};
1480
+
1481
+ const SKIP_TAGS = new Set([
1482
+ 'script', 'style', 'noscript', 'template', 'svg', 'path', 'meta', 'link',
1483
+ 'br', 'head', 'iframe', 'canvas',
1484
+ ]);
1485
+ const MAX_NODES = 600;
1486
+ const MAX_DEPTH = 12;
1487
+ const MAX_CHILDREN = 14; // cap repetitive lists
1488
+ const TEXT_PREVIEW = 50;
1489
+ const MAX_CHARS = 55000;
1490
+
1491
+ function simpleId(id) {
1492
+ // only emit ids that are valid, stable-looking selectors
1493
+ return id && /^[A-Za-z][\w-]*$/.test(id) ? id : null;
1494
+ }
1495
+
1496
+ function classes(el) {
1497
+ if (!el.classList || el.classList.length === 0) return [];
1498
+ return Array.from(el.classList)
1499
+ .filter((c) => /^[A-Za-z][\w-]*$/.test(c) && c.length <= 40)
1500
+ .slice(0, 3);
1501
+ }
1502
+
1503
+ function selectorHint(el) {
1504
+ const tag = el.tagName.toLowerCase();
1505
+ const id = simpleId(el.id);
1506
+ if (id) return `${tag}#${id}`;
1507
+ const cls = classes(el);
1508
+ return cls.length ? `${tag}.${cls.join('.')}` : tag;
1509
+ }
1510
+
1511
+ function ownText(el) {
1512
+ let t = '';
1513
+ for (const n of el.childNodes) {
1514
+ if (n.nodeType === 3) t += n.nodeValue;
1515
+ if (t.length > TEXT_PREVIEW * 2) break;
1516
+ }
1517
+ t = t.replace(/\s+/g, ' ').trim();
1518
+ return t.length > TEXT_PREVIEW ? t.slice(0, TEXT_PREVIEW) + '…' : t;
1519
+ }
1520
+
1521
+ function isVisible(el) {
1522
+ // cheap visibility filter; avoids dumping hidden subtrees
1523
+ if (el.hidden) return false;
1524
+ // Fast path: an inline display:none / visibility:hidden is a strict subset of
1525
+ // the computed value, so we can rule the node out WITHOUT forcing a
1526
+ // getComputedStyle reflow. The computed-style check below still catches
1527
+ // CSS-class / stylesheet-based hiding.
1528
+ const inline = el.style;
1529
+ if (inline && (inline.display === 'none' || inline.visibility === 'hidden')) return false;
1530
+ const view = el.ownerDocument.defaultView;
1531
+ const style = view && view.getComputedStyle(el);
1532
+ if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;
1533
+ return true;
1534
+ }
1535
+
1536
+ function extractContext(root) {
1537
+ root = root || document.body || document.documentElement;
1538
+ const lines = [];
1539
+ let nodeCount = 0;
1540
+ let charCount = 0;
1541
+ let truncated = false;
1542
+
1543
+ function walk(el, depth) {
1544
+ if (truncated || nodeCount >= MAX_NODES || depth > MAX_DEPTH) return;
1545
+ const tag = el.tagName ? el.tagName.toLowerCase() : null;
1546
+ if (!tag || SKIP_TAGS.has(tag)) return;
1547
+ if (!isVisible(el)) return;
1548
+
1549
+ nodeCount++;
1550
+ const indent = ' '.repeat(Math.min(depth, 10));
1551
+ const role = el.getAttribute && el.getAttribute('role');
1552
+ const aria = el.getAttribute && el.getAttribute('aria-label');
1553
+ const text = ownText(el);
1554
+
1555
+ let line = indent + selectorHint(el);
1556
+ if (role) line += ` [role=${role}]`;
1557
+ if (aria) line += ` [aria-label="${aria.slice(0, 40)}"]`;
1558
+ if (text) line += ` "${text}"`;
1559
+
1560
+ charCount += line.length + 1;
1561
+ if (charCount > MAX_CHARS) { truncated = true; return; }
1562
+ lines.push(line);
1563
+
1564
+ const kids = Array.from(el.children || []);
1565
+ const shown = kids.slice(0, MAX_CHILDREN);
1566
+ for (const child of shown) walk(child, depth + 1);
1567
+ if (kids.length > shown.length) {
1568
+ lines.push(indent + ` …(+${kids.length - shown.length} more siblings)`);
1569
+ }
1570
+ }
1571
+
1572
+ walk(root, 0);
1573
+ let out = lines.join('\n');
1574
+ if (truncated) out += '\n…(snapshot truncated)';
1575
+ return out;
1576
+ }
1577
+
1578
+ window.Morph.extractContext = extractContext;
1579
+ })();
1580
+
1581
+
1582
+ /* ---- extension/src/promptbar.js ---- */
1583
+ /*
1584
+ * Prompt bar — the universal, style-isolated overlay (Shadow DOM) shown on every site.
1585
+ * Pure UI: it knows nothing about the model or recipes; content.js wires the handlers.
1586
+ * Attaches to window.Morph.bar.
1587
+ *
1588
+ * Public contract consumed by content.js (DO NOT remove/rename or change semantics):
1589
+ * mount(handlers {onSubmit, onUndo}), show, hide, toggle, isVisible,
1590
+ * setStatus(text, kind), clearStatus, setBusy(bool), showActions(bool), clearInput.
1591
+ * A few NON-breaking helpers are added below (focusInput, setRecent).
1592
+ */
1593
+ (function () {
1594
+ 'use strict';
1595
+ window.Morph = window.Morph || {};
1596
+
1597
+ const HOST_ID = 'morph-host-root';
1598
+ // Recent prompts are stored PER-ORIGIN: a map { origin -> string[] } under one key,
1599
+ // so the bar only ever shows prompts you typed on the site you're currently on.
1600
+ const RECENT_KEY = 'morph:recentByOrigin';
1601
+ const LEGACY_RECENT_KEY = 'morph:recentPrompts'; // pre-1.0.5 global list; removed on mount
1602
+ const RECENT_CAP = 6; // prompts kept per origin
1603
+ const MAX_ORIGINS = 60; // bound total storage growth
1604
+
1605
+ let host, root, els, handlers = {}, visible = false;
1606
+ let lastFocused = null; // page element focused before show(), restored on hide()
1607
+ let recipeActive = false; // when a recipe is active we keep example chips hidden
1608
+ let connectHandler = null; // onConnect callback for the connector suggestion
1609
+ let currentSuggestions = []; // site-special model prompts (via setSuggestions); empty hides the row
1610
+ let targetClear = null; // onClear callback for the targeted-element indicator
1611
+
1612
+ const CSS = `
1613
+ :host { all: initial; }
1614
+
1615
+ /* Precision-instrument palette: monochrome, white accent, hairline rules.
1616
+ Dark by default; inverted (black accent) under prefers-color-scheme: light. */
1617
+ .wrap {
1618
+ --bg: #0a0a0b;
1619
+ --fg: #fafafa;
1620
+ --muted: #a1a1aa;
1621
+ --faint: #6e6e76;
1622
+ --line: rgba(255,255,255,0.14);
1623
+ --line-strong: rgba(255,255,255,0.30);
1624
+ --field: #161618;
1625
+ --accent: #ffffff; /* was purple */
1626
+ --accent-ink: #0a0a0b; /* text/icon on the white accent */
1627
+ --err: #ff6b6b;
1628
+ --shadow: 0 1px 0 rgba(255,255,255,0.05) inset, 0 24px 64px -22px rgba(0,0,0,0.9);
1629
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
1630
+ }
1631
+ @media (prefers-color-scheme: light) {
1632
+ .wrap {
1633
+ --bg: #ffffff;
1634
+ --fg: #0a0a0b;
1635
+ --muted: #52525b;
1636
+ --faint: #9a9aa2;
1637
+ --line: rgba(0,0,0,0.14);
1638
+ --line-strong: rgba(0,0,0,0.32);
1639
+ --field: #f5f5f5;
1640
+ --accent: #0a0a0b; /* black accent on light */
1641
+ --accent-ink: #ffffff;
1642
+ --err: #c4302b;
1643
+ --shadow: 0 1px 0 rgba(255,255,255,0.6) inset, 0 24px 64px -22px rgba(0,0,0,0.28);
1644
+ }
1645
+ }
1646
+
1647
+ .wrap {
1648
+ position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%) translateY(6px);
1649
+ z-index: 2147483647; font-family: var(--mono);
1650
+ width: min(660px, calc(100vw - 32px));
1651
+ background: var(--bg); color: var(--fg);
1652
+ border: 1px solid var(--line); border-radius: 4px;
1653
+ box-shadow: var(--shadow); padding: 14px;
1654
+ opacity: 0; pointer-events: none; transition: opacity .14s ease, transform .14s cubic-bezier(.2,.7,.2,1);
1655
+ box-sizing: border-box;
1656
+ }
1657
+ .wrap.show { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }
1658
+ @media (prefers-reduced-motion: reduce) {
1659
+ .wrap, .wrap.show { transition: opacity .1s linear; transform: translateX(-50%) translateY(0); }
1660
+ .spinner { animation: none; }
1661
+ }
1662
+
1663
+ .row { display: flex; align-items: stretch; gap: 10px; }
1664
+
1665
+ /* Wordmark: a hard accent block + tracked monospace MORPH. */
1666
+ .logo { display: flex; align-items: center; gap: 9px; user-select: none; white-space: nowrap;
1667
+ font-size: 11px; font-weight: 700; letter-spacing: .24em; color: var(--fg); padding: 0 2px; }
1668
+ .logo .mark { width: 9px; height: 9px; background: var(--accent); display: inline-block; }
1669
+
1670
+ input.q {
1671
+ flex: 1; min-width: 0; background: var(--field);
1672
+ border: 1px solid var(--line); border-radius: 2px;
1673
+ color: var(--fg); font-family: var(--mono); font-size: 13.5px; letter-spacing: -.01em;
1674
+ padding: 11px 12px; outline: none; text-overflow: ellipsis;
1675
+ transition: border-color .12s ease;
1676
+ }
1677
+ input.q:focus { border-color: var(--line-strong); }
1678
+ input.q::placeholder { color: var(--faint); }
1679
+
1680
+ button {
1681
+ border: 0; border-radius: 2px; padding: 0 16px; font-family: var(--mono);
1682
+ font-size: 11px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
1683
+ cursor: pointer; white-space: nowrap; transition: opacity .12s ease, color .12s ease, border-color .12s ease;
1684
+ }
1685
+ button:disabled { opacity: .4; cursor: default; }
1686
+ .go { background: var(--accent); color: var(--accent-ink);
1687
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 100px; }
1688
+ .go:hover:not(:disabled) { opacity: .82; }
1689
+ .ghost { background: transparent; color: var(--muted); border: 1px solid var(--line); padding: 8px 12px; }
1690
+ .ghost:hover:not(:disabled) { color: var(--fg); border-color: var(--line-strong); }
1691
+ .x { background: transparent; color: var(--faint); padding: 0 8px; font-size: 17px; line-height: 1; }
1692
+ .x:hover { color: var(--fg); }
1693
+
1694
+ /* Crisp keyboard focus ring. */
1695
+ input.q:focus-visible, button:focus-visible, .chip:focus-visible {
1696
+ outline: 1px solid var(--accent); outline-offset: 2px;
1697
+ }
1698
+
1699
+ /* Suggestion + recent chips — square, hairline, mono. */
1700
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; align-items: center; }
1701
+ .chips.hidden { display: none; }
1702
+ .chips .lead { font-size: 9.5px; letter-spacing: .2em; text-transform: uppercase;
1703
+ color: var(--faint); margin-right: 4px; user-select: none; }
1704
+ .chip {
1705
+ background: transparent; color: var(--muted); border: 1px solid var(--line);
1706
+ border-radius: 2px; padding: 5px 9px; font-family: var(--mono); font-size: 11px; letter-spacing: .01em;
1707
+ cursor: pointer; white-space: nowrap; transition: color .12s ease, border-color .12s ease, background .12s ease;
1708
+ /* Recent prompts are arbitrary user text — clamp each chip so a long prompt
1709
+ can't overflow the bar; full text stays available via the title tooltip. */
1710
+ max-width: 200px; overflow: hidden; text-overflow: ellipsis;
1711
+ }
1712
+ .chip.dismiss { max-width: none; }
1713
+ .chip:hover { color: var(--fg); border-color: var(--line-strong); background: var(--field); }
1714
+ .chip.dismiss { padding: 5px 7px; color: var(--faint); }
1715
+
1716
+ .status {
1717
+ font-size: 11.5px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line);
1718
+ min-height: 15px; color: var(--muted); display: none; line-height: 1.55;
1719
+ word-break: break-word; overflow-wrap: anywhere; max-height: 8.5em; overflow-y: auto;
1720
+ }
1721
+ .status.show { display: block; }
1722
+ .status.err { color: var(--err); }
1723
+ .status.ok { color: var(--fg); }
1724
+ .status b { font-weight: 700; color: var(--fg); }
1725
+
1726
+ .actions { display: none; gap: 8px; margin-top: 12px; }
1727
+ .actions.show { display: flex; }
1728
+
1729
+ /* Connector suggestion ("You're on Gmail — connect …"). */
1730
+ .connector { display: none; align-items: center; gap: 10px; margin-top: 12px;
1731
+ padding: 10px 12px; border: 1px solid var(--line); border-radius: 2px; background: var(--field); }
1732
+ .connector.show { display: flex; }
1733
+ .connector .ctext { flex: 1; font-size: 11px; color: var(--muted); line-height: 1.45; letter-spacing: .01em; }
1734
+ .connector .ctext b { color: var(--fg); font-weight: 700; }
1735
+ .connector .cbtn { background: var(--accent); color: var(--accent-ink); border: 0; border-radius: 2px;
1736
+ padding: 7px 12px; font-family: var(--mono); font-size: 10.5px; font-weight: 700; letter-spacing: .12em;
1737
+ text-transform: uppercase; cursor: pointer; white-space: nowrap; }
1738
+ .connector .cbtn:hover { opacity: .85; }
1739
+ .connector .cx { background: transparent; border: 0; color: var(--faint); cursor: pointer;
1740
+ font-size: 15px; line-height: 1; padding: 0 4px; }
1741
+ .connector .cx:hover { color: var(--fg); }
1742
+
1743
+ /* Element-picker button + targeted-element indicator. */
1744
+ .aim { background: transparent; color: var(--muted); border: 1px solid var(--line);
1745
+ padding: 0 12px; font-size: 15px; line-height: 1; }
1746
+ .aim:hover:not(:disabled) { color: var(--fg); border-color: var(--line-strong); }
1747
+ .aim.on { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
1748
+ .target { display: none; align-items: center; gap: 8px; margin-top: 10px;
1749
+ padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 2px; background: var(--field); }
1750
+ .target.show { display: flex; }
1751
+ .target .ttext { flex: 1; font-size: 11px; color: var(--fg); letter-spacing: .01em; line-height: 1.4;
1752
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1753
+ .target .ttext b { font-weight: 700; }
1754
+ .target .tx { background: transparent; border: 0; color: var(--faint); cursor: pointer;
1755
+ font-size: 15px; line-height: 1; padding: 0 4px; }
1756
+ .target .tx:hover { color: var(--fg); }
1757
+
1758
+ .spinner { display: inline-block; width: 11px; height: 11px; border: 1.5px solid rgba(127,127,140,0.4);
1759
+ border-top-color: var(--accent-ink); border-radius: 50%; animation: spin .6s linear infinite; vertical-align: -1px; }
1760
+ @keyframes spin { to { transform: rotate(360deg); } }
1761
+ `;
1762
+
1763
+ function build() {
1764
+ host = document.createElement('div');
1765
+ host.id = HOST_ID;
1766
+ root = host.attachShadow({ mode: 'open' });
1767
+
1768
+ const style = document.createElement('style');
1769
+ style.textContent = CSS;
1770
+ root.appendChild(style);
1771
+
1772
+ const wrap = document.createElement('div');
1773
+ wrap.className = 'wrap';
1774
+ wrap.setAttribute('role', 'dialog');
1775
+ wrap.setAttribute('aria-modal', 'true');
1776
+ wrap.setAttribute('aria-label', 'Morph prompt');
1777
+ // Static structure only — no untrusted text is interpolated here.
1778
+ wrap.innerHTML = `
1779
+ <div class="row">
1780
+ <div class="logo" aria-hidden="true"><i class="mark"></i>MORPH</div>
1781
+ <input class="q" type="text" autocomplete="off" spellcheck="false"
1782
+ aria-label="Describe how this page should look"
1783
+ placeholder="Reshape this page…" />
1784
+ <button class="aim" type="button" aria-label="Pick an element to target" title="Pick an element to change (Esc to cancel)">⌖</button>
1785
+ <button class="go" aria-label="Reshape the page">
1786
+ <span class="go-spin" hidden><span class="spinner"></span></span><span class="go-label">Morph it</span>
1787
+ </button>
1788
+ <button class="x" aria-label="Close Morph" title="Close (Esc)">×</button>
1789
+ </div>
1790
+ <div class="connector" role="note">
1791
+ <span class="ctext"></span>
1792
+ <button class="cbtn" type="button">Connect</button>
1793
+ <button class="cx" type="button" aria-label="Dismiss connector suggestion">×</button>
1794
+ </div>
1795
+ <div class="target" role="note">
1796
+ <span class="ttext"></span>
1797
+ <button class="tx" type="button" aria-label="Clear the targeted element">×</button>
1798
+ </div>
1799
+ <div class="chips examples" aria-label="Example prompts"></div>
1800
+ <div class="chips recent" aria-label="Recent prompts" hidden></div>
1801
+ <div class="status" role="status" aria-live="polite"></div>
1802
+ <div class="actions">
1803
+ <button class="ghost undo" aria-label="Undo and forget this site's recipe">Undo &amp; forget</button>
1804
+ </div>
1805
+ `;
1806
+ root.appendChild(wrap);
1807
+
1808
+ els = {
1809
+ wrap,
1810
+ input: wrap.querySelector('input.q'),
1811
+ go: wrap.querySelector('.go'),
1812
+ goLabel: wrap.querySelector('.go-label'),
1813
+ goSpin: wrap.querySelector('.go-spin'),
1814
+ close: wrap.querySelector('.x'),
1815
+ examples: wrap.querySelector('.chips.examples'),
1816
+ recent: wrap.querySelector('.chips.recent'),
1817
+ status: wrap.querySelector('.status'),
1818
+ actions: wrap.querySelector('.actions'),
1819
+ undo: wrap.querySelector('.undo'),
1820
+ connector: wrap.querySelector('.connector'),
1821
+ ctext: wrap.querySelector('.connector .ctext'),
1822
+ cbtn: wrap.querySelector('.connector .cbtn'),
1823
+ cx: wrap.querySelector('.connector .cx'),
1824
+ aim: wrap.querySelector('.aim'),
1825
+ target: wrap.querySelector('.target'),
1826
+ ttext: wrap.querySelector('.target .ttext'),
1827
+ tx: wrap.querySelector('.target .tx'),
1828
+ };
1829
+
1830
+ renderExamples();
1831
+
1832
+ els.go.addEventListener('click', submit);
1833
+ els.input.addEventListener('keydown', (e) => {
1834
+ if (e.key === 'Enter') { e.preventDefault(); submit(); }
1835
+ if (e.key === 'Escape') { e.preventDefault(); hide(); }
1836
+ });
1837
+ els.input.addEventListener('input', updateChipVisibility);
1838
+
1839
+ // Contain ALL keystrokes inside the bar so they never reach the host page's
1840
+ // global keyboard shortcuts (e.g. GitHub/Gmail/X single-key hotkeys that
1841
+ // would otherwise fire because our shadow-host is not a recognized input).
1842
+ // stopPropagation does not block typing (default action still happens).
1843
+ ['keydown', 'keyup', 'keypress'].forEach((evt) =>
1844
+ wrap.addEventListener(evt, (e) => { e.stopPropagation(); }));
1845
+ els.close.addEventListener('click', hide);
1846
+ els.undo.addEventListener('click', () => handlers.onUndo && handlers.onUndo());
1847
+ els.cbtn.addEventListener('click', () => { if (connectHandler) connectHandler(); });
1848
+ els.cx.addEventListener('click', hideConnector);
1849
+ els.aim.addEventListener('click', () => { if (handlers.onPick) handlers.onPick(); });
1850
+ els.tx.addEventListener('click', () => { const cb = targetClear; clearTarget(); if (cb) cb(); });
1851
+
1852
+ // Focus trap + Esc, scoped to the shadow root while visible.
1853
+ wrap.addEventListener('keydown', onWrapKeydown);
1854
+
1855
+ (document.body || document.documentElement).appendChild(host);
1856
+
1857
+ // Drop the legacy global recents, then pull THIS site's recents asynchronously;
1858
+ // never throws if chrome.storage is absent.
1859
+ removeLegacyRecent();
1860
+ loadRecent();
1861
+ }
1862
+
1863
+ // --- example / recent chip rendering (text is injected via textContent only) ---
1864
+
1865
+ function makeChip(text, cls) {
1866
+ const b = document.createElement('button');
1867
+ b.type = 'button';
1868
+ b.className = 'chip' + (cls ? ' ' + cls : '');
1869
+ b.textContent = text; // NEVER innerHTML — text may be user-authored.
1870
+ b.setAttribute('aria-label', 'Use prompt: ' + text);
1871
+ return b;
1872
+ }
1873
+
1874
+ function fillAndSubmit(text) {
1875
+ if (!els) return;
1876
+ els.input.value = text;
1877
+ els.input.focus();
1878
+ submit(); // routes through the same submit() so onSubmit fires unchanged
1879
+ }
1880
+
1881
+ function renderExamples() {
1882
+ if (!els) return;
1883
+ els.examples.textContent = '';
1884
+ if (!currentSuggestions.length) { els.examples.hidden = true; return; }
1885
+ els.examples.hidden = false;
1886
+ currentSuggestions.forEach((ex) => {
1887
+ const chip = makeChip(ex);
1888
+ chip.addEventListener('click', () => fillAndSubmit(ex));
1889
+ els.examples.appendChild(chip);
1890
+ });
1891
+ updateChipVisibility();
1892
+ }
1893
+
1894
+ // Set the site-special suggestion chips (plain strings; rendered via textContent).
1895
+ // An empty list HIDES the row — generic restyle is offered by the one-tap presets,
1896
+ // so we never duplicate it here.
1897
+ function setSuggestions(list) {
1898
+ currentSuggestions = Array.isArray(list)
1899
+ ? list.filter((s) => typeof s === 'string' && s.trim()).slice(0, 8)
1900
+ : [];
1901
+ if (els) renderExamples();
1902
+ }
1903
+
1904
+ function renderRecent(list) {
1905
+ if (!els) return;
1906
+ els.recent.textContent = '';
1907
+ const items = Array.isArray(list) ? list.filter((s) => typeof s === 'string' && s.trim()) : [];
1908
+ if (!items.length) { els.recent.hidden = true; return; }
1909
+
1910
+ const lead = document.createElement('span');
1911
+ lead.className = 'lead';
1912
+ lead.textContent = 'Recent';
1913
+ els.recent.appendChild(lead);
1914
+
1915
+ items.slice(0, RECENT_CAP).forEach((p) => {
1916
+ const chip = makeChip(p);
1917
+ chip.title = p;
1918
+ chip.addEventListener('click', () => fillAndSubmit(p));
1919
+ els.recent.appendChild(chip);
1920
+ });
1921
+
1922
+ const clear = makeChip('×', 'dismiss');
1923
+ clear.setAttribute('aria-label', 'Clear recent prompts');
1924
+ clear.title = 'Clear recent prompts';
1925
+ clear.addEventListener('click', clearRecent);
1926
+ els.recent.appendChild(clear);
1927
+
1928
+ els.recent.hidden = false;
1929
+ }
1930
+
1931
+ // Examples are unobtrusive: hidden once a recipe is active or the input has text.
1932
+ function updateChipVisibility() {
1933
+ if (!els) return;
1934
+ const hasText = !!els.input.value.trim();
1935
+ els.examples.classList.toggle('hidden', recipeActive || hasText);
1936
+ }
1937
+
1938
+ // --- recent-prompts memory (chrome.storage.local, guarded for jsdom/plain DOM) ---
1939
+
1940
+ function hasStorage() {
1941
+ return typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local;
1942
+ }
1943
+
1944
+ // Pure: dedupe (case-insensitive), most-recent-first, capped. Exported for tests below.
1945
+ function updateRecentList(list, prompt, cap) {
1946
+ const max = cap || RECENT_CAP;
1947
+ const next = Array.isArray(list) ? list.slice() : [];
1948
+ const p = String(prompt == null ? '' : prompt).trim();
1949
+ if (!p) return next.slice(0, max);
1950
+ const out = [p];
1951
+ for (const item of next) {
1952
+ if (typeof item !== 'string') continue;
1953
+ const t = item.trim();
1954
+ if (!t) continue;
1955
+ if (t.toLowerCase() === p.toLowerCase()) continue;
1956
+ out.push(t);
1957
+ if (out.length >= max) break;
1958
+ }
1959
+ return out.slice(0, max);
1960
+ }
1961
+
1962
+ // The current site's bucket key. Recents are scoped to the page origin.
1963
+ function originKey() {
1964
+ try { return location.origin || '(unknown)'; } catch (_) { return '(unknown)'; }
1965
+ }
1966
+
1967
+ // Pure: write `prompt` into the per-origin recent map and return a NEW map. Each origin
1968
+ // keeps its own deduped/capped list; the number of origins is bounded (oldest-written
1969
+ // dropped first). Exported for tests below.
1970
+ function updateRecentMap(map, origin, prompt, cap, maxOrigins) {
1971
+ const m = map && typeof map === 'object' && !Array.isArray(map) ? Object.assign({}, map) : {};
1972
+ const key = origin || '(unknown)';
1973
+ const list = updateRecentList(Array.isArray(m[key]) ? m[key] : [], prompt, cap);
1974
+ delete m[key]; // re-insert last so this origin is "most recently written"
1975
+ if (list.length) m[key] = list;
1976
+ const capN = maxOrigins || MAX_ORIGINS;
1977
+ const keys = Object.keys(m); // insertion order
1978
+ if (keys.length > capN) for (const k of keys.slice(0, keys.length - capN)) delete m[k];
1979
+ return m;
1980
+ }
1981
+
1982
+ function loadRecent() {
1983
+ if (!hasStorage()) return;
1984
+ try {
1985
+ chrome.storage.local.get(RECENT_KEY, (d) => {
1986
+ if (chrome.runtime && chrome.runtime.lastError) return;
1987
+ const map = (d && d[RECENT_KEY]) || {};
1988
+ renderRecent(map[originKey()] || []);
1989
+ });
1990
+ } catch (_) { /* ignore */ }
1991
+ }
1992
+
1993
+ function pushRecent(prompt) {
1994
+ if (!hasStorage()) return;
1995
+ try {
1996
+ chrome.storage.local.get(RECENT_KEY, (d) => {
1997
+ const origin = originKey();
1998
+ const map = updateRecentMap((d && d[RECENT_KEY]) || {}, origin, prompt, RECENT_CAP, MAX_ORIGINS);
1999
+ chrome.storage.local.set({ [RECENT_KEY]: map }, () => {
2000
+ if (chrome.runtime && chrome.runtime.lastError) return;
2001
+ renderRecent(map[origin] || []);
2002
+ });
2003
+ });
2004
+ } catch (_) { /* ignore */ }
2005
+ }
2006
+
2007
+ function clearRecent() {
2008
+ if (hasStorage()) {
2009
+ try {
2010
+ chrome.storage.local.get(RECENT_KEY, (d) => {
2011
+ const map = (d && d[RECENT_KEY]) || {};
2012
+ delete map[originKey()]; // clear THIS site only
2013
+ chrome.storage.local.set({ [RECENT_KEY]: map });
2014
+ });
2015
+ } catch (_) {}
2016
+ }
2017
+ renderRecent([]);
2018
+ }
2019
+
2020
+ // One-time cleanup of the pre-1.0.5 global recents key so old cross-site prompts vanish.
2021
+ function removeLegacyRecent() {
2022
+ if (!hasStorage()) return;
2023
+ try { chrome.storage.local.remove(LEGACY_RECENT_KEY); } catch (_) {}
2024
+ }
2025
+
2026
+ // --- submit ---
2027
+
2028
+ function submit() {
2029
+ const q = els.input.value.trim();
2030
+ if (!q || !handlers.onSubmit) return;
2031
+ // Optimistically remember the prompt; content.js drives the actual success/error UI.
2032
+ pushRecent(q);
2033
+ handlers.onSubmit(q);
2034
+ }
2035
+
2036
+ // --- status (TRUSTED markup) ---
2037
+ //
2038
+ // INVARIANT: setStatus receives TRUSTED HTML from content.js — it passes spinner/<b>
2039
+ // markup plus user text that content.js has ALREADY escaped via its own escapeHtml().
2040
+ // We MUST render it with innerHTML and MUST NOT escape here; escaping would double-encode
2041
+ // the user text and break the spinner/bold markup. This is part of the public contract.
2042
+ function setStatus(text, kind) {
2043
+ els.status.className = 'status show' + (kind ? ' ' + kind : '');
2044
+ els.status.innerHTML = text;
2045
+ els.status.scrollTop = 0;
2046
+ }
2047
+ function clearStatus() { els.status.className = 'status'; els.status.innerHTML = ''; }
2048
+
2049
+ function setBusy(busy) {
2050
+ els.go.disabled = busy;
2051
+ els.input.disabled = busy;
2052
+ if (els.goSpin) els.goSpin.hidden = !busy;
2053
+ if (els.goLabel) els.goLabel.textContent = busy ? 'Reshaping…' : 'Morph it';
2054
+ // Disable chips while busy so a second submit can't race.
2055
+ setChipsDisabled(busy);
2056
+ els.wrap.setAttribute('aria-busy', busy ? 'true' : 'false');
2057
+ if (busy) setStatus('<span class="spinner"></span>Reshaping the page…');
2058
+ }
2059
+
2060
+ function setChipsDisabled(disabled) {
2061
+ if (!els) return;
2062
+ const chips = els.wrap.querySelectorAll('.chip');
2063
+ chips.forEach((c) => { c.disabled = !!disabled; });
2064
+ }
2065
+
2066
+ function showActions(show) {
2067
+ els.actions.className = 'actions' + (show ? ' show' : '');
2068
+ // A live recipe means example chips are no longer the right first thing to show.
2069
+ recipeActive = !!show;
2070
+ updateChipVisibility();
2071
+ }
2072
+
2073
+ // --- focus trap ---
2074
+
2075
+ function focusable() {
2076
+ if (!root) return [];
2077
+ const sel = 'input:not([disabled]), button:not([disabled]):not([hidden])';
2078
+ return Array.prototype.filter.call(root.querySelectorAll(sel), (el) => {
2079
+ // Skip elements inside a hidden chips row.
2080
+ if (el.classList && el.classList.contains('chip')) {
2081
+ const rowHidden = el.closest('.chips') && el.closest('.chips').hidden;
2082
+ const rowOff = el.closest('.chips') && el.closest('.chips').classList.contains('hidden');
2083
+ if (rowHidden || rowOff) return false;
2084
+ }
2085
+ return el.offsetParent !== null || el === els.input;
2086
+ });
2087
+ }
2088
+
2089
+ function onWrapKeydown(e) {
2090
+ if (e.key === 'Escape') { e.preventDefault(); hide(); return; }
2091
+ if (e.key !== 'Tab' || !visible) return;
2092
+ const items = focusable();
2093
+ if (!items.length) return;
2094
+ const first = items[0];
2095
+ const last = items[items.length - 1];
2096
+ const active = root.activeElement;
2097
+ if (e.shiftKey) {
2098
+ if (active === first || !root.contains(active)) { e.preventDefault(); last.focus(); }
2099
+ } else {
2100
+ if (active === last || !root.contains(active)) { e.preventDefault(); first.focus(); }
2101
+ }
2102
+ }
2103
+
2104
+ // --- show / hide ---
2105
+
2106
+ function show() {
2107
+ if (!host) build();
2108
+ visible = true;
2109
+ lastFocused = (document.activeElement && document.activeElement !== document.body)
2110
+ ? document.activeElement : null;
2111
+ updateChipVisibility();
2112
+ requestAnimationFrame(() => els.wrap.classList.add('show'));
2113
+ setTimeout(() => els.input && els.input.focus(), 60);
2114
+ }
2115
+ function hide() {
2116
+ if (!host) return;
2117
+ visible = false;
2118
+ els.wrap.classList.remove('show');
2119
+ // Let content.js tear down transient state (cancel pick mode, drop the target).
2120
+ if (handlers.onHide) { try { handlers.onHide(); } catch (_) {} }
2121
+ // Restore focus to whatever the page had before we opened — never trap when idle.
2122
+ const target = lastFocused;
2123
+ lastFocused = null;
2124
+ if (target && typeof target.focus === 'function' && document.contains(target)) {
2125
+ try { target.focus(); } catch (_) {}
2126
+ }
2127
+ }
2128
+ function toggle() { visible ? hide() : show(); }
2129
+
2130
+ function focusInput() { if (els && els.input) els.input.focus(); }
2131
+
2132
+ // Connector suggestion ("You're on Gmail — connect …"). info: {name, blurb, onConnect}.
2133
+ function setConnector(info) {
2134
+ if (!els || !info) return;
2135
+ connectHandler = typeof info.onConnect === 'function' ? info.onConnect : null;
2136
+ els.ctext.textContent = '';
2137
+ const b = document.createElement('b');
2138
+ b.textContent = info.name || 'this app';
2139
+ els.ctext.appendChild(b);
2140
+ els.ctext.appendChild(document.createTextNode(' — connect to ' + (info.blurb || 'pull real data') + '.'));
2141
+ els.cbtn.textContent = info.cta || 'Connect';
2142
+ els.connector.classList.add('show');
2143
+ }
2144
+ function hideConnector() { if (els) els.connector.classList.remove('show'); }
2145
+ function setConnectorBusy(text) { if (els) els.ctext.textContent = text || 'Connecting…'; }
2146
+
2147
+ // Targeted-element indicator. info: { label, onClear }. Text is set via
2148
+ // textContent (label may be page-authored), never innerHTML.
2149
+ function setTarget(info) {
2150
+ if (!els || !info) return;
2151
+ targetClear = typeof info.onClear === 'function' ? info.onClear : null;
2152
+ els.ttext.textContent = '';
2153
+ els.ttext.appendChild(document.createTextNode('Targeting '));
2154
+ const b = document.createElement('b');
2155
+ b.textContent = info.label || 'element';
2156
+ els.ttext.appendChild(b);
2157
+ els.target.classList.add('show');
2158
+ if (els.aim) els.aim.classList.add('on');
2159
+ }
2160
+ function clearTarget() {
2161
+ if (!els) return;
2162
+ els.target.classList.remove('show');
2163
+ if (els.aim) els.aim.classList.remove('on');
2164
+ targetClear = null;
2165
+ }
2166
+ function aimActive(on) { if (els && els.aim) els.aim.classList.toggle('on', !!on); }
2167
+
2168
+ window.Morph.bar = {
2169
+ mount(h) { handlers = h || {}; if (!host) build(); },
2170
+ show, hide, toggle,
2171
+ setStatus, clearStatus, setBusy, showActions,
2172
+ isVisible: () => visible,
2173
+ clearInput: () => { if (els) { els.input.value = ''; updateChipVisibility(); } },
2174
+ // Non-breaking additions:
2175
+ focusInput,
2176
+ setRecent: (list) => renderRecent(list),
2177
+ setSuggestions,
2178
+ setConnector, hideConnector, setConnectorBusy,
2179
+ setTarget, clearTarget, aimActive,
2180
+ };
2181
+
2182
+ // Expose pure helper for the optional test harness without coupling to chrome/DOM.
2183
+ if (typeof module !== 'undefined' && module.exports) {
2184
+ module.exports = { updateRecentList, updateRecentMap };
2185
+ }
2186
+ })();
2187
+
2188
+
2189
+ /* ---- sdk/src/embed.js ---- */
2190
+ /*
2191
+ * Morph SDK — embed entry. Wires the engine (MorphDSL), the vendor registry,
2192
+ * the recipe store, and the prompt bar into the host page.
2193
+ *
2194
+ * createEmbed(deps) is pure wiring with every dependency injected (tests use
2195
+ * it directly with a stubbed backend). The browser block at the bottom builds
2196
+ * the real deps and exposes window.Morph.init(config) for vendors.
2197
+ *
2198
+ * Security invariants enforced here:
2199
+ * - only source ops that pass registry.validateDataOp survive to apply;
2200
+ * - rows are resolved by TRUSTED code (registry -> query engine) and the
2201
+ * vendor's records never leave the page (only manifests go to the backend);
2202
+ * - action buttons route through registry.invokeAction (schema + confirm).
2203
+ */
2204
+ (function (global, factory) {
2205
+ const api = factory();
2206
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
2207
+ else global.MorphEmbed = api;
2208
+ })(typeof self !== 'undefined' ? self : this, function () {
2209
+ 'use strict';
2210
+
2211
+ const DEFAULT_BACKEND = 'https://morph-api-234584917638.us-central1.run.app';
2212
+
2213
+ function createEmbed(deps) {
2214
+ const config = (deps && deps.config) || {};
2215
+ const win = deps.win;
2216
+ const DSL = deps.DSL;
2217
+ const registry = deps.registry;
2218
+ const store = deps.store;
2219
+ const bar = deps.bar;
2220
+ const extractContext = deps.extractContext;
2221
+ const fetchFn = deps.fetchFn;
2222
+
2223
+ let backend = DEFAULT_BACKEND;
2224
+ let currentHandle = null; // live undo handle for everything applied this session
2225
+ let activeTransform = null; // merged transform applied this session
2226
+
2227
+ function pathKey() {
2228
+ try { return win.location.pathname || '/'; } catch (_) { return '/'; }
2229
+ }
2230
+
2231
+ function init() {
2232
+ if (typeof config.key !== 'string' || !config.key) {
2233
+ throw new TypeError('Morph: init requires a publishable key (config.key)');
2234
+ }
2235
+ backend = (typeof config.backend === 'string' && config.backend)
2236
+ ? config.backend.replace(/\/$/, '') : DEFAULT_BACKEND;
2237
+ bar.mount({ onSubmit, onUndo });
2238
+ return api;
2239
+ }
2240
+
2241
+ // Confirm gate for actions. window.confirm is trusted browser chrome the
2242
+ // page can't spoof; a bar-styled dialog can replace it later.
2243
+ function confirmFn(name, description) {
2244
+ try {
2245
+ return win.confirm('Morph: run action "' + name + '"?' + (description ? '\n' + description : ''));
2246
+ } catch (_) { return false; }
2247
+ }
2248
+
2249
+ function onAction(name, args) {
2250
+ return registry.invokeAction(name, args, confirmFn).then(
2251
+ () => { try { bar.setStatus('✓ Action "' + name + '" done.', 'ok'); } catch (_) { /* bar gone */ } },
2252
+ (e) => {
2253
+ const msg = String((e && e.message) || e);
2254
+ try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + msg, /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
2255
+ }
2256
+ );
2257
+ }
2258
+
2259
+ // Keep only source-form renderData ops the registry recognizes.
2260
+ function dropUnknownSourceOps(transform) {
2261
+ const ops = transform.ops.filter((op) => {
2262
+ if (op.op !== 'renderData' || op.source === undefined) return true;
2263
+ return registry.validateDataOp(op).ok;
2264
+ });
2265
+ return Object.assign({}, transform, { ops });
2266
+ }
2267
+
2268
+ // Resolve rows for every (registry-valid) source op. Failures drop that op
2269
+ // so one dead source never blocks the rest of the reshape.
2270
+ async function resolveAllData(transform) {
2271
+ const data = {};
2272
+ const sourceOps = transform.ops.filter((op) => op.op === 'renderData' && op.source !== undefined);
2273
+ const results = await Promise.allSettled(sourceOps.map(async (op) => {
2274
+ data[op.source] = await registry.resolveData(op);
2275
+ }));
2276
+ const failedSources = new Set();
2277
+ results.forEach((r, i) => { if (r.status === 'rejected') failedSources.add(sourceOps[i].source); });
2278
+ const ops = transform.ops.filter((op) =>
2279
+ !(op.op === 'renderData' && op.source !== undefined && failedSources.has(op.source) && !Array.isArray(data[op.source])));
2280
+ return { transform: Object.assign({}, transform, { ops }), data, failedCount: failedSources.size };
2281
+ }
2282
+
2283
+ async function applyMerged(incoming, data) {
2284
+ // Stack on top of anything already applied this session (additive), so
2285
+ // successive prompts accumulate instead of replacing each other.
2286
+ const merged = activeTransform ? DSL.mergeTransforms(activeTransform, incoming) : incoming;
2287
+ if (currentHandle) DSL.undoTransform(currentHandle);
2288
+ const handle = DSL.applyTransform(merged, win.document, { groupId: 'morph-sdk', data, onAction });
2289
+ currentHandle = handle;
2290
+ activeTransform = merged;
2291
+ return handle;
2292
+ }
2293
+
2294
+ async function onSubmit(prompt) {
2295
+ try { bar.setBusy(true); bar.showActions(false); } catch (_) { /* bar optional in tests */ }
2296
+ let context = '';
2297
+ try { context = extractContext() || ''; } catch (_) { /* snapshot is best-effort */ }
2298
+
2299
+ let resp;
2300
+ try {
2301
+ const body = { prompt, context, url: win.location.href };
2302
+ if (registry.hasSources() || registry.hasActions()) body.manifests = registry.manifests();
2303
+ const r = await fetchFn(backend + '/api/transform', {
2304
+ method: 'POST',
2305
+ headers: { 'Content-Type': 'application/json', 'X-Morph-Key': config.key },
2306
+ body: JSON.stringify(body),
2307
+ });
2308
+ if (!r || !r.ok) {
2309
+ const j = r && r.json ? await r.json().catch(() => null) : null;
2310
+ throw new Error((j && j.error) || ('backend error ' + (r && r.status)));
2311
+ }
2312
+ resp = await r.json();
2313
+ } catch (e) {
2314
+ try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
2315
+ return;
2316
+ }
2317
+
2318
+ try {
2319
+ const v = DSL.validateTransform(resp && resp.transform);
2320
+ if (!v.ok) {
2321
+ bar.setBusy(false);
2322
+ bar.setStatus('Couldn’t make that change here — try rephrasing.', '');
2323
+ return;
2324
+ }
2325
+ const gated = dropUnknownSourceOps(v.transform);
2326
+ const { transform, data, failedCount } = await resolveAllData(gated);
2327
+ if (!transform.ops.length) {
2328
+ bar.setBusy(false);
2329
+ bar.setStatus(failedCount ? 'Couldn’t load data for that widget.' : 'Couldn’t make that change here — try rephrasing.', failedCount ? 'err' : '');
2330
+ return;
2331
+ }
2332
+ const handle = await applyMerged(transform, data);
2333
+ bar.setBusy(false);
2334
+ if (!handle || handle.applied === 0) {
2335
+ bar.setStatus('Couldn’t make that change here — try rephrasing.', '');
2336
+ return;
2337
+ }
2338
+ const anchored = DSL.withAnchors ? DSL.withAnchors(transform, win.document) : transform;
2339
+ await store.appendTransform(pathKey(), anchored, { prompt }, DSL.mergeTransforms);
2340
+ const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
2341
+ bar.setStatus('✓ ' + ((resp.transform && resp.transform.explanation) || 'Done.') + ' Saved for this page.' + note, 'ok');
2342
+ bar.showActions(true);
2343
+ try { bar.clearInput(); } catch (_) { /* noop */ }
2344
+ } catch (e) {
2345
+ try { bar.setBusy(false); bar.setStatus('Something went wrong applying that change.', 'err'); } catch (_) { /* noop */ }
2346
+ }
2347
+ }
2348
+
2349
+ async function onUndo() {
2350
+ if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
2351
+ activeTransform = null;
2352
+ await store.removeRecipe(pathKey());
2353
+ try { bar.setStatus('Reverted this page to its original look.', ''); bar.showActions(false); } catch (_) { /* noop */ }
2354
+ }
2355
+
2356
+ // Reapply the saved recipe: CSS immediately (FOUC kill), the full
2357
+ // transform (with fresh data) once the DOM is ready.
2358
+ async function applySaved() {
2359
+ const recipe = await store.loadRecipe(pathKey());
2360
+ if (!recipe || recipe.enabled === false || !recipe.transform) return;
2361
+ const v = DSL.validateTransform(recipe.transform);
2362
+ if (!v.ok) return;
2363
+
2364
+ // 1) instant CSS so the page doesn't flash unstyled
2365
+ const cssOnly = Object.assign({}, v.transform, { ops: v.transform.ops.filter((o) => o.op === 'injectCSS') });
2366
+ let cssHandle = null;
2367
+ if (cssOnly.ops.length) cssHandle = DSL.applyTransform(cssOnly, win.document, { groupId: 'morph-sdk-early' });
2368
+
2369
+ // 2) full transform with fresh data once the DOM is ready
2370
+ const full = async () => {
2371
+ const gated = dropUnknownSourceOps(v.transform);
2372
+ const { transform, data } = await resolveAllData(gated);
2373
+ if (cssHandle) { DSL.undoTransform(cssHandle); cssHandle = null; }
2374
+ if (!transform.ops.length) return;
2375
+ await applyMerged(transform, data);
2376
+ };
2377
+ if (win.document.readyState === 'loading') {
2378
+ win.document.addEventListener('DOMContentLoaded', () => { full().catch(() => {}); }, { once: true });
2379
+ } else {
2380
+ await full();
2381
+ }
2382
+ }
2383
+
2384
+ const api = { init, onSubmit, onUndo, applySaved };
2385
+ return api;
2386
+ }
2387
+
2388
+ // ---- browser wiring (skipped under Node/tests) ----------------------------
2389
+ if (typeof window !== 'undefined' && !(typeof module !== 'undefined' && module.exports)) {
2390
+ window.Morph = window.Morph || {};
2391
+ window.Morph.init = function (config) {
2392
+ config = config || {};
2393
+ const registry = window.MorphRegistry.createRegistry({ sources: config.sources, actions: config.actions });
2394
+ const store = window.MorphStorage.createStore(config.storage);
2395
+ // window.fetch exists in every real browser; the guard keeps init from
2396
+ // throwing in DOM test environments without fetch.
2397
+ const doFetch = typeof window.fetch === 'function'
2398
+ ? window.fetch.bind(window)
2399
+ : () => Promise.reject(new Error('Morph: fetch unavailable'));
2400
+ const embed = createEmbed({
2401
+ config, win: window, DSL: window.MorphDSL, registry, store,
2402
+ bar: window.Morph.bar, extractContext: window.Morph.extractContext,
2403
+ fetchFn: doFetch,
2404
+ });
2405
+ embed.init();
2406
+ // Hotkey (default alt+m) + a programmatic opener for vendor-built buttons.
2407
+ const trigger = typeof config.trigger === 'string' ? config.trigger.toLowerCase() : 'alt+m';
2408
+ if (trigger !== 'none') {
2409
+ const key = trigger.split('+').pop();
2410
+ window.addEventListener('keydown', (e) => {
2411
+ if (e.altKey && String(e.key).toLowerCase() === key) { e.preventDefault(); window.Morph.bar.toggle(); }
2412
+ });
2413
+ }
2414
+ window.Morph.open = () => window.Morph.bar.show();
2415
+ embed.applySaved().catch(() => {});
2416
+ return embed;
2417
+ };
2418
+ }
2419
+
2420
+ return { createEmbed };
2421
+ });
2422
+
2423
+ export function init(config) { return g.Morph.init(config); }
2424
+ export default { init };