morph-embed 0.1.0 → 0.2.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.1.0 — https://usemorph.xyz — MIT */
1
+ /*! Morph Embed SDK v0.2.0 — 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. Persists one recipe per page path so generated
1326
- * widgets/reshapes reapply on every visit. Default backing is localStorage
1327
- * (per-browser); a vendor may pass a custom adapter {get,set,remove} (sync or
1328
- * Promise) to store recipes in their own backend so a user's generated
1329
- * features follow them across devices. Storage failures NEVER throw — a
1330
- * broken store must never break the host page.
1331
- * UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
1332
- */
1333
- (function (global, factory) {
1334
- const api = factory();
1335
- if (typeof module !== 'undefined' && module.exports) module.exports = api;
1336
- else global.MorphStorage = api;
1337
- })(typeof self !== 'undefined' ? self : this, function () {
1338
- 'use strict';
1339
-
1340
- const PREFIX = 'morph:sdk:recipe:';
1341
- const SCHEMA_VERSION = 1;
1342
- const MAX_PROMPT_LABEL = 300;
1343
-
1344
- // Default adapter: window.localStorage, JSON values, fully guarded.
1345
- function localAdapter() {
1346
- function ls() {
1347
- try {
1348
- return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
1349
- } catch (_) { return null; } // SecurityError in sandboxed frames
1350
- }
1351
- return {
1352
- get(key) {
1353
- const s = ls();
1354
- if (!s) return undefined;
1355
- const raw = s.getItem(key);
1356
- if (raw == null) return undefined;
1357
- try { return JSON.parse(raw); } catch (_) { return undefined; }
1358
- },
1359
- set(key, value) {
1360
- const s = ls();
1361
- if (!s) return;
1362
- s.setItem(key, JSON.stringify(value));
1363
- },
1364
- remove(key) {
1365
- const s = ls();
1366
- if (!s) return;
1367
- s.removeItem(key);
1368
- },
1369
- };
1370
- }
1371
-
1372
- // mergeTransforms concats; recipes must UNION. Drop structurally identical
1373
- // ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
1374
- function dedupeOps(transform) {
1375
- if (!transform || !Array.isArray(transform.ops)) return transform;
1376
- const seen = new Set();
1377
- const ops = transform.ops.filter((op) => {
1378
- let key;
1379
- try { key = JSON.stringify(op); } catch (_) { return true; }
1380
- if (seen.has(key)) return false;
1381
- seen.add(key);
1382
- return true;
1383
- });
1384
- return Object.assign({}, transform, { ops });
1385
- }
1386
-
1387
- // Dedupe prompt-label segments exactly like extension/src/recipes.js
1388
- // appendTransform: never re-append a segment that is already present.
1389
- function mergePrompt(existingPrompt, incoming) {
1390
- if (existingPrompt && incoming) {
1391
- const dup = existingPrompt.split(' + ')
1392
- .some((s) => s.trim().toLowerCase() === incoming.trim().toLowerCase());
1393
- return dup ? existingPrompt : String(existingPrompt + ' + ' + incoming).slice(0, MAX_PROMPT_LABEL);
1394
- }
1395
- return incoming || existingPrompt || '';
1396
- }
1397
-
1398
- function createStore(adapter) {
1399
- const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
1400
- const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
1401
-
1402
- // Serialize read-modify-write so rapid appends can't lose ops (same
1403
- // pattern as extension/src/recipes.js).
1404
- let writeChain = Promise.resolve();
1405
- function serialize(fn) {
1406
- const run = writeChain.then(fn, fn);
1407
- writeChain = run.then(() => {}, () => {});
1408
- return run;
1409
- }
1410
-
1411
- async function loadRecipe(pathKey) {
1412
- try {
1413
- const v = await backing.get(keyOf(pathKey));
1414
- return (v && typeof v === 'object') ? v : null;
1415
- } catch (_) { return null; }
1416
- }
1417
-
1418
- function saveRecipe(pathKey, recipe) {
1419
- return serialize(async () => {
1420
- try {
1421
- const merged = Object.assign(
1422
- { path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
1423
- recipe,
1424
- { schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
1425
- );
1426
- await backing.set(keyOf(pathKey), merged);
1427
- return merged;
1428
- } catch (_) { return null; }
1429
- });
1430
- }
1431
-
1432
- function appendTransform(pathKey, transform, meta, mergeFn) {
1433
- return serialize(async () => {
1434
- try {
1435
- let existing = null;
1436
- try { existing = await backing.get(keyOf(pathKey)); } catch (_) { /* treat as none */ }
1437
- if (!existing || typeof existing !== 'object') existing = null;
1438
- const mergedTransform = dedupeOps(
1439
- (existing && existing.transform && typeof mergeFn === 'function')
1440
- ? mergeFn(existing.transform, transform)
1441
- : transform
1442
- );
1443
- const prompt = mergePrompt(existing && existing.prompt, (meta && meta.prompt) || '');
1444
- const merged = Object.assign(
1445
- { path: String(pathKey || '/'), createdAt: Date.now() },
1446
- existing || {},
1447
- {
1448
- path: String(pathKey || '/'), prompt, transform: mergedTransform,
1449
- enabled: true, schemaVersion: SCHEMA_VERSION,
1450
- }
1451
- );
1452
- await backing.set(keyOf(pathKey), merged);
1453
- return merged;
1454
- } catch (_) { return null; }
1455
- });
1456
- }
1457
-
1458
- function removeRecipe(pathKey) {
1459
- return serialize(async () => {
1460
- try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
1461
- });
1462
- }
1463
-
1464
- return { loadRecipe, saveRecipe, appendTransform, removeRecipe };
1465
- }
1466
-
1467
- return { createStore, SCHEMA_VERSION };
1468
- });
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 ---- */
@@ -2186,6 +2290,301 @@ const g = typeof window !== "undefined" ? window : globalThis;
2186
2290
  })();
