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.
- package/README.md +63 -0
- package/bin/cmdzero.js +13 -0
- package/overlay/overlay.js +1284 -0
- package/package.json +35 -0
- package/src/resolver.js +694 -0
- package/src/router.js +276 -0
- package/src/server.js +316 -0
- package/src/telemetry.js +97 -0
|
@@ -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
|
+
})();
|