morph-embed 0.2.2 → 0.3.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.
@@ -1,4 +1,4 @@
1
- /*! Morph Embed SDK v0.2.2 — https://usemorph.xyz — MIT */
1
+ /*! Morph Embed SDK v0.3.0 — https://usemorph.xyz — MIT */
2
2
  const g = typeof window !== "undefined" ? window : globalThis;
3
3
 
4
4
  /* ---- shared/dsl.js ---- */
@@ -1365,255 +1365,259 @@ const g = typeof window !== "undefined" ? window : globalThis;
1365
1365
 
1366
1366
 
1367
1367
  /* ---- sdk/src/storage.js ---- */
1368
- /*
1369
- * Morph SDK — recipe store. SCHEMA v2: a recipe is a LIST OF EDITS
1370
- * ({id, prompt, ts, enabled, transform}) per page path, which is what makes
1371
- * per-edit undo, history, and toggling possible in the SDK UI (Nielsen #3,
1372
- * user control and freedom). v1 recipes (one merged blob) migrate on read.
1373
- *
1374
- * Default backing is localStorage (per-browser); a vendor may pass a custom
1375
- * adapter {get,set,remove} (sync or Promise) to store recipes in their own
1376
- * backend so a user's generated features follow them across devices.
1377
- * Storage failures NEVER throw — a broken store must never break the host page.
1378
- * UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
1379
- */
1380
- (function (global, factory) {
1381
- const api = factory();
1382
- if (typeof module !== 'undefined' && module.exports) module.exports = api;
1383
- else global.MorphStorage = api;
1384
- })(typeof self !== 'undefined' ? self : this, function () {
1385
- 'use strict';
1386
-
1387
- const PREFIX = 'morph:sdk:recipe:';
1388
- const SCHEMA_VERSION = 2;
1389
- const MAX_PROMPT_LABEL = 300;
1390
- const MAX_EDITS = 20;
1391
-
1392
- // Default adapter: window.localStorage, JSON values, fully guarded.
1393
- function localAdapter() {
1394
- function ls() {
1395
- try {
1396
- return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
1397
- } catch (_) { return null; } // SecurityError in sandboxed frames
1398
- }
1399
- return {
1400
- get(key) {
1401
- const s = ls();
1402
- if (!s) return undefined;
1403
- const raw = s.getItem(key);
1404
- if (raw == null) return undefined;
1405
- try { return JSON.parse(raw); } catch (_) { return undefined; }
1406
- },
1407
- set(key, value) {
1408
- const s = ls();
1409
- if (!s) return;
1410
- s.setItem(key, JSON.stringify(value));
1411
- },
1412
- remove(key) {
1413
- const s = ls();
1414
- if (!s) return;
1415
- s.removeItem(key);
1416
- },
1417
- };
1418
- }
1419
-
1420
- function genId() {
1421
- return 'e_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
1422
- }
1423
-
1424
- // mergeTransforms concats; recipes must UNION. Drop structurally identical
1425
- // ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
1426
- function dedupeOps(transform) {
1427
- if (!transform || !Array.isArray(transform.ops)) return transform;
1428
- const seen = new Set();
1429
- const ops = transform.ops.filter((op) => {
1430
- let key;
1431
- try { key = JSON.stringify(op); } catch (_) { return true; }
1432
- if (seen.has(key)) return false;
1433
- seen.add(key);
1434
- return true;
1435
- });
1436
- return Object.assign({}, transform, { ops });
1437
- }
1438
-
1439
- // Merge the ENABLED edits, in order, into one transform (deduped). Returns
1440
- // null when nothing is enabled. mergeFn is DSL.mergeTransforms when the
1441
- // caller has the DSL; the concat fallback keeps this module dependency-free.
1442
- function composeEdits(edits, mergeFn) {
1443
- const enabled = (Array.isArray(edits) ? edits : []).filter((e) => e && e.enabled !== false && e.transform);
1444
- if (!enabled.length) return null;
1445
- let merged = enabled[0].transform;
1446
- for (let i = 1; i < enabled.length; i++) {
1447
- merged = (typeof mergeFn === 'function')
1448
- ? mergeFn(merged, enabled[i].transform)
1449
- : Object.assign({}, merged, { ops: (merged.ops || []).concat(enabled[i].transform.ops || []) });
1450
- }
1451
- return dedupeOps(merged);
1452
- }
1453
-
1454
- // Human label for the whole recipe: unique enabled-edit prompts, in order.
1455
- function promptLabel(edits) {
1456
- const seen = new Set();
1457
- const parts = [];
1458
- for (const e of Array.isArray(edits) ? edits : []) {
1459
- if (!e || e.enabled === false) continue;
1460
- const p = String(e.prompt || '').trim();
1461
- const k = p.toLowerCase();
1462
- if (!p || seen.has(k)) continue;
1463
- seen.add(k);
1464
- parts.push(p);
1465
- }
1466
- return parts.join(' + ').slice(0, MAX_PROMPT_LABEL);
1467
- }
1468
-
1469
- // Any stored shape -> v2. v1 recipes (single merged transform) become one
1470
- // edit so nothing a user saved before the redesign is lost.
1471
- function migrate(raw, pathKey) {
1472
- const base = { path: String(pathKey || '/'), enabled: true, schemaVersion: SCHEMA_VERSION, createdAt: (raw && raw.createdAt) || Date.now(), edits: [] };
1473
- if (!raw || typeof raw !== 'object') return base;
1474
- if (Array.isArray(raw.edits)) {
1475
- base.edits = raw.edits.filter((e) => e && typeof e === 'object' && e.transform);
1476
- return base;
1477
- }
1478
- if (raw.transform) {
1479
- base.edits = [{
1480
- id: genId(),
1481
- prompt: String(raw.prompt || 'Earlier changes').slice(0, MAX_PROMPT_LABEL),
1482
- ts: raw.createdAt || Date.now(),
1483
- enabled: raw.enabled !== false,
1484
- transform: raw.transform,
1485
- }];
1486
- }
1487
- return base;
1488
- }
1489
-
1490
- function createStore(adapter) {
1491
- const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
1492
- const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
1493
-
1494
- // Serialize read-modify-write so rapid appends can't lose edits (same
1495
- // pattern as extension/src/recipes.js).
1496
- let writeChain = Promise.resolve();
1497
- function serialize(fn) {
1498
- const run = writeChain.then(fn, fn);
1499
- writeChain = run.then(() => {}, () => {});
1500
- return run;
1501
- }
1502
-
1503
- async function readMigrated(pathKey) {
1504
- let raw;
1505
- try { raw = await backing.get(keyOf(pathKey)); } catch (_) { raw = undefined; }
1506
- if (!raw || typeof raw !== 'object') return null;
1507
- return migrate(raw, pathKey);
1508
- }
1509
-
1510
- async function persist(pathKey, recipe) {
1511
- await backing.set(keyOf(pathKey), recipe);
1512
- return recipe;
1513
- }
1514
-
1515
- // Back-compat read shape: composed transform + merged prompt + the edits.
1516
- async function loadRecipe(pathKey) {
1517
- try {
1518
- const r = await readMigrated(pathKey);
1519
- if (!r) return null;
1520
- return {
1521
- path: r.path,
1522
- enabled: r.edits.some((e) => e.enabled !== false),
1523
- prompt: promptLabel(r.edits),
1524
- transform: composeEdits(r.edits),
1525
- edits: r.edits,
1526
- schemaVersion: SCHEMA_VERSION,
1527
- createdAt: r.createdAt,
1528
- };
1529
- } catch (_) { return null; }
1530
- }
1531
-
1532
- function saveRecipe(pathKey, recipe) {
1533
- return serialize(async () => {
1534
- try {
1535
- const merged = Object.assign(
1536
- { path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
1537
- recipe,
1538
- { schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
1539
- );
1540
- await persist(pathKey, merged);
1541
- return merged;
1542
- } catch (_) { return null; }
1543
- });
1544
- }
1545
-
1546
- // Add one edit (the unit of undo/history). Returns the new edit or null.
1547
- function appendEdit(pathKey, transform, meta) {
1548
- return serialize(async () => {
1549
- try {
1550
- const r = (await readMigrated(pathKey)) || migrate(null, pathKey);
1551
- const edit = {
1552
- id: genId(),
1553
- prompt: String((meta && meta.prompt) || '').slice(0, MAX_PROMPT_LABEL),
1554
- ts: Date.now(),
1555
- enabled: true,
1556
- transform,
1557
- };
1558
- r.edits.push(edit);
1559
- if (r.edits.length > MAX_EDITS) r.edits.splice(0, r.edits.length - MAX_EDITS);
1560
- await persist(pathKey, r);
1561
- return edit;
1562
- } catch (_) { return null; }
1563
- });
1564
- }
1565
-
1566
- async function listEdits(pathKey) {
1567
- try {
1568
- const r = await readMigrated(pathKey);
1569
- return r ? r.edits : [];
1570
- } catch (_) { return []; }
1571
- }
1572
-
1573
- function setEditEnabled(pathKey, id, enabled) {
1574
- return serialize(async () => {
1575
- try {
1576
- const r = await readMigrated(pathKey);
1577
- if (!r) return false;
1578
- const edit = r.edits.find((e) => e.id === id);
1579
- if (!edit) return false;
1580
- edit.enabled = enabled !== false;
1581
- await persist(pathKey, r);
1582
- return true;
1583
- } catch (_) { return false; }
1584
- });
1585
- }
1586
-
1587
- function removeEdit(pathKey, id) {
1588
- return serialize(async () => {
1589
- try {
1590
- const r = await readMigrated(pathKey);
1591
- if (!r) return false;
1592
- const i = r.edits.findIndex((e) => e.id === id);
1593
- if (i < 0) return false;
1594
- r.edits.splice(i, 1);
1595
- await persist(pathKey, r);
1596
- return true;
1597
- } catch (_) { return false; }
1598
- });
1599
- }
1600
-
1601
- // v1 API kept for existing callers: appends as an edit under the hood.
1602
- function appendTransform(pathKey, transform, meta, mergeFn) {
1603
- return appendEdit(pathKey, transform, meta).then((edit) => (edit ? loadRecipe(pathKey) : null), () => null);
1604
- }
1605
-
1606
- function removeRecipe(pathKey) {
1607
- return serialize(async () => {
1608
- try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
1609
- });
1610
- }
1611
-
1612
- return { loadRecipe, saveRecipe, appendTransform, appendEdit, listEdits, setEditEnabled, removeEdit, removeRecipe };
1613
- }
1614
-
1615
- return { createStore, composeEdits, SCHEMA_VERSION };
1616
- });
1368
+ /*
1369
+ * Morph SDK — recipe store. SCHEMA v2: a recipe is a LIST OF EDITS
1370
+ * ({id, prompt, ts, enabled, transform}) per page path, which is what makes
1371
+ * per-edit undo, history, and toggling possible in the SDK UI (Nielsen #3,
1372
+ * user control and freedom). v1 recipes (one merged blob) migrate on read.
1373
+ *
1374
+ * Default backing is localStorage (per-browser); a vendor may pass a custom
1375
+ * adapter {get,set,remove} (sync or Promise) to store recipes in their own
1376
+ * backend so a user's generated features follow them across devices.
1377
+ * Storage failures NEVER throw — a broken store must never break the host page.
1378
+ * UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
1379
+ */
1380
+ (function (global, factory) {
1381
+ const api = factory();
1382
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
1383
+ else global.MorphStorage = api;
1384
+ })(typeof self !== 'undefined' ? self : this, function () {
1385
+ 'use strict';
1386
+
1387
+ const PREFIX = 'morph:sdk:recipe:';
1388
+ const SCHEMA_VERSION = 2;
1389
+ const MAX_PROMPT_LABEL = 300;
1390
+ const MAX_EDITS = 20;
1391
+
1392
+ // Default adapter: window.localStorage, JSON values, fully guarded.
1393
+ function localAdapter() {
1394
+ function ls() {
1395
+ try {
1396
+ return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
1397
+ } catch (_) { return null; } // SecurityError in sandboxed frames
1398
+ }
1399
+ return {
1400
+ get(key) {
1401
+ const s = ls();
1402
+ if (!s) return undefined;
1403
+ const raw = s.getItem(key);
1404
+ if (raw == null) return undefined;
1405
+ try { return JSON.parse(raw); } catch (_) { return undefined; }
1406
+ },
1407
+ set(key, value) {
1408
+ const s = ls();
1409
+ if (!s) return;
1410
+ s.setItem(key, JSON.stringify(value));
1411
+ },
1412
+ remove(key) {
1413
+ const s = ls();
1414
+ if (!s) return;
1415
+ s.removeItem(key);
1416
+ },
1417
+ };
1418
+ }
1419
+
1420
+ function genId() {
1421
+ return 'e_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
1422
+ }
1423
+
1424
+ // mergeTransforms concats; recipes must UNION. Drop structurally identical
1425
+ // ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
1426
+ function dedupeOps(transform) {
1427
+ if (!transform || !Array.isArray(transform.ops)) return transform;
1428
+ const seen = new Set();
1429
+ const ops = transform.ops.filter((op) => {
1430
+ let key;
1431
+ try { key = JSON.stringify(op); } catch (_) { return true; }
1432
+ if (seen.has(key)) return false;
1433
+ seen.add(key);
1434
+ return true;
1435
+ });
1436
+ return Object.assign({}, transform, { ops });
1437
+ }
1438
+
1439
+ // Merge the ENABLED edits, in order, into one transform (deduped). Returns
1440
+ // null when nothing is enabled. mergeFn is DSL.mergeTransforms when the
1441
+ // caller has the DSL; the concat fallback keeps this module dependency-free.
1442
+ function composeEdits(edits, mergeFn) {
1443
+ const enabled = (Array.isArray(edits) ? edits : []).filter((e) => e && e.enabled !== false && e.transform);
1444
+ if (!enabled.length) return null;
1445
+ let merged = enabled[0].transform;
1446
+ for (let i = 1; i < enabled.length; i++) {
1447
+ merged = (typeof mergeFn === 'function')
1448
+ ? mergeFn(merged, enabled[i].transform)
1449
+ : Object.assign({}, merged, { ops: (merged.ops || []).concat(enabled[i].transform.ops || []) });
1450
+ }
1451
+ return dedupeOps(merged);
1452
+ }
1453
+
1454
+ // Human label for the whole recipe: unique enabled-edit prompts, in order.
1455
+ function promptLabel(edits) {
1456
+ const seen = new Set();
1457
+ const parts = [];
1458
+ for (const e of Array.isArray(edits) ? edits : []) {
1459
+ if (!e || e.enabled === false) continue;
1460
+ const p = String(e.prompt || '').trim();
1461
+ const k = p.toLowerCase();
1462
+ if (!p || seen.has(k)) continue;
1463
+ seen.add(k);
1464
+ parts.push(p);
1465
+ }
1466
+ return parts.join(' + ').slice(0, MAX_PROMPT_LABEL);
1467
+ }
1468
+
1469
+ // Any stored shape -> v2. v1 recipes (single merged transform) become one
1470
+ // edit so nothing a user saved before the redesign is lost.
1471
+ function migrate(raw, pathKey) {
1472
+ const base = { path: String(pathKey || '/'), enabled: true, schemaVersion: SCHEMA_VERSION, createdAt: (raw && raw.createdAt) || Date.now(), edits: [] };
1473
+ if (!raw || typeof raw !== 'object') return base;
1474
+ if (Array.isArray(raw.edits)) {
1475
+ base.edits = raw.edits.filter((e) => e && typeof e === 'object' && e.transform);
1476
+ return base;
1477
+ }
1478
+ if (raw.transform) {
1479
+ base.edits = [{
1480
+ id: genId(),
1481
+ prompt: String(raw.prompt || 'Earlier changes').slice(0, MAX_PROMPT_LABEL),
1482
+ ts: raw.createdAt || Date.now(),
1483
+ enabled: raw.enabled !== false,
1484
+ transform: raw.transform,
1485
+ }];
1486
+ }
1487
+ return base;
1488
+ }
1489
+
1490
+ function createStore(adapter) {
1491
+ const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
1492
+ const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
1493
+
1494
+ // Serialize read-modify-write so rapid appends can't lose edits (same
1495
+ // pattern as extension/src/recipes.js).
1496
+ let writeChain = Promise.resolve();
1497
+ function serialize(fn) {
1498
+ const run = writeChain.then(fn, fn);
1499
+ writeChain = run.then(() => {}, () => {});
1500
+ return run;
1501
+ }
1502
+
1503
+ async function readMigrated(pathKey) {
1504
+ let raw;
1505
+ try { raw = await backing.get(keyOf(pathKey)); } catch (_) { raw = undefined; }
1506
+ if (!raw || typeof raw !== 'object') return null;
1507
+ return migrate(raw, pathKey);
1508
+ }
1509
+
1510
+ async function persist(pathKey, recipe) {
1511
+ await backing.set(keyOf(pathKey), recipe);
1512
+ return recipe;
1513
+ }
1514
+
1515
+ // Back-compat read shape: composed transform + merged prompt + the edits.
1516
+ async function loadRecipe(pathKey) {
1517
+ try {
1518
+ const r = await readMigrated(pathKey);
1519
+ if (!r) return null;
1520
+ return {
1521
+ path: r.path,
1522
+ enabled: r.edits.some((e) => e.enabled !== false),
1523
+ prompt: promptLabel(r.edits),
1524
+ transform: composeEdits(r.edits),
1525
+ edits: r.edits,
1526
+ schemaVersion: SCHEMA_VERSION,
1527
+ createdAt: r.createdAt,
1528
+ };
1529
+ } catch (_) { return null; }
1530
+ }
1531
+
1532
+ function saveRecipe(pathKey, recipe) {
1533
+ return serialize(async () => {
1534
+ try {
1535
+ const merged = Object.assign(
1536
+ { path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
1537
+ recipe,
1538
+ { schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
1539
+ );
1540
+ await persist(pathKey, merged);
1541
+ return merged;
1542
+ } catch (_) { return null; }
1543
+ });
1544
+ }
1545
+
1546
+ // Add one edit (the unit of undo/history). Returns the new edit or null.
1547
+ function appendEdit(pathKey, transform, meta) {
1548
+ return serialize(async () => {
1549
+ try {
1550
+ const r = (await readMigrated(pathKey)) || migrate(null, pathKey);
1551
+ const edit = {
1552
+ id: genId(),
1553
+ prompt: String((meta && meta.prompt) || '').slice(0, MAX_PROMPT_LABEL),
1554
+ ts: Date.now(),
1555
+ enabled: true,
1556
+ transform,
1557
+ };
1558
+ const fps = (meta && Array.isArray(meta.fps))
1559
+ ? meta.fps.filter((f) => typeof f === 'string' && /^[a-f0-9]{12}$/.test(f)).slice(0, 8)
1560
+ : [];
1561
+ if (fps.length) edit.fps = fps;
1562
+ r.edits.push(edit);
1563
+ if (r.edits.length > MAX_EDITS) r.edits.splice(0, r.edits.length - MAX_EDITS);
1564
+ await persist(pathKey, r);
1565
+ return edit;
1566
+ } catch (_) { return null; }
1567
+ });
1568
+ }
1569
+
1570
+ async function listEdits(pathKey) {
1571
+ try {
1572
+ const r = await readMigrated(pathKey);
1573
+ return r ? r.edits : [];
1574
+ } catch (_) { return []; }
1575
+ }
1576
+
1577
+ function setEditEnabled(pathKey, id, enabled) {
1578
+ return serialize(async () => {
1579
+ try {
1580
+ const r = await readMigrated(pathKey);
1581
+ if (!r) return false;
1582
+ const edit = r.edits.find((e) => e.id === id);
1583
+ if (!edit) return false;
1584
+ edit.enabled = enabled !== false;
1585
+ await persist(pathKey, r);
1586
+ return true;
1587
+ } catch (_) { return false; }
1588
+ });
1589
+ }
1590
+
1591
+ function removeEdit(pathKey, id) {
1592
+ return serialize(async () => {
1593
+ try {
1594
+ const r = await readMigrated(pathKey);
1595
+ if (!r) return false;
1596
+ const i = r.edits.findIndex((e) => e.id === id);
1597
+ if (i < 0) return false;
1598
+ r.edits.splice(i, 1);
1599
+ await persist(pathKey, r);
1600
+ return true;
1601
+ } catch (_) { return false; }
1602
+ });
1603
+ }
1604
+
1605
+ // v1 API kept for existing callers: appends as an edit under the hood.
1606
+ function appendTransform(pathKey, transform, meta, mergeFn) {
1607
+ return appendEdit(pathKey, transform, meta).then((edit) => (edit ? loadRecipe(pathKey) : null), () => null);
1608
+ }
1609
+
1610
+ function removeRecipe(pathKey) {
1611
+ return serialize(async () => {
1612
+ try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
1613
+ });
1614
+ }
1615
+
1616
+ return { loadRecipe, saveRecipe, appendTransform, appendEdit, listEdits, setEditEnabled, removeEdit, removeRecipe };
1617
+ }
1618
+
1619
+ return { createStore, composeEdits, SCHEMA_VERSION };
1620
+ });
1617
1621
 
1618
1622
 
1619
1623
  /* ---- extension/src/context.js ---- */
@@ -2887,6 +2891,22 @@ const g = typeof window !== "undefined" ? window : globalThis;
2887
2891
  } catch (_) { /* badge is best-effort */ }
2888
2892
  }
2889
2893
 
2894
+ // Signal events: strictly fail-open, fire-and-forget. keepalive:true so a
2895
+ // keep followed by navigation still lands. fps come from the backend's
2896
+ // transform response and ride the stored edit records.
2897
+ function sendSignals(kind, fps) {
2898
+ const list = Array.isArray(fps) ? fps.slice(0, 8) : [];
2899
+ if (!list.length) return;
2900
+ try {
2901
+ fetchFn(backend + '/api/embed/event', {
2902
+ method: 'POST',
2903
+ headers: { 'Content-Type': 'application/json', 'X-Morph-Key': config.key },
2904
+ body: JSON.stringify({ kind, fps: list }),
2905
+ keepalive: true,
2906
+ }).catch(() => {});
2907
+ } catch (_) { /* signals must never break the host page */ }
2908
+ }
2909
+
2890
2910
  function init() {
2891
2911
  if (typeof config.key !== 'string' || !config.key) {
2892
2912
  throw new TypeError('Morph: init requires a publishable key (config.key)');
@@ -3088,8 +3108,10 @@ const g = typeof window !== "undefined" ? window : globalThis;
3088
3108
  bar.setStatus('Couldn’t make that change here — try rephrasing.', '');
3089
3109
  return;
3090
3110
  }
3111
+ const fps = (resp && resp.signal && Array.isArray(resp.signal.fps)) ? resp.signal.fps : [];
3091
3112
  const anchored = DSL.withAnchors ? DSL.withAnchors(transform, win.document) : transform;
3092
- const edit = await store.appendEdit(pathKey(), anchored, { prompt });
3113
+ const edit = await store.appendEdit(pathKey(), anchored, { prompt, fps });
3114
+ if (edit) sendSignals('kept', fps);
3093
3115
  const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
3094
3116
  const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
3095
3117
  // setStatus renders TRUSTED HTML (innerHTML) — escape the model's
@@ -3114,7 +3136,14 @@ const g = typeof window !== "undefined" ? window : globalThis;
3114
3136
 
3115
3137
  // Per-edit dismissal (HAX G8/G9): drop ONE edit, keep the rest.
3116
3138
  async function removeEdit(id) {
3139
+ let fps = [];
3140
+ try {
3141
+ const edits = await store.listEdits(pathKey());
3142
+ const e = edits.find((x) => x.id === id);
3143
+ fps = (e && e.fps) || [];
3144
+ } catch (_) { /* signal lookup is best-effort */ }
3117
3145
  await store.removeEdit(pathKey(), id);
3146
+ sendSignals('undone', fps);
3118
3147
  await reapplyFromStore();
3119
3148
  await refreshCount();
3120
3149
  try { bar.setStatus('Change removed.', ''); } catch (_) { /* noop */ }
@@ -3132,6 +3161,14 @@ const g = typeof window !== "undefined" ? window : globalThis;
3132
3161
  async function resetPage() {
3133
3162
  if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
3134
3163
  activeTransform = null;
3164
+ try {
3165
+ const edits = await store.listEdits(pathKey());
3166
+ const union = [];
3167
+ for (const e of edits) for (const f of (e.fps || [])) {
3168
+ if (union.length < 8 && !union.includes(f)) union.push(f);
3169
+ }
3170
+ sendSignals('undone', union);
3171
+ } catch (_) { /* best-effort */ }
3135
3172
  await store.removeRecipe(pathKey());
3136
3173
  await refreshCount();
3137
3174
  if (ui) await openPanelIfRendered();
@@ -3172,6 +3209,12 @@ const g = typeof window !== "undefined" ? window : globalThis;
3172
3209
  const v = DSL.validateTransform(recipe.transform);
3173
3210
  if (!v.ok) return;
3174
3211
 
3212
+ const savedFps = [];
3213
+ for (const e of (recipe.edits || [])) {
3214
+ if (!e || e.enabled === false) continue;
3215
+ for (const f of (e.fps || [])) if (savedFps.length < 8 && !savedFps.includes(f)) savedFps.push(f);
3216
+ }
3217
+
3175
3218
  // 1) instant CSS so the page doesn't flash unstyled
3176
3219
  const cssOnly = Object.assign({}, v.transform, { ops: v.transform.ops.filter((o) => o.op === 'injectCSS') });
3177
3220
  let cssHandle = null;
@@ -3184,6 +3227,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
3184
3227
  if (cssHandle) { DSL.undoTransform(cssHandle); cssHandle = null; }
3185
3228
  if (!transform.ops.length) return;
3186
3229
  await applyMerged(transform, data);
3230
+ sendSignals('reapplied', savedFps);
3187
3231
  };
3188
3232
  if (win.document.readyState === 'loading') {
3189
3233
  win.document.addEventListener('DOMContentLoaded', () => { full().catch(() => {}); }, { once: true });
@@ -1,4 +1,4 @@
1
- /*! Morph Embed SDK v0.2.2 — https://usemorph.xyz — MIT */
1
+ /*! Morph Embed SDK v0.3.0 — https://usemorph.xyz — MIT */
2
2
 
3
3
  /* ---- shared/dsl.js ---- */
4
4
  /*
@@ -1364,255 +1364,259 @@
1364
1364
 
1365
1365
 
1366
1366
  /* ---- sdk/src/storage.js ---- */
1367
- /*
1368
- * Morph SDK — recipe store. SCHEMA v2: a recipe is a LIST OF EDITS
1369
- * ({id, prompt, ts, enabled, transform}) per page path, which is what makes
1370
- * per-edit undo, history, and toggling possible in the SDK UI (Nielsen #3,
1371
- * user control and freedom). v1 recipes (one merged blob) migrate on read.
1372
- *
1373
- * Default backing is localStorage (per-browser); a vendor may pass a custom
1374
- * adapter {get,set,remove} (sync or Promise) to store recipes in their own
1375
- * backend so a user's generated features follow them across devices.
1376
- * Storage failures NEVER throw — a broken store must never break the host page.
1377
- * UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
1378
- */
1379
- (function (global, factory) {
1380
- const api = factory();
1381
- if (typeof module !== 'undefined' && module.exports) module.exports = api;
1382
- else global.MorphStorage = api;
1383
- })(typeof self !== 'undefined' ? self : this, function () {
1384
- 'use strict';
1385
-
1386
- const PREFIX = 'morph:sdk:recipe:';
1387
- const SCHEMA_VERSION = 2;
1388
- const MAX_PROMPT_LABEL = 300;
1389
- const MAX_EDITS = 20;
1390
-
1391
- // Default adapter: window.localStorage, JSON values, fully guarded.
1392
- function localAdapter() {
1393
- function ls() {
1394
- try {
1395
- return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
1396
- } catch (_) { return null; } // SecurityError in sandboxed frames
1397
- }
1398
- return {
1399
- get(key) {
1400
- const s = ls();
1401
- if (!s) return undefined;
1402
- const raw = s.getItem(key);
1403
- if (raw == null) return undefined;
1404
- try { return JSON.parse(raw); } catch (_) { return undefined; }
1405
- },
1406
- set(key, value) {
1407
- const s = ls();
1408
- if (!s) return;
1409
- s.setItem(key, JSON.stringify(value));
1410
- },
1411
- remove(key) {
1412
- const s = ls();
1413
- if (!s) return;
1414
- s.removeItem(key);
1415
- },
1416
- };
1417
- }
1418
-
1419
- function genId() {
1420
- return 'e_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
1421
- }
1422
-
1423
- // mergeTransforms concats; recipes must UNION. Drop structurally identical
1424
- // ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
1425
- function dedupeOps(transform) {
1426
- if (!transform || !Array.isArray(transform.ops)) return transform;
1427
- const seen = new Set();
1428
- const ops = transform.ops.filter((op) => {
1429
- let key;
1430
- try { key = JSON.stringify(op); } catch (_) { return true; }
1431
- if (seen.has(key)) return false;
1432
- seen.add(key);
1433
- return true;
1434
- });
1435
- return Object.assign({}, transform, { ops });
1436
- }
1437
-
1438
- // Merge the ENABLED edits, in order, into one transform (deduped). Returns
1439
- // null when nothing is enabled. mergeFn is DSL.mergeTransforms when the
1440
- // caller has the DSL; the concat fallback keeps this module dependency-free.
1441
- function composeEdits(edits, mergeFn) {
1442
- const enabled = (Array.isArray(edits) ? edits : []).filter((e) => e && e.enabled !== false && e.transform);
1443
- if (!enabled.length) return null;
1444
- let merged = enabled[0].transform;
1445
- for (let i = 1; i < enabled.length; i++) {
1446
- merged = (typeof mergeFn === 'function')
1447
- ? mergeFn(merged, enabled[i].transform)
1448
- : Object.assign({}, merged, { ops: (merged.ops || []).concat(enabled[i].transform.ops || []) });
1449
- }
1450
- return dedupeOps(merged);
1451
- }
1452
-
1453
- // Human label for the whole recipe: unique enabled-edit prompts, in order.
1454
- function promptLabel(edits) {
1455
- const seen = new Set();
1456
- const parts = [];
1457
- for (const e of Array.isArray(edits) ? edits : []) {
1458
- if (!e || e.enabled === false) continue;
1459
- const p = String(e.prompt || '').trim();
1460
- const k = p.toLowerCase();
1461
- if (!p || seen.has(k)) continue;
1462
- seen.add(k);
1463
- parts.push(p);
1464
- }
1465
- return parts.join(' + ').slice(0, MAX_PROMPT_LABEL);
1466
- }
1467
-
1468
- // Any stored shape -> v2. v1 recipes (single merged transform) become one
1469
- // edit so nothing a user saved before the redesign is lost.
1470
- function migrate(raw, pathKey) {
1471
- const base = { path: String(pathKey || '/'), enabled: true, schemaVersion: SCHEMA_VERSION, createdAt: (raw && raw.createdAt) || Date.now(), edits: [] };
1472
- if (!raw || typeof raw !== 'object') return base;
1473
- if (Array.isArray(raw.edits)) {
1474
- base.edits = raw.edits.filter((e) => e && typeof e === 'object' && e.transform);
1475
- return base;
1476
- }
1477
- if (raw.transform) {
1478
- base.edits = [{
1479
- id: genId(),
1480
- prompt: String(raw.prompt || 'Earlier changes').slice(0, MAX_PROMPT_LABEL),
1481
- ts: raw.createdAt || Date.now(),
1482
- enabled: raw.enabled !== false,
1483
- transform: raw.transform,
1484
- }];
1485
- }
1486
- return base;
1487
- }
1488
-
1489
- function createStore(adapter) {
1490
- const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
1491
- const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
1492
-
1493
- // Serialize read-modify-write so rapid appends can't lose edits (same
1494
- // pattern as extension/src/recipes.js).
1495
- let writeChain = Promise.resolve();
1496
- function serialize(fn) {
1497
- const run = writeChain.then(fn, fn);
1498
- writeChain = run.then(() => {}, () => {});
1499
- return run;
1500
- }
1501
-
1502
- async function readMigrated(pathKey) {
1503
- let raw;
1504
- try { raw = await backing.get(keyOf(pathKey)); } catch (_) { raw = undefined; }
1505
- if (!raw || typeof raw !== 'object') return null;
1506
- return migrate(raw, pathKey);
1507
- }
1508
-
1509
- async function persist(pathKey, recipe) {
1510
- await backing.set(keyOf(pathKey), recipe);
1511
- return recipe;
1512
- }
1513
-
1514
- // Back-compat read shape: composed transform + merged prompt + the edits.
1515
- async function loadRecipe(pathKey) {
1516
- try {
1517
- const r = await readMigrated(pathKey);
1518
- if (!r) return null;
1519
- return {
1520
- path: r.path,
1521
- enabled: r.edits.some((e) => e.enabled !== false),
1522
- prompt: promptLabel(r.edits),
1523
- transform: composeEdits(r.edits),
1524
- edits: r.edits,
1525
- schemaVersion: SCHEMA_VERSION,
1526
- createdAt: r.createdAt,
1527
- };
1528
- } catch (_) { return null; }
1529
- }
1530
-
1531
- function saveRecipe(pathKey, recipe) {
1532
- return serialize(async () => {
1533
- try {
1534
- const merged = Object.assign(
1535
- { path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
1536
- recipe,
1537
- { schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
1538
- );
1539
- await persist(pathKey, merged);
1540
- return merged;
1541
- } catch (_) { return null; }
1542
- });
1543
- }
1544
-
1545
- // Add one edit (the unit of undo/history). Returns the new edit or null.
1546
- function appendEdit(pathKey, transform, meta) {
1547
- return serialize(async () => {
1548
- try {
1549
- const r = (await readMigrated(pathKey)) || migrate(null, pathKey);
1550
- const edit = {
1551
- id: genId(),
1552
- prompt: String((meta && meta.prompt) || '').slice(0, MAX_PROMPT_LABEL),
1553
- ts: Date.now(),
1554
- enabled: true,
1555
- transform,
1556
- };
1557
- r.edits.push(edit);
1558
- if (r.edits.length > MAX_EDITS) r.edits.splice(0, r.edits.length - MAX_EDITS);
1559
- await persist(pathKey, r);
1560
- return edit;
1561
- } catch (_) { return null; }
1562
- });
1563
- }
1564
-
1565
- async function listEdits(pathKey) {
1566
- try {
1567
- const r = await readMigrated(pathKey);
1568
- return r ? r.edits : [];
1569
- } catch (_) { return []; }
1570
- }
1571
-
1572
- function setEditEnabled(pathKey, id, enabled) {
1573
- return serialize(async () => {
1574
- try {
1575
- const r = await readMigrated(pathKey);
1576
- if (!r) return false;
1577
- const edit = r.edits.find((e) => e.id === id);
1578
- if (!edit) return false;
1579
- edit.enabled = enabled !== false;
1580
- await persist(pathKey, r);
1581
- return true;
1582
- } catch (_) { return false; }
1583
- });
1584
- }
1585
-
1586
- function removeEdit(pathKey, id) {
1587
- return serialize(async () => {
1588
- try {
1589
- const r = await readMigrated(pathKey);
1590
- if (!r) return false;
1591
- const i = r.edits.findIndex((e) => e.id === id);
1592
- if (i < 0) return false;
1593
- r.edits.splice(i, 1);
1594
- await persist(pathKey, r);
1595
- return true;
1596
- } catch (_) { return false; }
1597
- });
1598
- }
1599
-
1600
- // v1 API kept for existing callers: appends as an edit under the hood.
1601
- function appendTransform(pathKey, transform, meta, mergeFn) {
1602
- return appendEdit(pathKey, transform, meta).then((edit) => (edit ? loadRecipe(pathKey) : null), () => null);
1603
- }
1604
-
1605
- function removeRecipe(pathKey) {
1606
- return serialize(async () => {
1607
- try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
1608
- });
1609
- }
1610
-
1611
- return { loadRecipe, saveRecipe, appendTransform, appendEdit, listEdits, setEditEnabled, removeEdit, removeRecipe };
1612
- }
1613
-
1614
- return { createStore, composeEdits, SCHEMA_VERSION };
1615
- });
1367
+ /*
1368
+ * Morph SDK — recipe store. SCHEMA v2: a recipe is a LIST OF EDITS
1369
+ * ({id, prompt, ts, enabled, transform}) per page path, which is what makes
1370
+ * per-edit undo, history, and toggling possible in the SDK UI (Nielsen #3,
1371
+ * user control and freedom). v1 recipes (one merged blob) migrate on read.
1372
+ *
1373
+ * Default backing is localStorage (per-browser); a vendor may pass a custom
1374
+ * adapter {get,set,remove} (sync or Promise) to store recipes in their own
1375
+ * backend so a user's generated features follow them across devices.
1376
+ * Storage failures NEVER throw — a broken store must never break the host page.
1377
+ * UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
1378
+ */
1379
+ (function (global, factory) {
1380
+ const api = factory();
1381
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
1382
+ else global.MorphStorage = api;
1383
+ })(typeof self !== 'undefined' ? self : this, function () {
1384
+ 'use strict';
1385
+
1386
+ const PREFIX = 'morph:sdk:recipe:';
1387
+ const SCHEMA_VERSION = 2;
1388
+ const MAX_PROMPT_LABEL = 300;
1389
+ const MAX_EDITS = 20;
1390
+
1391
+ // Default adapter: window.localStorage, JSON values, fully guarded.
1392
+ function localAdapter() {
1393
+ function ls() {
1394
+ try {
1395
+ return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
1396
+ } catch (_) { return null; } // SecurityError in sandboxed frames
1397
+ }
1398
+ return {
1399
+ get(key) {
1400
+ const s = ls();
1401
+ if (!s) return undefined;
1402
+ const raw = s.getItem(key);
1403
+ if (raw == null) return undefined;
1404
+ try { return JSON.parse(raw); } catch (_) { return undefined; }
1405
+ },
1406
+ set(key, value) {
1407
+ const s = ls();
1408
+ if (!s) return;
1409
+ s.setItem(key, JSON.stringify(value));
1410
+ },
1411
+ remove(key) {
1412
+ const s = ls();
1413
+ if (!s) return;
1414
+ s.removeItem(key);
1415
+ },
1416
+ };
1417
+ }
1418
+
1419
+ function genId() {
1420
+ return 'e_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
1421
+ }
1422
+
1423
+ // mergeTransforms concats; recipes must UNION. Drop structurally identical
1424
+ // ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
1425
+ function dedupeOps(transform) {
1426
+ if (!transform || !Array.isArray(transform.ops)) return transform;
1427
+ const seen = new Set();
1428
+ const ops = transform.ops.filter((op) => {
1429
+ let key;
1430
+ try { key = JSON.stringify(op); } catch (_) { return true; }
1431
+ if (seen.has(key)) return false;
1432
+ seen.add(key);
1433
+ return true;
1434
+ });
1435
+ return Object.assign({}, transform, { ops });
1436
+ }
1437
+
1438
+ // Merge the ENABLED edits, in order, into one transform (deduped). Returns
1439
+ // null when nothing is enabled. mergeFn is DSL.mergeTransforms when the
1440
+ // caller has the DSL; the concat fallback keeps this module dependency-free.
1441
+ function composeEdits(edits, mergeFn) {
1442
+ const enabled = (Array.isArray(edits) ? edits : []).filter((e) => e && e.enabled !== false && e.transform);
1443
+ if (!enabled.length) return null;
1444
+ let merged = enabled[0].transform;
1445
+ for (let i = 1; i < enabled.length; i++) {
1446
+ merged = (typeof mergeFn === 'function')
1447
+ ? mergeFn(merged, enabled[i].transform)
1448
+ : Object.assign({}, merged, { ops: (merged.ops || []).concat(enabled[i].transform.ops || []) });
1449
+ }
1450
+ return dedupeOps(merged);
1451
+ }
1452
+
1453
+ // Human label for the whole recipe: unique enabled-edit prompts, in order.
1454
+ function promptLabel(edits) {
1455
+ const seen = new Set();
1456
+ const parts = [];
1457
+ for (const e of Array.isArray(edits) ? edits : []) {
1458
+ if (!e || e.enabled === false) continue;
1459
+ const p = String(e.prompt || '').trim();
1460
+ const k = p.toLowerCase();
1461
+ if (!p || seen.has(k)) continue;
1462
+ seen.add(k);
1463
+ parts.push(p);
1464
+ }
1465
+ return parts.join(' + ').slice(0, MAX_PROMPT_LABEL);
1466
+ }
1467
+
1468
+ // Any stored shape -> v2. v1 recipes (single merged transform) become one
1469
+ // edit so nothing a user saved before the redesign is lost.
1470
+ function migrate(raw, pathKey) {
1471
+ const base = { path: String(pathKey || '/'), enabled: true, schemaVersion: SCHEMA_VERSION, createdAt: (raw && raw.createdAt) || Date.now(), edits: [] };
1472
+ if (!raw || typeof raw !== 'object') return base;
1473
+ if (Array.isArray(raw.edits)) {
1474
+ base.edits = raw.edits.filter((e) => e && typeof e === 'object' && e.transform);
1475
+ return base;
1476
+ }
1477
+ if (raw.transform) {
1478
+ base.edits = [{
1479
+ id: genId(),
1480
+ prompt: String(raw.prompt || 'Earlier changes').slice(0, MAX_PROMPT_LABEL),
1481
+ ts: raw.createdAt || Date.now(),
1482
+ enabled: raw.enabled !== false,
1483
+ transform: raw.transform,
1484
+ }];
1485
+ }
1486
+ return base;
1487
+ }
1488
+
1489
+ function createStore(adapter) {
1490
+ const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
1491
+ const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
1492
+
1493
+ // Serialize read-modify-write so rapid appends can't lose edits (same
1494
+ // pattern as extension/src/recipes.js).
1495
+ let writeChain = Promise.resolve();
1496
+ function serialize(fn) {
1497
+ const run = writeChain.then(fn, fn);
1498
+ writeChain = run.then(() => {}, () => {});
1499
+ return run;
1500
+ }
1501
+
1502
+ async function readMigrated(pathKey) {
1503
+ let raw;
1504
+ try { raw = await backing.get(keyOf(pathKey)); } catch (_) { raw = undefined; }
1505
+ if (!raw || typeof raw !== 'object') return null;
1506
+ return migrate(raw, pathKey);
1507
+ }
1508
+
1509
+ async function persist(pathKey, recipe) {
1510
+ await backing.set(keyOf(pathKey), recipe);
1511
+ return recipe;
1512
+ }
1513
+
1514
+ // Back-compat read shape: composed transform + merged prompt + the edits.
1515
+ async function loadRecipe(pathKey) {
1516
+ try {
1517
+ const r = await readMigrated(pathKey);
1518
+ if (!r) return null;
1519
+ return {
1520
+ path: r.path,
1521
+ enabled: r.edits.some((e) => e.enabled !== false),
1522
+ prompt: promptLabel(r.edits),
1523
+ transform: composeEdits(r.edits),
1524
+ edits: r.edits,
1525
+ schemaVersion: SCHEMA_VERSION,
1526
+ createdAt: r.createdAt,
1527
+ };
1528
+ } catch (_) { return null; }
1529
+ }
1530
+
1531
+ function saveRecipe(pathKey, recipe) {
1532
+ return serialize(async () => {
1533
+ try {
1534
+ const merged = Object.assign(
1535
+ { path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
1536
+ recipe,
1537
+ { schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
1538
+ );
1539
+ await persist(pathKey, merged);
1540
+ return merged;
1541
+ } catch (_) { return null; }
1542
+ });
1543
+ }
1544
+
1545
+ // Add one edit (the unit of undo/history). Returns the new edit or null.
1546
+ function appendEdit(pathKey, transform, meta) {
1547
+ return serialize(async () => {
1548
+ try {
1549
+ const r = (await readMigrated(pathKey)) || migrate(null, pathKey);
1550
+ const edit = {
1551
+ id: genId(),
1552
+ prompt: String((meta && meta.prompt) || '').slice(0, MAX_PROMPT_LABEL),
1553
+ ts: Date.now(),
1554
+ enabled: true,
1555
+ transform,
1556
+ };
1557
+ const fps = (meta && Array.isArray(meta.fps))
1558
+ ? meta.fps.filter((f) => typeof f === 'string' && /^[a-f0-9]{12}$/.test(f)).slice(0, 8)
1559
+ : [];
1560
+ if (fps.length) edit.fps = fps;
1561
+ r.edits.push(edit);
1562
+ if (r.edits.length > MAX_EDITS) r.edits.splice(0, r.edits.length - MAX_EDITS);
1563
+ await persist(pathKey, r);
1564
+ return edit;
1565
+ } catch (_) { return null; }
1566
+ });
1567
+ }
1568
+
1569
+ async function listEdits(pathKey) {
1570
+ try {
1571
+ const r = await readMigrated(pathKey);
1572
+ return r ? r.edits : [];
1573
+ } catch (_) { return []; }
1574
+ }
1575
+
1576
+ function setEditEnabled(pathKey, id, enabled) {
1577
+ return serialize(async () => {
1578
+ try {
1579
+ const r = await readMigrated(pathKey);
1580
+ if (!r) return false;
1581
+ const edit = r.edits.find((e) => e.id === id);
1582
+ if (!edit) return false;
1583
+ edit.enabled = enabled !== false;
1584
+ await persist(pathKey, r);
1585
+ return true;
1586
+ } catch (_) { return false; }
1587
+ });
1588
+ }
1589
+
1590
+ function removeEdit(pathKey, id) {
1591
+ return serialize(async () => {
1592
+ try {
1593
+ const r = await readMigrated(pathKey);
1594
+ if (!r) return false;
1595
+ const i = r.edits.findIndex((e) => e.id === id);
1596
+ if (i < 0) return false;
1597
+ r.edits.splice(i, 1);
1598
+ await persist(pathKey, r);
1599
+ return true;
1600
+ } catch (_) { return false; }
1601
+ });
1602
+ }
1603
+
1604
+ // v1 API kept for existing callers: appends as an edit under the hood.
1605
+ function appendTransform(pathKey, transform, meta, mergeFn) {
1606
+ return appendEdit(pathKey, transform, meta).then((edit) => (edit ? loadRecipe(pathKey) : null), () => null);
1607
+ }
1608
+
1609
+ function removeRecipe(pathKey) {
1610
+ return serialize(async () => {
1611
+ try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
1612
+ });
1613
+ }
1614
+
1615
+ return { loadRecipe, saveRecipe, appendTransform, appendEdit, listEdits, setEditEnabled, removeEdit, removeRecipe };
1616
+ }
1617
+
1618
+ return { createStore, composeEdits, SCHEMA_VERSION };
1619
+ });
1616
1620
 
1617
1621
 
1618
1622
  /* ---- extension/src/context.js ---- */
@@ -2886,6 +2890,22 @@
2886
2890
  } catch (_) { /* badge is best-effort */ }
2887
2891
  }
2888
2892
 
2893
+ // Signal events: strictly fail-open, fire-and-forget. keepalive:true so a
2894
+ // keep followed by navigation still lands. fps come from the backend's
2895
+ // transform response and ride the stored edit records.
2896
+ function sendSignals(kind, fps) {
2897
+ const list = Array.isArray(fps) ? fps.slice(0, 8) : [];
2898
+ if (!list.length) return;
2899
+ try {
2900
+ fetchFn(backend + '/api/embed/event', {
2901
+ method: 'POST',
2902
+ headers: { 'Content-Type': 'application/json', 'X-Morph-Key': config.key },
2903
+ body: JSON.stringify({ kind, fps: list }),
2904
+ keepalive: true,
2905
+ }).catch(() => {});
2906
+ } catch (_) { /* signals must never break the host page */ }
2907
+ }
2908
+
2889
2909
  function init() {
2890
2910
  if (typeof config.key !== 'string' || !config.key) {
2891
2911
  throw new TypeError('Morph: init requires a publishable key (config.key)');
@@ -3087,8 +3107,10 @@
3087
3107
  bar.setStatus('Couldn’t make that change here — try rephrasing.', '');
3088
3108
  return;
3089
3109
  }
3110
+ const fps = (resp && resp.signal && Array.isArray(resp.signal.fps)) ? resp.signal.fps : [];
3090
3111
  const anchored = DSL.withAnchors ? DSL.withAnchors(transform, win.document) : transform;
3091
- const edit = await store.appendEdit(pathKey(), anchored, { prompt });
3112
+ const edit = await store.appendEdit(pathKey(), anchored, { prompt, fps });
3113
+ if (edit) sendSignals('kept', fps);
3092
3114
  const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
3093
3115
  const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
3094
3116
  // setStatus renders TRUSTED HTML (innerHTML) — escape the model's
@@ -3113,7 +3135,14 @@
3113
3135
 
3114
3136
  // Per-edit dismissal (HAX G8/G9): drop ONE edit, keep the rest.
3115
3137
  async function removeEdit(id) {
3138
+ let fps = [];
3139
+ try {
3140
+ const edits = await store.listEdits(pathKey());
3141
+ const e = edits.find((x) => x.id === id);
3142
+ fps = (e && e.fps) || [];
3143
+ } catch (_) { /* signal lookup is best-effort */ }
3116
3144
  await store.removeEdit(pathKey(), id);
3145
+ sendSignals('undone', fps);
3117
3146
  await reapplyFromStore();
3118
3147
  await refreshCount();
3119
3148
  try { bar.setStatus('Change removed.', ''); } catch (_) { /* noop */ }
@@ -3131,6 +3160,14 @@
3131
3160
  async function resetPage() {
3132
3161
  if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
3133
3162
  activeTransform = null;
3163
+ try {
3164
+ const edits = await store.listEdits(pathKey());
3165
+ const union = [];
3166
+ for (const e of edits) for (const f of (e.fps || [])) {
3167
+ if (union.length < 8 && !union.includes(f)) union.push(f);
3168
+ }
3169
+ sendSignals('undone', union);
3170
+ } catch (_) { /* best-effort */ }
3134
3171
  await store.removeRecipe(pathKey());
3135
3172
  await refreshCount();
3136
3173
  if (ui) await openPanelIfRendered();
@@ -3171,6 +3208,12 @@
3171
3208
  const v = DSL.validateTransform(recipe.transform);
3172
3209
  if (!v.ok) return;
3173
3210
 
3211
+ const savedFps = [];
3212
+ for (const e of (recipe.edits || [])) {
3213
+ if (!e || e.enabled === false) continue;
3214
+ for (const f of (e.fps || [])) if (savedFps.length < 8 && !savedFps.includes(f)) savedFps.push(f);
3215
+ }
3216
+
3174
3217
  // 1) instant CSS so the page doesn't flash unstyled
3175
3218
  const cssOnly = Object.assign({}, v.transform, { ops: v.transform.ops.filter((o) => o.op === 'injectCSS') });
3176
3219
  let cssHandle = null;
@@ -3183,6 +3226,7 @@
3183
3226
  if (cssHandle) { DSL.undoTransform(cssHandle); cssHandle = null; }
3184
3227
  if (!transform.ops.length) return;
3185
3228
  await applyMerged(transform, data);
3229
+ sendSignals('reapplied', savedFps);
3186
3230
  };
3187
3231
  if (win.document.readyState === 'loading') {
3188
3232
  win.document.addEventListener('DOMContentLoaded', () => { full().catch(() => {}); }, { once: true });
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "morph-embed",
3
- "version": "0.2.2",
4
- "description": "Embed a prompt bar in your product so users can generate the features you haven't built — real-data widgets, layout reshapes, and confirm-gated actions. Declarative-DSL security model; your data never leaves the page.",
5
- "license": "MIT",
6
- "main": "dist/morph-embed.esm.mjs",
7
- "exports": {
8
- ".": "./dist/morph-embed.esm.mjs",
9
- "./iife": "./dist/morph-embed.js"
10
- },
11
- "files": [
12
- "dist",
13
- "README.md",
14
- "LICENSE"
15
- ],
16
- "repository": {
17
- "type": "git",
18
- "url": "git+https://github.com/mandarwagh9/morph.git",
19
- "directory": "sdk"
20
- },
21
- "homepage": "https://usemorph.xyz",
22
- "bugs": {
23
- "url": "https://github.com/mandarwagh9/morph/issues"
24
- },
25
- "author": "Mandar Wagh (https://usemorph.xyz)",
26
- "keywords": [
27
- "generative-ui",
28
- "copilot",
29
- "embed",
30
- "widgets",
31
- "prompt",
32
- "llm",
33
- "dashboard",
34
- "no-code",
35
- "morph"
36
- ]
37
- }
1
+ {
2
+ "name": "morph-embed",
3
+ "version": "0.3.0",
4
+ "description": "Embed a prompt bar in your product so users can generate the features you haven't built — real-data widgets, layout reshapes, and confirm-gated actions. Declarative-DSL security model; your data never leaves the page.",
5
+ "license": "MIT",
6
+ "main": "dist/morph-embed.esm.mjs",
7
+ "exports": {
8
+ ".": "./dist/morph-embed.esm.mjs",
9
+ "./iife": "./dist/morph-embed.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/mandarwagh9/morph-embed.git",
19
+ "directory": "sdk"
20
+ },
21
+ "homepage": "https://usemorph.xyz",
22
+ "bugs": {
23
+ "url": "https://github.com/mandarwagh9/morph/issues"
24
+ },
25
+ "author": "Mandar Wagh (https://usemorph.xyz)",
26
+ "keywords": [
27
+ "generative-ui",
28
+ "copilot",
29
+ "embed",
30
+ "widgets",
31
+ "prompt",
32
+ "llm",
33
+ "dashboard",
34
+ "no-code",
35
+ "morph"
36
+ ]
37
+ }