2187
2291
 
2188
2292
 
2293
+ /* ---- sdk/src/ui.js ---- */
2294
+ /*
2295
+ * Morph SDK — UI layer: launcher + edits panel + undo toast, isolated in a
2296
+ * closed-over Shadow DOM so host-page CSS can't leak in either direction.
2297
+ *
2298
+ * Every behavior here is deliberate, from the sprint's research pass:
2299
+ * - The launcher is small, corner-pinned, and NEVER auto-opens (Baymard's
2300
+ * live-chat testing: sticky elements that act on their own are "highly
2301
+ * disruptive"; everything is user-initiated).
2302
+ * - First-run pulse fires exactly once (persisted flag) and not at all under
2303
+ * prefers-reduced-motion (NN/g animation-usability).
2304
+ * - Edits apply immediately and the toast offers Undo afterwards — the Gmail
2305
+ * pattern; confirmation-first dialogs habituate and fail (NN/g).
2306
+ * - The panel gives per-edit visibility, toggle, and delete — HAX G17
2307
+ * "provide global controls", Nielsen #3 "user control and freedom".
2308
+ * - The launcher menu never has more than two options (Hick's law), and the
2309
+ * whole surface is at most two disclosure levels deep (NN/g progressive
2310
+ * disclosure).
2311
+ * UMD: Node module in tests, browser global `MorphUI` in the SDK bundle.
2312
+ */
2313
+ (function (global, factory) {
2314
+ const api = factory();
2315
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
2316
+ else global.MorphUI = api;
2317
+ })(typeof self !== 'undefined' ? self : this, function () {
2318
+ 'use strict';
2319
+
2320
+ const HOST_ID = 'morph-sdk-ui';
2321
+ const NUDGE_FLAG = 'morph:sdk:nudged';
2322
+
2323
+ const CSS = `
2324
+ :host { all: initial; }
2325
+ * { box-sizing: border-box; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
2326
+ .morph-ui-root { position: fixed; z-index: 2147483600; bottom: 20px; right: 20px;
2327
+ display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
2328
+ .morph-ui-root.pos-bottom-left { right: auto; left: 20px; align-items: flex-start; }
2329
+
2330
+ .morph-fab { width: 46px; height: 46px; border-radius: 50%; border: none; cursor: pointer;
2331
+ background: var(--morph-accent, #111114); color: #fff; font-size: 20px; line-height: 1;
2332
+ box-shadow: 0 4px 18px rgba(0,0,0,.28); display: flex; align-items: center; justify-content: center;
2333
+ position: relative; transition: transform .12s ease; }
2334
+ .morph-fab:hover { transform: scale(1.06); }
2335
+ .morph-fab:focus-visible { outline: 2px solid #4c8dff; outline-offset: 2px; }
2336
+ .morph-fab.pulse { animation: morphPulse 1.6s ease-out 2; }
2337
+ @keyframes morphPulse {
2338
+ 0% { box-shadow: 0 0 0 0 rgba(76,141,255,.55), 0 4px 18px rgba(0,0,0,.28); }
2339
+ 100% { box-shadow: 0 0 0 16px rgba(76,141,255,0), 0 4px 18px rgba(0,0,0,.28); }
2340
+ }
2341
+ @media (prefers-reduced-motion: reduce) { .morph-fab.pulse { animation: none; } }
2342
+
2343
+ .morph-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px;
2344
+ border-radius: 9px; background: #e8554d; color: #fff; font-size: 11px; font-weight: 700;
2345
+ display: none; align-items: center; justify-content: center; padding: 0 5px; }
2346
+ .morph-badge.show { display: flex; }
2347
+
2348
+ .morph-menu { display: none; flex-direction: column; background: #fff; border-radius: 10px;
2349
+ box-shadow: 0 8px 30px rgba(0,0,0,.22); overflow: hidden; min-width: 200px; }
2350
+ .morph-menu.open { display: flex; }
2351
+ .morph-menu-item { border: none; background: none; text-align: left; padding: 12px 16px;
2352
+ font-size: 13.5px; color: #17171c; cursor: pointer; display: flex; gap: 9px; align-items: center; }
2353
+ .morph-menu-item:hover { background: #f2f3f7; }
2354
+
2355
+ .morph-panel { display: none; width: 320px; max-height: 420px; overflow: auto; background: #fff;
2356
+ border-radius: 12px; box-shadow: 0 10px 38px rgba(0,0,0,.26); padding: 14px 14px 10px; }
2357
+ .morph-panel.open { display: block; }
2358
+ .morph-panel h3 { margin: 0 0 2px; font-size: 13px; color: #17171c; }
2359
+ .morph-panel .morph-panel-sub { margin: 0 0 10px; font-size: 11px; color: #7a7a85; }
2360
+ .morph-edit-row { display: flex; align-items: center; gap: 8px; padding: 8px 4px;
2361
+ border-top: 1px solid #eef0f4; }
2362
+ .morph-edit-label { flex: 1; min-width: 0; }
2363
+ .morph-edit-label .p { font-size: 12.5px; color: #17171c; overflow: hidden; text-overflow: ellipsis;
2364
+ white-space: nowrap; }
2365
+ .morph-edit-label .t { font-size: 10.5px; color: #9a9aa5; }
2366
+ .morph-edit-row.off .morph-edit-label .p { color: #9a9aa5; text-decoration: line-through; }
2367
+ .morph-edit-toggle { width: 32px; height: 18px; border-radius: 9px; border: none; cursor: pointer;
2368
+ background: #cfd3dc; position: relative; flex: none; }
2369
+ .morph-edit-toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px;
2370
+ border-radius: 50%; background: #fff; transition: left .12s ease; }
2371
+ .morph-edit-toggle[aria-checked="true"] { background: var(--morph-accent, #2f9e63); }
2372
+ .morph-edit-toggle[aria-checked="true"]::after { left: 16px; }
2373
+ .morph-edit-delete { border: none; background: none; color: #b9bcc6; cursor: pointer; font-size: 15px;
2374
+ padding: 4px; flex: none; }
2375
+ .morph-edit-delete:hover { color: #e8554d; }
2376
+ .morph-panel-empty { padding: 14px 4px 10px; font-size: 12.5px; color: #6f6f7a; line-height: 1.5; }
2377
+ .morph-panel-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 10px;
2378
+ padding-top: 8px; border-top: 1px solid #eef0f4; }
2379
+ .morph-panel-reset { border: none; background: none; color: #e8554d; font-size: 12px; cursor: pointer; padding: 4px; }
2380
+ .morph-panel-close { border: none; background: none; color: #7a7a85; font-size: 12px; cursor: pointer; padding: 4px; }
2381
+
2382
+ .morph-toast { position: fixed; left: 50%; transform: translateX(-50%) translateY(8px); bottom: 24px;
2383
+ display: none; align-items: center; gap: 12px; background: #17171c; color: #fff; border-radius: 10px;
2384
+ padding: 11px 16px; font-size: 13px; box-shadow: 0 8px 30px rgba(0,0,0,.32); opacity: 0;
2385
+ transition: opacity .15s ease, transform .15s ease; max-width: min(92vw, 460px); }
2386
+ .morph-toast.show { display: flex; opacity: 1; transform: translateX(-50%) translateY(0); }
2387
+ .morph-toast-action { border: none; background: none; color: #7db4ff; font-weight: 700; font-size: 13px;
2388
+ cursor: pointer; padding: 2px 4px; flex: none; }
2389
+ `;
2390
+
2391
+ function createUI(opts) {
2392
+ opts = opts || {};
2393
+ const win = opts.win;
2394
+ const position = opts.position === undefined ? 'bottom-right' : opts.position;
2395
+ const getFlag = opts.getFlag || defaultGetFlag(win);
2396
+ const setFlag = opts.setFlag || defaultSetFlag(win);
2397
+ const reducedMotion = opts.reducedMotion !== undefined ? !!opts.reducedMotion : prefersReducedMotion(win);
2398
+
2399
+ // Vendor opted out of the launcher entirely -> inert API (they drive
2400
+ // everything through Morph.open() and the bar).
2401
+ if (position === false) {
2402
+ const noop = () => {};
2403
+ return { mount: noop, setCount: noop, showToast: noop, renderPanel: noop, showPanel: noop, hidePanel: noop };
2404
+ }
2405
+
2406
+ let root = null, fab = null, badge = null, menu = null, panel = null, toast = null;
2407
+ let toastTimer = null;
2408
+ let handlers = { onPrimary: null, onPanel: null };
2409
+ let panelHandlers = null;
2410
+ let count = 0;
2411
+
2412
+ function el(tag, cls, text) {
2413
+ const n = win.document.createElement(tag);
2414
+ if (cls) n.className = cls;
2415
+ if (text !== undefined) n.textContent = text;
2416
+ return n;
2417
+ }
2418
+
2419
+ function mount(h) {
2420
+ handlers = h || handlers;
2421
+ if (win.document.getElementById(HOST_ID)) return;
2422
+ const host = el('div');
2423
+ host.id = HOST_ID;
2424
+ const shadow = host.attachShadow({ mode: 'open' });
2425
+ const style = el('style'); style.textContent = CSS;
2426
+ shadow.appendChild(style);
2427
+
2428
+ root = el('div', 'morph-ui-root' + (position === 'bottom-left' ? ' pos-bottom-left' : ''));
2429
+ if (opts.accent) root.style.setProperty('--morph-accent', String(opts.accent));
2430
+
2431
+ panel = el('div', 'morph-panel');
2432
+ menu = el('div', 'morph-menu');
2433
+ fab = el('button', 'morph-fab', '✦');
2434
+ fab.setAttribute('aria-label', 'Customize this page with Morph (Alt+M)');
2435
+ fab.setAttribute('title', 'Customize this page (Alt+M)');
2436
+ badge = el('span', 'morph-badge');
2437
+ badge.setAttribute('aria-hidden', 'true');
2438
+ fab.appendChild(badge);
2439
+
2440
+ toast = el('div', 'morph-toast');
2441
+ toast.setAttribute('role', 'status');
2442
+ toast.setAttribute('aria-live', 'polite');
2443
+
2444
+ fab.addEventListener('click', () => {
2445
+ hidePanel();
2446
+ if (count > 0) { menu.classList.toggle('open'); renderMenu(); return; }
2447
+ menu.classList.remove('open');
2448
+ if (handlers.onPrimary) handlers.onPrimary();
2449
+ });
2450
+
2451
+ // One escape hatch for everything floating (Nielsen #3).
2452
+ win.document.addEventListener('keydown', (e) => {
2453
+ if (e.key === 'Escape') { hidePanel(); menu.classList.remove('open'); }
2454
+ });
2455
+
2456
+ root.appendChild(panel);
2457
+ root.appendChild(menu);
2458
+ root.appendChild(fab);
2459
+ shadow.appendChild(root);
2460
+ shadow.appendChild(toast);
2461
+ win.document.documentElement.appendChild(host);
2462
+
2463
+ // First-run nudge: one pulse, ever, and none under reduced motion.
2464
+ let seen;
2465
+ try { seen = getFlag(NUDGE_FLAG); } catch (_) { seen = true; }
2466
+ if (!seen && !reducedMotion) {
2467
+ fab.classList.add('pulse');
2468
+ try { setFlag(NUDGE_FLAG, '1'); } catch (_) { /* private mode */ }
2469
+ } else if (!seen) {
2470
+ try { setFlag(NUDGE_FLAG, '1'); } catch (_) { /* private mode */ }
2471
+ }
2472
+ }
2473
+
2474
+ function renderMenu() {
2475
+ if (!menu) return;
2476
+ menu.textContent = '';
2477
+ const make = el('button', 'morph-menu-item', '✦ Make a change');
2478
+ make.addEventListener('click', () => { menu.classList.remove('open'); if (handlers.onPrimary) handlers.onPrimary(); });
2479
+ const edits = el('button', 'morph-menu-item', '☰ Your edits (' + count + ')');
2480
+ edits.addEventListener('click', () => { menu.classList.remove('open'); if (handlers.onPanel) handlers.onPanel(); });
2481
+ menu.appendChild(make);
2482
+ menu.appendChild(edits);
2483
+ }
2484
+
2485
+ function setCount(n) {
2486
+ count = Number(n) || 0;
2487
+ if (!badge) return;
2488
+ badge.textContent = String(count);
2489
+ badge.classList.toggle('show', count > 0);
2490
+ }
2491
+
2492
+ // Act-then-undo toast (the Gmail pattern). One at a time; new replaces old.
2493
+ function showToast(t) {
2494
+ if (!toast) return;
2495
+ t = t || {};
2496
+ if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; }
2497
+ toast.textContent = '';
2498
+ const txt = el('span', 'morph-toast-text', String(t.text || ''));
2499
+ toast.appendChild(txt);
2500
+ if (t.actionLabel) {
2501
+ const act = el('button', 'morph-toast-action', String(t.actionLabel));
2502
+ act.addEventListener('click', () => { hideToast(); if (t.onAction) t.onAction(); });
2503
+ toast.appendChild(act);
2504
+ }
2505
+ toast.classList.add('show');
2506
+ const ttl = typeof t.ttlMs === 'number' ? t.ttlMs : 10000;
2507
+ toastTimer = setTimeout(hideToast, ttl);
2508
+ if (toastTimer && toastTimer.unref) toastTimer.unref();
2509
+ }
2510
+ function hideToast() {
2511
+ if (toast) toast.classList.remove('show');
2512
+ if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; }
2513
+ }
2514
+
2515
+ function fmtWhen(ts) {
2516
+ try {
2517
+ const d = new Date(ts);
2518
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ', ' +
2519
+ d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
2520
+ } catch (_) { return ''; }
2521
+ }
2522
+
2523
+ // "Your edits on this page": visibility + per-edit control (HAX G17).
2524
+ function renderPanel(edits, h) {
2525
+ panelHandlers = h || panelHandlers || {};
2526
+ if (!panel) return;
2527
+ panel.textContent = '';
2528
+ panel.appendChild(el('h3', '', 'Your edits on this page'));
2529
+ panel.appendChild(el('p', 'morph-panel-sub', 'These re-apply every time you visit. Toggle one off to preview the page without it.'));
2530
+
2531
+ if (!edits || !edits.length) {
2532
+ panel.appendChild(el('div', 'morph-panel-empty',
2533
+ '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.'));
2534
+ } else {
2535
+ edits.forEach((e) => {
2536
+ const row = el('div', 'morph-edit-row' + (e.enabled === false ? ' off' : ''));
2537
+ const label = el('div', 'morph-edit-label');
2538
+ label.appendChild(el('div', 'p', e.prompt || 'Untitled edit'));
2539
+ label.appendChild(el('div', 't', fmtWhen(e.ts)));
2540
+ const toggle = el('button', 'morph-edit-toggle');
2541
+ toggle.setAttribute('role', 'switch');
2542
+ toggle.setAttribute('aria-checked', e.enabled === false ? 'false' : 'true');
2543
+ toggle.setAttribute('aria-label', 'Toggle edit: ' + (e.prompt || 'untitled'));
2544
+ toggle.addEventListener('click', () => {
2545
+ if (panelHandlers.onToggle) panelHandlers.onToggle(e.id, e.enabled === false);
2546
+ });
2547
+ const del = el('button', 'morph-edit-delete', '✕');
2548
+ del.setAttribute('aria-label', 'Remove edit: ' + (e.prompt || 'untitled'));
2549
+ del.addEventListener('click', () => { if (panelHandlers.onDelete) panelHandlers.onDelete(e.id); });
2550
+ row.appendChild(label);
2551
+ row.appendChild(toggle);
2552
+ row.appendChild(del);
2553
+ panel.appendChild(row);
2554
+ });
2555
+ }
2556
+
2557
+ const foot = el('div', 'morph-panel-foot');
2558
+ const reset = el('button', 'morph-panel-reset', 'Reset this page');
2559
+ reset.addEventListener('click', () => { if (panelHandlers.onReset) panelHandlers.onReset(); });
2560
+ const close = el('button', 'morph-panel-close', 'Close');
2561
+ close.addEventListener('click', () => { hidePanel(); if (panelHandlers.onClose) panelHandlers.onClose(); });
2562
+ foot.appendChild(reset);
2563
+ foot.appendChild(close);
2564
+ panel.appendChild(foot);
2565
+ }
2566
+
2567
+ function showPanel() { if (panel) { menu && menu.classList.remove('open'); panel.classList.add('open'); } }
2568
+ function hidePanel() { if (panel) panel.classList.remove('open'); }
2569
+
2570
+ return { mount, setCount, showToast, renderPanel, showPanel, hidePanel };
2571
+ }
2572
+
2573
+ function defaultGetFlag(win) {
2574
+ return (k) => { try { return win.localStorage.getItem(k); } catch (_) { return '1'; } };
2575
+ }
2576
+ function defaultSetFlag(win) {
2577
+ return (k, v) => { try { win.localStorage.setItem(k, v); } catch (_) { /* noop */ } };
2578
+ }
2579
+ function prefersReducedMotion(win) {
2580
+ try { return !!(win.matchMedia && win.matchMedia('(prefers-reduced-motion: reduce)').matches); }
2581
+ catch (_) { return false; }
2582
+ }
2583
+
2584
+ return { createUI };
2585
+ });
2586
+
2587
+
2189
2588
  /* ---- sdk/src/embed.js ---- */
