morph-embed 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -6
- package/dist/morph-embed.esm.mjs +947 -163
- package/dist/morph-embed.js +947 -163
- package/package.json +1 -1
package/dist/morph-embed.esm.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Morph Embed SDK v0.1
|
|
1
|
+
/*! Morph Embed SDK v0.2.1 — https://usemorph.xyz — MIT */
|
|
2
2
|
const g = typeof window !== "undefined" ? window : globalThis;
|
|
3
3
|
|
|
4
4
|
/* ---- shared/dsl.js ---- */
|
|
@@ -1321,151 +1321,255 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
1321
1321
|
|
|
1322
1322
|
|
|
1323
1323
|
/* ---- sdk/src/storage.js ---- */
|
|
1324
|
-
/*
|
|
1325
|
-
* Morph SDK — recipe store.
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
'
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
function
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
});
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
});
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1324
|
+
/*
|
|
1325
|
+
* Morph SDK — recipe store. SCHEMA v2: a recipe is a LIST OF EDITS
|
|
1326
|
+
* ({id, prompt, ts, enabled, transform}) per page path, which is what makes
|
|
1327
|
+
* per-edit undo, history, and toggling possible in the SDK UI (Nielsen #3,
|
|
1328
|
+
* user control and freedom). v1 recipes (one merged blob) migrate on read.
|
|
1329
|
+
*
|
|
1330
|
+
* Default backing is localStorage (per-browser); a vendor may pass a custom
|
|
1331
|
+
* adapter {get,set,remove} (sync or Promise) to store recipes in their own
|
|
1332
|
+
* backend so a user's generated features follow them across devices.
|
|
1333
|
+
* Storage failures NEVER throw — a broken store must never break the host page.
|
|
1334
|
+
* UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
|
|
1335
|
+
*/
|
|
1336
|
+
(function (global, factory) {
|
|
1337
|
+
const api = factory();
|
|
1338
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
1339
|
+
else global.MorphStorage = api;
|
|
1340
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
1341
|
+
'use strict';
|
|
1342
|
+
|
|
1343
|
+
const PREFIX = 'morph:sdk:recipe:';
|
|
1344
|
+
const SCHEMA_VERSION = 2;
|
|
1345
|
+
const MAX_PROMPT_LABEL = 300;
|
|
1346
|
+
const MAX_EDITS = 20;
|
|
1347
|
+
|
|
1348
|
+
// Default adapter: window.localStorage, JSON values, fully guarded.
|
|
1349
|
+
function localAdapter() {
|
|
1350
|
+
function ls() {
|
|
1351
|
+
try {
|
|
1352
|
+
return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
|
|
1353
|
+
} catch (_) { return null; } // SecurityError in sandboxed frames
|
|
1354
|
+
}
|
|
1355
|
+
return {
|
|
1356
|
+
get(key) {
|
|
1357
|
+
const s = ls();
|
|
1358
|
+
if (!s) return undefined;
|
|
1359
|
+
const raw = s.getItem(key);
|
|
1360
|
+
if (raw == null) return undefined;
|
|
1361
|
+
try { return JSON.parse(raw); } catch (_) { return undefined; }
|
|
1362
|
+
},
|
|
1363
|
+
set(key, value) {
|
|
1364
|
+
const s = ls();
|
|
1365
|
+
if (!s) return;
|
|
1366
|
+
s.setItem(key, JSON.stringify(value));
|
|
1367
|
+
},
|
|
1368
|
+
remove(key) {
|
|
1369
|
+
const s = ls();
|
|
1370
|
+
if (!s) return;
|
|
1371
|
+
s.removeItem(key);
|
|
1372
|
+
},
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function genId() {
|
|
1377
|
+
return 'e_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// mergeTransforms concats; recipes must UNION. Drop structurally identical
|
|
1381
|
+
// ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
|
|
1382
|
+
function dedupeOps(transform) {
|
|
1383
|
+
if (!transform || !Array.isArray(transform.ops)) return transform;
|
|
1384
|
+
const seen = new Set();
|
|
1385
|
+
const ops = transform.ops.filter((op) => {
|
|
1386
|
+
let key;
|
|
1387
|
+
try { key = JSON.stringify(op); } catch (_) { return true; }
|
|
1388
|
+
if (seen.has(key)) return false;
|
|
1389
|
+
seen.add(key);
|
|
1390
|
+
return true;
|
|
1391
|
+
});
|
|
1392
|
+
return Object.assign({}, transform, { ops });
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Merge the ENABLED edits, in order, into one transform (deduped). Returns
|
|
1396
|
+
// null when nothing is enabled. mergeFn is DSL.mergeTransforms when the
|
|
1397
|
+
// caller has the DSL; the concat fallback keeps this module dependency-free.
|
|
1398
|
+
function composeEdits(edits, mergeFn) {
|
|
1399
|
+
const enabled = (Array.isArray(edits) ? edits : []).filter((e) => e && e.enabled !== false && e.transform);
|
|
1400
|
+
if (!enabled.length) return null;
|
|
1401
|
+
let merged = enabled[0].transform;
|
|
1402
|
+
for (let i = 1; i < enabled.length; i++) {
|
|
1403
|
+
merged = (typeof mergeFn === 'function')
|
|
1404
|
+
? mergeFn(merged, enabled[i].transform)
|
|
1405
|
+
: Object.assign({}, merged, { ops: (merged.ops || []).concat(enabled[i].transform.ops || []) });
|
|
1406
|
+
}
|
|
1407
|
+
return dedupeOps(merged);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Human label for the whole recipe: unique enabled-edit prompts, in order.
|
|
1411
|
+
function promptLabel(edits) {
|
|
1412
|
+
const seen = new Set();
|
|
1413
|
+
const parts = [];
|
|
1414
|
+
for (const e of Array.isArray(edits) ? edits : []) {
|
|
1415
|
+
if (!e || e.enabled === false) continue;
|
|
1416
|
+
const p = String(e.prompt || '').trim();
|
|
1417
|
+
const k = p.toLowerCase();
|
|
1418
|
+
if (!p || seen.has(k)) continue;
|
|
1419
|
+
seen.add(k);
|
|
1420
|
+
parts.push(p);
|
|
1421
|
+
}
|
|
1422
|
+
return parts.join(' + ').slice(0, MAX_PROMPT_LABEL);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Any stored shape -> v2. v1 recipes (single merged transform) become one
|
|
1426
|
+
// edit so nothing a user saved before the redesign is lost.
|
|
1427
|
+
function migrate(raw, pathKey) {
|
|
1428
|
+
const base = { path: String(pathKey || '/'), enabled: true, schemaVersion: SCHEMA_VERSION, createdAt: (raw && raw.createdAt) || Date.now(), edits: [] };
|
|
1429
|
+
if (!raw || typeof raw !== 'object') return base;
|
|
1430
|
+
if (Array.isArray(raw.edits)) {
|
|
1431
|
+
base.edits = raw.edits.filter((e) => e && typeof e === 'object' && e.transform);
|
|
1432
|
+
return base;
|
|
1433
|
+
}
|
|
1434
|
+
if (raw.transform) {
|
|
1435
|
+
base.edits = [{
|
|
1436
|
+
id: genId(),
|
|
1437
|
+
prompt: String(raw.prompt || 'Earlier changes').slice(0, MAX_PROMPT_LABEL),
|
|
1438
|
+
ts: raw.createdAt || Date.now(),
|
|
1439
|
+
enabled: raw.enabled !== false,
|
|
1440
|
+
transform: raw.transform,
|
|
1441
|
+
}];
|
|
1442
|
+
}
|
|
1443
|
+
return base;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function createStore(adapter) {
|
|
1447
|
+
const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
|
|
1448
|
+
const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
|
|
1449
|
+
|
|
1450
|
+
// Serialize read-modify-write so rapid appends can't lose edits (same
|
|
1451
|
+
// pattern as extension/src/recipes.js).
|
|
1452
|
+
let writeChain = Promise.resolve();
|
|
1453
|
+
function serialize(fn) {
|
|
1454
|
+
const run = writeChain.then(fn, fn);
|
|
1455
|
+
writeChain = run.then(() => {}, () => {});
|
|
1456
|
+
return run;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
async function readMigrated(pathKey) {
|
|
1460
|
+
let raw;
|
|
1461
|
+
try { raw = await backing.get(keyOf(pathKey)); } catch (_) { raw = undefined; }
|
|
1462
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
1463
|
+
return migrate(raw, pathKey);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
async function persist(pathKey, recipe) {
|
|
1467
|
+
await backing.set(keyOf(pathKey), recipe);
|
|
1468
|
+
return recipe;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Back-compat read shape: composed transform + merged prompt + the edits.
|
|
1472
|
+
async function loadRecipe(pathKey) {
|
|
1473
|
+
try {
|
|
1474
|
+
const r = await readMigrated(pathKey);
|
|
1475
|
+
if (!r) return null;
|
|
1476
|
+
return {
|
|
1477
|
+
path: r.path,
|
|
1478
|
+
enabled: r.edits.some((e) => e.enabled !== false),
|
|
1479
|
+
prompt: promptLabel(r.edits),
|
|
1480
|
+
transform: composeEdits(r.edits),
|
|
1481
|
+
edits: r.edits,
|
|
1482
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1483
|
+
createdAt: r.createdAt,
|
|
1484
|
+
};
|
|
1485
|
+
} catch (_) { return null; }
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function saveRecipe(pathKey, recipe) {
|
|
1489
|
+
return serialize(async () => {
|
|
1490
|
+
try {
|
|
1491
|
+
const merged = Object.assign(
|
|
1492
|
+
{ path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
|
|
1493
|
+
recipe,
|
|
1494
|
+
{ schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
|
|
1495
|
+
);
|
|
1496
|
+
await persist(pathKey, merged);
|
|
1497
|
+
return merged;
|
|
1498
|
+
} catch (_) { return null; }
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Add one edit (the unit of undo/history). Returns the new edit or null.
|
|
1503
|
+
function appendEdit(pathKey, transform, meta) {
|
|
1504
|
+
return serialize(async () => {
|
|
1505
|
+
try {
|
|
1506
|
+
const r = (await readMigrated(pathKey)) || migrate(null, pathKey);
|
|
1507
|
+
const edit = {
|
|
1508
|
+
id: genId(),
|
|
1509
|
+
prompt: String((meta && meta.prompt) || '').slice(0, MAX_PROMPT_LABEL),
|
|
1510
|
+
ts: Date.now(),
|
|
1511
|
+
enabled: true,
|
|
1512
|
+
transform,
|
|
1513
|
+
};
|
|
1514
|
+
r.edits.push(edit);
|
|
1515
|
+
if (r.edits.length > MAX_EDITS) r.edits.splice(0, r.edits.length - MAX_EDITS);
|
|
1516
|
+
await persist(pathKey, r);
|
|
1517
|
+
return edit;
|
|
1518
|
+
} catch (_) { return null; }
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
async function listEdits(pathKey) {
|
|
1523
|
+
try {
|
|
1524
|
+
const r = await readMigrated(pathKey);
|
|
1525
|
+
return r ? r.edits : [];
|
|
1526
|
+
} catch (_) { return []; }
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function setEditEnabled(pathKey, id, enabled) {
|
|
1530
|
+
return serialize(async () => {
|
|
1531
|
+
try {
|
|
1532
|
+
const r = await readMigrated(pathKey);
|
|
1533
|
+
if (!r) return false;
|
|
1534
|
+
const edit = r.edits.find((e) => e.id === id);
|
|
1535
|
+
if (!edit) return false;
|
|
1536
|
+
edit.enabled = enabled !== false;
|
|
1537
|
+
await persist(pathKey, r);
|
|
1538
|
+
return true;
|
|
1539
|
+
} catch (_) { return false; }
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function removeEdit(pathKey, id) {
|
|
1544
|
+
return serialize(async () => {
|
|
1545
|
+
try {
|
|
1546
|
+
const r = await readMigrated(pathKey);
|
|
1547
|
+
if (!r) return false;
|
|
1548
|
+
const i = r.edits.findIndex((e) => e.id === id);
|
|
1549
|
+
if (i < 0) return false;
|
|
1550
|
+
r.edits.splice(i, 1);
|
|
1551
|
+
await persist(pathKey, r);
|
|
1552
|
+
return true;
|
|
1553
|
+
} catch (_) { return false; }
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// v1 API kept for existing callers: appends as an edit under the hood.
|
|
1558
|
+
function appendTransform(pathKey, transform, meta, mergeFn) {
|
|
1559
|
+
return appendEdit(pathKey, transform, meta).then((edit) => (edit ? loadRecipe(pathKey) : null), () => null);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function removeRecipe(pathKey) {
|
|
1563
|
+
return serialize(async () => {
|
|
1564
|
+
try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
return { loadRecipe, saveRecipe, appendTransform, appendEdit, listEdits, setEditEnabled, removeEdit, removeRecipe };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
return { createStore, composeEdits, SCHEMA_VERSION };
|
|
1572
|
+
});
|
|
1469
1573
|
|
|
1470
1574
|
|
|
1471
1575
|
/* ---- extension/src/context.js ---- */
|
|
@@ -2166,7 +2270,9 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2166
2270
|
function aimActive(on) { if (els && els.aim) els.aim.classList.toggle('on', !!on); }
|
|
2167
2271
|
|
|
2168
2272
|
window.Morph.bar = {
|
|
2169
|
-
|
|
2273
|
+
// No onPick handler -> no picker in this embedding: hide the ⌖ button so
|
|
2274
|
+
// users never see a dead control (the extension always passes onPick).
|
|
2275
|
+
mount(h) { handlers = h || {}; if (!host) build(); if (els && els.aim) els.aim.style.display = handlers.onPick ? '' : 'none'; },
|
|
2170
2276
|
show, hide, toggle,
|
|
2171
2277
|
setStatus, clearStatus, setBusy, showActions,
|
|
2172
2278
|
isVisible: () => visible,
|
|
@@ -2186,6 +2292,447 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2186
2292
|
})();
|
|
2187
2293
|
|
|
2188
2294
|
|
|
2295
|
+
/* ---- extension/src/picker.js ---- */
|
|
2296
|
+
/*
|
|
2297
|
+
* Element picker — lets the user click a specific element on the page so the next
|
|
2298
|
+
* reshape is scoped to it (and its descendants) instead of the model guessing
|
|
2299
|
+
* selectors across the whole page.
|
|
2300
|
+
*
|
|
2301
|
+
* start(onPick, onCancel) : enter pick mode (hover highlights, click selects, Esc cancels)
|
|
2302
|
+
* stop() : leave pick mode
|
|
2303
|
+
* selectorFor(el, doc) : PURE — a stable-ish unique CSS selector for el
|
|
2304
|
+
* describe(el) : PURE — a short human label for el
|
|
2305
|
+
*
|
|
2306
|
+
* Attaches to window.Morph.picker. Pure helpers are CJS-exported for tests.
|
|
2307
|
+
*/
|
|
2308
|
+
(function () {
|
|
2309
|
+
'use strict';
|
|
2310
|
+
window.Morph = window.Morph || {};
|
|
2311
|
+
|
|
2312
|
+
// Prefer the spec-correct native CSS.escape (all modern browsers have it). Fall
|
|
2313
|
+
// back (e.g. jsdom / very old engines) to a regex that backslash-escapes any
|
|
2314
|
+
// non-identifier char AND escapes a leading digit per the CSS spec — otherwise
|
|
2315
|
+
// an id like "2col" yields the INVALID selector "#2col", which throws at
|
|
2316
|
+
// querySelectorAll and forces a weaker, possibly non-unique path.
|
|
2317
|
+
function cssEscape(s) {
|
|
2318
|
+
s = String(s);
|
|
2319
|
+
if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') {
|
|
2320
|
+
return window.CSS.escape(s);
|
|
2321
|
+
}
|
|
2322
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c).replace(/^([0-9])/, (m, d) => '\\3' + d + ' ');
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// A reasonably-unique selector: prefer a unique #id (anchoring the path there if
|
|
2326
|
+
// an ancestor has one), else a short `>`-path with :nth-of-type disambiguation.
|
|
2327
|
+
function selectorFor(el, doc) {
|
|
2328
|
+
doc = doc || (el && el.ownerDocument) || (typeof document !== 'undefined' ? document : null);
|
|
2329
|
+
if (!el || el.nodeType !== 1 || !doc) return '';
|
|
2330
|
+
if (el.id) {
|
|
2331
|
+
const sel = '#' + cssEscape(el.id);
|
|
2332
|
+
try { if (doc.querySelectorAll(sel).length === 1) return sel; } catch (_) { /* keep going */ }
|
|
2333
|
+
}
|
|
2334
|
+
const parts = [];
|
|
2335
|
+
let node = el;
|
|
2336
|
+
let depth = 0;
|
|
2337
|
+
while (node && node.nodeType === 1 && node !== doc.body && node !== doc.documentElement && depth < 6) {
|
|
2338
|
+
if (node.id) {
|
|
2339
|
+
const idSel = '#' + cssEscape(node.id);
|
|
2340
|
+
let unique = false;
|
|
2341
|
+
try { unique = doc.querySelectorAll(idSel).length === 1; } catch (_) { unique = false; }
|
|
2342
|
+
if (unique) { parts.unshift(idSel); return parts.join(' > '); }
|
|
2343
|
+
}
|
|
2344
|
+
let part = node.tagName.toLowerCase();
|
|
2345
|
+
const parent = node.parentNode;
|
|
2346
|
+
if (parent && parent.children) {
|
|
2347
|
+
const sameTag = Array.prototype.filter.call(parent.children, (c) => c.tagName === node.tagName);
|
|
2348
|
+
if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
|
|
2349
|
+
}
|
|
2350
|
+
parts.unshift(part);
|
|
2351
|
+
node = node.parentNode;
|
|
2352
|
+
depth++;
|
|
2353
|
+
}
|
|
2354
|
+
return parts.join(' > ');
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function describe(el) {
|
|
2358
|
+
if (!el || el.nodeType !== 1) return 'element';
|
|
2359
|
+
let s = el.tagName.toLowerCase();
|
|
2360
|
+
if (el.id) s += '#' + el.id;
|
|
2361
|
+
else if (el.classList && el.classList.length) s += '.' + el.classList[0];
|
|
2362
|
+
const text = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
|
|
2363
|
+
if (text) s += ' “' + text + '”';
|
|
2364
|
+
return s;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// --- interactive pick mode (needs a live DOM) -----------------------------
|
|
2368
|
+
let active = false, overlay = null, current = null, pickCb = null, cancelCb = null;
|
|
2369
|
+
|
|
2370
|
+
function ensureOverlay() {
|
|
2371
|
+
if (overlay) return overlay;
|
|
2372
|
+
overlay = document.createElement('div');
|
|
2373
|
+
overlay.setAttribute('data-morph-pick-overlay', '1');
|
|
2374
|
+
const s = overlay.style;
|
|
2375
|
+
s.position = 'fixed'; s.zIndex = '2147483646'; s.pointerEvents = 'none';
|
|
2376
|
+
s.border = '2px solid #ffffff'; s.boxShadow = '0 0 0 1px #000, 0 0 0 4px rgba(255,255,255,0.25)';
|
|
2377
|
+
s.background = 'rgba(255,255,255,0.06)'; s.borderRadius = '2px'; s.display = 'none';
|
|
2378
|
+
s.transition = 'left .04s linear, top .04s linear, width .04s linear, height .04s linear';
|
|
2379
|
+
return overlay;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// Never let the picker target Morph's own UI (the bar host or this overlay).
|
|
2383
|
+
function isOurs(el) {
|
|
2384
|
+
try { return !!(el && el.closest && el.closest('#morph-host-root, [data-morph-pick-overlay]')); }
|
|
2385
|
+
catch (_) { return false; }
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
function moveOverlay(el) {
|
|
2389
|
+
const r = el.getBoundingClientRect();
|
|
2390
|
+
const s = overlay.style;
|
|
2391
|
+
s.display = 'block';
|
|
2392
|
+
s.left = r.left + 'px'; s.top = r.top + 'px'; s.width = r.width + 'px'; s.height = r.height + 'px';
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function onMove(e) {
|
|
2396
|
+
const el = e.target;
|
|
2397
|
+
if (!el || isOurs(el)) { overlay.style.display = 'none'; current = null; return; }
|
|
2398
|
+
current = el;
|
|
2399
|
+
try { moveOverlay(el); } catch (_) { /* never break the page */ }
|
|
2400
|
+
}
|
|
2401
|
+
function onClick(e) {
|
|
2402
|
+
if (isOurs(e.target)) return; // let our own UI receive clicks
|
|
2403
|
+
e.preventDefault(); e.stopPropagation();
|
|
2404
|
+
finish(current || e.target);
|
|
2405
|
+
}
|
|
2406
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } }
|
|
2407
|
+
|
|
2408
|
+
function teardown() {
|
|
2409
|
+
active = false; current = null;
|
|
2410
|
+
try {
|
|
2411
|
+
document.removeEventListener('mousemove', onMove, true);
|
|
2412
|
+
document.removeEventListener('click', onClick, true);
|
|
2413
|
+
document.removeEventListener('keydown', onKey, true);
|
|
2414
|
+
if (document.documentElement) document.documentElement.style.cursor = '';
|
|
2415
|
+
if (overlay) overlay.style.display = 'none';
|
|
2416
|
+
} catch (_) { /* noop */ }
|
|
2417
|
+
}
|
|
2418
|
+
function finish(el) { const cb = pickCb; teardown(); if (cb) { try { cb(el); } catch (_) {} } }
|
|
2419
|
+
function cancel() { const cb = cancelCb; teardown(); if (cb) { try { cb(); } catch (_) {} } }
|
|
2420
|
+
|
|
2421
|
+
function start(onPick, onCancel) {
|
|
2422
|
+
if (active) return;
|
|
2423
|
+
active = true; pickCb = onPick; cancelCb = onCancel;
|
|
2424
|
+
try {
|
|
2425
|
+
ensureOverlay();
|
|
2426
|
+
(document.body || document.documentElement).appendChild(overlay);
|
|
2427
|
+
document.addEventListener('mousemove', onMove, true);
|
|
2428
|
+
document.addEventListener('click', onClick, true);
|
|
2429
|
+
document.addEventListener('keydown', onKey, true);
|
|
2430
|
+
if (document.documentElement) document.documentElement.style.cursor = 'crosshair';
|
|
2431
|
+
} catch (_) { teardown(); }
|
|
2432
|
+
}
|
|
2433
|
+
function stop() { teardown(); }
|
|
2434
|
+
function isActive() { return active; }
|
|
2435
|
+
|
|
2436
|
+
window.Morph.picker = { start, stop, isActive, selectorFor, describe };
|
|
2437
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = { selectorFor, describe };
|
|
2438
|
+
})();
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
/* ---- sdk/src/ui.js ---- */
|
|
2442
|
+
/*
|
|
2443
|
+
* Morph SDK — UI layer: launcher + edits panel + undo toast, isolated in a
|
|
2444
|
+
* closed-over Shadow DOM so host-page CSS can't leak in either direction.
|
|
2445
|
+
*
|
|
2446
|
+
* Every behavior here is deliberate, from the sprint's research pass:
|
|
2447
|
+
* - The launcher is small, corner-pinned, and NEVER auto-opens (Baymard's
|
|
2448
|
+
* live-chat testing: sticky elements that act on their own are "highly
|
|
2449
|
+
* disruptive"; everything is user-initiated).
|
|
2450
|
+
* - First-run pulse fires exactly once (persisted flag) and not at all under
|
|
2451
|
+
* prefers-reduced-motion (NN/g animation-usability).
|
|
2452
|
+
* - Edits apply immediately and the toast offers Undo afterwards — the Gmail
|
|
2453
|
+
* pattern; confirmation-first dialogs habituate and fail (NN/g).
|
|
2454
|
+
* - The panel gives per-edit visibility, toggle, and delete — HAX G17
|
|
2455
|
+
* "provide global controls", Nielsen #3 "user control and freedom".
|
|
2456
|
+
* - The launcher menu never has more than two options (Hick's law), and the
|
|
2457
|
+
* whole surface is at most two disclosure levels deep (NN/g progressive
|
|
2458
|
+
* disclosure).
|
|
2459
|
+
* UMD: Node module in tests, browser global `MorphUI` in the SDK bundle.
|
|
2460
|
+
*/
|
|
2461
|
+
(function (global, factory) {
|
|
2462
|
+
const api = factory();
|
|
2463
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
2464
|
+
else global.MorphUI = api;
|
|
2465
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
2466
|
+
'use strict';
|
|
2467
|
+
|
|
2468
|
+
const HOST_ID = 'morph-sdk-ui';
|
|
2469
|
+
const NUDGE_FLAG = 'morph:sdk:nudged';
|
|
2470
|
+
|
|
2471
|
+
const CSS = `
|
|
2472
|
+
:host { all: initial; }
|
|
2473
|
+
* { box-sizing: border-box; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
2474
|
+
.morph-ui-root { position: fixed; z-index: 2147483600; bottom: 20px; right: 20px;
|
|
2475
|
+
display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
|
|
2476
|
+
.morph-ui-root.pos-bottom-left { right: auto; left: 20px; align-items: flex-start; }
|
|
2477
|
+
|
|
2478
|
+
.morph-fab { width: 46px; height: 46px; border-radius: 50%; border: none; cursor: pointer;
|
|
2479
|
+
background: var(--morph-accent, #111114); color: #fff; font-size: 20px; line-height: 1;
|
|
2480
|
+
box-shadow: 0 4px 18px rgba(0,0,0,.28); display: flex; align-items: center; justify-content: center;
|
|
2481
|
+
position: relative; transition: transform .12s ease; }
|
|
2482
|
+
.morph-fab:hover { transform: scale(1.06); }
|
|
2483
|
+
.morph-fab:focus-visible { outline: 2px solid #4c8dff; outline-offset: 2px; }
|
|
2484
|
+
.morph-fab.pulse { animation: morphPulse 1.6s ease-out 2; }
|
|
2485
|
+
@keyframes morphPulse {
|
|
2486
|
+
0% { box-shadow: 0 0 0 0 rgba(76,141,255,.55), 0 4px 18px rgba(0,0,0,.28); }
|
|
2487
|
+
100% { box-shadow: 0 0 0 16px rgba(76,141,255,0), 0 4px 18px rgba(0,0,0,.28); }
|
|
2488
|
+
}
|
|
2489
|
+
@media (prefers-reduced-motion: reduce) { .morph-fab.pulse { animation: none; } }
|
|
2490
|
+
|
|
2491
|
+
.morph-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px;
|
|
2492
|
+
border-radius: 9px; background: #e8554d; color: #fff; font-size: 11px; font-weight: 700;
|
|
2493
|
+
display: none; align-items: center; justify-content: center; padding: 0 5px; }
|
|
2494
|
+
.morph-badge.show { display: flex; }
|
|
2495
|
+
|
|
2496
|
+
.morph-menu { display: none; flex-direction: column; background: #fff; border-radius: 10px;
|
|
2497
|
+
box-shadow: 0 8px 30px rgba(0,0,0,.22); overflow: hidden; min-width: 200px; }
|
|
2498
|
+
.morph-menu.open { display: flex; }
|
|
2499
|
+
.morph-menu-item { border: none; background: none; text-align: left; padding: 12px 16px;
|
|
2500
|
+
font-size: 13.5px; color: #17171c; cursor: pointer; display: flex; gap: 9px; align-items: center; }
|
|
2501
|
+
.morph-menu-item:hover { background: #f2f3f7; }
|
|
2502
|
+
|
|
2503
|
+
.morph-panel { display: none; width: 320px; max-height: 420px; overflow: auto; background: #fff;
|
|
2504
|
+
border-radius: 12px; box-shadow: 0 10px 38px rgba(0,0,0,.26); padding: 14px 14px 10px; }
|
|
2505
|
+
.morph-panel.open { display: block; }
|
|
2506
|
+
.morph-panel h3 { margin: 0 0 2px; font-size: 13px; color: #17171c; }
|
|
2507
|
+
.morph-panel .morph-panel-sub { margin: 0 0 10px; font-size: 11px; color: #7a7a85; }
|
|
2508
|
+
.morph-edit-row { display: flex; align-items: center; gap: 8px; padding: 8px 4px;
|
|
2509
|
+
border-top: 1px solid #eef0f4; }
|
|
2510
|
+
.morph-edit-label { flex: 1; min-width: 0; }
|
|
2511
|
+
.morph-edit-label .p { font-size: 12.5px; color: #17171c; overflow: hidden; text-overflow: ellipsis;
|
|
2512
|
+
white-space: nowrap; }
|
|
2513
|
+
.morph-edit-label .t { font-size: 10.5px; color: #9a9aa5; }
|
|
2514
|
+
.morph-edit-row.off .morph-edit-label .p { color: #9a9aa5; text-decoration: line-through; }
|
|
2515
|
+
.morph-edit-toggle { width: 32px; height: 18px; border-radius: 9px; border: none; cursor: pointer;
|
|
2516
|
+
background: #cfd3dc; position: relative; flex: none; }
|
|
2517
|
+
.morph-edit-toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px;
|
|
2518
|
+
border-radius: 50%; background: #fff; transition: left .12s ease; }
|
|
2519
|
+
.morph-edit-toggle[aria-checked="true"] { background: var(--morph-accent, #2f9e63); }
|
|
2520
|
+
.morph-edit-toggle[aria-checked="true"]::after { left: 16px; }
|
|
2521
|
+
.morph-edit-delete { border: none; background: none; color: #b9bcc6; cursor: pointer; font-size: 15px;
|
|
2522
|
+
padding: 4px; flex: none; }
|
|
2523
|
+
.morph-edit-delete:hover { color: #e8554d; }
|
|
2524
|
+
.morph-panel-empty { padding: 14px 4px 10px; font-size: 12.5px; color: #6f6f7a; line-height: 1.5; }
|
|
2525
|
+
.morph-panel-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 10px;
|
|
2526
|
+
padding-top: 8px; border-top: 1px solid #eef0f4; }
|
|
2527
|
+
.morph-panel-reset { border: none; background: none; color: #e8554d; font-size: 12px; cursor: pointer; padding: 4px; }
|
|
2528
|
+
.morph-panel-close { border: none; background: none; color: #7a7a85; font-size: 12px; cursor: pointer; padding: 4px; }
|
|
2529
|
+
|
|
2530
|
+
.morph-toast { position: fixed; left: 50%; transform: translateX(-50%) translateY(8px); bottom: 24px;
|
|
2531
|
+
display: none; align-items: center; gap: 12px; background: #17171c; color: #fff; border-radius: 10px;
|
|
2532
|
+
padding: 11px 16px; font-size: 13px; box-shadow: 0 8px 30px rgba(0,0,0,.32); opacity: 0;
|
|
2533
|
+
transition: opacity .15s ease, transform .15s ease; max-width: min(92vw, 460px); }
|
|
2534
|
+
.morph-toast.show { display: flex; opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
2535
|
+
.morph-toast-action { border: none; background: none; color: #7db4ff; font-weight: 700; font-size: 13px;
|
|
2536
|
+
cursor: pointer; padding: 2px 4px; flex: none; }
|
|
2537
|
+
`;
|
|
2538
|
+
|
|
2539
|
+
function createUI(opts) {
|
|
2540
|
+
opts = opts || {};
|
|
2541
|
+
const win = opts.win;
|
|
2542
|
+
const position = opts.position === undefined ? 'bottom-right' : opts.position;
|
|
2543
|
+
const getFlag = opts.getFlag || defaultGetFlag(win);
|
|
2544
|
+
const setFlag = opts.setFlag || defaultSetFlag(win);
|
|
2545
|
+
const reducedMotion = opts.reducedMotion !== undefined ? !!opts.reducedMotion : prefersReducedMotion(win);
|
|
2546
|
+
|
|
2547
|
+
// Vendor opted out of the launcher entirely -> inert API (they drive
|
|
2548
|
+
// everything through Morph.open() and the bar).
|
|
2549
|
+
if (position === false) {
|
|
2550
|
+
const noop = () => {};
|
|
2551
|
+
return { mount: noop, setCount: noop, showToast: noop, renderPanel: noop, showPanel: noop, hidePanel: noop };
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
let root = null, fab = null, badge = null, menu = null, panel = null, toast = null;
|
|
2555
|
+
let toastTimer = null;
|
|
2556
|
+
let handlers = { onPrimary: null, onPanel: null };
|
|
2557
|
+
let panelHandlers = null;
|
|
2558
|
+
let count = 0;
|
|
2559
|
+
|
|
2560
|
+
function el(tag, cls, text) {
|
|
2561
|
+
const n = win.document.createElement(tag);
|
|
2562
|
+
if (cls) n.className = cls;
|
|
2563
|
+
if (text !== undefined) n.textContent = text;
|
|
2564
|
+
return n;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
function mount(h) {
|
|
2568
|
+
handlers = h || handlers;
|
|
2569
|
+
if (win.document.getElementById(HOST_ID)) return;
|
|
2570
|
+
const host = el('div');
|
|
2571
|
+
host.id = HOST_ID;
|
|
2572
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
2573
|
+
const style = el('style'); style.textContent = CSS;
|
|
2574
|
+
shadow.appendChild(style);
|
|
2575
|
+
|
|
2576
|
+
root = el('div', 'morph-ui-root' + (position === 'bottom-left' ? ' pos-bottom-left' : ''));
|
|
2577
|
+
if (opts.accent) root.style.setProperty('--morph-accent', String(opts.accent));
|
|
2578
|
+
|
|
2579
|
+
panel = el('div', 'morph-panel');
|
|
2580
|
+
menu = el('div', 'morph-menu');
|
|
2581
|
+
fab = el('button', 'morph-fab', '✦');
|
|
2582
|
+
fab.setAttribute('aria-label', 'Customize this page with Morph (Alt+M)');
|
|
2583
|
+
fab.setAttribute('title', 'Customize this page (Alt+M)');
|
|
2584
|
+
badge = el('span', 'morph-badge');
|
|
2585
|
+
badge.setAttribute('aria-hidden', 'true');
|
|
2586
|
+
fab.appendChild(badge);
|
|
2587
|
+
|
|
2588
|
+
toast = el('div', 'morph-toast');
|
|
2589
|
+
toast.setAttribute('role', 'status');
|
|
2590
|
+
toast.setAttribute('aria-live', 'polite');
|
|
2591
|
+
|
|
2592
|
+
fab.addEventListener('click', () => {
|
|
2593
|
+
hidePanel();
|
|
2594
|
+
if (count > 0) { menu.classList.toggle('open'); renderMenu(); return; }
|
|
2595
|
+
menu.classList.remove('open');
|
|
2596
|
+
if (handlers.onPrimary) handlers.onPrimary();
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2599
|
+
// One escape hatch for everything floating (Nielsen #3).
|
|
2600
|
+
win.document.addEventListener('keydown', (e) => {
|
|
2601
|
+
if (e.key === 'Escape') { hidePanel(); menu.classList.remove('open'); }
|
|
2602
|
+
});
|
|
2603
|
+
|
|
2604
|
+
root.appendChild(panel);
|
|
2605
|
+
root.appendChild(menu);
|
|
2606
|
+
root.appendChild(fab);
|
|
2607
|
+
shadow.appendChild(root);
|
|
2608
|
+
shadow.appendChild(toast);
|
|
2609
|
+
win.document.documentElement.appendChild(host);
|
|
2610
|
+
|
|
2611
|
+
// First-run nudge: one pulse, ever, and none under reduced motion.
|
|
2612
|
+
let seen;
|
|
2613
|
+
try { seen = getFlag(NUDGE_FLAG); } catch (_) { seen = true; }
|
|
2614
|
+
if (!seen && !reducedMotion) {
|
|
2615
|
+
fab.classList.add('pulse');
|
|
2616
|
+
try { setFlag(NUDGE_FLAG, '1'); } catch (_) { /* private mode */ }
|
|
2617
|
+
} else if (!seen) {
|
|
2618
|
+
try { setFlag(NUDGE_FLAG, '1'); } catch (_) { /* private mode */ }
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function renderMenu() {
|
|
2623
|
+
if (!menu) return;
|
|
2624
|
+
menu.textContent = '';
|
|
2625
|
+
const make = el('button', 'morph-menu-item', '✦ Make a change');
|
|
2626
|
+
make.addEventListener('click', () => { menu.classList.remove('open'); if (handlers.onPrimary) handlers.onPrimary(); });
|
|
2627
|
+
const edits = el('button', 'morph-menu-item', '☰ Your edits (' + count + ')');
|
|
2628
|
+
edits.addEventListener('click', () => { menu.classList.remove('open'); if (handlers.onPanel) handlers.onPanel(); });
|
|
2629
|
+
menu.appendChild(make);
|
|
2630
|
+
menu.appendChild(edits);
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
function setCount(n) {
|
|
2634
|
+
count = Number(n) || 0;
|
|
2635
|
+
if (!badge) return;
|
|
2636
|
+
badge.textContent = String(count);
|
|
2637
|
+
badge.classList.toggle('show', count > 0);
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
// Act-then-undo toast (the Gmail pattern). One at a time; new replaces old.
|
|
2641
|
+
function showToast(t) {
|
|
2642
|
+
if (!toast) return;
|
|
2643
|
+
t = t || {};
|
|
2644
|
+
if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; }
|
|
2645
|
+
toast.textContent = '';
|
|
2646
|
+
const txt = el('span', 'morph-toast-text', String(t.text || ''));
|
|
2647
|
+
toast.appendChild(txt);
|
|
2648
|
+
if (t.actionLabel) {
|
|
2649
|
+
const act = el('button', 'morph-toast-action', String(t.actionLabel));
|
|
2650
|
+
act.addEventListener('click', () => { hideToast(); if (t.onAction) t.onAction(); });
|
|
2651
|
+
toast.appendChild(act);
|
|
2652
|
+
}
|
|
2653
|
+
toast.classList.add('show');
|
|
2654
|
+
const ttl = typeof t.ttlMs === 'number' ? t.ttlMs : 10000;
|
|
2655
|
+
toastTimer = setTimeout(hideToast, ttl);
|
|
2656
|
+
if (toastTimer && toastTimer.unref) toastTimer.unref();
|
|
2657
|
+
}
|
|
2658
|
+
function hideToast() {
|
|
2659
|
+
if (toast) toast.classList.remove('show');
|
|
2660
|
+
if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; }
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function fmtWhen(ts) {
|
|
2664
|
+
try {
|
|
2665
|
+
const d = new Date(ts);
|
|
2666
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ', ' +
|
|
2667
|
+
d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
2668
|
+
} catch (_) { return ''; }
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// "Your edits on this page": visibility + per-edit control (HAX G17).
|
|
2672
|
+
function renderPanel(edits, h) {
|
|
2673
|
+
panelHandlers = h || panelHandlers || {};
|
|
2674
|
+
if (!panel) return;
|
|
2675
|
+
panel.textContent = '';
|
|
2676
|
+
panel.appendChild(el('h3', '', 'Your edits on this page'));
|
|
2677
|
+
panel.appendChild(el('p', 'morph-panel-sub', 'These re-apply every time you visit. Toggle one off to preview the page without it.'));
|
|
2678
|
+
|
|
2679
|
+
if (!edits || !edits.length) {
|
|
2680
|
+
panel.appendChild(el('div', 'morph-panel-empty',
|
|
2681
|
+
'Nothing yet. Click the ✦ button and describe a change — “make this easier to read”, “add a box totaling this table” — and it will live here.'));
|
|
2682
|
+
} else {
|
|
2683
|
+
edits.forEach((e) => {
|
|
2684
|
+
const row = el('div', 'morph-edit-row' + (e.enabled === false ? ' off' : ''));
|
|
2685
|
+
const label = el('div', 'morph-edit-label');
|
|
2686
|
+
label.appendChild(el('div', 'p', e.prompt || 'Untitled edit'));
|
|
2687
|
+
label.appendChild(el('div', 't', fmtWhen(e.ts)));
|
|
2688
|
+
const toggle = el('button', 'morph-edit-toggle');
|
|
2689
|
+
toggle.setAttribute('role', 'switch');
|
|
2690
|
+
toggle.setAttribute('aria-checked', e.enabled === false ? 'false' : 'true');
|
|
2691
|
+
toggle.setAttribute('aria-label', 'Toggle edit: ' + (e.prompt || 'untitled'));
|
|
2692
|
+
toggle.addEventListener('click', () => {
|
|
2693
|
+
if (panelHandlers.onToggle) panelHandlers.onToggle(e.id, e.enabled === false);
|
|
2694
|
+
});
|
|
2695
|
+
const del = el('button', 'morph-edit-delete', '✕');
|
|
2696
|
+
del.setAttribute('aria-label', 'Remove edit: ' + (e.prompt || 'untitled'));
|
|
2697
|
+
del.addEventListener('click', () => { if (panelHandlers.onDelete) panelHandlers.onDelete(e.id); });
|
|
2698
|
+
row.appendChild(label);
|
|
2699
|
+
row.appendChild(toggle);
|
|
2700
|
+
row.appendChild(del);
|
|
2701
|
+
panel.appendChild(row);
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
const foot = el('div', 'morph-panel-foot');
|
|
2706
|
+
const reset = el('button', 'morph-panel-reset', 'Reset this page');
|
|
2707
|
+
reset.addEventListener('click', () => { if (panelHandlers.onReset) panelHandlers.onReset(); });
|
|
2708
|
+
const close = el('button', 'morph-panel-close', 'Close');
|
|
2709
|
+
close.addEventListener('click', () => { hidePanel(); if (panelHandlers.onClose) panelHandlers.onClose(); });
|
|
2710
|
+
foot.appendChild(reset);
|
|
2711
|
+
foot.appendChild(close);
|
|
2712
|
+
panel.appendChild(foot);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function showPanel() { if (panel) { menu && menu.classList.remove('open'); panel.classList.add('open'); } }
|
|
2716
|
+
function hidePanel() { if (panel) panel.classList.remove('open'); }
|
|
2717
|
+
|
|
2718
|
+
return { mount, setCount, showToast, renderPanel, showPanel, hidePanel };
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
function defaultGetFlag(win) {
|
|
2722
|
+
return (k) => { try { return win.localStorage.getItem(k); } catch (_) { return '1'; } };
|
|
2723
|
+
}
|
|
2724
|
+
function defaultSetFlag(win) {
|
|
2725
|
+
return (k, v) => { try { win.localStorage.setItem(k, v); } catch (_) { /* noop */ } };
|
|
2726
|
+
}
|
|
2727
|
+
function prefersReducedMotion(win) {
|
|
2728
|
+
try { return !!(win.matchMedia && win.matchMedia('(prefers-reduced-motion: reduce)').matches); }
|
|
2729
|
+
catch (_) { return false; }
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
return { createUI };
|
|
2733
|
+
});
|
|
2734
|
+
|
|
2735
|
+
|
|
2189
2736
|
/* ---- sdk/src/embed.js ---- */
|
|
2190
2737
|
/*
|
|
2191
2738
|
* Morph SDK — embed entry. Wires the engine (MorphDSL), the vendor registry,
|
|
@@ -2210,6 +2757,56 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2210
2757
|
|
|
2211
2758
|
const DEFAULT_BACKEND = 'https://morph-api-234584917638.us-central1.run.app';
|
|
2212
2759
|
|
|
2760
|
+
// Hotkey matching on the PHYSICAL key (e.code), not the produced character:
|
|
2761
|
+
// on macOS Option acts as a character modifier (Option+M types 'µ'), so an
|
|
2762
|
+
// e.key comparison never fires there. Modifier state must match EXACTLY —
|
|
2763
|
+
// that also keeps AltGr (reported as ctrl+alt on Windows) from triggering
|
|
2764
|
+
// an alt-only shortcut while someone types accented characters.
|
|
2765
|
+
function matchesTrigger(e, trigger) {
|
|
2766
|
+
if (!e || typeof trigger !== 'string' || !trigger) return false;
|
|
2767
|
+
// IME guard: isComposing can be false on the first/last composition
|
|
2768
|
+
// keystroke while keyCode is still the spec-mandated 229 — check both.
|
|
2769
|
+
if (e.isComposing || e.keyCode === 229) return false;
|
|
2770
|
+
const parts = trigger.toLowerCase().split('+').map((s) => s.trim()).filter(Boolean);
|
|
2771
|
+
if (!parts.length) return false;
|
|
2772
|
+
const key = parts[parts.length - 1];
|
|
2773
|
+
const mods = new Set(parts.slice(0, -1));
|
|
2774
|
+
const want = {
|
|
2775
|
+
alt: mods.has('alt') || mods.has('option'),
|
|
2776
|
+
ctrl: mods.has('ctrl') || mods.has('control'),
|
|
2777
|
+
shift: mods.has('shift'),
|
|
2778
|
+
meta: mods.has('meta') || mods.has('cmd') || mods.has('command'),
|
|
2779
|
+
};
|
|
2780
|
+
if (!!e.altKey !== want.alt || !!e.ctrlKey !== want.ctrl ||
|
|
2781
|
+
!!e.shiftKey !== want.shift || !!e.metaKey !== want.meta) return false;
|
|
2782
|
+
if (typeof e.code === 'string' && e.code) {
|
|
2783
|
+
if (/^[a-z]$/.test(key)) return e.code === 'Key' + key.toUpperCase();
|
|
2784
|
+
if (/^[0-9]$/.test(key)) return e.code === 'Digit' + key;
|
|
2785
|
+
}
|
|
2786
|
+
return typeof e.key === 'string' && e.key.toLowerCase() === key;
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
// Don't hijack keys while the user is typing in the host app (Mousetrap /
|
|
2790
|
+
// github-hotkey convention). composedPath()[0] sees the REAL target even
|
|
2791
|
+
// when the event comes from inside a shadow root (retargeting would
|
|
2792
|
+
// otherwise hide the input element from us).
|
|
2793
|
+
function isTypingTarget(e) {
|
|
2794
|
+
let t = e && e.target;
|
|
2795
|
+
try { if (e && typeof e.composedPath === 'function') t = e.composedPath()[0] || t; } catch (_) { /* keep target */ }
|
|
2796
|
+
if (!t) return false;
|
|
2797
|
+
const tag = String(t.tagName || '').toUpperCase();
|
|
2798
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable === true;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
// Wait narration (NN/g response-time bands: acknowledge <100ms, actively
|
|
2802
|
+
// manage the 2-10s AI band; a moving/staged indicator triples patience).
|
|
2803
|
+
const STAGE_MESSAGES = [
|
|
2804
|
+
'Reading this page…',
|
|
2805
|
+
'Designing your change…',
|
|
2806
|
+
'Still working — bigger changes can take a few seconds…',
|
|
2807
|
+
];
|
|
2808
|
+
const STAGE_DELAYS = [1500, 6000];
|
|
2809
|
+
|
|
2213
2810
|
function createEmbed(deps) {
|
|
2214
2811
|
const config = (deps && deps.config) || {};
|
|
2215
2812
|
const win = deps.win;
|
|
@@ -2217,8 +2814,18 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2217
2814
|
const registry = deps.registry;
|
|
2218
2815
|
const store = deps.store;
|
|
2219
2816
|
const bar = deps.bar;
|
|
2817
|
+
const ui = deps.ui || null;
|
|
2818
|
+
const picker = deps.picker || null;
|
|
2220
2819
|
const extractContext = deps.extractContext;
|
|
2221
2820
|
const fetchFn = deps.fetchFn;
|
|
2821
|
+
const stageDelays = deps.stageDelays || STAGE_DELAYS;
|
|
2822
|
+
// Merge enabled edits into one transform (storage exports the canonical
|
|
2823
|
+
// deduping composer; the fallback keeps createEmbed usable without it).
|
|
2824
|
+
const compose = deps.compose || function (edits) {
|
|
2825
|
+
const on = (edits || []).filter((e) => e && e.enabled !== false && e.transform);
|
|
2826
|
+
if (!on.length) return null;
|
|
2827
|
+
return on.slice(1).reduce((m, e) => DSL.mergeTransforms(m, e.transform), on[0].transform);
|
|
2828
|
+
};
|
|
2222
2829
|
|
|
2223
2830
|
let backend = DEFAULT_BACKEND;
|
|
2224
2831
|
let currentHandle = null; // live undo handle for everything applied this session
|
|
@@ -2228,16 +2835,82 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2228
2835
|
try { return win.location.pathname || '/'; } catch (_) { return '/'; }
|
|
2229
2836
|
}
|
|
2230
2837
|
|
|
2838
|
+
async function refreshCount() {
|
|
2839
|
+
if (!ui) return;
|
|
2840
|
+
try {
|
|
2841
|
+
const edits = await store.listEdits(pathKey());
|
|
2842
|
+
ui.setCount(edits.filter((e) => e.enabled !== false).length);
|
|
2843
|
+
} catch (_) { /* badge is best-effort */ }
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2231
2846
|
function init() {
|
|
2232
2847
|
if (typeof config.key !== 'string' || !config.key) {
|
|
2233
2848
|
throw new TypeError('Morph: init requires a publishable key (config.key)');
|
|
2234
2849
|
}
|
|
2235
2850
|
backend = (typeof config.backend === 'string' && config.backend)
|
|
2236
2851
|
? config.backend.replace(/\/$/, '') : DEFAULT_BACKEND;
|
|
2237
|
-
bar
|
|
2852
|
+
// onPick only when a picker exists — the bar hides the ⌖ button when
|
|
2853
|
+
// no handler is passed, so users never see a dead control.
|
|
2854
|
+
const handlers = { onSubmit, onUndo, onHide };
|
|
2855
|
+
if (picker) handlers.onPick = onPick;
|
|
2856
|
+
bar.mount(handlers);
|
|
2238
2857
|
return api;
|
|
2239
2858
|
}
|
|
2240
2859
|
|
|
2860
|
+
// ---- element targeting (mirrors extension/src/content.js) --------------
|
|
2861
|
+
let activeTarget = null; // { selector, label } while the user has a pick
|
|
2862
|
+
|
|
2863
|
+
function escapeHtml(s) {
|
|
2864
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2865
|
+
}
|
|
2866
|
+
function outlineTarget(el) {
|
|
2867
|
+
try {
|
|
2868
|
+
if (!win.document.getElementById('morph-sdk-target-style')) {
|
|
2869
|
+
const st = win.document.createElement('style');
|
|
2870
|
+
st.id = 'morph-sdk-target-style';
|
|
2871
|
+
st.textContent = '[data-morph-targeted="1"]{outline:2px solid #4c8dff !important;outline-offset:2px !important;}';
|
|
2872
|
+
win.document.documentElement.appendChild(st);
|
|
2873
|
+
}
|
|
2874
|
+
el.setAttribute('data-morph-targeted', '1');
|
|
2875
|
+
} catch (_) { /* cosmetic only */ }
|
|
2876
|
+
}
|
|
2877
|
+
function clearTargetState() {
|
|
2878
|
+
activeTarget = null;
|
|
2879
|
+
try {
|
|
2880
|
+
const el = win.document.querySelector('[data-morph-targeted="1"]');
|
|
2881
|
+
if (el) el.removeAttribute('data-morph-targeted');
|
|
2882
|
+
} catch (_) { /* noop */ }
|
|
2883
|
+
try { bar.clearTarget(); } catch (_) { /* bar optional */ }
|
|
2884
|
+
}
|
|
2885
|
+
function targetHtml() {
|
|
2886
|
+
try {
|
|
2887
|
+
const el = activeTarget && win.document.querySelector(activeTarget.selector);
|
|
2888
|
+
return el ? String(el.outerHTML).slice(0, 4000) : '';
|
|
2889
|
+
} catch (_) { return ''; }
|
|
2890
|
+
}
|
|
2891
|
+
function onPick() {
|
|
2892
|
+
if (!picker || !picker.start) return;
|
|
2893
|
+
try { bar.setStatus('Click any element on the page to target it — Esc to cancel.', ''); } catch (_) { /* noop */ }
|
|
2894
|
+
picker.start((el) => {
|
|
2895
|
+
try {
|
|
2896
|
+
const selector = picker.selectorFor(el, win.document);
|
|
2897
|
+
if (!selector) { bar.setStatus('Couldn’t target that element — try another.', 'err'); return; }
|
|
2898
|
+
activeTarget = { selector, label: picker.describe(el) };
|
|
2899
|
+
outlineTarget(el);
|
|
2900
|
+
bar.setTarget({ label: activeTarget.label, onClear: clearTargetState });
|
|
2901
|
+
bar.setStatus('Targeting <b>' + escapeHtml(activeTarget.label) + '</b>. Now type how to change it.', 'ok');
|
|
2902
|
+
if (bar.focusInput) bar.focusInput();
|
|
2903
|
+
} catch (_) { /* never break the page */ }
|
|
2904
|
+
}, () => {
|
|
2905
|
+
try { bar.aimActive(false); bar.setStatus('Pick cancelled.', ''); } catch (_) { /* noop */ }
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
// Closing the bar ends targeting: cancel any in-progress pick, drop the chip.
|
|
2909
|
+
function onHide() {
|
|
2910
|
+
try { if (picker && picker.stop) picker.stop(); } catch (_) { /* noop */ }
|
|
2911
|
+
clearTargetState();
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2241
2914
|
// Confirm gate for actions. window.confirm is trusted browser chrome the
|
|
2242
2915
|
// page can't spoof; a bar-styled dialog can replace it later.
|
|
2243
2916
|
function confirmFn(name, description) {
|
|
@@ -2280,25 +2953,59 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2280
2953
|
return { transform: Object.assign({}, transform, { ops }), data, failedCount: failedSources.size };
|
|
2281
2954
|
}
|
|
2282
2955
|
|
|
2956
|
+
// Replace whatever this session applied with `transform` (fresh handle).
|
|
2957
|
+
function applyFresh(transform, data) {
|
|
2958
|
+
if (currentHandle) DSL.undoTransform(currentHandle);
|
|
2959
|
+
const handle = DSL.applyTransform(transform, win.document, { groupId: 'morph-sdk', data, onAction });
|
|
2960
|
+
currentHandle = handle;
|
|
2961
|
+
activeTransform = transform;
|
|
2962
|
+
return handle;
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2283
2965
|
async function applyMerged(incoming, data) {
|
|
2284
2966
|
// Stack on top of anything already applied this session (additive), so
|
|
2285
2967
|
// successive prompts accumulate instead of replacing each other.
|
|
2286
2968
|
const merged = activeTransform ? DSL.mergeTransforms(activeTransform, incoming) : incoming;
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2969
|
+
return applyFresh(merged, data);
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
// Rebuild the applied state from the store (after per-edit remove/toggle).
|
|
2973
|
+
async function reapplyFromStore() {
|
|
2974
|
+
const edits = await store.listEdits(pathKey());
|
|
2975
|
+
const merged = compose(edits);
|
|
2976
|
+
if (!merged || !merged.ops || !merged.ops.length) {
|
|
2977
|
+
if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
|
|
2978
|
+
activeTransform = null;
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
const gated = dropUnknownSourceOps(merged);
|
|
2982
|
+
const { transform, data } = await resolveAllData(gated);
|
|
2983
|
+
if (!transform.ops.length) {
|
|
2984
|
+
if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
|
|
2985
|
+
activeTransform = null;
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2988
|
+
applyFresh(transform, data);
|
|
2292
2989
|
}
|
|
2293
2990
|
|
|
2294
2991
|
async function onSubmit(prompt) {
|
|
2295
2992
|
try { bar.setBusy(true); bar.showActions(false); } catch (_) { /* bar optional in tests */ }
|
|
2993
|
+
// Acknowledge instantly, then narrate the wait in stages.
|
|
2994
|
+
const stageTimers = [];
|
|
2995
|
+
try {
|
|
2996
|
+
bar.setStatus(STAGE_MESSAGES[0], '');
|
|
2997
|
+
stageTimers.push(setTimeout(() => { try { bar.setStatus(STAGE_MESSAGES[1], ''); } catch (_) { /* noop */ } }, stageDelays[0]));
|
|
2998
|
+
stageTimers.push(setTimeout(() => { try { bar.setStatus(STAGE_MESSAGES[2], ''); } catch (_) { /* noop */ } }, stageDelays[1]));
|
|
2999
|
+
} catch (_) { /* noop */ }
|
|
3000
|
+
const clearStages = () => { while (stageTimers.length) clearTimeout(stageTimers.pop()); };
|
|
3001
|
+
|
|
2296
3002
|
let context = '';
|
|
2297
3003
|
try { context = extractContext() || ''; } catch (_) { /* snapshot is best-effort */ }
|
|
2298
3004
|
|
|
2299
3005
|
let resp;
|
|
2300
3006
|
try {
|
|
2301
3007
|
const body = { prompt, context, url: win.location.href };
|
|
3008
|
+
if (activeTarget) body.target = { selector: activeTarget.selector, html: targetHtml() };
|
|
2302
3009
|
if (registry.hasSources() || registry.hasActions()) body.manifests = registry.manifests();
|
|
2303
3010
|
const r = await fetchFn(backend + '/api/transform', {
|
|
2304
3011
|
method: 'POST',
|
|
@@ -2311,9 +3018,11 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2311
3018
|
}
|
|
2312
3019
|
resp = await r.json();
|
|
2313
3020
|
} catch (e) {
|
|
3021
|
+
clearStages();
|
|
2314
3022
|
try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
|
|
2315
3023
|
return;
|
|
2316
3024
|
}
|
|
3025
|
+
clearStages();
|
|
2317
3026
|
|
|
2318
3027
|
try {
|
|
2319
3028
|
const v = DSL.validateTransform(resp && resp.transform);
|
|
@@ -2336,23 +3045,79 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2336
3045
|
return;
|
|
2337
3046
|
}
|
|
2338
3047
|
const anchored = DSL.withAnchors ? DSL.withAnchors(transform, win.document) : transform;
|
|
2339
|
-
await store.
|
|
3048
|
+
const edit = await store.appendEdit(pathKey(), anchored, { prompt });
|
|
2340
3049
|
const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
|
|
2341
|
-
|
|
3050
|
+
const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
|
|
3051
|
+
bar.setStatus('✓ ' + explanation + ' Saved for this page.' + note, 'ok');
|
|
2342
3052
|
bar.showActions(true);
|
|
2343
3053
|
try { bar.clearInput(); } catch (_) { /* noop */ }
|
|
3054
|
+
await refreshCount();
|
|
3055
|
+
// Act-then-undo (the Gmail pattern): the change is already saved;
|
|
3056
|
+
// the toast is the low-cost escape hatch — never a confirm dialog.
|
|
3057
|
+
if (ui && edit) {
|
|
3058
|
+
ui.showToast({
|
|
3059
|
+
text: '✓ ' + explanation + ' Saved for this page.',
|
|
3060
|
+
actionLabel: 'Undo',
|
|
3061
|
+
onAction: () => removeEdit(edit.id),
|
|
3062
|
+
});
|
|
3063
|
+
}
|
|
2344
3064
|
} catch (e) {
|
|
2345
3065
|
try { bar.setBusy(false); bar.setStatus('Something went wrong applying that change.', 'err'); } catch (_) { /* noop */ }
|
|
2346
3066
|
}
|
|
2347
3067
|
}
|
|
2348
3068
|
|
|
2349
|
-
|
|
3069
|
+
// Per-edit dismissal (HAX G8/G9): drop ONE edit, keep the rest.
|
|
3070
|
+
async function removeEdit(id) {
|
|
3071
|
+
await store.removeEdit(pathKey(), id);
|
|
3072
|
+
await reapplyFromStore();
|
|
3073
|
+
await refreshCount();
|
|
3074
|
+
try { bar.setStatus('Change removed.', ''); } catch (_) { /* noop */ }
|
|
3075
|
+
if (ui) await openPanelIfRendered();
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
async function toggleEdit(id, enabled) {
|
|
3079
|
+
await store.setEditEnabled(pathKey(), id, enabled);
|
|
3080
|
+
await reapplyFromStore();
|
|
3081
|
+
await refreshCount();
|
|
3082
|
+
if (ui) await openPanelIfRendered();
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
// Full reset — the "emergency exit" (Nielsen #3). Bar's Undo maps here.
|
|
3086
|
+
async function resetPage() {
|
|
2350
3087
|
if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
|
|
2351
3088
|
activeTransform = null;
|
|
2352
3089
|
await store.removeRecipe(pathKey());
|
|
3090
|
+
await refreshCount();
|
|
3091
|
+
if (ui) await openPanelIfRendered();
|
|
2353
3092
|
try { bar.setStatus('Reverted this page to its original look.', ''); bar.showActions(false); } catch (_) { /* noop */ }
|
|
2354
3093
|
}
|
|
2355
3094
|
|
|
3095
|
+
async function onUndo() { return resetPage(); }
|
|
3096
|
+
|
|
3097
|
+
// "Your edits on this page" — visibility + per-edit control (HAX G17).
|
|
3098
|
+
let panelRendered = false;
|
|
3099
|
+
async function renderPanel() {
|
|
3100
|
+
if (!ui) return;
|
|
3101
|
+
const edits = await store.listEdits(pathKey());
|
|
3102
|
+
ui.renderPanel(edits, {
|
|
3103
|
+
onToggle: (id, enabled) => toggleEdit(id, enabled),
|
|
3104
|
+
onDelete: (id) => removeEdit(id),
|
|
3105
|
+
onReset: () => {
|
|
3106
|
+
// Destroying ALL edits is the one legitimately confirm-worthy act.
|
|
3107
|
+
let ok = true;
|
|
3108
|
+
try { ok = win.confirm('Remove all your edits on this page?'); } catch (_) { /* headless */ }
|
|
3109
|
+
if (ok) resetPage();
|
|
3110
|
+
},
|
|
3111
|
+
onClose: () => {},
|
|
3112
|
+
});
|
|
3113
|
+
panelRendered = true;
|
|
3114
|
+
}
|
|
3115
|
+
async function openPanelIfRendered() { if (panelRendered) await renderPanel(); }
|
|
3116
|
+
async function openPanel() {
|
|
3117
|
+
await renderPanel();
|
|
3118
|
+
if (ui) ui.showPanel();
|
|
3119
|
+
}
|
|
3120
|
+
|
|
2356
3121
|
// Reapply the saved recipe: CSS immediately (FOUC kill), the full
|
|
2357
3122
|
// transform (with fresh data) once the DOM is ready.
|
|
2358
3123
|
async function applySaved() {
|
|
@@ -2379,9 +3144,10 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2379
3144
|
} else {
|
|
2380
3145
|
await full();
|
|
2381
3146
|
}
|
|
3147
|
+
await refreshCount();
|
|
2382
3148
|
}
|
|
2383
3149
|
|
|
2384
|
-
const api = { init, onSubmit, onUndo, applySaved };
|
|
3150
|
+
const api = { init, onSubmit, onUndo, applySaved, removeEdit, toggleEdit, resetPage, openPanel };
|
|
2385
3151
|
return api;
|
|
2386
3152
|
}
|
|
2387
3153
|
|
|
@@ -2397,19 +3163,37 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2397
3163
|
const doFetch = typeof window.fetch === 'function'
|
|
2398
3164
|
? window.fetch.bind(window)
|
|
2399
3165
|
: () => Promise.reject(new Error('Morph: fetch unavailable'));
|
|
3166
|
+
// Visible launcher (discoverability: HAX G7 — an invisible hotkey is
|
|
3167
|
+
// not an entry point). config.launcher: false to hide, or
|
|
3168
|
+
// { position: 'bottom-left', accent: '#hex' } to theme.
|
|
3169
|
+
const launcherCfg = config.launcher === false ? { position: false } : (config.launcher || {});
|
|
3170
|
+
const ui = window.MorphUI.createUI({
|
|
3171
|
+
win: window,
|
|
3172
|
+
position: launcherCfg.position === undefined ? 'bottom-right' : launcherCfg.position,
|
|
3173
|
+
accent: launcherCfg.accent,
|
|
3174
|
+
});
|
|
2400
3175
|
const embed = createEmbed({
|
|
2401
3176
|
config, win: window, DSL: window.MorphDSL, registry, store,
|
|
2402
|
-
bar: window.Morph.bar,
|
|
2403
|
-
|
|
3177
|
+
bar: window.Morph.bar, ui, picker: window.Morph.picker,
|
|
3178
|
+
extractContext: window.Morph.extractContext,
|
|
3179
|
+
fetchFn: doFetch, compose: window.MorphStorage.composeEdits,
|
|
2404
3180
|
});
|
|
2405
3181
|
embed.init();
|
|
3182
|
+
ui.mount({
|
|
3183
|
+
onPrimary: () => window.Morph.bar.show(),
|
|
3184
|
+
onPanel: () => { embed.openPanel().catch(() => {}); },
|
|
3185
|
+
});
|
|
2406
3186
|
// Hotkey (default alt+m) + a programmatic opener for vendor-built buttons.
|
|
3187
|
+
// Physical-key matching (e.code) so macOS Option+M — which types 'µ' —
|
|
3188
|
+
// still fires; see matchesTrigger above.
|
|
2407
3189
|
const trigger = typeof config.trigger === 'string' ? config.trigger.toLowerCase() : 'alt+m';
|
|
2408
3190
|
if (trigger !== 'none') {
|
|
2409
|
-
|
|
3191
|
+
// capture:true so host-page handlers can't swallow the shortcut;
|
|
3192
|
+
// typing guard so we never steal keystrokes from the host's inputs.
|
|
2410
3193
|
window.addEventListener('keydown', (e) => {
|
|
2411
|
-
if (
|
|
2412
|
-
|
|
3194
|
+
if (isTypingTarget(e)) return;
|
|
3195
|
+
if (matchesTrigger(e, trigger)) { e.preventDefault(); e.stopPropagation(); window.Morph.bar.toggle(); }
|
|
3196
|
+
}, { capture: true });
|
|
2413
3197
|
}
|
|
2414
3198
|
window.Morph.open = () => window.Morph.bar.show();
|
|
2415
3199
|
embed.applySaved().catch(() => {});
|
|
@@ -2417,7 +3201,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2417
3201
|
};
|
|
2418
3202
|
}
|
|
2419
3203
|
|
|
2420
|
-
return { createEmbed };
|
|
3204
|
+
return { createEmbed, matchesTrigger, isTypingTarget };
|
|
2421
3205
|
});
|
|
2422
3206
|
|
|
2423
3207
|
export function init(config) { return g.Morph.init(config); }
|