@drip-apex/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- package/src/wait.ts +238 -0
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
import { EDITOR_DEVICE_FRAME_NAME } from "../editor-bootstrap";
|
|
2
|
+
import type { Mutation } from "../types";
|
|
3
|
+
import { DevicePreview, type DevicePreviewSize } from "./device-preview";
|
|
4
|
+
import { ElementPicker } from "./picker";
|
|
5
|
+
import { elementLabel } from "./selector";
|
|
6
|
+
import type { EditorController } from "./controller";
|
|
7
|
+
import {
|
|
8
|
+
redactEditorContextUrl,
|
|
9
|
+
runEditorOperatorTurn,
|
|
10
|
+
type EditorOperatorMessage,
|
|
11
|
+
} from "./operator";
|
|
12
|
+
import { cloneArtifacts, type EditorArtifacts, type EditorState } from "./types";
|
|
13
|
+
import { KIT_CSS, esc, icon, operatorMark } from "./view-kit";
|
|
14
|
+
|
|
15
|
+
const DOCK_WIDTH = 352;
|
|
16
|
+
const RAIL_WIDTH = 52;
|
|
17
|
+
const SESSION_WARN_SECONDS = 300;
|
|
18
|
+
|
|
19
|
+
const VIEW_CSS = `
|
|
20
|
+
.zone{position:fixed;left:0;top:0;bottom:0;right:${DOCK_WIDTH}px;pointer-events:none}
|
|
21
|
+
.zone.rail{right:${RAIL_WIDTH}px}
|
|
22
|
+
.zone.narrow{right:0}
|
|
23
|
+
.page-border{position:absolute;inset:0;pointer-events:none;box-shadow:inset 0 0 0 3px rgba(91,41,255,0.75)}
|
|
24
|
+
.select-banner{position:absolute;top:12px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:9px;padding:7px 12px;background:var(--purple500);color:#fff;border-radius:999px;border:1px solid rgba(255,255,255,0.25);box-shadow:0 12px 32px rgba(91,41,255,0.45);font-size:12.5px;font-weight:600;white-space:nowrap;pointer-events:auto}
|
|
25
|
+
.select-banner button{border:none;background:rgba(255,255,255,0.16);color:#fff;font-size:11.5px;font-weight:600;border-radius:999px;padding:3px 9px}
|
|
26
|
+
.banners{position:absolute;left:50%;transform:translateX(-50%);display:grid;gap:8px;width:max-content;max-width:calc(100% - 24px);pointer-events:none}
|
|
27
|
+
.banners.lower{top:54px}
|
|
28
|
+
.banners:not(.lower){top:12px}
|
|
29
|
+
.orig-chip{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;background:rgba(10,10,13,0.92);border:1px solid var(--border-px);color:var(--text2);font-size:12px;font-weight:550;white-space:nowrap;pointer-events:auto}
|
|
30
|
+
.orig-chip svg{color:var(--purple300)}
|
|
31
|
+
.orig-chip button{border:none;background:var(--purple-soft);color:var(--purple200);font-size:11.5px;font-weight:600;border-radius:999px;padding:3px 9px}
|
|
32
|
+
|
|
33
|
+
.dock{position:fixed;right:0;top:0;bottom:0;width:${DOCK_WIDTH}px;background:var(--bg1);border-left:1px solid var(--border-px);display:flex;flex-direction:column;min-height:0;pointer-events:auto}
|
|
34
|
+
.rail-dock{position:fixed;right:0;top:0;bottom:0;width:${RAIL_WIDTH}px;background:var(--bg1);border-left:1px solid var(--border-px);display:flex;flex-direction:column;align-items:center;gap:10px;padding:12px 0;pointer-events:auto}
|
|
35
|
+
.rail-dock .sep{width:24px;height:1px;background:var(--border)}
|
|
36
|
+
.rail-dock .grow{flex:1}
|
|
37
|
+
|
|
38
|
+
.id-head{padding:12px 14px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
|
39
|
+
.id-row{display:flex;align-items:center;gap:9px}
|
|
40
|
+
.id-meta{flex:1;min-width:0}
|
|
41
|
+
.id-title{font-size:13px;font-weight:600;letter-spacing:-0.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
42
|
+
.id-sub{font-size:11px;color:var(--text3);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
43
|
+
.id-chips{display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap}
|
|
44
|
+
.id-chips .grow{flex:1}
|
|
45
|
+
.id-conn{margin-top:8px}
|
|
46
|
+
|
|
47
|
+
.var-wrap{position:relative}
|
|
48
|
+
.var-btn{display:flex;align-items:center;gap:8px;padding:5px 9px;background:var(--raised);border:1px solid var(--border-hi);border-radius:8px;color:var(--text1);font-size:12.5px;font-weight:550;max-width:190px;pointer-events:auto}
|
|
49
|
+
.var-btn .vid{width:18px;height:18px;border-radius:5px;background:var(--purple-soft);border:1px solid var(--purple-bd);color:var(--purple200);font-size:10.5px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
|
|
50
|
+
.var-btn .vname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
51
|
+
.var-menu{position:absolute;top:calc(100% + 6px);left:0;z-index:250;width:248px;background:var(--bg1);border:1px solid var(--border-px);border-radius:10px;box-shadow:0 20px 48px rgba(0,0,0,0.55);padding:5px;pointer-events:auto}
|
|
52
|
+
.var-menu .note{font-size:11px;color:var(--text3);padding:6px 9px 4px}
|
|
53
|
+
.var-item{display:flex;align-items:center;gap:9px;width:100%;text-align:left;padding:8px 9px;border-radius:7px;border:none;background:transparent;color:var(--text1)}
|
|
54
|
+
.var-item[aria-selected="true"]{background:var(--purple-soft)}
|
|
55
|
+
.var-item .vid{width:20px;height:20px;border-radius:5px;flex-shrink:0;background:var(--raised-hi);color:#fff;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center}
|
|
56
|
+
.var-item[aria-selected="true"] .vid{background:var(--purple500)}
|
|
57
|
+
.var-item .meta{flex:1;min-width:0}
|
|
58
|
+
.var-item .n{display:block;font-size:12.5px;font-weight:550;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
59
|
+
.var-item .s{display:block;font-size:11px;color:var(--text3)}
|
|
60
|
+
|
|
61
|
+
.view-row{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--border);flex-shrink:0}
|
|
62
|
+
.view-row .grow{flex:1}
|
|
63
|
+
.undo-redo{display:flex;gap:2px}
|
|
64
|
+
|
|
65
|
+
.tabs{display:flex;gap:2px;padding:0 8px;border-bottom:1px solid var(--border);flex-shrink:0}
|
|
66
|
+
.tab{display:inline-flex;align-items:center;gap:6px;padding:9px 10px 8px;background:none;border:none;border-bottom:2px solid transparent;color:var(--text3);font-size:12.5px;font-weight:550;margin-bottom:-1px;transition:color 120ms;pointer-events:auto}
|
|
67
|
+
.tab[aria-selected="true"]{color:var(--text1);border-bottom-color:var(--purple400)}
|
|
68
|
+
.tab .count{font-size:10.5px;font-weight:650;background:var(--raised-hi);color:var(--text3);border-radius:999px;padding:1px 6px}
|
|
69
|
+
.tab[aria-selected="true"] .count{background:var(--purple-soft);color:var(--purple200)}
|
|
70
|
+
|
|
71
|
+
.panel{flex:1;min-height:0;display:flex;flex-direction:column}
|
|
72
|
+
.foot{display:flex;align-items:center;gap:10px;padding:10px 14px;border-top:1px solid var(--border);flex-shrink:0}
|
|
73
|
+
.foot .grow{flex:1}
|
|
74
|
+
|
|
75
|
+
/* Operator */
|
|
76
|
+
.op-ctx{padding:10px 14px 8px;border-bottom:1px solid var(--border);flex-shrink:0;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
77
|
+
.op-ctx .lbl{font-size:11.5px;color:var(--text3)}
|
|
78
|
+
.op-ctx .opt{font-size:11.5px;color:var(--text4)}
|
|
79
|
+
.op-thread{flex:1;overflow-y:auto;min-height:0;padding:12px 14px;display:flex;flex-direction:column;gap:10px}
|
|
80
|
+
.op-empty{margin:auto 0;text-align:center;padding:18px 6px}
|
|
81
|
+
.op-empty .mark{width:42px;height:42px;margin:0 auto 12px;border-radius:12px;background:var(--purple-soft);border:1px solid var(--purple-bd);display:flex;align-items:center;justify-content:center}
|
|
82
|
+
.op-empty .t{font-size:13.5px;font-weight:600;color:var(--text1);margin-bottom:5px}
|
|
83
|
+
.op-empty p{font-size:12.5px;color:var(--text3);line-height:1.6;margin:0 0 14px}
|
|
84
|
+
.op-empty .sugs{display:flex;flex-direction:column;gap:6px;align-items:stretch}
|
|
85
|
+
.sug{font-size:12px;color:var(--text2);background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:7px 11px;text-align:left;pointer-events:auto}
|
|
86
|
+
.msg-user{align-self:flex-end;max-width:88%;display:grid;gap:4px;justify-items:end}
|
|
87
|
+
.msg-user .ref{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;color:var(--purple200);background:var(--purple-soft);border:1px solid var(--purple-bd);border-radius:6px;padding:2px 7px}
|
|
88
|
+
.msg-user .bubble{background:var(--purple500);color:#fff;border-radius:12px 12px 3px 12px;padding:8px 11px;font-size:12.5px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
|
89
|
+
.op-card{align-self:stretch;border-radius:12px;padding:10px 12px}
|
|
90
|
+
.op-card.working{background:var(--inset);border:1px solid var(--border)}
|
|
91
|
+
.op-card.failed{background:rgba(235,82,82,0.08);border:1px solid rgba(235,82,82,0.3)}
|
|
92
|
+
.op-card.done{background:rgba(61,214,140,0.06);border:1px solid rgba(61,214,140,0.28)}
|
|
93
|
+
.op-card .head{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;margin-bottom:7px}
|
|
94
|
+
.op-card.working .head{color:var(--text1);margin-bottom:8px}
|
|
95
|
+
.op-card.failed .head{color:var(--negative-soft);margin-bottom:5px}
|
|
96
|
+
.op-card.done .head{color:var(--positive)}
|
|
97
|
+
.op-card .steps{display:grid;gap:7px}
|
|
98
|
+
.op-card .step{display:flex;align-items:center;gap:9px;font-size:12px}
|
|
99
|
+
.op-card .step.done{color:var(--text3)}
|
|
100
|
+
.op-card .step.cur{color:var(--text1)}
|
|
101
|
+
.op-card .step.todo{color:var(--text4)}
|
|
102
|
+
.op-card .step .idle{width:12px;text-align:center;color:var(--text4)}
|
|
103
|
+
.op-card p{font-size:12px;color:var(--text2);line-height:1.55;margin:0}
|
|
104
|
+
.op-card .items{display:grid;gap:6px}
|
|
105
|
+
.op-card .item{font-size:12px;color:var(--text2);display:flex;gap:8px;align-items:baseline}
|
|
106
|
+
.op-card .item .b{flex:1;line-height:1.45}
|
|
107
|
+
.op-card .item .sel{display:block;font-size:10.5px;color:var(--text4);word-break:break-all}
|
|
108
|
+
.op-card .row{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
|
|
109
|
+
.op-card .hint{font-size:10.5px;color:var(--text4);margin-top:8px}
|
|
110
|
+
.op-input{padding:10px 14px 12px;border-top:1px solid var(--border);flex-shrink:0}
|
|
111
|
+
.op-input .line{display:flex;gap:8px;align-items:flex-end}
|
|
112
|
+
.op-input textarea{flex:1;min-height:52px;max-height:120px}
|
|
113
|
+
.op-input .send{width:34px;height:34px;flex:0 0 34px;border-radius:8px;background:var(--purple500);color:#fff;display:inline-flex;align-items:center;justify-content:center;border:1px solid rgba(255,255,255,0.14);pointer-events:auto}
|
|
114
|
+
.op-input .send:disabled{opacity:0.4}
|
|
115
|
+
.op-input .note{font-size:10.5px;color:var(--text4);margin-top:6px}
|
|
116
|
+
|
|
117
|
+
/* Code */
|
|
118
|
+
.code-panel{display:flex;flex-direction:column;min-height:0;overflow-y:auto;flex:1}
|
|
119
|
+
.code-head{padding:10px 14px 0;display:flex;align-items:center;gap:8px}
|
|
120
|
+
.code-head .peer{font-size:11px;color:var(--text4)}
|
|
121
|
+
.code-body{padding:12px 14px;display:grid;gap:10px}
|
|
122
|
+
.code-ok{font-size:11.5px;color:var(--text3);display:flex;gap:7px;align-items:center}
|
|
123
|
+
.code-ok svg{color:var(--positive)}
|
|
124
|
+
.js-warn{display:flex;gap:9px;padding:9px 11px;border-radius:9px;background:rgba(250,204,21,0.08);border:1px solid rgba(250,204,21,0.3);font-size:11.5px;line-height:1.55;color:var(--text2)}
|
|
125
|
+
.js-warn svg{color:var(--warning);flex-shrink:0;margin-top:1px}
|
|
126
|
+
.js-warn strong{color:var(--warning);font-weight:600}
|
|
127
|
+
.code-foot{border-top:1px solid var(--border);padding:10px 14px 12px;font-size:11.5px;color:var(--text3);line-height:1.6}
|
|
128
|
+
.code-foot button{border:none;background:none;color:var(--purple200);font-size:11.5px;padding:0;text-decoration:underline;text-underline-offset:2px;pointer-events:auto}
|
|
129
|
+
|
|
130
|
+
/* Changes */
|
|
131
|
+
.chg-panel{overflow-y:auto;flex:1;min-height:0}
|
|
132
|
+
.chg-head{padding:12px 14px 8px;display:flex;align-items:center;gap:8px}
|
|
133
|
+
.chg-head .t{font-size:13px;font-weight:600;color:var(--text1);flex:1}
|
|
134
|
+
.chg-empty{font-size:12.5px;color:var(--text3);padding:4px 14px 14px;line-height:1.6}
|
|
135
|
+
.chg-list{display:grid;gap:6px;padding:2px 14px 12px}
|
|
136
|
+
.chg-row{display:flex;gap:9px;align-items:flex-start;padding:9px 10px;background:var(--inset);border:1px solid var(--border);border-radius:9px}
|
|
137
|
+
.chg-row .org{margin-top:1px;flex-shrink:0;color:var(--purple300)}
|
|
138
|
+
.chg-row .meta{flex:1;min-width:0}
|
|
139
|
+
.chg-row .l{font-size:12.5px;color:var(--text1);font-weight:550;line-height:1.4;word-break:break-word}
|
|
140
|
+
.chg-row .s{font-size:11px;color:var(--text4);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
141
|
+
.chg-art{border-top:1px solid var(--border);padding:10px 14px 12px}
|
|
142
|
+
.chg-art .toggle{display:inline-flex;align-items:center;gap:7px;border:none;background:none;color:var(--text2);font-size:12px;font-weight:550;padding:0;pointer-events:auto}
|
|
143
|
+
.chg-art .json{margin-top:8px;background:var(--inset);border:1px solid var(--border);border-radius:9px;padding:10px 12px;font-size:11px;line-height:1.65;color:var(--text2);white-space:pre-wrap;word-break:break-word;font-variant-numeric:tabular-nums;max-height:280px;overflow:auto}
|
|
144
|
+
.chg-art .note{font-size:11.5px;color:var(--text3);line-height:1.55;margin-top:10px}
|
|
145
|
+
.chg-art .pill-row{margin-top:8px}
|
|
146
|
+
|
|
147
|
+
/* Boot overlay */
|
|
148
|
+
.boot{position:fixed;inset:0;z-index:400;background:#08080A;display:flex;align-items:center;justify-content:center;pointer-events:auto}
|
|
149
|
+
.boot[hidden]{display:none}
|
|
150
|
+
.boot .box{width:330px;max-width:calc(100vw - 48px)}
|
|
151
|
+
.boot .kicker{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:15px;font-weight:650;letter-spacing:-0.02em}
|
|
152
|
+
.boot .sub{font-size:12.5px;color:var(--text3);margin-bottom:22px}
|
|
153
|
+
.boot .steps{display:grid;gap:12px}
|
|
154
|
+
.boot .step{display:flex;align-items:center;gap:10px;font-size:13px}
|
|
155
|
+
.boot .step.done{color:var(--text3)}
|
|
156
|
+
.boot .step.cur{color:#fff}
|
|
157
|
+
.boot .step.todo{color:var(--text4)}
|
|
158
|
+
.boot .step .idle{width:14px;text-align:center}
|
|
159
|
+
.boot .bar{margin-top:26px;height:3px;border-radius:999px;background:var(--raised-hi);overflow:hidden}
|
|
160
|
+
.boot .bar i{display:block;height:100%;background:var(--purple500);border-radius:999px;transition:width 400ms ease}
|
|
161
|
+
.boot .scope{margin-top:22px;font-size:11.5px;color:var(--text4);line-height:1.6}
|
|
162
|
+
.boot .err{margin-top:18px;font-size:12.5px;color:var(--negative-soft);line-height:1.55}
|
|
163
|
+
|
|
164
|
+
/* Picker overlay */
|
|
165
|
+
.picker-box{position:fixed;left:0;top:0;border:2px solid var(--purple400);background:rgba(91,41,255,0.08);pointer-events:none;z-index:200}
|
|
166
|
+
.picker-box[data-state="hover"]{border-style:dashed}
|
|
167
|
+
.picker-box[data-state="selected"]{border-style:solid;border-color:var(--purple500)}
|
|
168
|
+
.picker-label{position:fixed;left:0;top:0;padding:3px 8px;border-radius:6px;background:var(--purple500);color:#fff;font-size:11px;font-weight:600;white-space:nowrap;pointer-events:none;z-index:201}
|
|
169
|
+
|
|
170
|
+
/* Device preview */
|
|
171
|
+
.device{position:fixed;left:0;top:0;bottom:0;right:${DOCK_WIDTH}px;background:var(--bg0);display:flex;align-items:center;justify-content:center;padding:24px;pointer-events:auto;z-index:90}
|
|
172
|
+
.device.narrow{right:0}
|
|
173
|
+
.device[hidden]{display:none}
|
|
174
|
+
.device-shell{display:flex;flex-direction:column;gap:12px;align-items:center;max-width:100%;max-height:100%}
|
|
175
|
+
.device-head{width:min(420px,100%);display:flex;align-items:center;justify-content:space-between;color:var(--text2);font-size:12px}
|
|
176
|
+
.device-frame{border:1px solid var(--border-hi);border-radius:10px;background:#fff;box-shadow:0 24px 64px rgba(0,0,0,0.5);max-width:calc(100% - 8px);max-height:calc(100vh - 140px)}
|
|
177
|
+
.device-fallback{width:390px;max-width:calc(100vw - 48px);padding:16px;background:var(--raised);border:1px solid var(--border-hi);border-radius:10px;color:var(--text2);font-size:12px;line-height:1.6}
|
|
178
|
+
.device-fallback .row{display:flex;gap:8px;margin-top:10px}
|
|
179
|
+
|
|
180
|
+
/* Narrow viewport: bottom sheet */
|
|
181
|
+
.sheet{position:fixed;left:0;right:0;bottom:0;background:var(--bg1);border-top:1px solid var(--border-px);border-radius:16px 16px 0 0;box-shadow:0 -16px 48px rgba(0,0,0,0.5);display:flex;flex-direction:column;pointer-events:auto;max-height:64vh}
|
|
182
|
+
.sheet .handle{background:none;border:none;padding:8px 0 4px;display:flex;justify-content:center;width:100%;pointer-events:auto}
|
|
183
|
+
.sheet .handle i{width:36px;height:4px;border-radius:999px;background:var(--border-px);display:block}
|
|
184
|
+
.sheet .bar{display:flex;align-items:center;gap:8px;padding:2px 14px 10px;flex-wrap:wrap}
|
|
185
|
+
.sheet .bar .grow{flex:1}
|
|
186
|
+
.sheet .row2{display:flex;align-items:center;gap:8px;padding:0 14px 8px;flex-wrap:wrap}
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
type PanelTab = "operator" | "code" | "changes";
|
|
190
|
+
|
|
191
|
+
type ThreadUserMessage = {
|
|
192
|
+
id: number;
|
|
193
|
+
role: "user";
|
|
194
|
+
text: string;
|
|
195
|
+
ctxLabel: string | null;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type ThreadOperatorMessage = {
|
|
199
|
+
id: number;
|
|
200
|
+
role: "op";
|
|
201
|
+
phase: "working" | "done" | "failed" | "save-failed";
|
|
202
|
+
step: number;
|
|
203
|
+
steps: string[];
|
|
204
|
+
prompt: string;
|
|
205
|
+
ctxSelector: string | null;
|
|
206
|
+
ctxLabel: string | null;
|
|
207
|
+
preArtifacts: EditorArtifacts;
|
|
208
|
+
postArtifacts: EditorArtifacts | null;
|
|
209
|
+
results: Array<{ label: string; target: string }>;
|
|
210
|
+
error: string | null;
|
|
211
|
+
undone: boolean;
|
|
212
|
+
abort: AbortController | null;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
type ThreadMessage = ThreadUserMessage | ThreadOperatorMessage;
|
|
216
|
+
|
|
217
|
+
type ChangeEntry = {
|
|
218
|
+
kind: "mutation" | "css" | "js";
|
|
219
|
+
selector: string | null;
|
|
220
|
+
mutationIndex: number | null;
|
|
221
|
+
label: string;
|
|
222
|
+
target: string;
|
|
223
|
+
origin: "operator" | "code";
|
|
224
|
+
risky: boolean;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
type SaveChipStatus = "unsaved" | "saving" | "saved" | "failed" | "conflict" | "loading";
|
|
228
|
+
|
|
229
|
+
const OPERATOR_SUGGESTIONS = [
|
|
230
|
+
"Make the announcement bar mention free returns",
|
|
231
|
+
"Add a trust message below the buy button",
|
|
232
|
+
"Make this section more compact",
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
function mutationLabel(mutation: Mutation): string {
|
|
236
|
+
const target = elementNameFromSelector(mutation.selector);
|
|
237
|
+
switch (mutation.action) {
|
|
238
|
+
case "text":
|
|
239
|
+
case "setText":
|
|
240
|
+
return `${target}: “${(mutation.text ?? "").trim().slice(0, 80)}”`;
|
|
241
|
+
case "html":
|
|
242
|
+
return `${target}: content replaced`;
|
|
243
|
+
case "attribute": {
|
|
244
|
+
const names = [
|
|
245
|
+
...(mutation.attribute ? [mutation.attribute.name] : []),
|
|
246
|
+
...Object.keys(mutation.attributes ?? {}),
|
|
247
|
+
];
|
|
248
|
+
if (names.includes("src")) return `${target}: image swapped`;
|
|
249
|
+
return `${target}: ${names.join(", ") || "attribute"} changed`;
|
|
250
|
+
}
|
|
251
|
+
case "style":
|
|
252
|
+
case "setStyle":
|
|
253
|
+
return `${target}: style changed`;
|
|
254
|
+
case "remove":
|
|
255
|
+
return `${target}: removed`;
|
|
256
|
+
case "insertBefore":
|
|
257
|
+
case "insertAfter":
|
|
258
|
+
return `${target}: content added`;
|
|
259
|
+
default:
|
|
260
|
+
return `${target}: updated`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function elementNameFromSelector(selector: string): string {
|
|
265
|
+
const last = selector.split(/[\s>+~]+/).filter(Boolean).pop() ?? selector;
|
|
266
|
+
return last.slice(0, 60) || "Element";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function saveChipStatus(state: EditorState): SaveChipStatus {
|
|
270
|
+
if (state.saveState === "saving") return "saving";
|
|
271
|
+
if (state.saveState === "saved") return "saved";
|
|
272
|
+
if (state.saveState === "conflict") return "conflict";
|
|
273
|
+
if (state.saveState === "error") return "failed";
|
|
274
|
+
if (!state.variation) return "loading";
|
|
275
|
+
return "unsaved";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function saveChipHtml(state: EditorState): string {
|
|
279
|
+
const status = saveChipStatus(state);
|
|
280
|
+
const label =
|
|
281
|
+
status === "unsaved" ? "Unsaved changes"
|
|
282
|
+
: status === "saving" ? "Saving…"
|
|
283
|
+
: status === "saved" ? "Saved"
|
|
284
|
+
: status === "failed" ? "Save failed"
|
|
285
|
+
: status === "conflict" ? "Conflict"
|
|
286
|
+
: "Loading draft…";
|
|
287
|
+
const iconHtml =
|
|
288
|
+
status === "saving" || status === "loading" ? icon("refresh", 12, 'class="spin"')
|
|
289
|
+
: status === "saved" ? icon("check", 12)
|
|
290
|
+
: status === "unsaved" ? icon("dot", 12)
|
|
291
|
+
: icon("alert", 12);
|
|
292
|
+
const retry = status === "failed"
|
|
293
|
+
? `<button type="button" class="retry" data-action="retry-save">Retry</button>`
|
|
294
|
+
: "";
|
|
295
|
+
const chipClass = status === "loading" ? "saving" : status;
|
|
296
|
+
return `<span class="save-chip ${chipClass}" role="status" aria-live="polite">${iconHtml}${label}${retry}</span>`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function changeListFromState(state: EditorState): ChangeEntry[] {
|
|
300
|
+
const items: ChangeEntry[] = state.artifacts.mutations.map((mutation, mutationIndex) => ({
|
|
301
|
+
kind: "mutation" as const,
|
|
302
|
+
selector: mutation.selector,
|
|
303
|
+
mutationIndex,
|
|
304
|
+
label: mutationLabel(mutation),
|
|
305
|
+
target: mutation.selector,
|
|
306
|
+
origin: "operator" as const,
|
|
307
|
+
risky: false,
|
|
308
|
+
}));
|
|
309
|
+
if (state.artifacts.customCss.trim()) {
|
|
310
|
+
items.push({
|
|
311
|
+
kind: "css",
|
|
312
|
+
selector: null,
|
|
313
|
+
mutationIndex: null,
|
|
314
|
+
label: "Custom CSS",
|
|
315
|
+
target: `${state.artifacts.customCss.trim().split("\n").length} lines`,
|
|
316
|
+
origin: "code",
|
|
317
|
+
risky: false,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (state.artifacts.customJs.trim()) {
|
|
321
|
+
items.push({
|
|
322
|
+
kind: "js",
|
|
323
|
+
selector: null,
|
|
324
|
+
mutationIndex: null,
|
|
325
|
+
label: "Custom JavaScript",
|
|
326
|
+
target: "runs when the variation loads",
|
|
327
|
+
origin: "code",
|
|
328
|
+
risky: state.reloadRequired,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return items;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function describeRunChanges(
|
|
335
|
+
before: EditorArtifacts,
|
|
336
|
+
after: EditorArtifacts,
|
|
337
|
+
): Array<{ label: string; target: string }> {
|
|
338
|
+
const beforeKeys = new Set(
|
|
339
|
+
before.mutations.map((mutation) => JSON.stringify(mutation)),
|
|
340
|
+
);
|
|
341
|
+
const results = after.mutations
|
|
342
|
+
.filter((mutation) => !beforeKeys.has(JSON.stringify(mutation)))
|
|
343
|
+
.map((mutation) => ({
|
|
344
|
+
label: mutationLabel(mutation),
|
|
345
|
+
target: mutation.selector,
|
|
346
|
+
}));
|
|
347
|
+
if (after.customCss.trim() !== before.customCss.trim()) {
|
|
348
|
+
results.push({ label: "Custom CSS updated", target: "variation stylesheet" });
|
|
349
|
+
}
|
|
350
|
+
if (after.customJs.trim() !== before.customJs.trim()) {
|
|
351
|
+
results.push({ label: "Custom JavaScript updated", target: "variation script" });
|
|
352
|
+
}
|
|
353
|
+
return results;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function formatMinutes(seconds: number): string {
|
|
357
|
+
const m = Math.floor(seconds / 60);
|
|
358
|
+
const s = Math.max(0, Math.floor(seconds % 60));
|
|
359
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export type EditorView = { destroy(): void };
|
|
363
|
+
|
|
364
|
+
export function mountEditorView(
|
|
365
|
+
shadow: ShadowRoot,
|
|
366
|
+
controller: EditorController,
|
|
367
|
+
): EditorView {
|
|
368
|
+
const style = document.createElement("style");
|
|
369
|
+
style.textContent = KIT_CSS + VIEW_CSS;
|
|
370
|
+
const chrome = document.createElement("div");
|
|
371
|
+
const zone = document.createElement("div");
|
|
372
|
+
zone.className = "zone";
|
|
373
|
+
const box = document.createElement("div");
|
|
374
|
+
box.className = "picker-box";
|
|
375
|
+
box.hidden = true;
|
|
376
|
+
const label = document.createElement("div");
|
|
377
|
+
label.className = "picker-label";
|
|
378
|
+
label.hidden = true;
|
|
379
|
+
const device = document.createElement("div");
|
|
380
|
+
device.className = "device";
|
|
381
|
+
device.hidden = true;
|
|
382
|
+
const boot = document.createElement("div");
|
|
383
|
+
boot.className = "boot";
|
|
384
|
+
shadow.append(style, zone, chrome, box, label, device, boot);
|
|
385
|
+
|
|
386
|
+
const session = controller.session;
|
|
387
|
+
const targetHost = (() => {
|
|
388
|
+
try {
|
|
389
|
+
return new URL(session.targetUrl).host;
|
|
390
|
+
} catch {
|
|
391
|
+
return session.targetOrigin.replace(/^https?:\/\//, "");
|
|
392
|
+
}
|
|
393
|
+
})();
|
|
394
|
+
const targetPath = (() => {
|
|
395
|
+
try {
|
|
396
|
+
const url = new URL(session.targetUrl);
|
|
397
|
+
return url.pathname + url.search;
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
})();
|
|
402
|
+
const experimentTitle = session.experimentName?.trim() || "On-site editor";
|
|
403
|
+
const dashboardVariationUrl = (variationId: string) =>
|
|
404
|
+
`${controller.apiOrigin}/tests/${encodeURIComponent(session.experimentId)}/variations/${encodeURIComponent(variationId)}`;
|
|
405
|
+
const sessionSecondsLeft = () =>
|
|
406
|
+
Math.max(0, Math.floor((Date.parse(session.expiresAt) - Date.now()) / 1000));
|
|
407
|
+
|
|
408
|
+
// ── view-local state ──────────────────────────────────────────────────
|
|
409
|
+
let latestState = controller.getState();
|
|
410
|
+
let panelTab: PanelTab = "operator";
|
|
411
|
+
let codeLang: "css" | "js" = "css";
|
|
412
|
+
let collapsed = false;
|
|
413
|
+
let sheetOpen = false;
|
|
414
|
+
let narrow = false;
|
|
415
|
+
let varMenuOpen = false;
|
|
416
|
+
let viewMode: "variation" | "original" = "variation";
|
|
417
|
+
let ctxSelector: string | null = latestState.selectedSelector;
|
|
418
|
+
let ctxLabel: string | null = latestState.selectedElement
|
|
419
|
+
? elementLabel(latestState.selectedElement)
|
|
420
|
+
: latestState.selectedSelector;
|
|
421
|
+
let devicePick: "desktop" | "tablet" | "mobile" = "desktop";
|
|
422
|
+
let conn: "connected" | "offline" = navigator.onLine === false ? "offline" : "connected";
|
|
423
|
+
let banner: "unsupported" | "spanav" | null = null;
|
|
424
|
+
let spaDismissed = false;
|
|
425
|
+
let modal: "stop" | "expired" | null = null;
|
|
426
|
+
let artifactOpen = false;
|
|
427
|
+
let operatorDraft = "";
|
|
428
|
+
let operatorBusy = false;
|
|
429
|
+
let operatorRunPromise: Promise<void> | null = null;
|
|
430
|
+
let variationSwitch = 0;
|
|
431
|
+
let lastSavedAt: number | null = null;
|
|
432
|
+
let sessionWarn = sessionSecondsLeft() <= SESSION_WARN_SECONDS;
|
|
433
|
+
let bootDone = false;
|
|
434
|
+
let messageId = 0;
|
|
435
|
+
const thread: ThreadMessage[] = [];
|
|
436
|
+
const operatorConversation: EditorOperatorMessage[] = [];
|
|
437
|
+
|
|
438
|
+
const media = typeof window.matchMedia === "function"
|
|
439
|
+
? window.matchMedia("(max-width: 759px)")
|
|
440
|
+
: null;
|
|
441
|
+
narrow = media?.matches ?? false;
|
|
442
|
+
|
|
443
|
+
const devicePreview = new DevicePreview(
|
|
444
|
+
createDeviceFrame(),
|
|
445
|
+
(deviceState) => {
|
|
446
|
+
controller.setDeviceState(deviceState);
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
controller.attachDevicePreview(devicePreview);
|
|
450
|
+
|
|
451
|
+
function createDeviceFrame(): HTMLIFrameElement {
|
|
452
|
+
device.innerHTML = `<div class="device-shell">
|
|
453
|
+
<div class="device-head"><span class="d-title">Device preview</span><button type="button" class="iconbtn" data-action="device-close" aria-label="Close device preview">${icon("x", 13)}</button></div>
|
|
454
|
+
<iframe class="device-frame" name="${EDITOR_DEVICE_FRAME_NAME}" title="Store device preview"></iframe>
|
|
455
|
+
<div class="device-fallback" hidden>The store blocks device framing. Open this draft on a mobile device or use your browser's device mode.<div class="row"><button type="button" class="btn ghost sm" data-action="device-copy">Copy current URL</button></div></div>
|
|
456
|
+
</div>`;
|
|
457
|
+
return device.querySelector("iframe")!;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const frame = device.querySelector("iframe")!;
|
|
461
|
+
const fallback = device.querySelector<HTMLElement>(".device-fallback")!;
|
|
462
|
+
|
|
463
|
+
const picker = new ElementPicker(
|
|
464
|
+
{ box, label },
|
|
465
|
+
(element, selector) => {
|
|
466
|
+
controller.selectElement(element, selector);
|
|
467
|
+
picker.setSelected(element);
|
|
468
|
+
ctxSelector = selector;
|
|
469
|
+
ctxLabel = elementLabel(element);
|
|
470
|
+
panelTab = "operator";
|
|
471
|
+
if (narrow) sheetOpen = true;
|
|
472
|
+
render();
|
|
473
|
+
},
|
|
474
|
+
(active) => {
|
|
475
|
+
controller.setPickerActive(active);
|
|
476
|
+
render();
|
|
477
|
+
},
|
|
478
|
+
() => {
|
|
479
|
+
banner = "unsupported";
|
|
480
|
+
render();
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
// ── html builders ─────────────────────────────────────────────────────
|
|
485
|
+
const draftPill = (compact: boolean) =>
|
|
486
|
+
`<span class="draft-pill">${icon("eye", 11)}${compact ? "Draft" : "Draft — not visible to shoppers"}</span>`;
|
|
487
|
+
|
|
488
|
+
const connChip = () =>
|
|
489
|
+
conn === "offline"
|
|
490
|
+
? `<span class="conn-chip offline" role="status"><span class="dot err"><i></i><b></b></span>Offline — reconnecting…</span>`
|
|
491
|
+
: "";
|
|
492
|
+
|
|
493
|
+
const sessionNoteHtml = () => {
|
|
494
|
+
const left = sessionSecondsLeft();
|
|
495
|
+
if (left <= SESSION_WARN_SECONDS) {
|
|
496
|
+
return `<span class="session-chip" title="Editing session time remaining">${icon("clock", 12)}<span data-session-text>Session ends in ${formatMinutes(left)}</span><button type="button" class="extend" data-action="extend-session">Extend</button></span>`;
|
|
497
|
+
}
|
|
498
|
+
return `<span class="session-note" title="Editing session time remaining" data-session-text>Session · ${Math.floor(left / 60)} min left</span>`;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const variationSwitcherHtml = (compact: boolean) => {
|
|
502
|
+
const current = latestState.variation;
|
|
503
|
+
const currentName = current?.name || "Variation";
|
|
504
|
+
const initial = (currentName.trim()[0] || "V").toUpperCase();
|
|
505
|
+
const menu = varMenuOpen
|
|
506
|
+
? `<div class="var-menu ee-in" role="listbox" aria-label="Eligible variations">
|
|
507
|
+
<div class="note">Switching keeps your saved draft — nothing is lost.</div>
|
|
508
|
+
${session.variations.map((variation) => {
|
|
509
|
+
const on = variation.id === current?.id;
|
|
510
|
+
const name = variation.name || variation.slug || "Variation";
|
|
511
|
+
const vInitial = (name.trim()[0] || "V").toUpperCase();
|
|
512
|
+
return `<button type="button" class="var-item" role="option" aria-selected="${on}" data-action="variation-pick" data-variation="${esc(variation.id)}">
|
|
513
|
+
<span class="vid" aria-hidden="true">${esc(vInitial)}</span>
|
|
514
|
+
<span class="meta"><span class="n">${esc(name)}</span><span class="s">${on ? `${changeListFromState(latestState).length} change${changeListFromState(latestState).length === 1 ? "" : "s"} in draft` : "Draft"}</span></span>
|
|
515
|
+
${on ? icon("check", 13) : ""}
|
|
516
|
+
</button>`;
|
|
517
|
+
}).join("")}
|
|
518
|
+
</div>`
|
|
519
|
+
: "";
|
|
520
|
+
return `<div class="var-wrap">
|
|
521
|
+
<button type="button" class="var-btn" data-action="variation-open" aria-haspopup="listbox" aria-expanded="${varMenuOpen}" aria-label="Variation: ${esc(currentName)}. Switch variation">
|
|
522
|
+
<span class="vid" aria-hidden="true">${esc(initial)}</span>
|
|
523
|
+
${compact ? "" : `<span class="vname">${esc(currentName)}</span>`}
|
|
524
|
+
${icon("chevD", 12)}
|
|
525
|
+
</button>
|
|
526
|
+
${menu}
|
|
527
|
+
</div>`;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const compareSegHtml = () => {
|
|
531
|
+
const variationName = latestState.variation?.name || "Variation";
|
|
532
|
+
return `<div class="seg" role="radiogroup" aria-label="Compare original and variation">
|
|
533
|
+
<button type="button" role="radio" aria-checked="${viewMode === "original"}" data-action="compare" data-view="original">Original</button>
|
|
534
|
+
<button type="button" role="radio" aria-checked="${viewMode === "variation"}" data-action="compare" data-view="variation">${esc(variationName)}</button>
|
|
535
|
+
</div>`;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const deviceSegHtml = () => `<div class="seg" role="radiogroup" aria-label="Preview viewport">
|
|
539
|
+
<button type="button" class="sm" role="radio" aria-checked="${devicePick === "desktop"}" data-action="device" data-device="desktop" title="Desktop preview" aria-label="Desktop preview">${icon("monitor", 13)}</button>
|
|
540
|
+
<button type="button" class="sm" role="radio" aria-checked="${devicePick === "tablet"}" data-action="device" data-device="tablet" title="Tablet preview — 768px" aria-label="Tablet preview, 768 pixels">${icon("tablet", 13)}</button>
|
|
541
|
+
<button type="button" class="sm" role="radio" aria-checked="${devicePick === "mobile"}" data-action="device" data-device="mobile" title="Mobile preview — 390px" aria-label="Mobile preview, 390 pixels">${icon("phone", 13)}</button>
|
|
542
|
+
</div>`;
|
|
543
|
+
|
|
544
|
+
const undoRedoHtml = () => `<div class="undo-redo">
|
|
545
|
+
<button type="button" class="iconbtn" data-action="undo" aria-label="Undo" title="Undo" ${latestState.canUndo ? "" : "disabled"}>${icon("undo", 15)}</button>
|
|
546
|
+
<button type="button" class="iconbtn" data-action="redo" aria-label="Redo" title="Redo" ${latestState.canRedo ? "" : "disabled"}>${icon("redo", 15)}</button>
|
|
547
|
+
</div>`;
|
|
548
|
+
|
|
549
|
+
const tabsHtml = () => {
|
|
550
|
+
const count = changeListFromState(latestState).length;
|
|
551
|
+
const tab = (id: PanelTab, labelText: string, iconHtml: string, badge?: number) =>
|
|
552
|
+
`<button type="button" class="tab" role="tab" aria-selected="${panelTab === id}" data-action="tab" data-tab="${id}">${iconHtml}${labelText}${badge ? `<span class="count">${badge}</span>` : ""}</button>`;
|
|
553
|
+
return `<div class="tabs" role="tablist" aria-label="Editor panels">
|
|
554
|
+
${tab("operator", "Operator", operatorMark(12, panelTab === "operator" ? "#fff" : "rgba(255,255,255,0.55)"))}
|
|
555
|
+
${tab("code", "Code", icon("braces", 13))}
|
|
556
|
+
${tab("changes", "Changes", icon("history", 13), count)}
|
|
557
|
+
</div>`;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const ctxChipHtml = (inThread: boolean, labelText: string) =>
|
|
561
|
+
`<span class="ctx-chip">${icon("cursorBox", 12)}<span class="name">${esc(labelText)}</span>${inThread ? "" : `<button type="button" class="replace" data-action="ctx-replace" aria-label="Replace selected element">Replace</button><button type="button" class="clear" data-action="ctx-clear" aria-label="Clear selected element">${icon("x", 11)}</button>`}</span>`;
|
|
562
|
+
|
|
563
|
+
const operatorPanelHtml = () => {
|
|
564
|
+
const pickerActive = latestState.pickerActive;
|
|
565
|
+
const ctxBar = ctxSelector && ctxLabel
|
|
566
|
+
? `<span class="lbl">Talking about</span>${ctxChipHtml(false, ctxLabel)}`
|
|
567
|
+
: `<button type="button" class="btn sm ${pickerActive ? "primary" : "soft"}" data-action="inspect-toggle">${icon("pointer", 13)}${pickerActive ? "Click something on the page…" : "Point at an element"}</button><span class="opt">optional — you can also just describe the change</span>`;
|
|
568
|
+
|
|
569
|
+
const empty = thread.length === 0;
|
|
570
|
+
const emptyHtml = `<div class="op-empty">
|
|
571
|
+
<div class="mark" aria-hidden="true">${operatorMark(20)}</div>
|
|
572
|
+
<div class="t">What should change on this page?</div>
|
|
573
|
+
<p>Point at an element or just describe it. Operator makes the change on the draft — you see it on the real page immediately.</p>
|
|
574
|
+
<div class="sugs">${OPERATOR_SUGGESTIONS.map((suggestion) => `<button type="button" class="sug" data-action="suggestion">${esc(suggestion)}</button>`).join("")}</div>
|
|
575
|
+
</div>`;
|
|
576
|
+
|
|
577
|
+
const messages = thread.map((message) => {
|
|
578
|
+
if (message.role === "user") {
|
|
579
|
+
return `<div class="msg-user">${message.ctxLabel ? `<span class="ref">${icon("cursorBox", 10)} ${esc(message.ctxLabel)}</span>` : ""}<div class="bubble">${esc(message.text)}</div></div>`;
|
|
580
|
+
}
|
|
581
|
+
if (message.phase === "working") {
|
|
582
|
+
return `<div class="op-card working ee-in">
|
|
583
|
+
<div class="head">${operatorMark(13)}<span>Operator</span>${message.abort ? `<button type="button" class="btn sm bare" style="margin-left:auto;padding:2px 6px" data-action="op-stop" data-msg="${message.id}">${icon("stop", 12)}Stop</button>` : ""}</div>
|
|
584
|
+
<div class="steps" role="status" aria-live="polite">${message.steps.map((step, index) => {
|
|
585
|
+
const state = index < message.step ? "done" : index === message.step ? "cur" : "todo";
|
|
586
|
+
const mark = state === "done" ? icon("check", 12) : state === "cur" ? icon("refresh", 12, 'class="spin"') : `<span class="idle">·</span>`;
|
|
587
|
+
return `<div class="step ${state}">${mark}${esc(step)}</div>`;
|
|
588
|
+
}).join("")}</div>
|
|
589
|
+
</div>`;
|
|
590
|
+
}
|
|
591
|
+
if (message.phase === "failed") {
|
|
592
|
+
return `<div class="op-card failed ee-in" role="alert">
|
|
593
|
+
<div class="head">${icon("alert", 13)} Operator couldn't finish</div>
|
|
594
|
+
<p>${esc(message.error ?? "Something went wrong.")} Nothing was changed on the draft.</p>
|
|
595
|
+
<div class="row"><button type="button" class="btn sm soft" data-action="op-retry" data-msg="${message.id}">${icon("refresh", 12)}Try again</button></div>
|
|
596
|
+
</div>`;
|
|
597
|
+
}
|
|
598
|
+
if (message.phase === "save-failed") {
|
|
599
|
+
return `<div class="op-card failed ee-in" role="alert">
|
|
600
|
+
<div class="head">${icon("alert", 13)} Change applied, but not saved</div>
|
|
601
|
+
<p>${esc(message.error ?? "The draft could not be saved.")} The proposed change remains on this page so you can review it and retry saving.</p>
|
|
602
|
+
<div class="row"><button type="button" class="btn sm soft" data-action="retry-save" data-msg="${message.id}">${icon("refresh", 12)}Retry save</button><button type="button" class="btn sm bare" data-action="op-all-changes">${icon("history", 12)}Review changes</button></div>
|
|
603
|
+
</div>`;
|
|
604
|
+
}
|
|
605
|
+
const count = message.results.length;
|
|
606
|
+
const canUndo = canUndoRun(message);
|
|
607
|
+
return `<div class="op-card done ee-in">
|
|
608
|
+
<div class="head">${icon("check", 13)} ${count === 1 ? "Made 1 change" : `Made ${count} changes`} — it's on the page now</div>
|
|
609
|
+
<div class="items">${message.results.map((result) => `<div class="item">${operatorMark(11)}<span class="b">${esc(result.label)}<span class="sel">${esc(result.target)}</span></span></div>`).join("")}</div>
|
|
610
|
+
<div class="row">
|
|
611
|
+
<button type="button" class="btn sm ghost" data-action="op-undo-run" data-msg="${message.id}" ${canUndo ? "" : "disabled"}>${icon("undo", 12)}${message.undone ? "Undone" : "Undo this"}</button>
|
|
612
|
+
<button type="button" class="btn sm bare" data-action="op-all-changes">${icon("history", 12)}All changes</button>
|
|
613
|
+
</div>
|
|
614
|
+
<div class="hint">Not right? Just tell Operator what to adjust.</div>
|
|
615
|
+
</div>`;
|
|
616
|
+
}).join("");
|
|
617
|
+
|
|
618
|
+
const placeholder = ctxLabel
|
|
619
|
+
? `What should change about the ${ctxLabel.toLowerCase()}?`
|
|
620
|
+
: "Describe a change…";
|
|
621
|
+
|
|
622
|
+
return `<div class="panel" role="tabpanel" aria-label="Operator">
|
|
623
|
+
<div class="op-ctx">${ctxBar}</div>
|
|
624
|
+
<div class="op-thread" data-op-thread>${empty ? emptyHtml : messages}</div>
|
|
625
|
+
<form class="op-input" data-operator-form>
|
|
626
|
+
<div class="line">
|
|
627
|
+
<textarea class="field" data-field="operator-prompt" rows="2" maxlength="30000" placeholder="${esc(placeholder)}" aria-label="Describe the change for Operator" ${operatorBusy ? "disabled" : ""}>${esc(operatorDraft)}</textarea>
|
|
628
|
+
<button type="submit" class="send" aria-label="Send to Operator" title="Send to Operator" ${operatorBusy || !latestState.variation ? "disabled" : ""}>${icon("send", 15)}</button>
|
|
629
|
+
</div>
|
|
630
|
+
<div class="note">Operator edits this draft only — shoppers never see it.</div>
|
|
631
|
+
</form>
|
|
632
|
+
</div>`;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const codePanelHtml = () => {
|
|
636
|
+
const cssBody = `<div class="code-body">
|
|
637
|
+
<label><span class="flabel">CSS on this variation</span>
|
|
638
|
+
<textarea class="field code" data-field="css" rows="9" spellcheck="false" aria-label="Custom CSS for this variation">${esc(latestState.artifacts.customCss)}</textarea>
|
|
639
|
+
<span class="fhint">Scoped to the experiment page. Applies live as you type.</span>
|
|
640
|
+
</label>
|
|
641
|
+
<div class="code-ok">${icon("check", 12)} CSS changes are fully undoable.</div>
|
|
642
|
+
</div>`;
|
|
643
|
+
const jsBody = `<div class="code-body">
|
|
644
|
+
<label><span class="flabel">JavaScript on this variation</span>
|
|
645
|
+
<textarea class="field code" data-field="js" rows="9" spellcheck="false" aria-label="Custom JavaScript for this variation">${esc(latestState.artifacts.customJs)}</textarea>
|
|
646
|
+
</label>
|
|
647
|
+
<div class="js-warn" role="note">${icon("alert", 14)}<span>JavaScript changes the live page directly and <strong>can't be undone by the editor</strong>. Undo removes it from the draft; close and relaunch the editor to fully reset the page.</span></div>
|
|
648
|
+
<div style="display:flex;gap:8px">
|
|
649
|
+
<button type="button" class="btn soft" data-action="run-js">${icon("play", 13)}Run on page</button>
|
|
650
|
+
</div>
|
|
651
|
+
${latestState.reloadRequired ? `<div class="code-ok" style="color:var(--warning)">${icon("alert", 12, 'style="color:var(--warning)"')} JavaScript ran on this page. Relaunch the editor to fully reset it.</div>` : ""}
|
|
652
|
+
</div>`;
|
|
653
|
+
return `<div class="code-panel" role="tabpanel" aria-label="Code">
|
|
654
|
+
<div class="code-head">
|
|
655
|
+
<div class="seg" role="radiogroup" aria-label="Code language">
|
|
656
|
+
<button type="button" role="radio" aria-checked="${codeLang === "css"}" data-action="code-lang" data-lang="css">${icon("braces", 13)}CSS</button>
|
|
657
|
+
<button type="button" role="radio" aria-checked="${codeLang === "js"}" data-action="code-lang" data-lang="js">${icon("code", 13)}JavaScript</button>
|
|
658
|
+
</div>
|
|
659
|
+
<span class="peer">works without Operator</span>
|
|
660
|
+
</div>
|
|
661
|
+
${codeLang === "css" ? cssBody : jsBody}
|
|
662
|
+
<div class="code-foot">Code and Operator write to the same draft. Operator's changes appear as artifact operations you can inspect under <button type="button" data-action="view-artifact">Changes → View artifact</button>. Full code authoring also lives in the variation workspace in Apex.</div>
|
|
663
|
+
</div>`;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const changesPanelHtml = () => {
|
|
667
|
+
const items = changeListFromState(latestState);
|
|
668
|
+
const rows = items.map((item, index) => `<div class="chg-row">
|
|
669
|
+
<span class="org" title="${item.origin === "operator" ? "Made by Operator" : "Custom code"}">${item.origin === "operator" ? operatorMark(13) : icon(item.kind === "css" ? "braces" : "code", 13)}</span>
|
|
670
|
+
<div class="meta">
|
|
671
|
+
<div class="l">${esc(item.label)}</div>
|
|
672
|
+
<div class="s">${esc(item.target)} · ${item.origin === "operator" ? "Operator" : "code"}${item.risky ? " · relaunch the editor to fully revert" : ""}</div>
|
|
673
|
+
</div>
|
|
674
|
+
<button type="button" class="iconbtn xs" data-action="chg-remove" data-kind="${item.kind}" data-index="${item.mutationIndex ?? index}" aria-label="Remove change: ${esc(item.label)}">${icon("x", 11)}</button>
|
|
675
|
+
</div>`).join("");
|
|
676
|
+
const artifactJson = JSON.stringify(
|
|
677
|
+
{
|
|
678
|
+
variation: latestState.variation?.name ?? null,
|
|
679
|
+
artifactHash: latestState.variation?.artifactHash ?? null,
|
|
680
|
+
operations: latestState.artifacts.mutations,
|
|
681
|
+
customCss: latestState.artifacts.customCss || null,
|
|
682
|
+
customJs: latestState.artifacts.customJs || null,
|
|
683
|
+
},
|
|
684
|
+
null,
|
|
685
|
+
2,
|
|
686
|
+
);
|
|
687
|
+
return `<div class="chg-panel" role="tabpanel" aria-label="Changes">
|
|
688
|
+
<div class="chg-head"><span class="t">${items.length} change${items.length === 1 ? "" : "s"} in this draft</span>${saveChipHtml(latestState)}</div>
|
|
689
|
+
${items.length === 0
|
|
690
|
+
? `<p class="chg-empty">No changes yet. Ask Operator, or add CSS or JavaScript in Code.</p>`
|
|
691
|
+
: `<div class="chg-list">${rows}</div>`}
|
|
692
|
+
<div class="chg-art">
|
|
693
|
+
<button type="button" class="toggle" data-action="artifact-toggle" aria-expanded="${artifactOpen}">${icon(artifactOpen ? "chevD" : "chevR", 12)}View artifact (JSON)</button>
|
|
694
|
+
${artifactOpen ? `<div class="json ee-in" aria-label="Variation artifact JSON">${esc(artifactJson)}</div>` : ""}
|
|
695
|
+
<div class="note">Drafts save automatically. Nothing is shown to shoppers until the experiment is launched from Apex.</div>
|
|
696
|
+
<div class="pill-row">${draftPill(false)}</div>
|
|
697
|
+
</div>
|
|
698
|
+
</div>`;
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const panelBodyHtml = () =>
|
|
702
|
+
panelTab === "operator" ? operatorPanelHtml()
|
|
703
|
+
: panelTab === "code" ? codePanelHtml()
|
|
704
|
+
: changesPanelHtml();
|
|
705
|
+
|
|
706
|
+
const dockHtml = () => {
|
|
707
|
+
if (collapsed) {
|
|
708
|
+
return `<div class="rail-dock">
|
|
709
|
+
${operatorMark(16)}
|
|
710
|
+
<button type="button" class="iconbtn" data-action="expand" aria-label="Expand editor panel">${icon("collapse", 15)}</button>
|
|
711
|
+
<div class="sep"></div>
|
|
712
|
+
<button type="button" class="iconbtn ${latestState.pickerActive ? "active" : ""}" data-action="rail-inspect" aria-label="Point at an element">${icon("pointer", 15)}</button>
|
|
713
|
+
<div class="grow"></div>
|
|
714
|
+
<span title="Connection: ${conn}"><span class="dot ${conn === "connected" ? "ok" : "err"}"><i></i>${conn === "connected" ? "" : "<b></b>"}</span></span>
|
|
715
|
+
</div>`;
|
|
716
|
+
}
|
|
717
|
+
return `<div class="dock">
|
|
718
|
+
<div class="id-head">
|
|
719
|
+
<div class="id-row">
|
|
720
|
+
${operatorMark(18)}
|
|
721
|
+
<div class="id-meta">
|
|
722
|
+
<div class="id-title" title="${esc(experimentTitle)}">${esc(experimentTitle)}</div>
|
|
723
|
+
<div class="id-sub">On your store · ${esc(targetHost)}</div>
|
|
724
|
+
</div>
|
|
725
|
+
<button type="button" class="iconbtn" data-action="collapse" aria-label="Collapse editor panel">${icon("collapse", 15)}</button>
|
|
726
|
+
</div>
|
|
727
|
+
<div class="id-chips">
|
|
728
|
+
${variationSwitcherHtml(false)}
|
|
729
|
+
${draftPill(true)}
|
|
730
|
+
<span class="grow"></span>
|
|
731
|
+
${saveChipHtml(latestState)}
|
|
732
|
+
</div>
|
|
733
|
+
${conn !== "connected" ? `<div class="id-conn">${connChip()}</div>` : ""}
|
|
734
|
+
</div>
|
|
735
|
+
<div class="view-row">
|
|
736
|
+
${undoRedoHtml()}
|
|
737
|
+
${compareSegHtml()}
|
|
738
|
+
<span class="grow"></span>
|
|
739
|
+
${deviceSegHtml()}
|
|
740
|
+
</div>
|
|
741
|
+
${tabsHtml()}
|
|
742
|
+
${panelBodyHtml()}
|
|
743
|
+
<div class="foot">
|
|
744
|
+
${sessionNoteHtml()}
|
|
745
|
+
<span class="grow"></span>
|
|
746
|
+
<button type="button" class="btn primary" data-action="done">${icon("check", 13)}Done</button>
|
|
747
|
+
</div>
|
|
748
|
+
</div>`;
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const sheetHtml = () => {
|
|
752
|
+
const barRow = `<div class="bar">
|
|
753
|
+
${operatorMark(14)}
|
|
754
|
+
${variationSwitcherHtml(true)}
|
|
755
|
+
${draftPill(true)}
|
|
756
|
+
<span class="grow"></span>
|
|
757
|
+
${saveChipHtml(latestState)}
|
|
758
|
+
</div>`;
|
|
759
|
+
if (!sheetOpen) {
|
|
760
|
+
return `<div class="sheet">
|
|
761
|
+
<button type="button" class="handle" data-action="sheet-toggle" aria-label="Expand editor" aria-expanded="false"><i></i></button>
|
|
762
|
+
${barRow}
|
|
763
|
+
</div>`;
|
|
764
|
+
}
|
|
765
|
+
return `<div class="sheet">
|
|
766
|
+
<button type="button" class="handle" data-action="sheet-toggle" aria-label="Collapse editor" aria-expanded="true"><i></i></button>
|
|
767
|
+
${barRow}
|
|
768
|
+
<div class="row2">
|
|
769
|
+
${undoRedoHtml()}
|
|
770
|
+
${compareSegHtml()}
|
|
771
|
+
${sessionNoteHtml()}
|
|
772
|
+
<span class="grow"></span>
|
|
773
|
+
<button type="button" class="btn primary sm" data-action="done">${icon("check", 12)}Done</button>
|
|
774
|
+
</div>
|
|
775
|
+
${tabsHtml()}
|
|
776
|
+
${panelBodyHtml()}
|
|
777
|
+
</div>`;
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const zoneHtml = () => {
|
|
781
|
+
const selectMode = latestState.pickerActive;
|
|
782
|
+
const banners: string[] = [];
|
|
783
|
+
if (banner === "unsupported") {
|
|
784
|
+
banners.push(`<div class="banner warn ee-in" role="status">${icon("alert", 14)}<span class="body">That element can't be edited here. Point at an element on the storefront page instead.</span><button type="button" class="btn sm ghost" data-action="banner-dismiss">OK</button></div>`);
|
|
785
|
+
}
|
|
786
|
+
if (banner === "spanav") {
|
|
787
|
+
banners.push(`<div class="banner info ee-in" role="status">${icon("globe", 14)}<span class="body">You navigated to ${esc(location.pathname + location.search)}. This variation targets ${esc(targetPath || "")}, so changes are paused until you return.</span><button type="button" class="btn sm soft" data-action="spa-back">Back to target page</button><button type="button" class="btn sm ghost" data-action="banner-dismiss">Stay</button></div>`);
|
|
788
|
+
}
|
|
789
|
+
if (conn === "offline") {
|
|
790
|
+
banners.push(`<div class="banner error ee-in" role="status">${icon("wifiOff", 14)}<span class="body">Connection lost. Your edits are kept locally and will save when you're back online.</span></div>`);
|
|
791
|
+
}
|
|
792
|
+
return `${selectMode ? `<div class="page-border" aria-hidden="true"></div><div class="select-banner ee-in">${icon("pointer", 13)}Click an element to talk about it<button type="button" data-action="back-to-browsing">Back to browsing · Esc</button></div>` : ""}
|
|
793
|
+
${banners.length ? `<div class="banners ${selectMode ? "lower" : ""}">${banners.join("")}</div>` : ""}
|
|
794
|
+
${viewMode === "original" ? `<div class="orig-chip ee-in">${icon("eye", 13)}Viewing the original — draft changes hidden<button type="button" data-action="show-variation">Show ${esc(latestState.variation?.name || "variation")}</button></div>` : ""}`;
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const modalHtml = () => {
|
|
798
|
+
if (latestState.saveState === "conflict") {
|
|
799
|
+
return `<div class="modal-backdrop"><div class="modal ee-in" role="dialog" aria-modal="true" aria-label="This draft changed somewhere else">
|
|
800
|
+
<div class="m-head"><span class="m-icon warn">${icon("alert", 15)}</span><div class="m-title">This draft changed somewhere else</div></div>
|
|
801
|
+
<div class="m-body">
|
|
802
|
+
<p>Someone saved a newer version of <strong style="color:var(--text1)">${esc(latestState.variation?.name || "this variation")}</strong> while you were editing — likely from the Apex dashboard.</p>
|
|
803
|
+
<p>Loading the newer version discards your unsaved edits here. Keeping yours overwrites theirs.</p>
|
|
804
|
+
</div>
|
|
805
|
+
<div class="m-actions"><button type="button" class="btn ghost" data-action="conflict-mine">Keep my version</button><button type="button" class="btn primary" data-action="conflict-theirs">Load newer version</button></div>
|
|
806
|
+
</div></div>`;
|
|
807
|
+
}
|
|
808
|
+
if (modal === "expired") {
|
|
809
|
+
const savedAgo = lastSavedAt
|
|
810
|
+
? `${Math.max(1, Math.round((Date.now() - lastSavedAt) / 1000))} seconds ago`
|
|
811
|
+
: null;
|
|
812
|
+
return `<div class="modal-backdrop"><div class="modal ee-in" role="dialog" aria-modal="true" aria-label="Editing session ended" style="width:410px">
|
|
813
|
+
<div class="m-head"><span class="m-icon purple">${icon("clock", 15)}</span><div class="m-title">Editing session ended</div></div>
|
|
814
|
+
<div class="m-body">
|
|
815
|
+
<p>Sessions are time-limited for security. ${savedAgo ? `Your draft was saved <strong style="color:var(--text1)">${savedAgo}</strong> — nothing was lost.` : "Your saved draft is safe in Apex."}</p>
|
|
816
|
+
<p>Start a new session from the variation in Apex.</p>
|
|
817
|
+
</div>
|
|
818
|
+
<div class="m-actions"><button type="button" class="btn ghost" data-action="expired-close">Close editor</button><button type="button" class="btn primary" data-action="expired-apex">${icon("external", 13)}Open variation in Apex</button></div>
|
|
819
|
+
</div></div>`;
|
|
820
|
+
}
|
|
821
|
+
if (modal === "stop") {
|
|
822
|
+
const items = changeListFromState(latestState);
|
|
823
|
+
return `<div class="modal-backdrop"><div class="modal ee-in" role="dialog" aria-modal="true" aria-label="Done editing?">
|
|
824
|
+
<div class="m-head"><span class="m-icon ok">${icon("check", 15)}</span><div class="m-title">Done editing?</div></div>
|
|
825
|
+
<div class="m-body">
|
|
826
|
+
<p>${items.length} change${items.length === 1 ? "" : "s"} saved to the ${esc(latestState.variation?.name || "variation")} draft. The editor will be removed from the page and the session closed.</p>
|
|
827
|
+
${items.length ? `<div style="margin-top:12px;display:grid;gap:5px">${items.slice(0, 4).map((item) => `<div style="font-size:12px;color:var(--text3);display:flex;gap:7px;align-items:center">${icon("check", 11, 'style="color:var(--positive)"')} ${esc(item.label)}</div>`).join("")}</div>` : ""}
|
|
828
|
+
<p style="font-size:12px;color:var(--text3)">Launching the experiment still happens from Apex — closing here never starts traffic.</p>
|
|
829
|
+
</div>
|
|
830
|
+
<div class="m-actions"><button type="button" class="btn ghost" data-action="modal-keep">Keep editing</button><button type="button" class="btn primary" data-action="modal-save-close">${icon("check", 13)}Save and close</button></div>
|
|
831
|
+
</div></div>`;
|
|
832
|
+
}
|
|
833
|
+
return "";
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const bootHtml = () => {
|
|
837
|
+
const step = latestState.variation ? 3 : 2;
|
|
838
|
+
const error = !latestState.variation && latestState.saveState === "error";
|
|
839
|
+
const ttlMinutes = Math.max(1, Math.round((Date.parse(session.expiresAt) - Date.now()) / 60000));
|
|
840
|
+
const steps = [
|
|
841
|
+
"Starting a secure editing session",
|
|
842
|
+
`Connecting to ${targetHost}`,
|
|
843
|
+
`Loading the ${latestState.variation?.name || "variation"} draft`,
|
|
844
|
+
];
|
|
845
|
+
return `<div class="box">
|
|
846
|
+
<div class="kicker">${operatorMark(22)} Apex</div>
|
|
847
|
+
<div class="sub">On-site editor · ${esc(experimentTitle)}</div>
|
|
848
|
+
<div class="steps" role="status" aria-live="polite">${steps.map((stepLabel, index) => {
|
|
849
|
+
const state = index < step ? "done" : index === step ? "cur" : "todo";
|
|
850
|
+
const mark = state === "done" ? icon("check", 14, 'style="color:var(--positive)"')
|
|
851
|
+
: state === "cur" ? icon("refresh", 14, 'class="spin" style="color:var(--purple300)"')
|
|
852
|
+
: `<span class="idle">·</span>`;
|
|
853
|
+
return `<div class="step ${state}">${mark}${esc(stepLabel)}</div>`;
|
|
854
|
+
}).join("")}</div>
|
|
855
|
+
<div class="bar"><i style="width:${Math.min(100, (step / 3) * 100 + 12)}%"></i></div>
|
|
856
|
+
${error ? `<div class="err">${esc(latestState.saveMessage || "The draft could not be loaded.")}<div style="margin-top:10px"><button type="button" class="btn soft sm" data-action="boot-retry">Try again</button></div></div>` : ""}
|
|
857
|
+
<div class="scope">Session is scoped to this experiment and store, and expires after ${ttlMinutes} minutes. Drafts only — shoppers never see your edits.</div>
|
|
858
|
+
</div>`;
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
// ── render ────────────────────────────────────────────────────────────
|
|
862
|
+
const render = () => {
|
|
863
|
+
latestState = controller.getState();
|
|
864
|
+
if (!bootDone && latestState.variation) bootDone = true;
|
|
865
|
+
boot.hidden = bootDone;
|
|
866
|
+
if (!bootDone) boot.innerHTML = bootHtml();
|
|
867
|
+
|
|
868
|
+
const active = shadow.activeElement as HTMLInputElement | HTMLTextAreaElement | null;
|
|
869
|
+
const activeField = active?.dataset?.field;
|
|
870
|
+
const selection = active && "selectionStart" in active
|
|
871
|
+
? [active.selectionStart, active.selectionEnd] as const
|
|
872
|
+
: null;
|
|
873
|
+
|
|
874
|
+
zone.className = `zone${narrow ? " narrow" : collapsed ? " rail" : ""}`;
|
|
875
|
+
zone.innerHTML = bootDone ? zoneHtml() : "";
|
|
876
|
+
device.className = `device${narrow ? " narrow" : ""}`;
|
|
877
|
+
chrome.innerHTML = bootDone
|
|
878
|
+
? `${narrow ? sheetHtml() : dockHtml()}${modalHtml()}`
|
|
879
|
+
: "";
|
|
880
|
+
(shadow.host as HTMLElement).dataset.apexEditorSheetState = narrow
|
|
881
|
+
? (sheetOpen ? "expanded" : "collapsed")
|
|
882
|
+
: "desktop";
|
|
883
|
+
|
|
884
|
+
device.hidden = latestState.deviceState === "closed";
|
|
885
|
+
frame.hidden = latestState.deviceState === "blocked";
|
|
886
|
+
fallback.hidden = latestState.deviceState !== "blocked";
|
|
887
|
+
const deviceTitle = device.querySelector<HTMLElement>(".d-title");
|
|
888
|
+
if (deviceTitle) {
|
|
889
|
+
deviceTitle.textContent = devicePick === "tablet"
|
|
890
|
+
? "Tablet preview · 768 × 1024"
|
|
891
|
+
: "Mobile preview · 390 × 844";
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (activeField) {
|
|
895
|
+
const next = chrome.querySelector<HTMLInputElement | HTMLTextAreaElement>(`[data-field="${activeField}"]`);
|
|
896
|
+
next?.focus();
|
|
897
|
+
if (next && selection && "setSelectionRange" in next) {
|
|
898
|
+
next.setSelectionRange(selection[0], selection[1]);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
scrollThread();
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const scrollThread = () => {
|
|
905
|
+
const threadElement = chrome.querySelector<HTMLElement>("[data-op-thread]");
|
|
906
|
+
if (threadElement) threadElement.scrollTop = threadElement.scrollHeight;
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const unsubscribe = controller.subscribe(() => render());
|
|
910
|
+
|
|
911
|
+
const artifactsEqual = (left: EditorArtifacts, right: EditorArtifacts) =>
|
|
912
|
+
JSON.stringify(left) === JSON.stringify(right);
|
|
913
|
+
|
|
914
|
+
function canUndoRun(message: ThreadOperatorMessage): boolean {
|
|
915
|
+
if (message.phase !== "done" || message.undone || !message.postArtifacts) return false;
|
|
916
|
+
const latestApplicable = [...thread].reverse().find(
|
|
917
|
+
(entry): entry is ThreadOperatorMessage => entry.role === "op" && entry.phase === "done" && !entry.undone,
|
|
918
|
+
);
|
|
919
|
+
return latestApplicable?.id === message.id &&
|
|
920
|
+
artifactsEqual(controller.getState().artifacts, message.postArtifacts);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ── operator run ──────────────────────────────────────────────────────
|
|
924
|
+
const startOperatorRun = (prompt: string, runCtxSelector: string | null, runCtxLabel: string | null) => {
|
|
925
|
+
if (!latestState.variation || operatorBusy) return;
|
|
926
|
+
const preArtifacts = cloneArtifacts(controller.getState().artifacts);
|
|
927
|
+
const referencedElement = runCtxSelector
|
|
928
|
+
? {
|
|
929
|
+
selector: runCtxSelector,
|
|
930
|
+
tag: latestState.selectedSelector === runCtxSelector
|
|
931
|
+
? latestState.selectedElement?.tagName.toLowerCase()
|
|
932
|
+
: undefined,
|
|
933
|
+
textPreview: latestState.selectedSelector === runCtxSelector
|
|
934
|
+
? (latestState.selectedElement?.textContent ?? "")
|
|
935
|
+
.replace(/\s+/g, " ")
|
|
936
|
+
.trim()
|
|
937
|
+
.slice(0, 200) || undefined
|
|
938
|
+
: undefined,
|
|
939
|
+
}
|
|
940
|
+
: null;
|
|
941
|
+
const abort = new AbortController();
|
|
942
|
+
const userMessage: ThreadUserMessage = {
|
|
943
|
+
id: ++messageId,
|
|
944
|
+
role: "user",
|
|
945
|
+
text: prompt,
|
|
946
|
+
ctxLabel: runCtxLabel,
|
|
947
|
+
};
|
|
948
|
+
const opMessage: ThreadOperatorMessage = {
|
|
949
|
+
id: ++messageId,
|
|
950
|
+
role: "op",
|
|
951
|
+
phase: "working",
|
|
952
|
+
step: 0,
|
|
953
|
+
steps: [
|
|
954
|
+
runCtxLabel ? `Inspecting the ${runCtxLabel.toLowerCase()}` : "Reading the page",
|
|
955
|
+
"Asking Operator to draft the change",
|
|
956
|
+
"Applying the change to the draft",
|
|
957
|
+
],
|
|
958
|
+
prompt,
|
|
959
|
+
ctxSelector: runCtxSelector,
|
|
960
|
+
ctxLabel: runCtxLabel,
|
|
961
|
+
preArtifacts,
|
|
962
|
+
postArtifacts: null,
|
|
963
|
+
results: [],
|
|
964
|
+
error: null,
|
|
965
|
+
undone: false,
|
|
966
|
+
abort,
|
|
967
|
+
};
|
|
968
|
+
thread.push(userMessage, opMessage);
|
|
969
|
+
operatorConversation.push({ role: "user", content: prompt });
|
|
970
|
+
operatorBusy = true;
|
|
971
|
+
if (referencedElement) {
|
|
972
|
+
ctxSelector = null;
|
|
973
|
+
ctxLabel = null;
|
|
974
|
+
picker.setSelected(null);
|
|
975
|
+
controller.clearSelectedElement();
|
|
976
|
+
}
|
|
977
|
+
render();
|
|
978
|
+
|
|
979
|
+
const stepTimer = setTimeout(() => {
|
|
980
|
+
if (opMessage.phase === "working") {
|
|
981
|
+
opMessage.step = 1;
|
|
982
|
+
render();
|
|
983
|
+
}
|
|
984
|
+
}, 600);
|
|
985
|
+
|
|
986
|
+
const pageContext = [
|
|
987
|
+
`Live merchant page: ${redactEditorContextUrl(session.targetUrl)}`,
|
|
988
|
+
runCtxSelector
|
|
989
|
+
? `The user selected this element as the request context: selector "${runCtxSelector}"${runCtxLabel ? ` (${runCtxLabel})` : ""}. Ground the change on it.`
|
|
990
|
+
: "",
|
|
991
|
+
].filter(Boolean).join("\n");
|
|
992
|
+
|
|
993
|
+
const runPromise = runEditorOperatorTurn({
|
|
994
|
+
apiOrigin: controller.apiOrigin,
|
|
995
|
+
session,
|
|
996
|
+
variationId: latestState.variation.id,
|
|
997
|
+
prompt,
|
|
998
|
+
artifacts: preArtifacts,
|
|
999
|
+
conversation: operatorConversation.slice(0, -1).slice(-12),
|
|
1000
|
+
pageContext,
|
|
1001
|
+
signal: abort.signal,
|
|
1002
|
+
selectedElement: referencedElement,
|
|
1003
|
+
}).then(async (result) => {
|
|
1004
|
+
clearTimeout(stepTimer);
|
|
1005
|
+
if (opMessage.phase !== "working" || abort.signal.aborted) return;
|
|
1006
|
+
opMessage.step = 2;
|
|
1007
|
+
render();
|
|
1008
|
+
if (abort.signal.aborted) return;
|
|
1009
|
+
const audit = controller.applyCommandArtifacts(result.artifacts);
|
|
1010
|
+
opMessage.postArtifacts = cloneArtifacts(controller.getState().artifacts);
|
|
1011
|
+
if (abort.signal.aborted) {
|
|
1012
|
+
controller.restoreArtifacts(preArtifacts);
|
|
1013
|
+
await controller.saveNow();
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
operatorConversation.push({ role: "assistant", content: result.explanation });
|
|
1017
|
+
opMessage.abort = null;
|
|
1018
|
+
await controller.saveNow();
|
|
1019
|
+
const saveState = controller.getState().saveState;
|
|
1020
|
+
if (saveState === "error" || saveState === "conflict") {
|
|
1021
|
+
opMessage.phase = "save-failed";
|
|
1022
|
+
opMessage.abort = null;
|
|
1023
|
+
opMessage.results = describeRunChanges(preArtifacts, controller.getState().artifacts);
|
|
1024
|
+
opMessage.error = controller.getState().saveMessage;
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
opMessage.phase = "done";
|
|
1028
|
+
opMessage.abort = null;
|
|
1029
|
+
opMessage.postArtifacts = cloneArtifacts(controller.getState().artifacts);
|
|
1030
|
+
opMessage.results = describeRunChanges(preArtifacts, controller.getState().artifacts);
|
|
1031
|
+
if (!opMessage.results.length) {
|
|
1032
|
+
opMessage.results = [{ label: result.explanation, target: "no page changes" }];
|
|
1033
|
+
}
|
|
1034
|
+
if (!audit.ok) {
|
|
1035
|
+
opMessage.results.push({
|
|
1036
|
+
label: "One selector needs review — check the result on the page.",
|
|
1037
|
+
target: audit.repairHint ?? "selector audit",
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
}).catch((error: unknown) => {
|
|
1041
|
+
clearTimeout(stepTimer);
|
|
1042
|
+
if (abort.signal.aborted) return;
|
|
1043
|
+
opMessage.phase = opMessage.postArtifacts ? "save-failed" : "failed";
|
|
1044
|
+
opMessage.abort = null;
|
|
1045
|
+
opMessage.error = error instanceof Error ? error.message : "Operator could not build the draft.";
|
|
1046
|
+
if (opMessage.postArtifacts) {
|
|
1047
|
+
opMessage.results = describeRunChanges(preArtifacts, controller.getState().artifacts);
|
|
1048
|
+
}
|
|
1049
|
+
}).finally(() => {
|
|
1050
|
+
if (abort.signal.aborted) {
|
|
1051
|
+
const userIndex = thread.indexOf(userMessage);
|
|
1052
|
+
if (userIndex >= 0) thread.splice(userIndex, 2);
|
|
1053
|
+
operatorConversation.pop();
|
|
1054
|
+
}
|
|
1055
|
+
if (operatorRunPromise === runPromise) operatorRunPromise = null;
|
|
1056
|
+
operatorBusy = false;
|
|
1057
|
+
render();
|
|
1058
|
+
});
|
|
1059
|
+
operatorRunPromise = runPromise;
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
const switchVariation = async (variationId: string) => {
|
|
1063
|
+
const request = ++variationSwitch;
|
|
1064
|
+
for (const message of thread) {
|
|
1065
|
+
if (message.role === "op" && message.phase === "working") message.abort?.abort();
|
|
1066
|
+
}
|
|
1067
|
+
await operatorRunPromise;
|
|
1068
|
+
if (request !== variationSwitch) return;
|
|
1069
|
+
await controller.saveNow();
|
|
1070
|
+
if (request !== variationSwitch) return;
|
|
1071
|
+
const saveState = controller.getState().saveState;
|
|
1072
|
+
if (saveState === "error" || saveState === "conflict") {
|
|
1073
|
+
render();
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
ctxSelector = null;
|
|
1077
|
+
ctxLabel = null;
|
|
1078
|
+
picker.setSelected(null);
|
|
1079
|
+
controller.clearSelection();
|
|
1080
|
+
thread.length = 0;
|
|
1081
|
+
operatorConversation.length = 0;
|
|
1082
|
+
viewMode = "variation";
|
|
1083
|
+
controller.toggleArtifacts(true);
|
|
1084
|
+
await controller.selectVariation(variationId);
|
|
1085
|
+
render();
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
const retrySave = async (message: ThreadOperatorMessage | null) => {
|
|
1089
|
+
const stillMatchesRun = Boolean(
|
|
1090
|
+
message?.postArtifacts && artifactsEqual(controller.getState().artifacts, message.postArtifacts),
|
|
1091
|
+
);
|
|
1092
|
+
await controller.saveNow();
|
|
1093
|
+
const saveState = controller.getState().saveState;
|
|
1094
|
+
if (message?.phase === "save-failed" && saveState !== "error" && saveState !== "conflict") {
|
|
1095
|
+
message.phase = "done";
|
|
1096
|
+
message.error = null;
|
|
1097
|
+
message.postArtifacts = stillMatchesRun
|
|
1098
|
+
? cloneArtifacts(controller.getState().artifacts)
|
|
1099
|
+
: null;
|
|
1100
|
+
}
|
|
1101
|
+
render();
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
// ── event wiring ──────────────────────────────────────────────────────
|
|
1105
|
+
const findMessage = (target: HTMLElement): ThreadOperatorMessage | null => {
|
|
1106
|
+
const id = Number(target.dataset.msg);
|
|
1107
|
+
const message = thread.find((entry) => entry.id === id);
|
|
1108
|
+
return message && message.role === "op" ? message : null;
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
const setDevice = (pick: "desktop" | "tablet" | "mobile") => {
|
|
1112
|
+
devicePick = pick;
|
|
1113
|
+
if (pick === "desktop") {
|
|
1114
|
+
controller.closeDevicePreview();
|
|
1115
|
+
render();
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const size: DevicePreviewSize = pick === "tablet"
|
|
1119
|
+
? { width: 768, height: 1024 }
|
|
1120
|
+
: { width: 390, height: 844 };
|
|
1121
|
+
frame.style.width = `${size.width}px`;
|
|
1122
|
+
frame.style.height = `${size.height}px`;
|
|
1123
|
+
controller.openDevicePreview();
|
|
1124
|
+
render();
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
const onChromeClick = (event: Event) => {
|
|
1128
|
+
const target = (event.target as Element).closest<HTMLElement>("[data-action]");
|
|
1129
|
+
if (!target) {
|
|
1130
|
+
if (varMenuOpen) {
|
|
1131
|
+
varMenuOpen = false;
|
|
1132
|
+
render();
|
|
1133
|
+
}
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const action = target.dataset.action;
|
|
1137
|
+
switch (action) {
|
|
1138
|
+
case "collapse": collapsed = true; render(); break;
|
|
1139
|
+
case "expand": collapsed = false; render(); break;
|
|
1140
|
+
case "rail-inspect": collapsed = false; picker.setActive(true); break;
|
|
1141
|
+
case "sheet-toggle": sheetOpen = !sheetOpen; render(); break;
|
|
1142
|
+
case "variation-open": varMenuOpen = !varMenuOpen; render(); break;
|
|
1143
|
+
case "variation-pick": {
|
|
1144
|
+
varMenuOpen = false;
|
|
1145
|
+
const variationId = target.dataset.variation;
|
|
1146
|
+
if (variationId && variationId !== latestState.variation?.id) {
|
|
1147
|
+
void switchVariation(variationId);
|
|
1148
|
+
}
|
|
1149
|
+
render();
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
case "tab": panelTab = (target.dataset.tab as PanelTab) ?? "operator"; render(); break;
|
|
1153
|
+
case "compare": {
|
|
1154
|
+
const nextView = target.dataset.view === "original" ? "original" : "variation";
|
|
1155
|
+
if (nextView !== viewMode) {
|
|
1156
|
+
viewMode = nextView;
|
|
1157
|
+
controller.toggleArtifacts(nextView === "variation");
|
|
1158
|
+
}
|
|
1159
|
+
render();
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
case "show-variation": viewMode = "variation"; controller.toggleArtifacts(true); render(); break;
|
|
1163
|
+
case "device": setDevice((target.dataset.device as "desktop" | "tablet" | "mobile") ?? "desktop"); break;
|
|
1164
|
+
case "undo": controller.undo(); break;
|
|
1165
|
+
case "redo": controller.redo(); break;
|
|
1166
|
+
case "inspect-toggle": picker.setActive(!latestState.pickerActive); break;
|
|
1167
|
+
case "back-to-browsing": picker.setActive(false); break;
|
|
1168
|
+
case "ctx-replace": picker.setActive(true); break;
|
|
1169
|
+
case "ctx-clear":
|
|
1170
|
+
ctxSelector = null;
|
|
1171
|
+
ctxLabel = null;
|
|
1172
|
+
picker.setSelected(null);
|
|
1173
|
+
controller.clearSelection();
|
|
1174
|
+
render();
|
|
1175
|
+
break;
|
|
1176
|
+
case "suggestion": operatorDraft = target.textContent?.trim() ?? ""; render(); break;
|
|
1177
|
+
case "op-stop": findMessage(target)?.abort?.abort(); break;
|
|
1178
|
+
case "op-retry": {
|
|
1179
|
+
const message = findMessage(target);
|
|
1180
|
+
if (message) startOperatorRun(message.prompt, message.ctxSelector, message.ctxLabel);
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
1183
|
+
case "op-undo-run": {
|
|
1184
|
+
const message = findMessage(target);
|
|
1185
|
+
if (message && canUndoRun(message)) {
|
|
1186
|
+
controller.restoreArtifacts(message.preArtifacts);
|
|
1187
|
+
message.undone = true;
|
|
1188
|
+
render();
|
|
1189
|
+
}
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
1192
|
+
case "op-all-changes": panelTab = "changes"; render(); break;
|
|
1193
|
+
case "code-lang": codeLang = target.dataset.lang === "js" ? "js" : "css"; render(); break;
|
|
1194
|
+
case "run-js": controller.applyCustomJs(); break;
|
|
1195
|
+
case "view-artifact": panelTab = "changes"; artifactOpen = true; render(); break;
|
|
1196
|
+
case "artifact-toggle": artifactOpen = !artifactOpen; render(); break;
|
|
1197
|
+
case "chg-remove": {
|
|
1198
|
+
const kind = target.dataset.kind;
|
|
1199
|
+
if (kind === "mutation") controller.removeMutation(Number(target.dataset.index));
|
|
1200
|
+
else if (kind === "css") controller.setCustomCss("");
|
|
1201
|
+
else if (kind === "js") controller.setCustomJs("");
|
|
1202
|
+
break;
|
|
1203
|
+
}
|
|
1204
|
+
case "retry-save": void retrySave(findMessage(target)); break;
|
|
1205
|
+
case "extend-session": void extendSession(); break;
|
|
1206
|
+
case "banner-dismiss":
|
|
1207
|
+
if (banner === "spanav") spaDismissed = true;
|
|
1208
|
+
banner = null;
|
|
1209
|
+
render();
|
|
1210
|
+
break;
|
|
1211
|
+
case "spa-back":
|
|
1212
|
+
banner = null;
|
|
1213
|
+
spaDismissed = false;
|
|
1214
|
+
location.assign(session.targetUrl);
|
|
1215
|
+
break;
|
|
1216
|
+
case "done": modal = "stop"; render(); break;
|
|
1217
|
+
case "modal-keep": modal = null; render(); break;
|
|
1218
|
+
case "modal-save-close":
|
|
1219
|
+
void controller.saveNow().then(() => {
|
|
1220
|
+
const saveState = controller.getState().saveState;
|
|
1221
|
+
if (saveState !== "error" && saveState !== "conflict") return controller.exit();
|
|
1222
|
+
render();
|
|
1223
|
+
}).catch(() => render());
|
|
1224
|
+
break;
|
|
1225
|
+
case "conflict-mine": void controller.resolveConflict("mine"); break;
|
|
1226
|
+
case "conflict-theirs": void controller.resolveConflict("theirs"); break;
|
|
1227
|
+
case "expired-close": void controller.exit(); break;
|
|
1228
|
+
case "expired-apex":
|
|
1229
|
+
window.open(dashboardVariationUrl(latestState.variation?.id ?? session.initialVariationId), "_blank", "noopener");
|
|
1230
|
+
break;
|
|
1231
|
+
case "boot-retry": void controller.selectVariation(session.initialVariationId); break;
|
|
1232
|
+
default: break;
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
chrome.addEventListener("click", onChromeClick);
|
|
1237
|
+
zone.addEventListener("click", onChromeClick);
|
|
1238
|
+
boot.addEventListener("click", onChromeClick);
|
|
1239
|
+
|
|
1240
|
+
chrome.addEventListener("input", (event) => {
|
|
1241
|
+
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
|
1242
|
+
if (target.dataset.field === "css") controller.setCustomCss(target.value);
|
|
1243
|
+
else if (target.dataset.field === "js") controller.setCustomJs(target.value);
|
|
1244
|
+
else if (target.dataset.field === "operator-prompt") operatorDraft = target.value;
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
chrome.addEventListener("submit", (event) => {
|
|
1248
|
+
const form = (event.target as Element).closest<HTMLFormElement>("[data-operator-form]");
|
|
1249
|
+
if (!form) return;
|
|
1250
|
+
event.preventDefault();
|
|
1251
|
+
const prompt = operatorDraft.trim();
|
|
1252
|
+
if (!prompt) return;
|
|
1253
|
+
operatorDraft = "";
|
|
1254
|
+
startOperatorRun(prompt, ctxSelector, ctxLabel);
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
device.addEventListener("click", (event) => {
|
|
1258
|
+
const action = (event.target as Element).closest<HTMLElement>("[data-action]")?.dataset.action;
|
|
1259
|
+
if (action === "device-close") setDevice("desktop");
|
|
1260
|
+
if (action === "device-copy") void navigator.clipboard?.writeText(location.href);
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
1264
|
+
const target = event.composedPath()[0];
|
|
1265
|
+
const typing =
|
|
1266
|
+
target instanceof HTMLInputElement ||
|
|
1267
|
+
target instanceof HTMLTextAreaElement ||
|
|
1268
|
+
target instanceof HTMLSelectElement ||
|
|
1269
|
+
(target instanceof HTMLElement && target.isContentEditable);
|
|
1270
|
+
if (event.key === "Escape") {
|
|
1271
|
+
if (modal === "stop") {
|
|
1272
|
+
modal = null;
|
|
1273
|
+
render();
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
if (latestState.deviceState !== "closed") {
|
|
1277
|
+
event.preventDefault();
|
|
1278
|
+
setDevice("desktop");
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (typing) return;
|
|
1284
|
+
if (event.key === "s" || event.key === "S") {
|
|
1285
|
+
picker.setActive(!latestState.pickerActive);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if ((event.metaKey || event.ctrlKey) && (event.key === "z" || event.key === "Z")) {
|
|
1289
|
+
event.preventDefault();
|
|
1290
|
+
if (event.shiftKey) controller.redo();
|
|
1291
|
+
else controller.undo();
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
1295
|
+
|
|
1296
|
+
// ── session & environment tickers ─────────────────────────────────────
|
|
1297
|
+
const extendSession = async () => {
|
|
1298
|
+
try {
|
|
1299
|
+
const expiresAt = await controller.extendSession();
|
|
1300
|
+
if (expiresAt) {
|
|
1301
|
+
sessionWarn = sessionSecondsLeft() <= SESSION_WARN_SECONDS;
|
|
1302
|
+
render();
|
|
1303
|
+
}
|
|
1304
|
+
} catch {
|
|
1305
|
+
// The footer keeps counting down; expiry handling still applies.
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
const previousSaveState = { value: latestState.saveState };
|
|
1310
|
+
const saveWatch = controller.subscribe((state) => {
|
|
1311
|
+
if (state.saveState === "saved" && previousSaveState.value !== "saved") {
|
|
1312
|
+
lastSavedAt = Date.now();
|
|
1313
|
+
}
|
|
1314
|
+
previousSaveState.value = state.saveState;
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
const ticker = setInterval(() => {
|
|
1318
|
+
const left = sessionSecondsLeft();
|
|
1319
|
+
if (left <= 0 && modal !== "expired") {
|
|
1320
|
+
modal = "expired";
|
|
1321
|
+
picker.setActive(false);
|
|
1322
|
+
render();
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const warn = left <= SESSION_WARN_SECONDS;
|
|
1326
|
+
if (warn !== sessionWarn) {
|
|
1327
|
+
sessionWarn = warn;
|
|
1328
|
+
render();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
const node = chrome.querySelector<HTMLElement>("[data-session-text]");
|
|
1332
|
+
if (node) {
|
|
1333
|
+
node.textContent = warn
|
|
1334
|
+
? `Session ends in ${formatMinutes(left)}`
|
|
1335
|
+
: `Session · ${Math.floor(left / 60)} min left`;
|
|
1336
|
+
}
|
|
1337
|
+
if (targetPath && bootDone) {
|
|
1338
|
+
const offTarget = location.pathname + location.search !== targetPath;
|
|
1339
|
+
if (offTarget && banner !== "spanav" && !spaDismissed && banner === null) {
|
|
1340
|
+
banner = "spanav";
|
|
1341
|
+
render();
|
|
1342
|
+
} else if (!offTarget && (banner === "spanav" || spaDismissed)) {
|
|
1343
|
+
if (banner === "spanav") banner = null;
|
|
1344
|
+
spaDismissed = false;
|
|
1345
|
+
render();
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}, 1000);
|
|
1349
|
+
|
|
1350
|
+
const onOnline = () => {
|
|
1351
|
+
conn = "connected";
|
|
1352
|
+
render();
|
|
1353
|
+
void controller.saveNow();
|
|
1354
|
+
};
|
|
1355
|
+
const onOffline = () => {
|
|
1356
|
+
conn = "offline";
|
|
1357
|
+
render();
|
|
1358
|
+
};
|
|
1359
|
+
window.addEventListener("online", onOnline);
|
|
1360
|
+
window.addEventListener("offline", onOffline);
|
|
1361
|
+
|
|
1362
|
+
const onMediaChange = () => {
|
|
1363
|
+
narrow = media?.matches ?? false;
|
|
1364
|
+
render();
|
|
1365
|
+
};
|
|
1366
|
+
media?.addEventListener?.("change", onMediaChange);
|
|
1367
|
+
|
|
1368
|
+
render();
|
|
1369
|
+
|
|
1370
|
+
return {
|
|
1371
|
+
destroy() {
|
|
1372
|
+
for (const message of thread) {
|
|
1373
|
+
if (message.role === "op" && message.phase === "working") {
|
|
1374
|
+
message.abort?.abort();
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
unsubscribe();
|
|
1378
|
+
saveWatch();
|
|
1379
|
+
clearInterval(ticker);
|
|
1380
|
+
picker.destroy();
|
|
1381
|
+
devicePreview.close(false);
|
|
1382
|
+
window.removeEventListener("keydown", onKeyDown, true);
|
|
1383
|
+
window.removeEventListener("online", onOnline);
|
|
1384
|
+
window.removeEventListener("offline", onOffline);
|
|
1385
|
+
media?.removeEventListener?.("change", onMediaChange);
|
|
1386
|
+
style.remove();
|
|
1387
|
+
chrome.remove();
|
|
1388
|
+
zone.remove();
|
|
1389
|
+
box.remove();
|
|
1390
|
+
label.remove();
|
|
1391
|
+
device.remove();
|
|
1392
|
+
boot.remove();
|
|
1393
|
+
},
|
|
1394
|
+
};
|
|
1395
|
+
}
|