2190
2589
  /*
2191
2590
  * Morph SDK — embed entry. Wires the engine (MorphDSL), the vendor registry,
@@ -2210,6 +2609,56 @@ const g = typeof window !== "undefined" ? window : globalThis;
2210
2609
 
2211
2610
  const DEFAULT_BACKEND = 'https://morph-api-234584917638.us-central1.run.app';
2212
2611
 
2612
+ // Hotkey matching on the PHYSICAL key (e.code), not the produced character:
2613
+ // on macOS Option acts as a character modifier (Option+M types 'µ'), so an
2614
+ // e.key comparison never fires there. Modifier state must match EXACTLY —
2615
+ // that also keeps AltGr (reported as ctrl+alt on Windows) from triggering
2616
+ // an alt-only shortcut while someone types accented characters.
2617
+ function matchesTrigger(e, trigger) {
2618
+ if (!e || typeof trigger !== 'string' || !trigger) return false;
2619
+ // IME guard: isComposing can be false on the first/last composition
2620
+ // keystroke while keyCode is still the spec-mandated 229 — check both.
2621
+ if (e.isComposing || e.keyCode === 229) return false;
2622
+ const parts = trigger.toLowerCase().split('+').map((s) => s.trim()).filter(Boolean);
2623
+ if (!parts.length) return false;
2624
+ const key = parts[parts.length - 1];
2625
+ const mods = new Set(parts.slice(0, -1));
2626
+ const want = {
2627
+ alt: mods.has('alt') || mods.has('option'),
2628
+ ctrl: mods.has('ctrl') || mods.has('control'),
2629
+ shift: mods.has('shift'),
2630
+ meta: mods.has('meta') || mods.has('cmd') || mods.has('command'),
2631
+ };
2632
+ if (!!e.altKey !== want.alt || !!e.ctrlKey !== want.ctrl ||
2633
+ !!e.shiftKey !== want.shift || !!e.metaKey !== want.meta) return false;
2634
+ if (typeof e.code === 'string' && e.code) {
2635
+ if (/^[a-z]$/.test(key)) return e.code === 'Key' + key.toUpperCase();
2636
+ if (/^[0-9]$/.test(key)) return e.code === 'Digit' + key;
2637
+ }
2638
+ return typeof e.key === 'string' && e.key.toLowerCase() === key;
2639
+ }
2640
+
2641
+ // Don't hijack keys while the user is typing in the host app (Mousetrap /
2642
+ // github-hotkey convention). composedPath()[0] sees the REAL target even
2643
+ // when the event comes from inside a shadow root (retargeting would
2644
+ // otherwise hide the input element from us).
2645
+ function isTypingTarget(e) {
2646
+ let t = e && e.target;
2647
+ try { if (e && typeof e.composedPath === 'function') t = e.composedPath()[0] || t; } catch (_) { /* keep target */ }
2648
+ if (!t) return false;
2649
+ const tag = String(t.tagName || '').toUpperCase();
2650
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable === true;
2651
+ }
2652
+
2653
+ // Wait narration (NN/g response-time bands: acknowledge <100ms, actively
2654
+ // manage the 2-10s AI band; a moving/staged indicator triples patience).
2655
+ const STAGE_MESSAGES = [
2656
+ 'Reading this page…',
2657
+ 'Designing your change…',
2658
+ 'Still working — bigger changes can take a few seconds…',
2659
+ ];
2660
+ const STAGE_DELAYS = [1500, 6000];
2661
+
2213
2662
  function createEmbed(deps) {
2214
2663
  const config = (deps && deps.config) || {};
2215
2664
  const win = deps.win;
@@ -2217,8 +2666,17 @@ const g = typeof window !== "undefined" ? window : globalThis;
2217
2666
  const registry = deps.registry;
2218
2667
  const store = deps.store;
2219
2668
  const bar = deps.bar;
2669
+ const ui = deps.ui || null;
2220
2670
  const extractContext = deps.extractContext;
2221
2671
  const fetchFn = deps.fetchFn;
2672
+ const stageDelays = deps.stageDelays || STAGE_DELAYS;
2673
+ // Merge enabled edits into one transform (storage exports the canonical
2674
+ // deduping composer; the fallback keeps createEmbed usable without it).
2675
+ const compose = deps.compose || function (edits) {
2676
+ const on = (edits || []).filter((e) => e && e.enabled !== false && e.transform);
2677
+ if (!on.length) return null;
2678
+ return on.slice(1).reduce((m, e) => DSL.mergeTransforms(m, e.transform), on[0].transform);
2679
+ };
2222
2680
 
2223
2681
  let backend = DEFAULT_BACKEND;
2224
2682
  let currentHandle = null; // live undo handle for everything applied this session
@@ -2228,6 +2686,14 @@ const g = typeof window !== "undefined" ? window : globalThis;
2228
2686
  try { return win.location.pathname || '/'; } catch (_) { return '/'; }
2229
2687
  }
