cmdzero 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1284 @@
1
+ // CmdZero overlay — injected into the app in dev. Vanilla JS, no deps.
2
+ (() => {
3
+ if (window.__CMDZERO__) return;
4
+ window.__CMDZERO__ = true;
5
+
6
+ const script = [...document.scripts].find((s) => /\/overlay\.js/.test(s.src));
7
+ const ORIGIN = window.CMDZERO_ORIGIN || (script ? new URL(script.src).origin : 'http://localhost:4100');
8
+
9
+ const SPACE_SCALE = ['0', '0.5', '1', '1.5', '2', '2.5', '3', '3.5', '4', '5', '6', '7', '8', '9', '10', '11', '12', '14', '16', '20', '24'];
10
+ const FONT_FALLBACK = ['text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl', 'text-6xl', 'text-7xl'];
11
+ // px scales for the style-system-agnostic lane (inline style edits)
12
+ const PX_SPACE = [0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96];
13
+ const PX_FONT = [10, 11, 12, 13, 14, 15, 16, 18, 20, 24, 28, 32, 36, 42, 48, 56, 64, 72, 88, 96];
14
+
15
+ const css = `
16
+ #cz-root{position:fixed;inset:0;pointer-events:none;z-index:2147483000;font-family:ui-sans-serif,system-ui,sans-serif}
17
+ .cz-outline{position:fixed;border:1.5px solid #6366f1;border-radius:3px;background:rgba(99,102,241,.08);pointer-events:none;transition:all .04s linear}
18
+ .cz-outline.cz-selected{border-color:#10b981;background:rgba(16,185,129,.06)}
19
+ .cz-multi{position:fixed;border:1.5px solid #f59e0b;border-radius:3px;background:rgba(245,158,11,.10);pointer-events:none}
20
+ .cz-multibar{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);background:#111827;color:#f9fafb;border:1.5px solid #f59e0b;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.4);padding:8px;pointer-events:auto;display:flex;gap:6px;align-items:center;font-size:12px;z-index:2147483001}
21
+ .cz-multibar input{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:5px 8px;font-size:12px;outline:none;width:220px}
22
+ .cz-multibar button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
23
+ .cz-multibar button.cz-danger{background:#dc2626}
24
+ .cz-multibar .cz-count{color:#fbbf24;font-weight:700}
25
+ .cz-badge{position:fixed;background:#312e81;color:#e0e7ff;font-size:11px;padding:2px 7px;border-radius:4px;pointer-events:none;white-space:nowrap;transform:translateY(-100%)}
26
+ .cz-pop-label{position:fixed;background:#10b981;color:#052e1b;font-size:13.8px;font-weight:700;padding:3px 10px;border-radius:8px 8px 0 0;pointer-events:none;white-space:nowrap;transform:translate(-50%,-100%);text-align:center}
27
+ .cz-delete-btn{position:fixed;width:22px;height:22px;border-radius:50%;background:#dc2626;color:#fff;border:none;cursor:pointer;pointer-events:auto;display:flex;align-items:center;justify-content:center;padding:0;box-shadow:0 2px 6px rgba(0,0,0,.4)}
28
+ .cz-movebar{position:fixed;display:flex;gap:2px;align-items:center;background:#111827;border:1px solid #6366f1;border-radius:7px;padding:2px;pointer-events:auto;box-shadow:0 2px 8px rgba(0,0,0,.45)}
29
+ .cz-movebar button{width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:#374151;color:#e5e7eb;border:none;border-radius:4px;cursor:pointer;padding:0;font-size:12px;line-height:1}
30
+ .cz-movebar button:hover{background:#6366f1}
31
+ .cz-grip{cursor:grab;color:#9ca3af;font-size:13px;padding:0 3px;user-select:none}
32
+ .cz-grip:active{cursor:grabbing}
33
+ .cz-drop{position:fixed;background:#6366f1;border-radius:2px;pointer-events:none;z-index:2147483400;box-shadow:0 0 6px rgba(99,102,241,.8)}
34
+ .cz-dragghost{position:fixed;pointer-events:none;z-index:2147483350;opacity:.5;outline:2px dashed #6366f1;border-radius:4px;background:rgba(99,102,241,.12)}
35
+ .cz-delete-btn:hover{background:#ef4444}
36
+ .cz-delete-btn svg{width:12px;height:12px;pointer-events:none}
37
+ .cz-pop{position:fixed;background:#111827;color:#f9fafb;border:1.5px solid #10b981;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.35);padding:8px;pointer-events:auto;display:flex;flex-direction:column;gap:6px;min-width:300px;max-width:340px;font-size:12px}
38
+ .cz-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
39
+ .cz-pop button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
40
+ .cz-pop button:hover{background:#4b5563}
41
+ .cz-pop button.cz-primary{background:#6366f1}
42
+ .cz-pop input{flex:1;background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:5px 8px;font-size:12px;outline:none}
43
+ .cz-pop select{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:3px 4px;font-size:11.5px;outline:none}
44
+ .cz-label{color:#9ca3af;min-width:50px}
45
+ .cz-note{color:#fbbf24;font-size:11px;line-height:1.35;background:rgba(245,158,11,.1);border-radius:6px;padding:4px 7px}
46
+ .cz-note.cz-info{color:#93c5fd;background:rgba(59,130,246,.12)}
47
+ .cz-cur{color:#6ee7b7;font-size:11px;margin-left:auto}
48
+ .cz-swatches{display:flex;gap:4px;flex-wrap:wrap;max-height:96px;overflow-y:auto;padding:2px}
49
+ .cz-swatch{width:18px;height:18px;border-radius:4px;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0}
50
+ .cz-swatch:hover{transform:scale(1.15)}
51
+ .cz-chip{font-size:10.5px !important;padding:2px 6px !important}
52
+ .cz-tray{position:fixed;right:14px;bottom:14px;display:flex;flex-direction:column;align-items:flex-end;gap:6px;pointer-events:auto}
53
+ .cz-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
54
+ .cz-wrap.cz-expanded{max-height:calc(100vh - 90px);overflow-y:auto;overflow-x:hidden;padding:2px}
55
+ .cz-wrap:not(.cz-expanded) > .cz-tweak:nth-child(n+6){display:none}
56
+ .cz-fade{height:16px;width:160px;background:linear-gradient(to bottom,rgba(17,24,39,0),rgba(17,24,39,.55));pointer-events:none;margin-top:-10px}
57
+ .cz-history{background:#0b1220;color:#93c5fd;border:1px solid #263041;border-radius:8px;padding:4px 10px;font-size:11.5px;cursor:pointer;pointer-events:auto;box-shadow:0 4px 14px rgba(0,0,0,.3)}
58
+ .cz-history:hover{border-color:#6366f1}
59
+ .cz-total{background:#064e3b;color:#a7f3d0;border-radius:8px;padding:6px 11px;font-size:13px;box-shadow:0 4px 14px rgba(0,0,0,.3);white-space:nowrap;width:max-content;max-width:720px}
60
+ .cz-total a{color:#6ee7b7;margin-left:10px;text-decoration:underline;cursor:pointer}
61
+ .cz-total a:hover{color:#a7f3d0}
62
+ .cz-tweak{background:#111827;color:#e5e7eb;border-radius:8px;padding:7px 11px;font-size:13px;display:flex;gap:8px;align-items:center;box-shadow:0 4px 14px rgba(0,0,0,.3);white-space:nowrap;width:max-content;max-width:720px;overflow:hidden;text-overflow:ellipsis}
63
+ .cz-dot{width:8px;height:8px;border-radius:50%;flex:none}
64
+ .cz-dot.done{background:#10b981}.cz-dot.queued,.cz-dot.running{background:#f59e0b;animation:cz-pulse 1s infinite}.cz-dot.error{background:#ef4444}.cz-dot.reverted,.cz-dot.cancelled{background:#6b7280}
65
+ .cz-tweak button{background:none;border:none;color:#818cf8;cursor:pointer;font-size:12.5px;padding:0}
66
+ .cz-meta{color:#9ca3af}
67
+ .cz-hint{position:fixed;left:14px;bottom:14px;background:#111827;color:#9ca3af;font-size:12.5px;padding:6px 11px;border-radius:6px;pointer-events:none}
68
+ .cz-reload-toggle{position:fixed;left:14px;bottom:46px;background:#111827;color:#6ee7b7;border:1px solid #10b981;font-size:11.5px;padding:4px 9px;border-radius:6px;cursor:pointer;pointer-events:auto}
69
+ .cz-reload-toggle.off{color:#9ca3af;border-color:#374151}
70
+ [contenteditable="plaintext-only"],[contenteditable="true"]{outline:2px dashed #10b981;outline-offset:2px}
71
+ @keyframes cz-pulse{50%{opacity:.4}}`;
72
+
73
+ const style = document.createElement('style');
74
+ style.textContent = css;
75
+ document.head.appendChild(style);
76
+
77
+ const root = document.createElement('div');
78
+ root.id = 'cz-root';
79
+ document.body.appendChild(root);
80
+
81
+ const state = {
82
+ selectMode: false,
83
+ hoverEl: null,
84
+ selected: null, // { el, loc }
85
+ editing: null, // { el, original }
86
+ tailwind: true, // daemon reports whether the app uses Tailwind
87
+ model: 'auto', // NL model override; 'auto' = router picks
88
+ multi: [], // [{ el, loc }] shift-click multi-selection
89
+ autoReload: true, // seamlessly reload after a write so changes always show live
90
+ };
91
+ try { state.autoReload = localStorage.getItem('cz-autoreload') !== '0'; } catch { /* no storage */ }
92
+
93
+ // Stack of undoable tweak ids (LIFO) for ⌘Z / Ctrl-Z global undo.
94
+ const undoStack = [];
95
+
96
+ // ---------- helpers ----------
97
+ const el = (tag, cls, text) => {
98
+ const n = document.createElement(tag);
99
+ if (cls) n.className = cls;
100
+ if (text != null) n.textContent = text;
101
+ return n;
102
+ };
103
+ const api = async (p, body) => {
104
+ const r = await fetch(`${ORIGIN}/api/${p}`, {
105
+ method: 'POST',
106
+ headers: { 'content-type': 'application/json' },
107
+ body: JSON.stringify(body),
108
+ });
109
+ const j = await r.json();
110
+ if (j && j.ok === false) throw new Error(j.error);
111
+ return j;
112
+ };
113
+ const inOverlay = (t) => t instanceof Node && root.contains(t);
114
+ const classList = (e) => (e.getAttribute('class') || '').split(/\s+/).filter(Boolean);
115
+ // "components/sections/Hero.tsx:50:9" (or absolute path) -> "Hero.tsx:50"
116
+ const shortLoc = (loc) => {
117
+ const m = /^(.*):(\d+):(\d+)$/.exec(loc);
118
+ if (!m) return loc;
119
+ return `${m[1].split('/').pop()}:${m[2]}`;
120
+ };
121
+
122
+ function positionBox(box, target) {
123
+ const r = target.getBoundingClientRect();
124
+ Object.assign(box.style, { left: r.left + 'px', top: r.top + 'px', width: r.width + 'px', height: r.height + 'px' });
125
+ return r;
126
+ }
127
+
128
+ // ---------- design system (Tailwind v4 theme vars from the page's CSS) ----------
129
+ let theme = null;
130
+ function readTheme() {
131
+ if (theme) return theme;
132
+ const vars = {};
133
+ const visit = (rules) => {
134
+ for (const rule of rules) {
135
+ if (rule.cssRules) { try { visit(rule.cssRules); } catch { /* cross-origin */ } }
136
+ if (rule.style) {
137
+ for (const prop of rule.style) {
138
+ if (prop.startsWith('--')) vars[prop] = rule.style.getPropertyValue(prop).trim();
139
+ }
140
+ }
141
+ }
142
+ };
143
+ for (const sheet of document.styleSheets) {
144
+ try { visit(sheet.cssRules); } catch { /* cross-origin */ }
145
+ }
146
+ const px = (v) => {
147
+ const m = /^([\d.]+)(px|rem|em)?$/.exec(v);
148
+ return m ? parseFloat(m[1]) * (m[2] === 'px' ? 1 : 16) : NaN;
149
+ };
150
+ // Design-system sizes first, merged with the canonical Tailwind scale so
151
+ // stepping never dead-ends when the app only uses a few sizes.
152
+ const CANON_PX = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72 };
153
+ const sizeMap = new Map(Object.entries(CANON_PX));
154
+ for (const k of Object.keys(vars)) {
155
+ if (!/^--text-[a-z0-9]+$/.test(k)) continue;
156
+ const v = px(vars[k]);
157
+ if (!isNaN(v)) sizeMap.set('text-' + k.slice(7), v);
158
+ }
159
+ const textSizes = [...sizeMap.entries()].sort((a, b) => a[1] - b[1]).map(([cls]) => cls);
160
+ const colors = Object.keys(vars)
161
+ .filter((k) => /^--color-[a-z0-9-]+$/.test(k))
162
+ .map((k) => ({ name: k.slice(8), value: vars[k] }))
163
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
164
+ // Any CSS custom property whose value is a color — the app's design
165
+ // tokens, whatever the styling system. Applied as var(--name) so the
166
+ // source edit stays token-based.
167
+ const varColors = Object.keys(vars)
168
+ .filter((k) => vars[k] && CSS.supports('color', vars[k]))
169
+ .map((k) => ({ name: k, value: vars[k], apply: `var(${k})` }));
170
+ theme = { textSizes, colors, varColors, fromDS: colors.length > 0 };
171
+ return theme;
172
+ }
173
+
174
+ // Fallback palette: the colors actually rendered on the page right now.
175
+ function harvestPageColors() {
176
+ const seen = new Map();
177
+ const els = document.querySelectorAll('[data-cz]');
178
+ for (const e of [...els].slice(0, 400)) {
179
+ const cs = getComputedStyle(e);
180
+ for (const v of [cs.color, cs.backgroundColor]) {
181
+ if (!v || v === 'rgba(0, 0, 0, 0)' || v === 'transparent') continue;
182
+ seen.set(v, (seen.get(v) || 0) + 1);
183
+ }
184
+ }
185
+ return [...seen.entries()]
186
+ .sort((a, b) => b[1] - a[1])
187
+ .slice(0, 20)
188
+ .map(([value]) => ({ name: value, value, apply: value }));
189
+ }
190
+
191
+ // ---------- hover ----------
192
+ const hoverBox = el('div', 'cz-outline');
193
+ const hoverBadge = el('div', 'cz-badge');
194
+ hoverBox.style.display = hoverBadge.style.display = 'none';
195
+ root.append(hoverBox, hoverBadge);
196
+
197
+ function setHover(target) {
198
+ state.hoverEl = target;
199
+ if (!target || target === state.selected?.el) {
200
+ hoverBox.style.display = hoverBadge.style.display = 'none';
201
+ return;
202
+ }
203
+ hoverBox.style.display = hoverBadge.style.display = 'block';
204
+ const r = positionBox(hoverBox, target);
205
+ hoverBadge.textContent = `<${target.tagName.toLowerCase()}> ${shortLoc(target.getAttribute('data-cz'))}`;
206
+ Object.assign(hoverBadge.style, { left: r.left + 'px', top: Math.max(r.top - 4, 16) + 'px' });
207
+ }
208
+
209
+ // ---------- selection ----------
210
+ const selBox = el('div', 'cz-outline cz-selected');
211
+ selBox.style.display = 'none';
212
+ root.appendChild(selBox);
213
+ const pop = el('div', 'cz-pop');
214
+ pop.style.display = 'none';
215
+ root.appendChild(pop);
216
+ const popLabel = el('div', 'cz-pop-label');
217
+ popLabel.style.display = 'none';
218
+ root.appendChild(popLabel);
219
+ const deleteBtn = el('button', 'cz-delete-btn');
220
+ deleteBtn.innerHTML =
221
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"/><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/></svg>';
222
+ deleteBtn.style.display = 'none';
223
+ deleteBtn.title = 'Delete element';
224
+ root.appendChild(deleteBtn);
225
+ // All instances of a mapped template share one source stamp; the DOM position
226
+ // of a given instance tells the daemon WHICH data item it is (list items
227
+ // render in array order).
228
+ function instanceIndex(elm, loc) {
229
+ const instances = [...document.querySelectorAll(`[data-cz="${CSS.escape(loc)}"]`)];
230
+ const i = instances.indexOf(elm);
231
+ return i >= 0 ? i : undefined;
232
+ }
233
+
234
+ async function deleteOne({ el: elm, loc }) {
235
+ const payload = { loc, index: instanceIndex(elm, loc) };
236
+ // Dry-run first so a refusal never flickers the element.
237
+ await api('delete', { ...payload, dryRun: true });
238
+ if (document.contains(elm)) elm.style.display = 'none'; // optimistic; HMR makes it real
239
+ await api('delete', payload);
240
+ }
241
+
242
+ deleteBtn.onclick = async (e) => {
243
+ e.preventDefault();
244
+ e.stopPropagation();
245
+ const sel = state.selected;
246
+ if (!sel || !document.contains(sel.el)) return;
247
+ deleteBtn.disabled = true;
248
+ try {
249
+ await deleteOne(sel);
250
+ } catch (err) {
251
+ if (document.contains(sel.el)) sel.el.style.display = '';
252
+ deleteBtn.disabled = false;
253
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message.slice(0, 140)}` });
254
+ return;
255
+ }
256
+ deleteBtn.disabled = false;
257
+ deselect();
258
+ };
259
+
260
+ // ---------- reorder / move ----------
261
+ const moveBar = el('div', 'cz-movebar');
262
+ const grip = el('span', 'cz-grip', '⠿');
263
+ grip.title = 'Drag to reorder';
264
+ const btnPrev = el('button');
265
+ const btnNext = el('button');
266
+ moveBar.append(grip, btnPrev, btnNext);
267
+ moveBar.style.display = 'none';
268
+ root.appendChild(moveBar);
269
+
270
+ // Is this element laid out in a row (reorder ←→) or a column (reorder ↑↓)?
271
+ function siblingAxis(elm) {
272
+ const p = elm.parentElement;
273
+ if (!p) return 'vertical';
274
+ const cs = getComputedStyle(p);
275
+ if (cs.display.includes('flex')) return cs.flexDirection.startsWith('row') ? 'horizontal' : 'vertical';
276
+ if (cs.display.includes('grid')) {
277
+ // count column tracks — "220px 220px" = 2 cols (a row); "none" = 1
278
+ const tracks = cs.gridTemplateColumns.split(/\s+/).filter(Boolean);
279
+ if (tracks.length > 1 && tracks[0] !== 'none') return 'horizontal';
280
+ }
281
+ return 'vertical';
282
+ }
283
+
284
+ async function doMove(dir) {
285
+ const s = state.selected;
286
+ if (!s || !document.contains(s.el)) return;
287
+ try {
288
+ await api('move', { loc: s.loc, dir, index: instanceIndex(s.el, s.loc) });
289
+ // the reordered source shows via auto-reload / HMR
290
+ } catch (e) {
291
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `move: ${e.message.slice(0, 140)}` });
292
+ }
293
+ }
294
+ btnPrev.onclick = (e) => { e.preventDefault(); e.stopPropagation(); doMove(moveBar._axis === 'horizontal' ? 'left' : 'up'); };
295
+ btnNext.onclick = (e) => { e.preventDefault(); e.stopPropagation(); doMove(moveBar._axis === 'horizontal' ? 'right' : 'down'); };
296
+
297
+ // The peers a drag reorders among, and the dragged element's index in them.
298
+ function dragPeers(elm, loc) {
299
+ const instances = [...document.querySelectorAll(`[data-cz="${CSS.escape(loc)}"]`)];
300
+ if (instances.length > 1 && instances.includes(elm)) return { kind: 'map', peers: instances };
301
+ const sibs = [...(elm.parentElement?.children || [])].filter((c) => c.nodeType === 1 && c.hasAttribute && c.hasAttribute('data-cz'));
302
+ return { kind: 'siblings', peers: sibs.length > 1 ? sibs : [elm] };
303
+ }
304
+
305
+ const dropLine = el('div', 'cz-drop');
306
+ dropLine.style.display = 'none';
307
+ root.appendChild(dropLine);
308
+
309
+ grip.addEventListener('mousedown', (e) => {
310
+ e.preventDefault();
311
+ e.stopPropagation();
312
+ const s = state.selected;
313
+ if (!s || !document.contains(s.el)) return;
314
+ const axis = siblingAxis(s.el);
315
+ const { peers } = dragPeers(s.el, s.loc);
316
+ if (peers.length < 2) return; // nothing to reorder among
317
+ const from = peers.indexOf(s.el);
318
+ let insertIndex = from;
319
+
320
+ const onMove = (ev) => {
321
+ const pos = axis === 'horizontal' ? ev.clientX : ev.clientY;
322
+ // find insertion slot: before the first peer whose midpoint is past pointer
323
+ insertIndex = peers.length;
324
+ for (let i = 0; i < peers.length; i++) {
325
+ const rr = peers[i].getBoundingClientRect();
326
+ const mid = axis === 'horizontal' ? rr.left + rr.width / 2 : rr.top + rr.height / 2;
327
+ if (pos < mid) { insertIndex = i; break; }
328
+ }
329
+ // draw the drop indicator at that gap
330
+ const ref = peers[Math.min(insertIndex, peers.length - 1)].getBoundingClientRect();
331
+ dropLine.style.display = 'block';
332
+ if (axis === 'horizontal') {
333
+ const x = insertIndex >= peers.length ? ref.right : ref.left;
334
+ Object.assign(dropLine.style, { left: x - 1 + 'px', top: ref.top + 'px', width: '3px', height: ref.height + 'px' });
335
+ } else {
336
+ const y = insertIndex >= peers.length ? ref.bottom : ref.top;
337
+ Object.assign(dropLine.style, { left: ref.left + 'px', top: y - 1 + 'px', width: ref.width + 'px', height: '3px' });
338
+ }
339
+ };
340
+ const onUp = async () => {
341
+ removeEventListener('mousemove', onMove, true);
342
+ removeEventListener('mouseup', onUp, true);
343
+ dropLine.style.display = 'none';
344
+ const finalIndex = insertIndex > from ? insertIndex - 1 : insertIndex;
345
+ if (finalIndex === from || finalIndex < 0) return;
346
+ try {
347
+ await api('move', { loc: s.loc, index: instanceIndex(s.el, s.loc), toIndex: finalIndex });
348
+ } catch (err) {
349
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `move: ${err.message.slice(0, 140)}` });
350
+ }
351
+ };
352
+ addEventListener('mousemove', onMove, true);
353
+ addEventListener('mouseup', onUp, true);
354
+ });
355
+
356
+ function select(target) {
357
+ finishTextEdit(false);
358
+ const loc = target.getAttribute('data-cz');
359
+ // How many rendered elements map to this same source line — i.e. how many
360
+ // instances a shared-source edit (style/functionality) will change.
361
+ const instances = document.querySelectorAll(`[data-cz="${CSS.escape(loc)}"]`).length;
362
+ state.selected = { el: target, loc, meta: null, instances };
363
+ selBox.style.display = 'block';
364
+ renderPopover();
365
+ reposition();
366
+ loadMeta();
367
+ }
368
+
369
+ // Source truth for the selection: the daemon's resolve tells us which text
370
+ // literals the element really has in JSX — the DOM can't (animation libs
371
+ // split text into spans, expressions render as text, etc).
372
+ async function loadMeta() {
373
+ const s = state.selected;
374
+ if (!s) return;
375
+ try {
376
+ const meta = await api('resolve', { loc: s.loc });
377
+ if (state.selected !== s) return; // selection changed meanwhile
378
+ s.meta = meta;
379
+ renderPopover();
380
+ reposition();
381
+ } catch { /* element not resolvable — leave popover as is */ }
382
+ }
383
+
384
+ function deselect() {
385
+ finishTextEdit(false);
386
+ state.selected = null;
387
+ selBox.style.display = 'none';
388
+ pop.style.display = 'none';
389
+ popLabel.style.display = 'none';
390
+ deleteBtn.style.display = 'none';
391
+ moveBar.style.display = 'none';
392
+ }
393
+
394
+ // ---------- multi-select (shift-click) ----------
395
+ const multiBoxes = []; // pooled outline divs
396
+ const multiBar = el('div', 'cz-multibar');
397
+ multiBar.style.display = 'none';
398
+ root.appendChild(multiBar);
399
+
400
+ function toggleMulti(target) {
401
+ const loc = target.getAttribute('data-cz');
402
+ const i = state.multi.findIndex((m) => m.el === target);
403
+ if (i >= 0) state.multi.splice(i, 1);
404
+ else {
405
+ // leaving single-selection mode: fold the current single selection in too
406
+ if (state.selected && !state.multi.some((m) => m.el === state.selected.el)) {
407
+ state.multi.push({ el: state.selected.el, loc: state.selected.loc });
408
+ }
409
+ if (!state.multi.some((m) => m.el === target)) state.multi.push({ el: target, loc });
410
+ deselect();
411
+ }
412
+ renderMulti();
413
+ }
414
+
415
+ function clearMulti() {
416
+ state.multi = [];
417
+ renderMulti();
418
+ }
419
+
420
+ function positionMulti() {
421
+ state.multi.forEach((m, i) => {
422
+ if (!multiBoxes[i]) { multiBoxes[i] = el('div', 'cz-multi'); root.appendChild(multiBoxes[i]); }
423
+ const box = multiBoxes[i];
424
+ if (!document.contains(m.el)) { box.style.display = 'none'; return; }
425
+ box.style.display = 'block';
426
+ positionBox(box, m.el);
427
+ });
428
+ for (let i = state.multi.length; i < multiBoxes.length; i++) multiBoxes[i].style.display = 'none';
429
+ }
430
+
431
+ let multiInput;
432
+ function renderMulti() {
433
+ positionMulti();
434
+ if (!state.multi.length) { multiBar.style.display = 'none'; return; }
435
+ multiBar.style.display = 'flex';
436
+ multiBar.textContent = '';
437
+ multiBar.append(el('span', 'cz-count', String(state.multi.length)));
438
+ multiBar.append(el('span', null, 'selected'));
439
+ multiInput = el('input');
440
+ multiInput.placeholder = 'Describe a change for all…';
441
+ multiInput.value = multiBar._draft || '';
442
+ multiInput.oninput = () => { multiBar._draft = multiInput.value; };
443
+ multiInput.onkeydown = (e) => { if (e.key === 'Enter') applyMultiNL(); e.stopPropagation(); };
444
+ const apply = el('button', 'cz-primary', 'Apply');
445
+ apply.onclick = applyMultiNL;
446
+ const del = el('button', 'cz-danger', 'Delete all');
447
+ del.onclick = deleteMulti;
448
+ const clear = el('button', null, 'Clear');
449
+ clear.onclick = clearMulti;
450
+ multiBar.append(multiInput, apply, del, clear);
451
+ }
452
+
453
+ async function applyMultiNL() {
454
+ const instruction = (multiInput?.value || '').trim();
455
+ if (!instruction) return;
456
+ const targets = state.multi.slice();
457
+ multiBar._draft = '';
458
+ clearMulti();
459
+ for (const t of targets) {
460
+ try {
461
+ const r = await api('nl', { loc: t.loc, instruction, model: state.model });
462
+ addTweak({ id: r.id, status: 'queued', model: r.model, label: `${shortLoc(t.loc)}: ${instruction.slice(0, 40)}` });
463
+ } catch (e) {
464
+ addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `${shortLoc(t.loc)}: ${e.message}` });
465
+ }
466
+ }
467
+ }
468
+
469
+ async function deleteMulti() {
470
+ const targets = state.multi.slice();
471
+ clearMulti();
472
+ // Delete bottom-up so a mapped-list removal doesn't shift the index of a
473
+ // not-yet-deleted sibling in the same list.
474
+ targets.sort((a, b) => (instanceIndex(b.el, b.loc) ?? 0) - (instanceIndex(a.el, a.loc) ?? 0));
475
+ for (const t of targets) {
476
+ try {
477
+ await deleteOne(t);
478
+ } catch (e) {
479
+ if (document.contains(t.el)) t.el.style.display = '';
480
+ addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `delete ${shortLoc(t.loc)}: ${e.message}` });
481
+ }
482
+ }
483
+ }
484
+
485
+ function reposition() {
486
+ const s = state.selected;
487
+ if (!s) return;
488
+ if (!document.contains(s.el)) {
489
+ // HMR replaced the node — re-acquire by stamp
490
+ const again = document.querySelector(`[data-cz="${CSS.escape(s.loc)}"]`);
491
+ if (!again) return deselect();
492
+ s.el = again;
493
+ }
494
+ const r = positionBox(selBox, s.el);
495
+ pop.style.display = 'flex';
496
+ const labelH = 22; // room for the file:line tab above the panel
497
+ const top = r.bottom + 8 + labelH + pop.offsetHeight > innerHeight ? r.top - pop.offsetHeight - 8 : r.bottom + 8 + labelH;
498
+ const left = Math.min(Math.max(r.left, 8), innerWidth - 356);
499
+ Object.assign(pop.style, { left: left + 'px', top: Math.max(top, 8 + labelH) + 'px' });
500
+ popLabel.style.display = 'block';
501
+ popLabel.textContent = s.instances > 1 ? `${shortLoc(s.loc)} · ${s.instances}×` : shortLoc(s.loc);
502
+ Object.assign(popLabel.style, { left: (left + pop.offsetWidth / 2) + 'px', top: pop.style.top });
503
+ deleteBtn.style.display = 'flex';
504
+ const inset = 4;
505
+ Object.assign(deleteBtn.style, {
506
+ left: r.right - 22 - inset + 'px',
507
+ top: r.top + inset + 'px',
508
+ });
509
+ // Move toolbar: top-left of the selection, arrows matching the layout axis.
510
+ const axis = siblingAxis(s.el);
511
+ moveBar._axis = axis;
512
+ btnPrev.textContent = axis === 'horizontal' ? '←' : '↑';
513
+ btnNext.textContent = axis === 'horizontal' ? '→' : '↓';
514
+ btnPrev.title = axis === 'horizontal' ? 'Move left' : 'Move up';
515
+ btnNext.title = axis === 'horizontal' ? 'Move right' : 'Move down';
516
+ moveBar.style.display = 'flex';
517
+ Object.assign(moveBar.style, { left: r.left + inset + 'px', top: r.top + inset + 'px' });
518
+ }
519
+
520
+ // ---------- class tweaks ----------
521
+ async function applyClassTweak(change, label) {
522
+ if (!change || (!change.remove.length && !change.add.length)) return;
523
+ const s = state.selected;
524
+ change.remove.forEach((c) => s.el.classList.remove(c)); // optimistic
525
+ change.add.forEach((c) => s.el.classList.add(c));
526
+ reposition();
527
+ try {
528
+ await api('edit-class', { loc: s.loc, remove: change.remove, add: change.add });
529
+ } catch (e) {
530
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `${label}: ${e.message}` });
531
+ }
532
+ setTimeout(() => { reposition(); renderPopover(); }, 350); // after HMR
533
+ }
534
+
535
+ // ---------- popover ----------
536
+ function hasEditableText(target) {
537
+ return target.children.length === 0 && target.textContent.trim().length > 0;
538
+ }
539
+
540
+ // Step a spacing class (p/m with optional side) along the Tailwind scale.
541
+ function spacingStep(target, base, side, dir) {
542
+ const prefix = base + side; // e.g. p, pt, m, ml
543
+ const re = new RegExp(`^${prefix}-(\\d+(?:\\.\\d+)?)$`);
544
+ const classes = classList(target);
545
+ const cur = classes.find((c) => re.test(c));
546
+ let from = cur ? re.exec(cur)[1] : null;
547
+ if (from == null && side) {
548
+ // side not set: start from the shorthand (pt-6 wins over p-4 in Tailwind ordering)
549
+ const allRe = new RegExp(`^${base}-(\\d+(?:\\.\\d+)?)$`);
550
+ const all = classes.find((c) => allRe.test(c));
551
+ from = all ? allRe.exec(all)[1] : '0';
552
+ }
553
+ if (from == null) from = '0';
554
+ let idx = SPACE_SCALE.indexOf(from);
555
+ if (idx < 0) idx = 0;
556
+ const next = Math.min(Math.max(idx + dir, 0), SPACE_SCALE.length - 1);
557
+ if (SPACE_SCALE[next] === from && cur) return null;
558
+ return { remove: cur ? [cur] : [], add: [`${prefix}-${SPACE_SCALE[next]}`] };
559
+ }
560
+
561
+ function fontStep(target, dir) {
562
+ const scale = readTheme().textSizes;
563
+ const classes = classList(target);
564
+ // An arbitrary size like `text-[34px]` also sets font-size and would
565
+ // override the scale class we add — remove it too, and seed the step from
566
+ // the element's rendered px so the first bump goes the right direction.
567
+ const arbitrary = classes.filter((c) => /^text-\[[^\]]+\]$/.test(c));
568
+ const cur = classes.find((c) => scale.includes(c));
569
+ let idx;
570
+ if (cur) idx = scale.indexOf(cur);
571
+ else {
572
+ const px = parseFloat(getComputedStyle(target).fontSize) || 16;
573
+ // nearest scale entry by rendered size, else middle
574
+ idx = scale.indexOf('text-base');
575
+ if (idx < 0) idx = Math.floor(scale.length / 2);
576
+ }
577
+ if (idx < 0) idx = Math.floor(scale.length / 2);
578
+ const next = Math.min(Math.max(idx + dir, 0), scale.length - 1);
579
+ const add = scale[next];
580
+ const remove = [...(cur ? [cur] : []), ...arbitrary];
581
+ if (add === cur && !arbitrary.length) return null;
582
+ return { remove, add: [add] };
583
+ }
584
+
585
+ // Property editor: which classes to strip when setting each property.
586
+ const colorClassRe = (prefix) =>
587
+ new RegExp(`^${prefix}-(?:[a-z]+-\\d+(?:/\\d+)?|white|black|transparent|current|inherit)$`);
588
+ const PROPS = {
589
+ Background: { type: 'color', prefix: 'bg' },
590
+ 'Text color': { type: 'color', prefix: 'text' },
591
+ 'Border color': { type: 'color', prefix: 'border', ensure: 'border' },
592
+ Radius: { type: 'list', removeRe: /^rounded(-(none|sm|md|lg|xl|2xl|3xl|full))?$/, options: ['rounded-none', 'rounded-sm', 'rounded-md', 'rounded-lg', 'rounded-xl', 'rounded-2xl', 'rounded-3xl', 'rounded-full'] },
593
+ Shadow: { type: 'list', removeRe: /^shadow(-(none|sm|md|lg|xl|2xl))?$/, options: ['shadow-none', 'shadow-sm', 'shadow-md', 'shadow-lg', 'shadow-xl', 'shadow-2xl'] },
594
+ };
595
+
596
+ function propChange(target, propName, valueClass) {
597
+ const p = PROPS[propName];
598
+ const classes = classList(target);
599
+ const re = p.type === 'color' ? colorClassRe(p.prefix) : p.removeRe;
600
+ const remove = classes.filter((c) => re.test(c) && c !== valueClass);
601
+ const add = classes.includes(valueClass) ? [] : [valueClass];
602
+ if (p.ensure && !classes.some((c) => /^border(-\d+)?$/.test(c))) add.push(p.ensure);
603
+ return { remove, add };
604
+ }
605
+
606
+ // ---------- inline-style lane (no Tailwind required) ----------
607
+ async function applyStyleTweak(styles, optimistic, label) {
608
+ const s = state.selected;
609
+ optimistic?.();
610
+ reposition();
611
+ try {
612
+ await api('edit-style', { loc: s.loc, styles });
613
+ } catch (e) {
614
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `${label}: ${e.message}` });
615
+ }
616
+ setTimeout(() => { reposition(); renderPopover(); }, 350);
617
+ }
618
+
619
+ function pxStep(current, scale, dir) {
620
+ let idx = scale.findIndex((v) => v >= Math.round(current));
621
+ if (idx < 0) idx = scale.length - 1;
622
+ return scale[Math.min(Math.max(idx + dir, 0), scale.length - 1)];
623
+ }
624
+
625
+ function spacingRowPx(label, cssBase) {
626
+ const s = state.selected;
627
+ const row = el('div', 'cz-row');
628
+ row.append(el('span', 'cz-label', label));
629
+ const sideSel = el('select');
630
+ for (const [name, v] of [['All', ''], ['Top', 'Top'], ['Right', 'Right'], ['Bottom', 'Bottom'], ['Left', 'Left']]) {
631
+ const o = el('option', null, name);
632
+ o.value = v;
633
+ sideSel.appendChild(o);
634
+ }
635
+ const cur = el('span', 'cz-cur');
636
+ const show = () => {
637
+ const prop = cssBase + (sideSel.value || 'Top');
638
+ cur.textContent = Math.round(parseFloat(getComputedStyle(s.el)[prop]) || 0) + 'px';
639
+ };
640
+ show();
641
+ sideSel.onchange = show;
642
+ const bump = (dir) => {
643
+ const side = sideSel.value;
644
+ const readProp = cssBase + (side || 'Top');
645
+ const writeProp = side ? cssBase + side : cssBase;
646
+ const current = parseFloat(getComputedStyle(s.el)[readProp]) || 0;
647
+ const next = pxStep(current, PX_SPACE, dir);
648
+ if (next === Math.round(current)) return;
649
+ applyStyleTweak(
650
+ { [writeProp]: next + 'px' },
651
+ () => { s.el.style[writeProp] = next + 'px'; show(); },
652
+ label
653
+ );
654
+ };
655
+ const minus = el('button', null, '−');
656
+ const plus = el('button', null, '+');
657
+ minus.onclick = () => bump(-1);
658
+ plus.onclick = () => bump(+1);
659
+ row.append(sideSel, minus, plus, cur);
660
+ return row;
661
+ }
662
+
663
+ function fontRowPx() {
664
+ const s = state.selected;
665
+ const row = el('div', 'cz-row');
666
+ row.append(el('span', 'cz-label', 'Font'));
667
+ const cur = el('span', 'cz-cur', Math.round(parseFloat(getComputedStyle(s.el).fontSize)) + 'px');
668
+ const bump = (dir) => {
669
+ const current = parseFloat(getComputedStyle(s.el).fontSize) || 16;
670
+ const next = pxStep(current, PX_FONT, dir);
671
+ if (next === Math.round(current)) return;
672
+ applyStyleTweak(
673
+ { fontSize: next + 'px' },
674
+ () => { s.el.style.fontSize = next + 'px'; cur.textContent = next + 'px'; },
675
+ 'font'
676
+ );
677
+ };
678
+ const minus = el('button', null, 'A−');
679
+ const plus = el('button', null, 'A+');
680
+ minus.onclick = () => bump(-1);
681
+ plus.onclick = () => bump(+1);
682
+ row.append(minus, plus, cur);
683
+ return row;
684
+ }
685
+
686
+ const STYLE_PROPS = {
687
+ Background: { css: 'backgroundColor', type: 'color' },
688
+ 'Text color': { css: 'color', type: 'color' },
689
+ 'Border color': { css: 'borderColor', type: 'color', ensureBorder: true },
690
+ Radius: { css: 'borderRadius', type: 'list', options: ['0px', '2px', '4px', '6px', '8px', '12px', '16px', '24px', '9999px'] },
691
+ Shadow: { css: 'boxShadow', type: 'list', options: ['none', '0 1px 2px rgba(0,0,0,.08)', '0 2px 8px rgba(0,0,0,.12)', '0 4px 16px rgba(0,0,0,.16)', '0 8px 30px rgba(0,0,0,.2)'], labels: ['none', 'sm', 'md', 'lg', 'xl'] },
692
+ };
693
+
694
+ function applyStyleProp(propName, applyValue) {
695
+ const s = state.selected;
696
+ const p = STYLE_PROPS[propName];
697
+ const styles = { [p.css]: applyValue };
698
+ if (p.ensureBorder && !parseFloat(getComputedStyle(s.el).borderTopWidth)) {
699
+ styles.borderWidth = '1px';
700
+ styles.borderStyle = 'solid';
701
+ }
702
+ applyStyleTweak(
703
+ styles,
704
+ () => { for (const [k, v] of Object.entries(styles)) s.el.style[k] = v; },
705
+ propName
706
+ );
707
+ }
708
+
709
+ function spacingRow(label, base) {
710
+ const s = state.selected;
711
+ const row = el('div', 'cz-row');
712
+ row.append(el('span', 'cz-label', label));
713
+ const sideSel = el('select');
714
+ for (const [name, v] of [['All', ''], ['Top', 't'], ['Right', 'r'], ['Bottom', 'b'], ['Left', 'l']]) {
715
+ const o = el('option', null, name);
716
+ o.value = v;
717
+ sideSel.appendChild(o);
718
+ }
719
+ const minus = el('button', null, '−');
720
+ const plus = el('button', null, '+');
721
+ minus.onclick = () => applyClassTweak(spacingStep(s.el, base, sideSel.value, -1), label);
722
+ plus.onclick = () => applyClassTweak(spacingStep(s.el, base, sideSel.value, +1), label);
723
+ row.append(sideSel, minus, plus);
724
+ const cur = classList(s.el).filter((c) => new RegExp(`^${base}[trbl]?-`).test(c)).join(' ');
725
+ if (cur) row.append(el('span', 'cz-cur', cur));
726
+ return row;
727
+ }
728
+
729
+ function renderPopover() {
730
+ const s = state.selected;
731
+ if (!s) return;
732
+ pop.textContent = '';
733
+
734
+ // location now lives in the green tab above the panel (popLabel)
735
+
736
+ // Reused component: this source line renders in >1 place, so style and
737
+ // functionality edits (which change the shared source) apply everywhere.
738
+ // Copy is the exception — it must stay on this one instance (below).
739
+ const shared = s.instances > 1;
740
+ if (shared) {
741
+ pop.appendChild(el('div', 'cz-note cz-info', `Reused component — ${s.instances} instances. Style & functionality changes apply to all of them.`));
742
+ }
743
+
744
+ // Copy lane, driven by the SOURCE text literals (s.meta.texts), not the
745
+ // DOM: animation libs split text into spans and expressions render as
746
+ // text, so DOM shape says nothing about what's editable in the JSX.
747
+ const literals = s.meta ? s.meta.texts : null;
748
+ if (shared && literals && literals.length) {
749
+ // The text literal is shared by every instance, so editing it here would
750
+ // change all of them — which is not what a copy edit should do. Keep copy
751
+ // local: block the in-place edit and point to the per-instance route.
752
+ pop.appendChild(el('div', 'cz-note', 'Copy here is shared across all instances — editing it would change every one. To change just this instance, make its text a prop/data value (describe it below and it\'ll route through the model).'));
753
+ } else if (literals && literals.length) {
754
+ // In-place editing only when the single literal IS the element's whole
755
+ // text (animation-split DOM still qualifies — same text, different
756
+ // nodes). Partial literals (mixed with {expressions}) get input fields
757
+ // showing exactly the editable part.
758
+ const wholeText =
759
+ literals.length === 1 && literals[0].value.trim() === s.el.textContent.trim();
760
+ if (wholeText) {
761
+ const row = el('div', 'cz-row');
762
+ row.append(el('span', 'cz-label', 'Copy'));
763
+ const b = el('button', null, '✎ Edit text in place');
764
+ b.onclick = () => startTextEdit();
765
+ row.appendChild(b);
766
+ pop.appendChild(row);
767
+ } else {
768
+ // DOM is transformed (or several literals) → edit the source text here
769
+ for (const t of literals) {
770
+ const row = el('div', 'cz-row');
771
+ row.append(el('span', 'cz-label', 'Copy'));
772
+ const input = el('input');
773
+ input.value = t.value;
774
+ const save = el('button', null, '✓');
775
+ const commit = async () => {
776
+ const newText = input.value.trim();
777
+ if (!newText || newText === t.value) return;
778
+ try {
779
+ await api('edit-text', { loc: s.loc, oldText: t.value, newText });
780
+ setTimeout(() => { reposition(); loadMeta(); }, 350);
781
+ } catch (e) {
782
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `copy: ${e.message}` });
783
+ }
784
+ };
785
+ save.onclick = commit;
786
+ input.onkeydown = (e) => { if (e.key === 'Enter') commit(); e.stopPropagation(); };
787
+ row.append(input, save);
788
+ pop.appendChild(row);
789
+ }
790
+ }
791
+ }
792
+
793
+ // Deterministic style controls. Tailwind apps get class edits; everything
794
+ // else gets inline-style edits — same zero-token lane, any styling system.
795
+ const tw = state.tailwind;
796
+
797
+ if (tw) {
798
+ pop.appendChild(spacingRow('Padding', 'p'));
799
+ pop.appendChild(spacingRow('Margin', 'm'));
800
+ const fontRow = el('div', 'cz-row');
801
+ fontRow.append(el('span', 'cz-label', 'Font'));
802
+ const fMinus = el('button', null, 'A−');
803
+ const fPlus = el('button', null, 'A+');
804
+ fMinus.onclick = () => applyClassTweak(fontStep(s.el, -1), 'font');
805
+ fPlus.onclick = () => applyClassTweak(fontStep(s.el, +1), 'font');
806
+ fontRow.append(fMinus, fPlus);
807
+ const curFont = classList(s.el).find((c) => readTheme().textSizes.includes(c));
808
+ fontRow.append(el('span', 'cz-cur', curFont || 'inherited'));
809
+ pop.appendChild(fontRow);
810
+ } else {
811
+ pop.appendChild(spacingRowPx('Padding', 'padding'));
812
+ pop.appendChild(spacingRowPx('Margin', 'margin'));
813
+ pop.appendChild(fontRowPx());
814
+ }
815
+
816
+ // property editor
817
+ const propRow = el('div', 'cz-row');
818
+ propRow.append(el('span', 'cz-label', 'Style'));
819
+ const propSel = el('select');
820
+ propSel.appendChild(el('option', null, 'Choose property…'));
821
+ for (const name of Object.keys(tw ? PROPS : STYLE_PROPS)) {
822
+ const o = el('option', null, name);
823
+ o.value = name;
824
+ propSel.appendChild(o);
825
+ }
826
+ propRow.appendChild(propSel);
827
+ pop.appendChild(propRow);
828
+ const swatches = el('div', 'cz-swatches');
829
+ pop.appendChild(swatches);
830
+ propSel.onchange = () => {
831
+ swatches.textContent = '';
832
+ const p = (tw ? PROPS : STYLE_PROPS)[propSel.value];
833
+ if (!p) return;
834
+ if (p.type === 'color') {
835
+ if (tw) {
836
+ const { colors, fromDS } = readTheme();
837
+ if (!fromDS) {
838
+ swatches.append(el('span', 'cz-meta', 'no design-system colors found in page CSS'));
839
+ return;
840
+ }
841
+ for (const c of colors) {
842
+ const b = el('button', 'cz-swatch');
843
+ b.style.background = c.value;
844
+ b.title = `${p.prefix}-${c.name}`;
845
+ b.onclick = () => applyClassTweak(propChange(s.el, propSel.value, `${p.prefix}-${c.name}`), propSel.value);
846
+ swatches.appendChild(b);
847
+ }
848
+ } else {
849
+ // design tokens if the app defines color custom properties,
850
+ // otherwise the palette actually rendered on the page
851
+ const { varColors } = readTheme();
852
+ const palette = varColors.length ? varColors.slice(0, 48) : harvestPageColors();
853
+ for (const c of palette) {
854
+ const b = el('button', 'cz-swatch');
855
+ b.style.background = c.value;
856
+ b.title = c.name;
857
+ b.onclick = () => applyStyleProp(propSel.value, c.apply);
858
+ swatches.appendChild(b);
859
+ }
860
+ }
861
+ } else {
862
+ p.options.forEach((opt, i) => {
863
+ const b = el('button', 'cz-chip', p.labels ? p.labels[i] : opt);
864
+ b.onclick = () =>
865
+ tw
866
+ ? applyClassTweak(propChange(s.el, propSel.value, opt), propSel.value)
867
+ : applyStyleProp(propSel.value, opt);
868
+ swatches.appendChild(b);
869
+ });
870
+ }
871
+ reposition();
872
+ };
873
+
874
+ const nlRow = el('div', 'cz-row');
875
+ const input = el('input');
876
+ input.placeholder = 'Describe a change…';
877
+ const go = el('button', 'cz-primary', 'Go');
878
+ const send = async () => {
879
+ const instruction = input.value.trim();
880
+ if (!instruction) return;
881
+ input.value = '';
882
+ input.blur(); // so the reload after the model finishes isn't blocked by focus
883
+ try {
884
+ const r = await api('nl', { loc: s.loc, instruction, model: state.model });
885
+ addTweak({ id: r.id, status: 'queued', model: r.model, label: instruction.slice(0, 60) });
886
+ } catch (e) {
887
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: e.message });
888
+ }
889
+ };
890
+ go.onclick = send;
891
+ input.onkeydown = (e) => { if (e.key === 'Enter') send(); e.stopPropagation(); };
892
+ nlRow.append(input, go);
893
+ pop.appendChild(nlRow);
894
+
895
+ // Model picker: Auto (router) or a forced tier.
896
+ const modelRow = el('div', 'cz-row');
897
+ modelRow.append(el('span', 'cz-label', 'Model'));
898
+ const modelSel = el('select');
899
+ for (const [label, value] of [['Auto (routed)', 'auto'], ['Sonnet', 'claude-sonnet-5'], ['Opus', 'claude-opus-4-8'], ['Fable', 'claude-fable-5']]) {
900
+ const o = el('option', null, label);
901
+ o.value = value;
902
+ if (value === state.model) o.selected = true;
903
+ modelSel.appendChild(o);
904
+ }
905
+ modelSel.onchange = () => { state.model = modelSel.value; };
906
+ modelRow.append(modelSel);
907
+ pop.appendChild(modelRow);
908
+ // delete lives as a floating icon on the element itself (deleteBtn)
909
+ }
910
+
911
+ // ---------- inline copy editing ----------
912
+ // Works on ANY element whose source has one text literal — titles,
913
+ // descriptions, buttons, links — even when animations have split the DOM
914
+ // into spans: we swap in the source text for editing and keep the original
915
+ // DOM aside so Esc restores it untouched.
916
+ function startTextEdit() {
917
+ const s = state.selected;
918
+ // the popover can outlive its selection (HMR, reloads) — never throw
919
+ const literal = s?.meta?.texts?.[0]?.value;
920
+ if (literal == null || !document.contains(s.el)) return;
921
+ const frag = document.createDocumentFragment();
922
+ while (s.el.firstChild) frag.appendChild(s.el.firstChild);
923
+ state.editing = {
924
+ el: s.el,
925
+ loc: s.loc,
926
+ literal,
927
+ frag,
928
+ prevUserSelect: s.el.style.userSelect,
929
+ };
930
+ s.el.textContent = literal;
931
+ s.el.style.userSelect = 'text'; // buttons often have user-select: none
932
+ try { s.el.contentEditable = 'plaintext-only'; } catch { s.el.contentEditable = 'true'; }
933
+ s.el.focus();
934
+ document.getSelection()?.selectAllChildren(s.el);
935
+ hint.textContent = 'editing copy — Enter saves · Esc cancels · click away saves';
936
+ }
937
+
938
+ async function finishTextEdit(commit) {
939
+ const ed = state.editing;
940
+ if (!ed) return;
941
+ state.editing = null;
942
+ ed.el.removeAttribute('contenteditable');
943
+ ed.el.style.userSelect = ed.prevUserSelect;
944
+ if (state.selectMode) hint.textContent = 'select mode — click an element · Esc to exit';
945
+ const newText = ed.el.textContent.trim();
946
+ const restore = () => {
947
+ ed.el.textContent = '';
948
+ ed.el.appendChild(ed.frag);
949
+ };
950
+ if (!commit || !newText || newText === ed.literal.trim()) {
951
+ restore();
952
+ return;
953
+ }
954
+ try {
955
+ await api('edit-text', { loc: ed.loc, oldText: ed.literal, newText });
956
+ // keep the new text; HMR re-renders the component (animations included)
957
+ } catch (e) {
958
+ restore();
959
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `copy: ${e.message}` });
960
+ }
961
+ setTimeout(() => { reposition(); loadMeta(); }, 350);
962
+ }
963
+
964
+ // ---------- tray ----------
965
+ const tray = el('div', 'cz-tray');
966
+ root.appendChild(tray);
967
+ const totalBar = el('div', 'cz-total');
968
+ totalBar.style.display = 'none';
969
+ const totalText = el('span');
970
+ const reportLink = el('a', null, 'monthly report →');
971
+ reportLink.href = 'https://cmdzero.dev/report';
972
+ reportLink.target = '_blank';
973
+ reportLink.rel = 'noopener';
974
+ totalBar.append(totalText, reportLink);
975
+ tray.appendChild(totalBar);
976
+ // Alerts live in their own wrap so history can collapse independently of the
977
+ // savings bar. Newest is inserted at the top; the 5 newest stay visible and
978
+ // older ones fold behind an expander (fades at the bottom).
979
+ const tweaksWrap = el('div', 'cz-wrap');
980
+ tray.appendChild(tweaksWrap);
981
+ const fade = el('div', 'cz-fade');
982
+ fade.style.display = 'none';
983
+ tray.appendChild(fade);
984
+ const historyChip = el('div', 'cz-history');
985
+ historyChip.style.display = 'none';
986
+ historyChip.onclick = () => { tweaksWrap.classList.toggle('cz-expanded'); updateHistoryUI(); };
987
+ tray.appendChild(historyChip);
988
+ const tweaks = new Map();
989
+ const tweakData = new Map(); // id -> merged record, persisted across reloads
990
+ const HKEY = 'cz-history';
991
+ const MAX_TWEAKS = 40;
992
+
993
+ function updateHistoryUI() {
994
+ const rows = tweaksWrap.children.length;
995
+ const expanded = tweaksWrap.classList.contains('cz-expanded');
996
+ const hidden = Math.max(0, rows - 5);
997
+ if (rows <= 5) { historyChip.style.display = 'none'; fade.style.display = 'none'; return; }
998
+ historyChip.style.display = '';
999
+ historyChip.textContent = expanded ? 'collapse ▴' : `+ ${hidden} older ▾`;
1000
+ fade.style.display = expanded ? 'none' : '';
1001
+ }
1002
+
1003
+ function persistTweaks() {
1004
+ try {
1005
+ const arr = [...tweakData.values()].slice(-MAX_TWEAKS).map((r) => ({
1006
+ id: r.id, label: r.label, status: r.status, model: r.model, effort: r.effort,
1007
+ kind: r.kind, tokens: r.tokens, durationMs: r.durationMs, costUSD: r.costUSD, error: r.error,
1008
+ }));
1009
+ localStorage.setItem(HKEY, JSON.stringify(arr));
1010
+ } catch { /* no storage */ }
1011
+ }
1012
+
1013
+ function showTotals(t) {
1014
+ if (!t || !t.count) return;
1015
+ totalBar.style.display = '';
1016
+ totalText.textContent = `≈ saved $${t.usd.toFixed(2)} · ${Math.round(t.ms / 1000)}s across ${t.count} tweak${t.count === 1 ? '' : 's'} (vs unscoped agent)`;
1017
+ }
1018
+
1019
+ function addTweak(t, hydrate) {
1020
+ const key = String(t.id);
1021
+ let row = tweaks.get(key);
1022
+ if (!row) {
1023
+ row = el('div', 'cz-tweak');
1024
+ row._id = key;
1025
+ row._dot = el('span', 'cz-dot');
1026
+ row._label = el('span', null, '');
1027
+ row._meta = el('span', 'cz-meta', '');
1028
+ row._cancel = el('button', null, 'cancel');
1029
+ row._cancel.style.display = 'none';
1030
+ row._cancel.onclick = async () => {
1031
+ try {
1032
+ await api('cancel', { id: t.id });
1033
+ } catch (e) { row._meta.textContent = e.message; }
1034
+ };
1035
+ row._undo = el('button', null, 'undo');
1036
+ row._undo.style.display = 'none';
1037
+ row._undo.onclick = async () => {
1038
+ try {
1039
+ await api('undo', { id: row._id });
1040
+ setTimeout(reposition, 350);
1041
+ } catch (e) { row._meta.textContent = e.message; }
1042
+ };
1043
+ row.append(row._dot, row._label, row._meta, row._cancel, row._undo);
1044
+ tweaksWrap.insertBefore(row, tweaksWrap.firstChild);
1045
+ tweaks.set(key, row);
1046
+ while (tweaksWrap.children.length > MAX_TWEAKS) {
1047
+ const last = tweaksWrap.lastChild;
1048
+ tweaks.delete(last._id);
1049
+ tweakData.delete(last._id);
1050
+ last.remove();
1051
+ }
1052
+ }
1053
+ if (t.label) row._label.textContent = t.label;
1054
+ if (t.status) {
1055
+ row._dot.className = 'cz-dot ' + t.status;
1056
+ const inFlight = t.status === 'queued' || t.status === 'running';
1057
+ row._cancel.style.display = inFlight ? '' : 'none';
1058
+ const undoable = t.status === 'done' && !key.startsWith('x');
1059
+ row._undo.style.display = undoable ? '' : 'none';
1060
+ if (undoable && !undoStack.includes(key)) undoStack.push(key);
1061
+ if (t.status === 'reverted') {
1062
+ const i = undoStack.indexOf(key);
1063
+ if (i >= 0) undoStack.splice(i, 1);
1064
+ }
1065
+ // Only reload when the edit isn't already reflected instantly: model (nl)
1066
+ // requests and reorders have no optimistic preview, so pull the live view
1067
+ // in sync. Copy/style/delete apply immediately (optimistic + HMR) — no
1068
+ // reload. Undo (revert) reloads so the reversal always shows. Never during
1069
+ // hydration (replaying saved alerts after a load).
1070
+ const needsReload = (undoable && (!!t.model || t.kind === 'move')) || t.status === 'reverted';
1071
+ if (needsReload && !hydrate) scheduleReload();
1072
+ }
1073
+ const bits = [];
1074
+ if (t.model) bits.push(t.model.replace(/^claude-/, '') + (t.effort ? ` @ ${t.effort}` : ''));
1075
+ if (t.tokens === 0) bits.push('0 tokens');
1076
+ if (t.durationMs) bits.push((t.durationMs / 1000).toFixed(1) + 's');
1077
+ if (t.costUSD != null) bits.push('$' + t.costUSD.toFixed(3));
1078
+ if (t.saved) bits.push(`saved ~$${t.saved.usd.toFixed(2)}`);
1079
+ if (t.error) bits.push(t.error.slice(0, 80));
1080
+ if (bits.length) row._meta.textContent = bits.join(' · ');
1081
+ if (t.totals) showTotals(t.totals);
1082
+
1083
+ // merge into the persisted record set (skip re-persisting during hydration)
1084
+ tweakData.set(key, { ...(tweakData.get(key) || {}), ...t, id: key });
1085
+ if (!hydrate) persistTweaks();
1086
+ updateHistoryUI();
1087
+ }
1088
+
1089
+ // Replay saved alerts after a page load so history survives reloads.
1090
+ (function hydrateTweaks() {
1091
+ let saved;
1092
+ try { saved = JSON.parse(localStorage.getItem(HKEY) || '[]'); } catch { saved = []; }
1093
+ for (const rec of saved) addTweak(rec, true); // oldest→newest keeps newest on top
1094
+ })();
1095
+
1096
+ try {
1097
+ const es = new EventSource(`${ORIGIN}/api/events`);
1098
+ es.onmessage = (m) => {
1099
+ const e = JSON.parse(m.data);
1100
+ if (e.type === 'tweak') addTweak(e);
1101
+ if (e.type === 'totals') showTotals(e.totals);
1102
+ };
1103
+ } catch { /* daemon offline */ }
1104
+ fetch(`${ORIGIN}/api/health`)
1105
+ .then((r) => r.json())
1106
+ .then((h) => {
1107
+ showTotals(h.totals);
1108
+ if (h.tailwind === false) state.tailwind = false;
1109
+ })
1110
+ .catch(() => {});
1111
+
1112
+ // ---------- auto-reload ----------
1113
+ // HMR/Fast Refresh reflects most edits live, but not all (module-scope
1114
+ // consts, some structural/model edits). To guarantee changes always show
1115
+ // without a manual refresh, do a seamless reload after a write settles —
1116
+ // debounced, and preserving scroll + select mode + the current selection.
1117
+ const RKEY = 'cz-reload-state';
1118
+ let reloadTimer = null;
1119
+ function scheduleReload() {
1120
+ if (!state.autoReload) return;
1121
+ clearTimeout(reloadTimer);
1122
+ reloadTimer = setTimeout(doReload, 650);
1123
+ }
1124
+ function doReload() {
1125
+ // never interrupt an in-progress copy edit, multi-select, or typing into a
1126
+ // text field (the app's or the overlay's own NL box) — wait and retry.
1127
+ const ae = document.activeElement;
1128
+ const typing = ae && (ae.isContentEditable || /^(INPUT|TEXTAREA)$/.test(ae.tagName));
1129
+ if (state.editing || state.multi.length || typing) {
1130
+ reloadTimer = setTimeout(doReload, 650);
1131
+ return;
1132
+ }
1133
+ try {
1134
+ // Only for THIS reload: stop the browser from also restoring scroll and
1135
+ // fighting ours. Not set globally (that interfered with the app's own
1136
+ // scroll handling and caused idle scroll jitter).
1137
+ history.scrollRestoration = 'manual';
1138
+ } catch { /* older browsers */ }
1139
+ try {
1140
+ sessionStorage.setItem(RKEY, JSON.stringify({ scrollX: scrollX, scrollY: scrollY, selectMode: state.selectMode, loc: state.selected?.loc || null }));
1141
+ } catch { /* no storage — reload anyway */ }
1142
+ location.reload();
1143
+ }
1144
+
1145
+ // Restore scroll + selection after an auto-reload so it feels seamless.
1146
+ function restoreAfterReload() {
1147
+ let saved;
1148
+ try { saved = JSON.parse(sessionStorage.getItem(RKEY) || 'null'); } catch { saved = null; }
1149
+ if (!saved) return; // normal load — leave the browser's scroll handling alone
1150
+ try { sessionStorage.removeItem(RKEY); } catch { /* ignore */ }
1151
+ // Re-apply scroll a few times as the app hydrates, then hand control back
1152
+ // to the browser so we never linger and fight the app's scrolling.
1153
+ const y = saved.scrollY || 0, x = saved.scrollX || 0;
1154
+ let tries = 12;
1155
+ (function pin() {
1156
+ scrollTo(x, y);
1157
+ if (tries-- > 0 && Math.abs(scrollY - y) > 2) setTimeout(pin, 30);
1158
+ else { try { history.scrollRestoration = 'auto'; } catch { /* ignore */ } }
1159
+ })();
1160
+ if (saved.selectMode) setMode(true);
1161
+ if (saved.loc) {
1162
+ const again = document.querySelector(`[data-cz="${CSS.escape(saved.loc)}"]`);
1163
+ if (again) select(again);
1164
+ }
1165
+ }
1166
+ if (document.readyState === 'complete') restoreAfterReload();
1167
+ else addEventListener('load', restoreAfterReload);
1168
+
1169
+ // ---------- mode + events ----------
1170
+ const hint = el('div', 'cz-hint', '⌘0 select mode');
1171
+ root.appendChild(hint);
1172
+
1173
+ const reloadToggle = el('button', 'cz-reload-toggle');
1174
+ const syncReloadToggle = () => {
1175
+ reloadToggle.textContent = state.autoReload ? '⟳ auto-reload on' : '⟳ auto-reload off';
1176
+ reloadToggle.classList.toggle('off', !state.autoReload);
1177
+ reloadToggle.title = 'Reload the page automatically after each change so it always shows live';
1178
+ };
1179
+ reloadToggle.onclick = () => {
1180
+ state.autoReload = !state.autoReload;
1181
+ try { localStorage.setItem('cz-autoreload', state.autoReload ? '1' : '0'); } catch { /* no storage */ }
1182
+ syncReloadToggle();
1183
+ };
1184
+ syncReloadToggle();
1185
+ root.appendChild(reloadToggle);
1186
+
1187
+ function setMode(on) {
1188
+ state.selectMode = on;
1189
+ hint.textContent = on ? 'select mode — click · shift-click multi · Tab next · ⌘Z undo · Esc exit' : '⌘0 select mode';
1190
+ if (!on) { setHover(null); deselect(); clearMulti(); }
1191
+ }
1192
+
1193
+ // Visible, stamped elements in document order — the Tab cycle.
1194
+ function stampedEls() {
1195
+ return [...document.querySelectorAll('[data-cz]')].filter((n) => n.getClientRects().length);
1196
+ }
1197
+ function selectNext(dir) {
1198
+ const els = stampedEls();
1199
+ if (!els.length) return;
1200
+ const cur = state.selected?.el;
1201
+ let idx = cur ? els.indexOf(cur) : -1;
1202
+ idx = (idx + dir + els.length) % els.length;
1203
+ const next = els[idx];
1204
+ select(next);
1205
+ next.scrollIntoView({ block: 'center', behavior: 'smooth' });
1206
+ }
1207
+
1208
+ async function undoLast() {
1209
+ const id = undoStack.pop();
1210
+ if (id == null) return;
1211
+ try {
1212
+ await api('undo', { id });
1213
+ setTimeout(reposition, 350);
1214
+ } catch { /* already reverted / unknown — drop it */ }
1215
+ }
1216
+
1217
+ // ⌘Z / Ctrl-Z should still do native undo inside inputs and while editing copy.
1218
+ const inTextField = (t) =>
1219
+ t instanceof Element && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
1220
+
1221
+ addEventListener('keydown', (e) => {
1222
+ if ((e.metaKey || e.ctrlKey) && e.key === '0') {
1223
+ e.preventDefault();
1224
+ setMode(!state.selectMode);
1225
+ return;
1226
+ }
1227
+ // Global undo (works whenever the overlay is active, not just in select
1228
+ // mode) — but never hijack undo inside a text field or mid copy-edit.
1229
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) {
1230
+ if (state.editing || inTextField(e.target)) return;
1231
+ e.preventDefault();
1232
+ undoLast();
1233
+ return;
1234
+ }
1235
+ if (!state.selectMode) return;
1236
+ if (e.key === 'Escape') {
1237
+ if (state.editing) return finishTextEdit(false);
1238
+ if (state.multi.length) return clearMulti();
1239
+ if (state.selected) return deselect();
1240
+ return setMode(false);
1241
+ }
1242
+ if (e.key === 'Tab' && !state.editing) {
1243
+ e.preventDefault();
1244
+ selectNext(e.shiftKey ? -1 : 1);
1245
+ return;
1246
+ }
1247
+ if (e.key === 'Enter' && state.editing) {
1248
+ e.preventDefault();
1249
+ finishTextEdit(true);
1250
+ }
1251
+ }, true);
1252
+
1253
+ addEventListener('mousemove', (e) => {
1254
+ if (!state.selectMode || state.editing) return;
1255
+ if (inOverlay(e.target)) return setHover(null);
1256
+ setHover(e.target instanceof Element ? e.target.closest('[data-cz]') : null);
1257
+ }, true);
1258
+
1259
+ addEventListener('click', (e) => {
1260
+ if (!state.selectMode) return;
1261
+ if (inOverlay(e.target)) return;
1262
+ if (state.editing) {
1263
+ // clicks inside the editable text place the caret; clicks anywhere
1264
+ // else save the edit (and never navigate/select mid-edit)
1265
+ if (!state.editing.el.contains(e.target)) {
1266
+ e.preventDefault();
1267
+ e.stopPropagation();
1268
+ finishTextEdit(true);
1269
+ }
1270
+ return;
1271
+ }
1272
+ e.preventDefault();
1273
+ e.stopPropagation();
1274
+ const target = e.target instanceof Element ? e.target.closest('[data-cz]') : null;
1275
+ // Shift-click toggles an element in the multi-selection set.
1276
+ if (target && e.shiftKey) return toggleMulti(target);
1277
+ if (state.multi.length) clearMulti();
1278
+ if (target) select(target);
1279
+ else deselect();
1280
+ }, true);
1281
+
1282
+ addEventListener('scroll', () => { reposition(); positionMulti(); setHover(state.hoverEl); }, true);
1283
+ addEventListener('resize', () => { reposition(); positionMulti(); });
1284
+ })();