cmdzero 0.5.0 → 0.7.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/overlay/overlay.js +293 -140
- package/package.json +1 -1
package/overlay/overlay.js
CHANGED
|
@@ -34,13 +34,29 @@
|
|
|
34
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
35
|
.cz-delete-btn:hover{background:#ef4444}
|
|
36
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:
|
|
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:340px;max-width:400px;font-size:12px}
|
|
38
|
+
.cz-pop textarea{flex:1;width:100%;min-height:66px;resize:vertical;background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:7px 9px;font-size:12.5px;line-height:1.45;outline:none;font-family:inherit}
|
|
39
|
+
.cz-pop textarea:focus{border-color:#6366f1}
|
|
38
40
|
.cz-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
|
39
41
|
.cz-pop button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
|
|
40
42
|
.cz-pop button:hover{background:#4b5563}
|
|
41
43
|
.cz-pop button.cz-primary{background:#6366f1}
|
|
42
44
|
.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
45
|
.cz-pop select{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:3px 4px;font-size:11.5px;outline:none}
|
|
46
|
+
.cz-panel{position:fixed;left:0;top:30px;bottom:0;width:264px;box-sizing:border-box;overflow-y:auto;background:#0f1523;color:#f9fafb;border-right:1.5px solid #10b981;box-shadow:8px 0 30px rgba(0,0,0,.4);padding:12px 12px 28px;pointer-events:auto;display:flex;flex-direction:column;gap:6px;font-size:12px;z-index:2147483100;transform:translateX(0);transition:transform .26s cubic-bezier(.4,0,.2,1)}
|
|
47
|
+
.cz-panel.cz-collapsed{transform:translateX(-100%)}
|
|
48
|
+
.cz-phead{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-bottom:7px;border-bottom:1px solid #1f2937}
|
|
49
|
+
.cz-min{background:#1f2937;color:#cbd5e1;border:none;border-radius:6px;padding:2px 9px;font-size:14px;line-height:1;cursor:pointer}
|
|
50
|
+
.cz-min:hover{background:#374151;color:#fff}
|
|
51
|
+
.cz-ptab{position:fixed;left:0;top:calc(50% + 15px);transform:translateY(-50%);z-index:2147483110;display:none;align-items:center;gap:5px;background:#10b981;color:#052e1b;border:none;border-radius:0 9px 9px 0;padding:13px 6px;cursor:pointer;pointer-events:auto;writing-mode:vertical-rl;font-size:11px;font-weight:700;letter-spacing:.05em;box-shadow:2px 0 10px rgba(0,0,0,.35)}
|
|
52
|
+
.cz-ptab.cz-show{display:flex}
|
|
53
|
+
.cz-ptab:hover{background:#34d399}
|
|
54
|
+
.cz-panel .cz-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
|
55
|
+
.cz-panel button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
|
|
56
|
+
.cz-panel button:hover{background:#4b5563}
|
|
57
|
+
.cz-panel select{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:3px 4px;font-size:11.5px;outline:none}
|
|
58
|
+
.cz-ptitle{font-family:ui-monospace,monospace;font-size:11px;color:#6ee7b7;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
59
|
+
.cz-sec{color:#94a3b8;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;margin:5px 0 1px}
|
|
44
60
|
.cz-label{color:#9ca3af;min-width:50px}
|
|
45
61
|
.cz-note{color:#fbbf24;font-size:11px;line-height:1.35;background:rgba(245,158,11,.1);border-radius:6px;padding:4px 7px}
|
|
46
62
|
.cz-note.cz-info{color:#93c5fd;background:rgba(59,130,246,.12)}
|
|
@@ -50,15 +66,29 @@
|
|
|
50
66
|
.cz-swatch:hover{transform:scale(1.15)}
|
|
51
67
|
.cz-chip{font-size:10.5px !important;padding:2px 6px !important}
|
|
52
68
|
.cz-tray{position:fixed;right:14px;bottom:14px;display:flex;flex-direction:column;align-items:flex-end;gap:6px;pointer-events:auto}
|
|
69
|
+
/* Compressed by default (just a dot + short label); hovering the tray expands
|
|
70
|
+
every alert to full detail with undo/cancel. */
|
|
71
|
+
.cz-tweak{transition:padding .12s ease}
|
|
72
|
+
.cz-tray:not(:hover) .cz-tweak{padding:4px 10px;font-size:12px}
|
|
73
|
+
.cz-tray:not(:hover) .cz-tweak .cz-meta,
|
|
74
|
+
.cz-tray:not(:hover) .cz-tweak button{display:none}
|
|
75
|
+
.cz-tray:not(:hover) .cz-tlabel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:230px;display:inline-block;vertical-align:bottom}
|
|
76
|
+
.cz-banner{position:fixed;top:0;left:0;right:0;height:30px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:0 14px;background:rgba(9,13,20,.94);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border-bottom:1px solid #1f2937;color:#cbd5e1;font-size:12px;line-height:1;pointer-events:auto;z-index:2147483200;box-sizing:border-box}
|
|
77
|
+
.cz-banner .cz-brand{display:flex;align-items:center;gap:6px;font-weight:600;color:#e2e8f0;flex:none}
|
|
78
|
+
.cz-banner .cz-brand kbd{background:#10b981;color:#052e1b;border-radius:5px;padding:2px 6px;font-size:10.5px;font-family:ui-monospace,monospace;font-weight:700}
|
|
79
|
+
.cz-banner .cz-stats{display:flex;gap:18px;align-items:center;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;flex:1;justify-content:center}
|
|
80
|
+
.cz-banner .cz-stat span{color:#64748b}
|
|
81
|
+
.cz-banner .cz-stat b{color:#f1f5f9;font-weight:600;margin-left:5px}
|
|
82
|
+
.cz-banner .cz-stat.cz-accent b{color:#6ee7b7}
|
|
83
|
+
.cz-banner a{color:#6ee7b7;text-decoration:none;flex:none}
|
|
84
|
+
.cz-banner a:hover{text-decoration:underline}
|
|
85
|
+
.cz-banner .cz-idle{color:#64748b}
|
|
53
86
|
.cz-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
|
|
54
87
|
.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+
|
|
88
|
+
.cz-wrap:not(.cz-expanded) > .cz-tweak:nth-child(n+4){display:none}
|
|
56
89
|
.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
90
|
.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
91
|
.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
92
|
.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
93
|
.cz-dot{width:8px;height:8px;border-radius:50%;flex:none}
|
|
64
94
|
.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}
|
|
@@ -87,8 +117,10 @@
|
|
|
87
117
|
model: 'auto', // NL model override; 'auto' = router picks
|
|
88
118
|
multi: [], // [{ el, loc }] shift-click multi-selection
|
|
89
119
|
autoReload: true, // seamlessly reload after a write so changes always show live
|
|
120
|
+
panelCollapsed: false, // left style panel minimized to a tab
|
|
90
121
|
};
|
|
91
122
|
try { state.autoReload = localStorage.getItem('cz-autoreload') !== '0'; } catch { /* no storage */ }
|
|
123
|
+
try { state.panelCollapsed = localStorage.getItem('cz-panel-collapsed') === '1'; } catch { /* no storage */ }
|
|
92
124
|
|
|
93
125
|
// Stack of undoable tweak ids (LIFO) for ⌘Z / Ctrl-Z global undo.
|
|
94
126
|
const undoStack = [];
|
|
@@ -213,6 +245,31 @@
|
|
|
213
245
|
const pop = el('div', 'cz-pop');
|
|
214
246
|
pop.style.display = 'none';
|
|
215
247
|
root.appendChild(pop);
|
|
248
|
+
// Left-side style inspector — a full-height dock that slides in from the left
|
|
249
|
+
// and pushes the page over (no overlap). Minimizable to a left-edge tab.
|
|
250
|
+
const PANEL_W = 264;
|
|
251
|
+
const panel = el('div', 'cz-panel cz-collapsed');
|
|
252
|
+
root.appendChild(panel);
|
|
253
|
+
const panelTab = el('button', 'cz-ptab');
|
|
254
|
+
panelTab.innerHTML = '<span>▸</span><span>STYLE</span>';
|
|
255
|
+
panelTab.title = 'Show style panel';
|
|
256
|
+
panelTab.onclick = () => setPanelCollapsed(false);
|
|
257
|
+
root.appendChild(panelTab);
|
|
258
|
+
try { document.body.style.transition = 'margin-left .26s cubic-bezier(.4,0,.2,1)'; } catch { /* ignore */ }
|
|
259
|
+
|
|
260
|
+
function setPanelCollapsed(v) {
|
|
261
|
+
state.panelCollapsed = v;
|
|
262
|
+
try { localStorage.setItem('cz-panel-collapsed', v ? '1' : '0'); } catch { /* no storage */ }
|
|
263
|
+
applyPanelLayout();
|
|
264
|
+
}
|
|
265
|
+
// Slide the panel in/out and push the page so nothing is covered.
|
|
266
|
+
function applyPanelLayout() {
|
|
267
|
+
const open = !!state.selected && !state.panelCollapsed;
|
|
268
|
+
panel.classList.toggle('cz-collapsed', !open);
|
|
269
|
+
panelTab.classList.toggle('cz-show', !!state.selected && state.panelCollapsed);
|
|
270
|
+
try { document.body.style.marginLeft = open ? PANEL_W + 'px' : '0px'; } catch { /* ignore */ }
|
|
271
|
+
}
|
|
272
|
+
|
|
216
273
|
const popLabel = el('div', 'cz-pop-label');
|
|
217
274
|
popLabel.style.display = 'none';
|
|
218
275
|
root.appendChild(popLabel);
|
|
@@ -305,6 +362,7 @@
|
|
|
305
362
|
const dropLine = el('div', 'cz-drop');
|
|
306
363
|
dropLine.style.display = 'none';
|
|
307
364
|
root.appendChild(dropLine);
|
|
365
|
+
let suppressClick = false; // swallow the click the browser fires right after a drag
|
|
308
366
|
|
|
309
367
|
grip.addEventListener('mousedown', (e) => {
|
|
310
368
|
e.preventDefault();
|
|
@@ -313,20 +371,36 @@
|
|
|
313
371
|
if (!s || !document.contains(s.el)) return;
|
|
314
372
|
const axis = siblingAxis(s.el);
|
|
315
373
|
const { peers } = dragPeers(s.el, s.loc);
|
|
316
|
-
if (peers.length < 2)
|
|
374
|
+
if (peers.length < 2) {
|
|
375
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: 'nothing to reorder here — select a card in a row, or the section container' });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
317
378
|
const from = peers.indexOf(s.el);
|
|
318
|
-
|
|
379
|
+
if (from < 0) return;
|
|
380
|
+
const startX = e.clientX, startY = e.clientY;
|
|
381
|
+
let engaged = false, insertIndex = from;
|
|
382
|
+
|
|
383
|
+
const engage = () => {
|
|
384
|
+
engaged = true;
|
|
385
|
+
document.body.style.cursor = 'grabbing';
|
|
386
|
+
grip.style.cursor = 'grabbing';
|
|
387
|
+
s.el.style.opacity = '0.45'; // show what's moving
|
|
388
|
+
// hide the panels/popover so they don't block the drop targets
|
|
389
|
+
pop.style.display = 'none'; popLabel.style.display = 'none'; // panel is docked left, doesn't block drop targets
|
|
390
|
+
};
|
|
319
391
|
|
|
320
392
|
const onMove = (ev) => {
|
|
393
|
+
if (!engaged) {
|
|
394
|
+
if (Math.abs(ev.clientX - startX) < 4 && Math.abs(ev.clientY - startY) < 4) return; // threshold
|
|
395
|
+
engage();
|
|
396
|
+
}
|
|
321
397
|
const pos = axis === 'horizontal' ? ev.clientX : ev.clientY;
|
|
322
|
-
// find insertion slot: before the first peer whose midpoint is past pointer
|
|
323
398
|
insertIndex = peers.length;
|
|
324
399
|
for (let i = 0; i < peers.length; i++) {
|
|
325
400
|
const rr = peers[i].getBoundingClientRect();
|
|
326
401
|
const mid = axis === 'horizontal' ? rr.left + rr.width / 2 : rr.top + rr.height / 2;
|
|
327
402
|
if (pos < mid) { insertIndex = i; break; }
|
|
328
403
|
}
|
|
329
|
-
// draw the drop indicator at that gap
|
|
330
404
|
const ref = peers[Math.min(insertIndex, peers.length - 1)].getBoundingClientRect();
|
|
331
405
|
dropLine.style.display = 'block';
|
|
332
406
|
if (axis === 'horizontal') {
|
|
@@ -341,11 +415,20 @@
|
|
|
341
415
|
removeEventListener('mousemove', onMove, true);
|
|
342
416
|
removeEventListener('mouseup', onUp, true);
|
|
343
417
|
dropLine.style.display = 'none';
|
|
418
|
+
document.body.style.cursor = '';
|
|
419
|
+
grip.style.cursor = 'grab';
|
|
420
|
+
if (document.contains(s.el)) s.el.style.opacity = '';
|
|
421
|
+
if (!engaged) return; // was a click, not a drag
|
|
422
|
+
suppressClick = true; // swallow the synthetic click that follows a drag
|
|
423
|
+
setTimeout(() => { suppressClick = false; }, 0);
|
|
424
|
+
const restore = () => { if (state.selected === s && document.contains(s.el)) { renderPopover(); renderPanel(); reposition(); } };
|
|
344
425
|
const finalIndex = insertIndex > from ? insertIndex - 1 : insertIndex;
|
|
345
|
-
if (finalIndex === from || finalIndex < 0) return;
|
|
426
|
+
if (finalIndex === from || finalIndex < 0) { restore(); return; } // dropped where it started
|
|
346
427
|
try {
|
|
347
428
|
await api('move', { loc: s.loc, index: instanceIndex(s.el, s.loc), toIndex: finalIndex });
|
|
429
|
+
restore(); // reorder shows via auto-reload; restore panels if it doesn't
|
|
348
430
|
} catch (err) {
|
|
431
|
+
restore();
|
|
349
432
|
addTweak({ id: 'x' + Date.now(), status: 'error', label: `move: ${err.message.slice(0, 140)}` });
|
|
350
433
|
}
|
|
351
434
|
};
|
|
@@ -354,7 +437,7 @@
|
|
|
354
437
|
});
|
|
355
438
|
|
|
356
439
|
function select(target) {
|
|
357
|
-
finishTextEdit(
|
|
440
|
+
finishTextEdit(true); // moving to another element saves the current text edit
|
|
358
441
|
const loc = target.getAttribute('data-cz');
|
|
359
442
|
// How many rendered elements map to this same source line — i.e. how many
|
|
360
443
|
// instances a shared-source edit (style/functionality) will change.
|
|
@@ -362,6 +445,7 @@
|
|
|
362
445
|
state.selected = { el: target, loc, meta: null, instances };
|
|
363
446
|
selBox.style.display = 'block';
|
|
364
447
|
renderPopover();
|
|
448
|
+
renderPanel();
|
|
365
449
|
reposition();
|
|
366
450
|
loadMeta();
|
|
367
451
|
}
|
|
@@ -377,15 +461,20 @@
|
|
|
377
461
|
if (state.selected !== s) return; // selection changed meanwhile
|
|
378
462
|
s.meta = meta;
|
|
379
463
|
renderPopover();
|
|
464
|
+
renderPanel();
|
|
380
465
|
reposition();
|
|
466
|
+
// Text editing is active the moment an editable element is highlighted —
|
|
467
|
+
// no button click needed. Caret goes to the end so it's non-destructive.
|
|
468
|
+
if (canEditWholeText(s) && !state.editing) startTextEdit(false);
|
|
381
469
|
} catch { /* element not resolvable — leave popover as is */ }
|
|
382
470
|
}
|
|
383
471
|
|
|
384
472
|
function deselect() {
|
|
385
|
-
finishTextEdit(
|
|
473
|
+
finishTextEdit(true); // exiting/⌘0 saves the current text edit
|
|
386
474
|
state.selected = null;
|
|
387
475
|
selBox.style.display = 'none';
|
|
388
476
|
pop.style.display = 'none';
|
|
477
|
+
applyPanelLayout(); // slides the panel out + un-shifts the page
|
|
389
478
|
popLabel.style.display = 'none';
|
|
390
479
|
deleteBtn.style.display = 'none';
|
|
391
480
|
moveBar.style.display = 'none';
|
|
@@ -494,8 +583,12 @@
|
|
|
494
583
|
const r = positionBox(selBox, s.el);
|
|
495
584
|
pop.style.display = 'flex';
|
|
496
585
|
const labelH = 22; // room for the file:line tab above the panel
|
|
497
|
-
|
|
498
|
-
|
|
586
|
+
// Keep a generous gap so the panel never sits on top of the text you're
|
|
587
|
+
// editing — below the element by default, above only if there's no room.
|
|
588
|
+
const GAP = 26;
|
|
589
|
+
const below = r.bottom + GAP + labelH;
|
|
590
|
+
const top = below + pop.offsetHeight > innerHeight ? r.top - pop.offsetHeight - GAP : below;
|
|
591
|
+
const left = Math.min(Math.max(r.left, 8), innerWidth - Math.max(pop.offsetWidth + 16, 356));
|
|
499
592
|
Object.assign(pop.style, { left: left + 'px', top: Math.max(top, 8 + labelH) + 'px' });
|
|
500
593
|
popLabel.style.display = 'block';
|
|
501
594
|
popLabel.textContent = s.instances > 1 ? `${shortLoc(s.loc)} · ${s.instances}×` : shortLoc(s.loc);
|
|
@@ -529,7 +622,7 @@
|
|
|
529
622
|
} catch (e) {
|
|
530
623
|
addTweak({ id: 'x' + Date.now(), status: 'error', label: `${label}: ${e.message}` });
|
|
531
624
|
}
|
|
532
|
-
setTimeout(() => { reposition(); renderPopover(); }, 350); // after HMR
|
|
625
|
+
setTimeout(() => { reposition(); renderPopover(); renderPanel(); }, 350); // after HMR
|
|
533
626
|
}
|
|
534
627
|
|
|
535
628
|
// ---------- popover ----------
|
|
@@ -613,7 +706,7 @@
|
|
|
613
706
|
} catch (e) {
|
|
614
707
|
addTweak({ id: 'x' + Date.now(), status: 'error', label: `${label}: ${e.message}` });
|
|
615
708
|
}
|
|
616
|
-
setTimeout(() => { reposition(); renderPopover(); }, 350);
|
|
709
|
+
setTimeout(() => { reposition(); renderPopover(); renderPanel(); }, 350);
|
|
617
710
|
}
|
|
618
711
|
|
|
619
712
|
function pxStep(current, scale, dir) {
|
|
@@ -726,6 +819,86 @@
|
|
|
726
819
|
return row;
|
|
727
820
|
}
|
|
728
821
|
|
|
822
|
+
// Build the inline swatches/chips control for one style property (used by the
|
|
823
|
+
// left panel — every attribute broken out, no dropdown).
|
|
824
|
+
function buildPropSwatches(name, tw) {
|
|
825
|
+
const s = state.selected;
|
|
826
|
+
const wrap = el('div', 'cz-swatches');
|
|
827
|
+
const p = (tw ? PROPS : STYLE_PROPS)[name];
|
|
828
|
+
if (!p) return wrap;
|
|
829
|
+
if (p.type === 'color') {
|
|
830
|
+
if (tw) {
|
|
831
|
+
const { colors, fromDS } = readTheme();
|
|
832
|
+
if (!fromDS) { wrap.append(el('span', 'cz-meta', 'no design-system colors in CSS')); return wrap; }
|
|
833
|
+
for (const c of colors) {
|
|
834
|
+
const b = el('button', 'cz-swatch');
|
|
835
|
+
b.style.background = c.value;
|
|
836
|
+
b.title = `${p.prefix}-${c.name}`;
|
|
837
|
+
b.onclick = () => applyClassTweak(propChange(s.el, name, `${p.prefix}-${c.name}`), name);
|
|
838
|
+
wrap.appendChild(b);
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
const { varColors } = readTheme();
|
|
842
|
+
const palette = varColors.length ? varColors.slice(0, 48) : harvestPageColors();
|
|
843
|
+
for (const c of palette) {
|
|
844
|
+
const b = el('button', 'cz-swatch');
|
|
845
|
+
b.style.background = c.value;
|
|
846
|
+
b.title = c.name;
|
|
847
|
+
b.onclick = () => applyStyleProp(name, c.apply);
|
|
848
|
+
wrap.appendChild(b);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
} else {
|
|
852
|
+
p.options.forEach((opt, i) => {
|
|
853
|
+
const b = el('button', 'cz-chip', p.labels ? p.labels[i] : opt);
|
|
854
|
+
b.onclick = () => tw ? applyClassTweak(propChange(s.el, name, opt), name) : applyStyleProp(name, opt);
|
|
855
|
+
wrap.appendChild(b);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
return wrap;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Left inspector panel: every style attribute broken out into its own control.
|
|
862
|
+
function renderPanel() {
|
|
863
|
+
const s = state.selected;
|
|
864
|
+
applyPanelLayout();
|
|
865
|
+
if (!s) return;
|
|
866
|
+
panel.textContent = '';
|
|
867
|
+
const head = el('div', 'cz-phead');
|
|
868
|
+
head.append(el('div', 'cz-ptitle', `<${s.el.tagName.toLowerCase()}> ${shortLoc(s.loc)}${s.instances > 1 ? ` · ${s.instances}×` : ''}`));
|
|
869
|
+
const min = el('button', 'cz-min', '‹');
|
|
870
|
+
min.title = 'Minimize panel';
|
|
871
|
+
min.onclick = () => setPanelCollapsed(true);
|
|
872
|
+
head.append(min);
|
|
873
|
+
panel.appendChild(head);
|
|
874
|
+
const tw = state.tailwind;
|
|
875
|
+
|
|
876
|
+
panel.appendChild(el('div', 'cz-sec', 'Spacing'));
|
|
877
|
+
if (tw) { panel.appendChild(spacingRow('Padding', 'p')); panel.appendChild(spacingRow('Margin', 'm')); }
|
|
878
|
+
else { panel.appendChild(spacingRowPx('Padding', 'padding')); panel.appendChild(spacingRowPx('Margin', 'margin')); }
|
|
879
|
+
|
|
880
|
+
panel.appendChild(el('div', 'cz-sec', 'Typography'));
|
|
881
|
+
if (tw) {
|
|
882
|
+
const fontRow = el('div', 'cz-row');
|
|
883
|
+
fontRow.append(el('span', 'cz-label', 'Font'));
|
|
884
|
+
const fMinus = el('button', null, 'A−');
|
|
885
|
+
const fPlus = el('button', null, 'A+');
|
|
886
|
+
fMinus.onclick = () => applyClassTweak(fontStep(s.el, -1), 'font');
|
|
887
|
+
fPlus.onclick = () => applyClassTweak(fontStep(s.el, +1), 'font');
|
|
888
|
+
const curFont = classList(s.el).find((c) => readTheme().textSizes.includes(c));
|
|
889
|
+
fontRow.append(fMinus, fPlus, el('span', 'cz-cur', curFont || 'inherited'));
|
|
890
|
+
panel.appendChild(fontRow);
|
|
891
|
+
} else {
|
|
892
|
+
panel.appendChild(fontRowPx());
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
panel.appendChild(el('div', 'cz-sec', 'Color & border'));
|
|
896
|
+
for (const name of Object.keys(tw ? PROPS : STYLE_PROPS)) {
|
|
897
|
+
panel.appendChild(el('span', 'cz-label', name));
|
|
898
|
+
panel.appendChild(buildPropSwatches(name, tw));
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
729
902
|
function renderPopover() {
|
|
730
903
|
const s = state.selected;
|
|
731
904
|
if (!s) return;
|
|
@@ -758,12 +931,7 @@
|
|
|
758
931
|
const wholeText =
|
|
759
932
|
literals.length === 1 && literals[0].value.trim() === s.el.textContent.trim();
|
|
760
933
|
if (wholeText) {
|
|
761
|
-
|
|
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);
|
|
934
|
+
pop.appendChild(el('div', 'cz-note cz-info', '✎ Editing text inline — just type. Saves on ⌘0 or when you move to another element; Esc cancels.'));
|
|
767
935
|
} else {
|
|
768
936
|
// DOM is transformed (or several literals) → edit the source text here
|
|
769
937
|
for (const t of literals) {
|
|
@@ -790,91 +958,13 @@
|
|
|
790
958
|
}
|
|
791
959
|
}
|
|
792
960
|
|
|
793
|
-
//
|
|
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
|
-
};
|
|
961
|
+
// Style controls now live in the left inspector panel (renderPanel).
|
|
873
962
|
|
|
874
|
-
const
|
|
875
|
-
const input = el('
|
|
876
|
-
input.placeholder = 'Describe a change…';
|
|
877
|
-
|
|
963
|
+
const nlWrap = el('div', 'cz-nlwrap');
|
|
964
|
+
const input = el('textarea');
|
|
965
|
+
input.placeholder = 'Describe a change… (⌘↵ to send)';
|
|
966
|
+
input.rows = 3;
|
|
967
|
+
const go = el('button', 'cz-primary', 'Go →');
|
|
878
968
|
const send = async () => {
|
|
879
969
|
const instruction = input.value.trim();
|
|
880
970
|
if (!instruction) return;
|
|
@@ -888,9 +978,13 @@
|
|
|
888
978
|
}
|
|
889
979
|
};
|
|
890
980
|
go.onclick = send;
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
981
|
+
// ⌘↵ / Ctrl↵ sends; plain Enter inserts a newline (multi-line prompts).
|
|
982
|
+
input.onkeydown = (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } e.stopPropagation(); };
|
|
983
|
+
const goRow = el('div', 'cz-row');
|
|
984
|
+
goRow.style.justifyContent = 'flex-end';
|
|
985
|
+
goRow.appendChild(go);
|
|
986
|
+
nlWrap.append(input, goRow);
|
|
987
|
+
pop.appendChild(nlWrap);
|
|
894
988
|
|
|
895
989
|
// Model picker: Auto (router) or a forced tier.
|
|
896
990
|
const modelRow = el('div', 'cz-row');
|
|
@@ -913,11 +1007,21 @@
|
|
|
913
1007
|
// descriptions, buttons, links — even when animations have split the DOM
|
|
914
1008
|
// into spans: we swap in the source text for editing and keep the original
|
|
915
1009
|
// DOM aside so Esc restores it untouched.
|
|
916
|
-
|
|
1010
|
+
// Whether the selected element's whole visible text is a single source
|
|
1011
|
+
// literal we can edit in place (headings, buttons, links, paragraphs — not
|
|
1012
|
+
// containers, and not text shared across multiple instances).
|
|
1013
|
+
function canEditWholeText(s) {
|
|
1014
|
+
const lits = s?.meta?.texts;
|
|
1015
|
+
if (!lits || lits.length !== 1 || s.instances > 1) return false;
|
|
1016
|
+
return document.contains(s.el) && lits[0].value.trim() === s.el.textContent.trim();
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function startTextEdit(selectAll = true) {
|
|
917
1020
|
const s = state.selected;
|
|
918
1021
|
// the popover can outlive its selection (HMR, reloads) — never throw
|
|
919
1022
|
const literal = s?.meta?.texts?.[0]?.value;
|
|
920
1023
|
if (literal == null || !document.contains(s.el)) return;
|
|
1024
|
+
if (state.editing && state.editing.el === s.el) return; // already editing this one
|
|
921
1025
|
const frag = document.createDocumentFragment();
|
|
922
1026
|
while (s.el.firstChild) frag.appendChild(s.el.firstChild);
|
|
923
1027
|
state.editing = {
|
|
@@ -930,9 +1034,11 @@
|
|
|
930
1034
|
s.el.textContent = literal;
|
|
931
1035
|
s.el.style.userSelect = 'text'; // buttons often have user-select: none
|
|
932
1036
|
try { s.el.contentEditable = 'plaintext-only'; } catch { s.el.contentEditable = 'true'; }
|
|
933
|
-
s.el.focus();
|
|
934
|
-
document.getSelection()
|
|
935
|
-
|
|
1037
|
+
s.el.focus({ preventScroll: true });
|
|
1038
|
+
const sel = document.getSelection();
|
|
1039
|
+
if (selectAll) sel?.selectAllChildren(s.el);
|
|
1040
|
+
else { const rng = document.createRange(); rng.selectNodeContents(s.el); rng.collapse(false); sel?.removeAllRanges(); sel?.addRange(rng); }
|
|
1041
|
+
hint.textContent = 'editing text — type to change · ⌘0 or click another element saves · Esc cancels';
|
|
936
1042
|
}
|
|
937
1043
|
|
|
938
1044
|
async function finishTextEdit(commit) {
|
|
@@ -961,21 +1067,35 @@
|
|
|
961
1067
|
setTimeout(() => { reposition(); loadMeta(); }, 350);
|
|
962
1068
|
}
|
|
963
1069
|
|
|
964
|
-
// ----------
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const
|
|
970
|
-
const reportLink = el('a', null, 'monthly report →');
|
|
1070
|
+
// ---------- stats banner (permanent, above the site) ----------
|
|
1071
|
+
const banner = el('div', 'cz-banner');
|
|
1072
|
+
const brand = el('div', 'cz-brand');
|
|
1073
|
+
brand.innerHTML = '<kbd>⌘0</kbd> CmdZero';
|
|
1074
|
+
const bannerStats = el('div', 'cz-stats');
|
|
1075
|
+
const reportLink = el('a', null, 'report →');
|
|
971
1076
|
reportLink.href = 'https://cmdzero.dev/report';
|
|
972
1077
|
reportLink.target = '_blank';
|
|
973
1078
|
reportLink.rel = 'noopener';
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
1079
|
+
banner.append(brand, bannerStats, reportLink);
|
|
1080
|
+
root.appendChild(banner);
|
|
1081
|
+
// Push the page down so the banner sits ABOVE the site rather than over it.
|
|
1082
|
+
// Inject a stylesheet instead of mutating body's style attribute: frameworks
|
|
1083
|
+
// that hydrate (React 19+) diff server-rendered attributes and flag a bare
|
|
1084
|
+
// style mutation as a hydration mismatch.
|
|
1085
|
+
try {
|
|
1086
|
+
const prevPad = getComputedStyle(document.body).paddingTop;
|
|
1087
|
+
const pad = document.createElement('style');
|
|
1088
|
+
pad.setAttribute('data-cz-banner-pad', '');
|
|
1089
|
+
pad.textContent = `body { padding-top: calc(${prevPad} + 30px) !important; }`;
|
|
1090
|
+
document.head.appendChild(pad);
|
|
1091
|
+
} catch { /* no body yet */ }
|
|
1092
|
+
|
|
1093
|
+
// ---------- tray ----------
|
|
1094
|
+
const tray = el('div', 'cz-tray');
|
|
1095
|
+
root.appendChild(tray);
|
|
1096
|
+
// Alerts live in their own wrap so history can collapse independently.
|
|
1097
|
+
// Newest is inserted at the top; the 3 newest stay visible (compressed; hover to expand) and older
|
|
1098
|
+
// ones fold behind an expander.
|
|
979
1099
|
const tweaksWrap = el('div', 'cz-wrap');
|
|
980
1100
|
tray.appendChild(tweaksWrap);
|
|
981
1101
|
const fade = el('div', 'cz-fade');
|
|
@@ -993,8 +1113,8 @@
|
|
|
993
1113
|
function updateHistoryUI() {
|
|
994
1114
|
const rows = tweaksWrap.children.length;
|
|
995
1115
|
const expanded = tweaksWrap.classList.contains('cz-expanded');
|
|
996
|
-
const hidden = Math.max(0, rows -
|
|
997
|
-
if (rows <=
|
|
1116
|
+
const hidden = Math.max(0, rows - 3);
|
|
1117
|
+
if (rows <= 3) { historyChip.style.display = 'none'; fade.style.display = 'none'; return; }
|
|
998
1118
|
historyChip.style.display = '';
|
|
999
1119
|
historyChip.textContent = expanded ? 'collapse ▴' : `+ ${hidden} older ▾`;
|
|
1000
1120
|
fade.style.display = expanded ? 'none' : '';
|
|
@@ -1010,11 +1130,35 @@
|
|
|
1010
1130
|
} catch { /* no storage */ }
|
|
1011
1131
|
}
|
|
1012
1132
|
|
|
1133
|
+
const stat = (label, value, accent) => {
|
|
1134
|
+
const s = el('div', 'cz-stat' + (accent ? ' cz-accent' : ''));
|
|
1135
|
+
s.append(el('span', null, label), el('b', null, value));
|
|
1136
|
+
return s;
|
|
1137
|
+
};
|
|
1138
|
+
// How many of the session's tweaks cost zero tokens (copy/style/move/delete).
|
|
1139
|
+
function zeroTokenCount() {
|
|
1140
|
+
let z = 0;
|
|
1141
|
+
for (const r of tweakData.values()) if (!r.model && r.tokens === 0 && !String(r.id).startsWith('x')) z++;
|
|
1142
|
+
return z;
|
|
1143
|
+
}
|
|
1013
1144
|
function showTotals(t) {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1145
|
+
bannerStats.textContent = '';
|
|
1146
|
+
if (!t || !t.count) {
|
|
1147
|
+
bannerStats.append(el('span', 'cz-idle', 'Press ⌘0, then click any element to tweak it — edits land in your source.'));
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const secs = Math.round(t.ms / 1000);
|
|
1151
|
+
const time = secs >= 60 ? `${Math.round(secs / 60)}m` : `${secs}s`;
|
|
1152
|
+
const z = zeroTokenCount();
|
|
1153
|
+
bannerStats.append(
|
|
1154
|
+
stat('tweaks', String(t.count)),
|
|
1155
|
+
stat('saved', `$${t.usd.toFixed(2)}`, true),
|
|
1156
|
+
stat('faster', time, true),
|
|
1157
|
+
);
|
|
1158
|
+
if (z > 0) bannerStats.append(stat('zero-token', String(z)));
|
|
1159
|
+
bannerStats.append(stat('vs', 'unscoped agent'));
|
|
1017
1160
|
}
|
|
1161
|
+
showTotals(null); // idle banner on load until /api/health arrives
|
|
1018
1162
|
|
|
1019
1163
|
function addTweak(t, hydrate) {
|
|
1020
1164
|
const key = String(t.id);
|
|
@@ -1023,7 +1167,7 @@
|
|
|
1023
1167
|
row = el('div', 'cz-tweak');
|
|
1024
1168
|
row._id = key;
|
|
1025
1169
|
row._dot = el('span', 'cz-dot');
|
|
1026
|
-
row._label = el('span',
|
|
1170
|
+
row._label = el('span', 'cz-tlabel', '');
|
|
1027
1171
|
row._meta = el('span', 'cz-meta', '');
|
|
1028
1172
|
row._cancel = el('button', null, 'cancel');
|
|
1029
1173
|
row._cancel.style.display = 'none';
|
|
@@ -1244,29 +1388,37 @@
|
|
|
1244
1388
|
selectNext(e.shiftKey ? -1 : 1);
|
|
1245
1389
|
return;
|
|
1246
1390
|
}
|
|
1247
|
-
|
|
1391
|
+
// Enter saves ONLY when the caret is in the edited element — not when
|
|
1392
|
+
// typing in the popover's textarea (which uses Enter for newlines).
|
|
1393
|
+
if (e.key === 'Enter' && state.editing && e.target === state.editing.el) {
|
|
1248
1394
|
e.preventDefault();
|
|
1249
1395
|
finishTextEdit(true);
|
|
1250
1396
|
}
|
|
1251
1397
|
}, true);
|
|
1252
1398
|
|
|
1253
1399
|
addEventListener('mousemove', (e) => {
|
|
1254
|
-
if (!state.selectMode
|
|
1400
|
+
if (!state.selectMode) return;
|
|
1401
|
+
// keep hover previews live even while editing text, so you can see the
|
|
1402
|
+
// next element you'll click (clicking saves + moves there)
|
|
1255
1403
|
if (inOverlay(e.target)) return setHover(null);
|
|
1404
|
+
if (state.editing && state.editing.el.contains(e.target)) return setHover(null);
|
|
1256
1405
|
setHover(e.target instanceof Element ? e.target.closest('[data-cz]') : null);
|
|
1257
1406
|
}, true);
|
|
1258
1407
|
|
|
1259
1408
|
addEventListener('click', (e) => {
|
|
1260
1409
|
if (!state.selectMode) return;
|
|
1410
|
+
if (suppressClick) { e.preventDefault(); e.stopPropagation(); return; } // just finished a drag
|
|
1261
1411
|
if (inOverlay(e.target)) return;
|
|
1262
1412
|
if (state.editing) {
|
|
1263
|
-
// clicks inside the editable text place the caret;
|
|
1264
|
-
//
|
|
1265
|
-
if (
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1413
|
+
// clicks inside the editable text just place the caret; clicking another
|
|
1414
|
+
// element saves the current edit AND moves the selection there.
|
|
1415
|
+
if (state.editing.el.contains(e.target)) return;
|
|
1416
|
+
e.preventDefault();
|
|
1417
|
+
e.stopPropagation();
|
|
1418
|
+
finishTextEdit(true);
|
|
1419
|
+
const next = e.target instanceof Element ? e.target.closest('[data-cz]') : null;
|
|
1420
|
+
if (next && next !== state.selected?.el) select(next);
|
|
1421
|
+
else if (!next) deselect();
|
|
1270
1422
|
return;
|
|
1271
1423
|
}
|
|
1272
1424
|
e.preventDefault();
|
|
@@ -1279,6 +1431,7 @@
|
|
|
1279
1431
|
else deselect();
|
|
1280
1432
|
}, true);
|
|
1281
1433
|
|
|
1434
|
+
|
|
1282
1435
|
addEventListener('scroll', () => { reposition(); positionMulti(); setHover(state.hoverEl); }, true);
|
|
1283
1436
|
addEventListener('resize', () => { reposition(); positionMulti(); });
|
|
1284
1437
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cmdzero",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Tweak your UI live in the browser and write the changes straight to source. Copy and Tailwind edits cost zero tokens; everything else routes to a right-sized model via headless claude.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|