2230
2688
 
2689
+ async function refreshCount() {
2690
+ if (!ui) return;
2691
+ try {
2692
+ const edits = await store.listEdits(pathKey());
2693
+ ui.setCount(edits.filter((e) => e.enabled !== false).length);
2694
+ } catch (_) { /* badge is best-effort */ }
2695
+ }
2696
+
2231
2697
  function init() {
2232
2698
  if (typeof config.key !== 'string' || !config.key) {
2233
2699
  throw new TypeError('Morph: init requires a publishable key (config.key)');
@@ -2280,19 +2746,52 @@ const g = typeof window !== "undefined" ? window : globalThis;
2280
2746
  return { transform: Object.assign({}, transform, { ops }), data, failedCount: failedSources.size };
2281
2747
  }
2282
2748
 
2749
+ // Replace whatever this session applied with `transform` (fresh handle).
2750
+ function applyFresh(transform, data) {
2751
+ if (currentHandle) DSL.undoTransform(currentHandle);
2752
+ const handle = DSL.applyTransform(transform, win.document, { groupId: 'morph-sdk', data, onAction });
2753
+ currentHandle = handle;
2754
+ activeTransform = transform;
2755
+ return handle;
2756
+ }
2757
+
2283
2758
  async function applyMerged(incoming, data) {
2284
2759
  // Stack on top of anything already applied this session (additive), so
2285
2760
  // successive prompts accumulate instead of replacing each other.
2286
2761
  const merged = activeTransform ? DSL.mergeTransforms(activeTransform, incoming) : incoming;
2287
- if (currentHandle) DSL.undoTransform(currentHandle);
2288
- const handle = DSL.applyTransform(merged, win.document, { groupId: 'morph-sdk', data, onAction });
2289
- currentHandle = handle;
2290
- activeTransform = merged;
2291
- return handle;
2762
+ return applyFresh(merged, data);
2763
+ }
2764
+
2765
+ // Rebuild the applied state from the store (after per-edit remove/toggle).
2766
+ async function reapplyFromStore() {
2767
+ const edits = await store.listEdits(pathKey());
2768
+ const merged = compose(edits);
2769
+ if (!merged || !merged.ops || !merged.ops.length) {
2770
+ if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
2771
+ activeTransform = null;
2772
+ return;
2773
+ }
2774
+ const gated = dropUnknownSourceOps(merged);
2775
+ const { transform, data } = await resolveAllData(gated);
2776
+ if (!transform.ops.length) {
2777
+ if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
2778
+ activeTransform = null;
2779
+ return;
2780
+ }
2781
+ applyFresh(transform, data);
2292
2782
  }
2293
2783
 
2294
2784
  async function onSubmit(prompt) {
2295
2785
  try { bar.setBusy(true); bar.showActions(false); } catch (_) { /* bar optional in tests */ }
2786
+ // Acknowledge instantly, then narrate the wait in stages.
2787
+ const stageTimers = [];
2788
+ try {
2789
+ bar.setStatus(STAGE_MESSAGES[0], '');
2790
+ stageTimers.push(setTimeout(() => { try { bar.setStatus(STAGE_MESSAGES[1], ''); } catch (_) { /* noop */ } }, stageDelays[0]));
2791
+ stageTimers.push(setTimeout(() => { try { bar.setStatus(STAGE_MESSAGES[2], ''); } catch (_) { /* noop */ } }, stageDelays[1]));
2792
+ } catch (_) { /* noop */ }
2793
+ const clearStages = () => { while (stageTimers.length) clearTimeout(stageTimers.pop()); };
2794
+
2296
2795
  let context = '';
2297
2796
  try { context = extractContext() || ''; } catch (_) { /* snapshot is best-effort */ }
2298
2797
 
@@ -2311,9 +2810,11 @@ const g = typeof window !== "undefined" ? window : globalThis;
2311
2810
  }
2312
2811
  resp = await r.json();
2313
2812
  } catch (e) {
2813
+ clearStages();
2314
2814
  try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
2315
2815
  return;
2316
2816
  }
2817
+ clearStages();
2317
2818
 
2318
2819
  try {
2319
2820
  const v = DSL.validateTransform(resp && resp.transform);
@@ -2336,23 +2837,79 @@ const g = typeof window !== "undefined" ? window : globalThis;
2336
2837
  return;
2337
2838
  }
2338
2839
  const anchored = DSL.withAnchors ? DSL.withAnchors(transform, win.document) : transform;
2339
- await store.appendTransform(pathKey(), anchored, { prompt }, DSL.mergeTransforms);
2840
+ const edit = await store.appendEdit(pathKey(), anchored, { prompt });
2340
2841
  const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
2341
- bar.setStatus('✓ ' + ((resp.transform && resp.transform.explanation) || 'Done.') + ' Saved for this page.' + note, 'ok');
2842
+ const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
2843
+ bar.setStatus('✓ ' + explanation + ' Saved for this page.' + note, 'ok');
2342
2844
  bar.showActions(true);
2343
2845
  try { bar.clearInput(); } catch (_) { /* noop */ }
2846
+ await refreshCount();
2847
+ // Act-then-undo (the Gmail pattern): the change is already saved;
2848
+ // the toast is the low-cost escape hatch — never a confirm dialog.
2849
+ if (ui && edit) {
2850
+ ui.showToast({
2851
+ text: '✓ ' + explanation + ' Saved for this page.',
2852
+ actionLabel: 'Undo',
2853
+ onAction: () => removeEdit(edit.id),
2854
+ });
2855
+ }
2344
2856
  } catch (e) {
2345
2857
  try { bar.setBusy(false); bar.setStatus('Something went wrong applying that change.', 'err'); } catch (_) { /* noop */ }
2346
2858
  }
2347
2859
  }
2348
2860
 
2349
- async function onUndo() {
2861
+ // Per-edit dismissal (HAX G8/G9): drop ONE edit, keep the rest.
2862
+ async function removeEdit(id) {
2863
+ await store.removeEdit(pathKey(), id);
2864
+ await reapplyFromStore();
2865
+ await refreshCount();
2866
+ try { bar.setStatus('Change removed.', ''); } catch (_) { /* noop */ }
2867
+ if (ui) await openPanelIfRendered();
2868
+ }
2869
+
2870
+ async function toggleEdit(id, enabled) {
2871
+ await store.setEditEnabled(pathKey(), id, enabled);
2872
+ await reapplyFromStore();
2873
+ await refreshCount();
2874
+ if (ui) await openPanelIfRendered();
2875
+ }
2876
+
2877
+ // Full reset — the "emergency exit" (Nielsen #3). Bar's Undo maps here.
2878
+ async function resetPage() {
2350
2879
  if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
2351
2880
  activeTransform = null;
2352
2881
  await store.removeRecipe(pathKey());
2882
+ await refreshCount();
2883
+ if (ui) await openPanelIfRendered();
2353
2884
  try { bar.setStatus('Reverted this page to its original look.', ''); bar.showActions(false); } catch (_) { /* noop */ }
2354
2885
  }
2355
2886
 
2887
+ async function onUndo() { return resetPage(); }
2888
+
2889
+ // "Your edits on this page" — visibility + per-edit control (HAX G17).
2890
+ let panelRendered = false;
2891
+ async function renderPanel() {
2892
+ if (!ui) return;
2893
+ const edits = await store.listEdits(pathKey());
2894
+ ui.renderPanel(edits, {
2895
+ onToggle: (id, enabled) => toggleEdit(id, enabled),
2896
+ onDelete: (id) => removeEdit(id),
2897
+ onReset: () => {
2898
+ // Destroying ALL edits is the one legitimately confirm-worthy act.
2899
+ let ok = true;
2900
+ try { ok = win.confirm('Remove all your edits on this page?'); } catch (_) { /* headless */ }
2901
+ if (ok) resetPage();
2902
+ },
2903
+ onClose: () => {},
2904
+ });
2905
+ panelRendered = true;
2906
+ }
2907
+ async function openPanelIfRendered() { if (panelRendered) await renderPanel(); }
2908
+ async function openPanel() {
2909
+ await renderPanel();
2910
+ if (ui) ui.showPanel();
2911
+ }
2912
+
2356
2913
  // Reapply the saved recipe: CSS immediately (FOUC kill), the full
2357
2914
  // transform (with fresh data) once the DOM is ready.
2358
2915
  async function applySaved() {
@@ -2379,9 +2936,10 @@ const g = typeof window !== "undefined" ? window : globalThis;
2379
2936
  } else {
2380
2937
  await full();
2381
2938
  }
2939
+ await refreshCount();
2382
2940
  }
2383
2941
 
2384
- const api = { init, onSubmit, onUndo, applySaved };
2942
+ const api = { init, onSubmit, onUndo, applySaved, removeEdit, toggleEdit, resetPage, openPanel };
2385
2943
  return api;
2386
2944
  }
2387
2945
 
@@ -2397,19 +2955,36 @@ const g = typeof window !== "undefined" ? window : globalThis;
2397
2955
  const doFetch = typeof window.fetch === 'function'
2398
2956
  ? window.fetch.bind(window)
2399
2957
  : () => Promise.reject(new Error('Morph: fetch unavailable'));
2958
+ // Visible launcher (discoverability: HAX G7 — an invisible hotkey is
2959
+ // not an entry point). config.launcher: false to hide, or
2960
+ // { position: 'bottom-left', accent: '#hex' } to theme.
2961
+ const launcherCfg = config.launcher === false ? { position: false } : (config.launcher || {});
2962
+ const ui = window.MorphUI.createUI({
2963
+ win: window,
2964
+ position: launcherCfg.position === undefined ? 'bottom-right' : launcherCfg.position,
2965
+ accent: launcherCfg.accent,
2966
+ });
2400
2967
  const embed = createEmbed({
2401
2968
  config, win: window, DSL: window.MorphDSL, registry, store,
2402
- bar: window.Morph.bar, extractContext: window.Morph.extractContext,
2403
- fetchFn: doFetch,
2969
+ bar: window.Morph.bar, ui, extractContext: window.Morph.extractContext,
2970
+ fetchFn: doFetch, compose: window.MorphStorage.composeEdits,
2404
2971
  });
2405
2972
  embed.init();
2973
+ ui.mount({
2974
+ onPrimary: () => window.Morph.bar.show(),
2975
+ onPanel: () => { embed.openPanel().catch(() => {}); },
2976
+ });
2406
2977
  // Hotkey (default alt+m) + a programmatic opener for vendor-built buttons.
2978
+ // Physical-key matching (e.code) so macOS Option+M — which types 'µ' —
2979
+ // still fires; see matchesTrigger above.
2407
2980
  const trigger = typeof config.trigger === 'string' ? config.trigger.toLowerCase() : 'alt+m';
2408
2981
  if (trigger !== 'none') {
2409
- const key = trigger.split('+').pop();
2982
+ // capture:true so host-page handlers can't swallow the shortcut;
2983
+ // typing guard so we never steal keystrokes from the host's inputs.
2410
2984
  window.addEventListener('keydown', (e) => {
2411
- if (e.altKey && String(e.key).toLowerCase() === key) { e.preventDefault(); window.Morph.bar.toggle(); }
2412
- });
2985
+ if (isTypingTarget(e)) return;
2986
+ if (matchesTrigger(e, trigger)) { e.preventDefault(); e.stopPropagation(); window.Morph.bar.toggle(); }
2987
+ }, { capture: true });
2413
2988
  }
2414
2989
  window.Morph.open = () => window.Morph.bar.show();
2415
2990
  embed.applySaved().catch(() => {});
@@ -2417,7 +2992,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
2417
2992
  };
2418
2993
  }
2419
2994
 
2420
- return { createEmbed };
2995
+ return { createEmbed, matchesTrigger, isTypingTarget };
2421
2996
  });
2422
2997
 
2423
2998
  export function init(config) { return g.Morph.init(config); }