cmdzero 0.5.0 → 0.6.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.
Files changed (2) hide show
  1. package/overlay/overlay.js +264 -136
  2. package/package.json +1 -1
@@ -34,13 +34,22 @@
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:300px;max-width:340px;font-size:12px}
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:12px;top:42px;width:238px;max-height:calc(100vh - 58px);overflow-y:auto;background:#111827;color:#f9fafb;border:1.5px solid #10b981;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.4);padding:9px;pointer-events:auto;display:flex;flex-direction:column;gap:6px;font-size:12px;z-index:2147483100}
47
+ .cz-panel .cz-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
48
+ .cz-panel button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
49
+ .cz-panel button:hover{background:#4b5563}
50
+ .cz-panel select{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:3px 4px;font-size:11.5px;outline:none}
51
+ .cz-ptitle{font-family:ui-monospace,monospace;font-size:11px;color:#6ee7b7;padding-bottom:5px;border-bottom:1px solid #1f2937}
52
+ .cz-sec{color:#94a3b8;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;margin:5px 0 1px}
44
53
  .cz-label{color:#9ca3af;min-width:50px}
45
54
  .cz-note{color:#fbbf24;font-size:11px;line-height:1.35;background:rgba(245,158,11,.1);border-radius:6px;padding:4px 7px}
46
55
  .cz-note.cz-info{color:#93c5fd;background:rgba(59,130,246,.12)}
@@ -49,16 +58,24 @@
49
58
  .cz-swatch{width:18px;height:18px;border-radius:4px;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0}
50
59
  .cz-swatch:hover{transform:scale(1.15)}
51
60
  .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}
61
+ .cz-tray{position:fixed;right:14px;bottom:14px;display:flex;flex-direction:column;align-items:flex-end;gap:6px;pointer-events:auto;transition:transform .28s cubic-bezier(.4,0,.2,1),opacity .22s ease}
62
+ .cz-tray.cz-dodged{transform:translateX(calc(100% + 28px));opacity:0;pointer-events:none}
63
+ .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}
64
+ .cz-banner .cz-brand{display:flex;align-items:center;gap:6px;font-weight:600;color:#e2e8f0;flex:none}
65
+ .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}
66
+ .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}
67
+ .cz-banner .cz-stat span{color:#64748b}
68
+ .cz-banner .cz-stat b{color:#f1f5f9;font-weight:600;margin-left:5px}
69
+ .cz-banner .cz-stat.cz-accent b{color:#6ee7b7}
70
+ .cz-banner a{color:#6ee7b7;text-decoration:none;flex:none}
71
+ .cz-banner a:hover{text-decoration:underline}
72
+ .cz-banner .cz-idle{color:#64748b}
53
73
  .cz-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
54
74
  .cz-wrap.cz-expanded{max-height:calc(100vh - 90px);overflow-y:auto;overflow-x:hidden;padding:2px}
55
75
  .cz-wrap:not(.cz-expanded) > .cz-tweak:nth-child(n+6){display:none}
56
76
  .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
77
  .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
78
  .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
79
  .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
80
  .cz-dot{width:8px;height:8px;border-radius:50%;flex:none}
64
81
  .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}
@@ -213,6 +230,11 @@
213
230
  const pop = el('div', 'cz-pop');
214
231
  pop.style.display = 'none';
215
232
  root.appendChild(pop);
233
+ // Left-side style inspector — every style attribute broken out into its own
234
+ // control, populated by renderPanel() when an element is selected.
235
+ const panel = el('div', 'cz-panel');
236
+ panel.style.display = 'none';
237
+ root.appendChild(panel);
216
238
  const popLabel = el('div', 'cz-pop-label');
217
239
  popLabel.style.display = 'none';
218
240
  root.appendChild(popLabel);
@@ -305,6 +327,7 @@
305
327
  const dropLine = el('div', 'cz-drop');
306
328
  dropLine.style.display = 'none';
307
329
  root.appendChild(dropLine);
330
+ let suppressClick = false; // swallow the click the browser fires right after a drag
308
331
 
309
332
  grip.addEventListener('mousedown', (e) => {
310
333
  e.preventDefault();
@@ -313,20 +336,36 @@
313
336
  if (!s || !document.contains(s.el)) return;
314
337
  const axis = siblingAxis(s.el);
315
338
  const { peers } = dragPeers(s.el, s.loc);
316
- if (peers.length < 2) return; // nothing to reorder among
339
+ if (peers.length < 2) {
340
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: 'nothing to reorder here — select a card in a row, or the section container' });
341
+ return;
342
+ }
317
343
  const from = peers.indexOf(s.el);
318
- let insertIndex = from;
344
+ if (from < 0) return;
345
+ const startX = e.clientX, startY = e.clientY;
346
+ let engaged = false, insertIndex = from;
347
+
348
+ const engage = () => {
349
+ engaged = true;
350
+ document.body.style.cursor = 'grabbing';
351
+ grip.style.cursor = 'grabbing';
352
+ s.el.style.opacity = '0.45'; // show what's moving
353
+ // hide the panels/popover so they don't block the drop targets
354
+ pop.style.display = 'none'; panel.style.display = 'none'; popLabel.style.display = 'none';
355
+ };
319
356
 
320
357
  const onMove = (ev) => {
358
+ if (!engaged) {
359
+ if (Math.abs(ev.clientX - startX) < 4 && Math.abs(ev.clientY - startY) < 4) return; // threshold
360
+ engage();
361
+ }
321
362
  const pos = axis === 'horizontal' ? ev.clientX : ev.clientY;
322
- // find insertion slot: before the first peer whose midpoint is past pointer
323
363
  insertIndex = peers.length;
324
364
  for (let i = 0; i < peers.length; i++) {
325
365
  const rr = peers[i].getBoundingClientRect();
326
366
  const mid = axis === 'horizontal' ? rr.left + rr.width / 2 : rr.top + rr.height / 2;
327
367
  if (pos < mid) { insertIndex = i; break; }
328
368
  }
329
- // draw the drop indicator at that gap
330
369
  const ref = peers[Math.min(insertIndex, peers.length - 1)].getBoundingClientRect();
331
370
  dropLine.style.display = 'block';
332
371
  if (axis === 'horizontal') {
@@ -341,11 +380,20 @@
341
380
  removeEventListener('mousemove', onMove, true);
342
381
  removeEventListener('mouseup', onUp, true);
343
382
  dropLine.style.display = 'none';
383
+ document.body.style.cursor = '';
384
+ grip.style.cursor = 'grab';
385
+ if (document.contains(s.el)) s.el.style.opacity = '';
386
+ if (!engaged) return; // was a click, not a drag
387
+ suppressClick = true; // swallow the synthetic click that follows a drag
388
+ setTimeout(() => { suppressClick = false; }, 0);
389
+ const restore = () => { if (state.selected === s && document.contains(s.el)) { renderPopover(); renderPanel(); reposition(); } };
344
390
  const finalIndex = insertIndex > from ? insertIndex - 1 : insertIndex;
345
- if (finalIndex === from || finalIndex < 0) return;
391
+ if (finalIndex === from || finalIndex < 0) { restore(); return; } // dropped where it started
346
392
  try {
347
393
  await api('move', { loc: s.loc, index: instanceIndex(s.el, s.loc), toIndex: finalIndex });
394
+ restore(); // reorder shows via auto-reload; restore panels if it doesn't
348
395
  } catch (err) {
396
+ restore();
349
397
  addTweak({ id: 'x' + Date.now(), status: 'error', label: `move: ${err.message.slice(0, 140)}` });
350
398
  }
351
399
  };
@@ -354,7 +402,7 @@
354
402
  });
355
403
 
356
404
  function select(target) {
357
- finishTextEdit(false);
405
+ finishTextEdit(true); // moving to another element saves the current text edit
358
406
  const loc = target.getAttribute('data-cz');
359
407
  // How many rendered elements map to this same source line — i.e. how many
360
408
  // instances a shared-source edit (style/functionality) will change.
@@ -362,6 +410,7 @@
362
410
  state.selected = { el: target, loc, meta: null, instances };
363
411
  selBox.style.display = 'block';
364
412
  renderPopover();
413
+ renderPanel();
365
414
  reposition();
366
415
  loadMeta();
367
416
  }
@@ -377,15 +426,20 @@
377
426
  if (state.selected !== s) return; // selection changed meanwhile
378
427
  s.meta = meta;
379
428
  renderPopover();
429
+ renderPanel();
380
430
  reposition();
431
+ // Text editing is active the moment an editable element is highlighted —
432
+ // no button click needed. Caret goes to the end so it's non-destructive.
433
+ if (canEditWholeText(s) && !state.editing) startTextEdit(false);
381
434
  } catch { /* element not resolvable — leave popover as is */ }
382
435
  }
383
436
 
384
437
  function deselect() {
385
- finishTextEdit(false);
438
+ finishTextEdit(true); // exiting/⌘0 saves the current text edit
386
439
  state.selected = null;
387
440
  selBox.style.display = 'none';
388
441
  pop.style.display = 'none';
442
+ panel.style.display = 'none';
389
443
  popLabel.style.display = 'none';
390
444
  deleteBtn.style.display = 'none';
391
445
  moveBar.style.display = 'none';
@@ -494,8 +548,12 @@
494
548
  const r = positionBox(selBox, s.el);
495
549
  pop.style.display = 'flex';
496
550
  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);
551
+ // Keep a generous gap so the panel never sits on top of the text you're
552
+ // editing below the element by default, above only if there's no room.
553
+ const GAP = 26;
554
+ const below = r.bottom + GAP + labelH;
555
+ const top = below + pop.offsetHeight > innerHeight ? r.top - pop.offsetHeight - GAP : below;
556
+ const left = Math.min(Math.max(r.left, 8), innerWidth - Math.max(pop.offsetWidth + 16, 356));
499
557
  Object.assign(pop.style, { left: left + 'px', top: Math.max(top, 8 + labelH) + 'px' });
500
558
  popLabel.style.display = 'block';
501
559
  popLabel.textContent = s.instances > 1 ? `${shortLoc(s.loc)} · ${s.instances}×` : shortLoc(s.loc);
@@ -529,7 +587,7 @@
529
587
  } catch (e) {
530
588
  addTweak({ id: 'x' + Date.now(), status: 'error', label: `${label}: ${e.message}` });
531
589
  }
532
- setTimeout(() => { reposition(); renderPopover(); }, 350); // after HMR
590
+ setTimeout(() => { reposition(); renderPopover(); renderPanel(); }, 350); // after HMR
533
591
  }
534
592
 
535
593
  // ---------- popover ----------
@@ -613,7 +671,7 @@
613
671
  } catch (e) {
614
672
  addTweak({ id: 'x' + Date.now(), status: 'error', label: `${label}: ${e.message}` });
615
673
  }
616
- setTimeout(() => { reposition(); renderPopover(); }, 350);
674
+ setTimeout(() => { reposition(); renderPopover(); renderPanel(); }, 350);
617
675
  }
618
676
 
619
677
  function pxStep(current, scale, dir) {
@@ -726,6 +784,80 @@
726
784
  return row;
727
785
  }
728
786
 
787
+ // Build the inline swatches/chips control for one style property (used by the
788
+ // left panel — every attribute broken out, no dropdown).
789
+ function buildPropSwatches(name, tw) {
790
+ const s = state.selected;
791
+ const wrap = el('div', 'cz-swatches');
792
+ const p = (tw ? PROPS : STYLE_PROPS)[name];
793
+ if (!p) return wrap;
794
+ if (p.type === 'color') {
795
+ if (tw) {
796
+ const { colors, fromDS } = readTheme();
797
+ if (!fromDS) { wrap.append(el('span', 'cz-meta', 'no design-system colors in CSS')); return wrap; }
798
+ for (const c of colors) {
799
+ const b = el('button', 'cz-swatch');
800
+ b.style.background = c.value;
801
+ b.title = `${p.prefix}-${c.name}`;
802
+ b.onclick = () => applyClassTweak(propChange(s.el, name, `${p.prefix}-${c.name}`), name);
803
+ wrap.appendChild(b);
804
+ }
805
+ } else {
806
+ const { varColors } = readTheme();
807
+ const palette = varColors.length ? varColors.slice(0, 48) : harvestPageColors();
808
+ for (const c of palette) {
809
+ const b = el('button', 'cz-swatch');
810
+ b.style.background = c.value;
811
+ b.title = c.name;
812
+ b.onclick = () => applyStyleProp(name, c.apply);
813
+ wrap.appendChild(b);
814
+ }
815
+ }
816
+ } else {
817
+ p.options.forEach((opt, i) => {
818
+ const b = el('button', 'cz-chip', p.labels ? p.labels[i] : opt);
819
+ b.onclick = () => tw ? applyClassTweak(propChange(s.el, name, opt), name) : applyStyleProp(name, opt);
820
+ wrap.appendChild(b);
821
+ });
822
+ }
823
+ return wrap;
824
+ }
825
+
826
+ // Left inspector panel: every style attribute broken out into its own control.
827
+ function renderPanel() {
828
+ const s = state.selected;
829
+ if (!s) { panel.style.display = 'none'; return; }
830
+ panel.style.display = 'flex';
831
+ panel.textContent = '';
832
+ panel.appendChild(el('div', 'cz-ptitle', `<${s.el.tagName.toLowerCase()}> ${shortLoc(s.loc)}${s.instances > 1 ? ` · ${s.instances}×` : ''}`));
833
+ const tw = state.tailwind;
834
+
835
+ panel.appendChild(el('div', 'cz-sec', 'Spacing'));
836
+ if (tw) { panel.appendChild(spacingRow('Padding', 'p')); panel.appendChild(spacingRow('Margin', 'm')); }
837
+ else { panel.appendChild(spacingRowPx('Padding', 'padding')); panel.appendChild(spacingRowPx('Margin', 'margin')); }
838
+
839
+ panel.appendChild(el('div', 'cz-sec', 'Typography'));
840
+ if (tw) {
841
+ const fontRow = el('div', 'cz-row');
842
+ fontRow.append(el('span', 'cz-label', 'Font'));
843
+ const fMinus = el('button', null, 'A−');
844
+ const fPlus = el('button', null, 'A+');
845
+ fMinus.onclick = () => applyClassTweak(fontStep(s.el, -1), 'font');
846
+ fPlus.onclick = () => applyClassTweak(fontStep(s.el, +1), 'font');
847
+ const curFont = classList(s.el).find((c) => readTheme().textSizes.includes(c));
848
+ fontRow.append(fMinus, fPlus, el('span', 'cz-cur', curFont || 'inherited'));
849
+ panel.appendChild(fontRow);
850
+ } else {
851
+ panel.appendChild(fontRowPx());
852
+ }
853
+
854
+ panel.appendChild(el('div', 'cz-sec', 'Color & border'));
855
+ for (const name of Object.keys(tw ? PROPS : STYLE_PROPS)) {
856
+ panel.appendChild(el('span', 'cz-label', name));
857
+ panel.appendChild(buildPropSwatches(name, tw));
858
+ }
859
+ }
860
+
729
861
  function renderPopover() {
730
862
  const s = state.selected;
731
863
  if (!s) return;
@@ -758,12 +890,7 @@
758
890
  const wholeText =
759
891
  literals.length === 1 && literals[0].value.trim() === s.el.textContent.trim();
760
892
  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);
893
+ 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
894
  } else {
768
895
  // DOM is transformed (or several literals) → edit the source text here
769
896
  for (const t of literals) {
@@ -790,91 +917,13 @@
790
917
  }
791
918
  }
792
919
 
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
- };
920
+ // Style controls now live in the left inspector panel (renderPanel).
873
921
 
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');
922
+ const nlWrap = el('div', 'cz-nlwrap');
923
+ const input = el('textarea');
924
+ input.placeholder = 'Describe a change… (⌘↵ to send)';
925
+ input.rows = 3;
926
+ const go = el('button', 'cz-primary', 'Go →');
878
927
  const send = async () => {
879
928
  const instruction = input.value.trim();
880
929
  if (!instruction) return;
@@ -888,9 +937,13 @@
888
937
  }
889
938
  };
890
939
  go.onclick = send;
891
- input.onkeydown = (e) => { if (e.key === 'Enter') send(); e.stopPropagation(); };
892
- nlRow.append(input, go);
893
- pop.appendChild(nlRow);
940
+ // ⌘↵ / Ctrl↵ sends; plain Enter inserts a newline (multi-line prompts).
941
+ input.onkeydown = (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } e.stopPropagation(); };
942
+ const goRow = el('div', 'cz-row');
943
+ goRow.style.justifyContent = 'flex-end';
944
+ goRow.appendChild(go);
945
+ nlWrap.append(input, goRow);
946
+ pop.appendChild(nlWrap);
894
947
 
895
948
  // Model picker: Auto (router) or a forced tier.
896
949
  const modelRow = el('div', 'cz-row');
@@ -913,11 +966,21 @@
913
966
  // descriptions, buttons, links — even when animations have split the DOM
914
967
  // into spans: we swap in the source text for editing and keep the original
915
968
  // DOM aside so Esc restores it untouched.
916
- function startTextEdit() {
969
+ // Whether the selected element's whole visible text is a single source
970
+ // literal we can edit in place (headings, buttons, links, paragraphs — not
971
+ // containers, and not text shared across multiple instances).
972
+ function canEditWholeText(s) {
973
+ const lits = s?.meta?.texts;
974
+ if (!lits || lits.length !== 1 || s.instances > 1) return false;
975
+ return document.contains(s.el) && lits[0].value.trim() === s.el.textContent.trim();
976
+ }
977
+
978
+ function startTextEdit(selectAll = true) {
917
979
  const s = state.selected;
918
980
  // the popover can outlive its selection (HMR, reloads) — never throw
919
981
  const literal = s?.meta?.texts?.[0]?.value;
920
982
  if (literal == null || !document.contains(s.el)) return;
983
+ if (state.editing && state.editing.el === s.el) return; // already editing this one
921
984
  const frag = document.createDocumentFragment();
922
985
  while (s.el.firstChild) frag.appendChild(s.el.firstChild);
923
986
  state.editing = {
@@ -930,9 +993,11 @@
930
993
  s.el.textContent = literal;
931
994
  s.el.style.userSelect = 'text'; // buttons often have user-select: none
932
995
  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';
996
+ s.el.focus({ preventScroll: true });
997
+ const sel = document.getSelection();
998
+ if (selectAll) sel?.selectAllChildren(s.el);
999
+ else { const rng = document.createRange(); rng.selectNodeContents(s.el); rng.collapse(false); sel?.removeAllRanges(); sel?.addRange(rng); }
1000
+ hint.textContent = 'editing text — type to change · ⌘0 or click another element saves · Esc cancels';
936
1001
  }
937
1002
 
938
1003
  async function finishTextEdit(commit) {
@@ -961,20 +1026,28 @@
961
1026
  setTimeout(() => { reposition(); loadMeta(); }, 350);
962
1027
  }
963
1028
 
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 →');
1029
+ // ---------- stats banner (permanent, above the site) ----------
1030
+ const banner = el('div', 'cz-banner');
1031
+ const brand = el('div', 'cz-brand');
1032
+ brand.innerHTML = '<kbd>⌘0</kbd> CmdZero';
1033
+ const bannerStats = el('div', 'cz-stats');
1034
+ const reportLink = el('a', null, 'report →');
971
1035
  reportLink.href = 'https://cmdzero.dev/report';
972
1036
  reportLink.target = '_blank';
973
1037
  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
1038
+ banner.append(brand, bannerStats, reportLink);
1039
+ root.appendChild(banner);
1040
+ // Push the page down so the banner sits ABOVE the site rather than over it.
1041
+ try {
1042
+ const prevPad = getComputedStyle(document.body).paddingTop;
1043
+ document.body.style.paddingTop = `calc(${prevPad} + 30px)`;
1044
+ } catch { /* no body yet */ }
1045
+
1046
+ // ---------- tray ----------
1047
+ const tray = el('div', 'cz-tray');
1048
+ root.appendChild(tray);
1049
+ // Alerts live in their own wrap so history can collapse independently.
1050
+ // Newest is inserted at the top; the 5 newest stay visible and
978
1051
  // older ones fold behind an expander (fades at the bottom).
979
1052
  const tweaksWrap = el('div', 'cz-wrap');
980
1053
  tray.appendChild(tweaksWrap);
@@ -1010,11 +1083,35 @@
1010
1083
  } catch { /* no storage */ }
1011
1084
  }
1012
1085
 
1086
+ const stat = (label, value, accent) => {
1087
+ const s = el('div', 'cz-stat' + (accent ? ' cz-accent' : ''));
1088
+ s.append(el('span', null, label), el('b', null, value));
1089
+ return s;
1090
+ };
1091
+ // How many of the session's tweaks cost zero tokens (copy/style/move/delete).
1092
+ function zeroTokenCount() {
1093
+ let z = 0;
1094
+ for (const r of tweakData.values()) if (!r.model && r.tokens === 0 && !String(r.id).startsWith('x')) z++;
1095
+ return z;
1096
+ }
1013
1097
  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)`;
1098
+ bannerStats.textContent = '';
1099
+ if (!t || !t.count) {
1100
+ bannerStats.append(el('span', 'cz-idle', 'Press ⌘0, then click any element to tweak it edits land in your source.'));
1101
+ return;
1102
+ }
1103
+ const secs = Math.round(t.ms / 1000);
1104
+ const time = secs >= 60 ? `${Math.round(secs / 60)}m` : `${secs}s`;
1105
+ const z = zeroTokenCount();
1106
+ bannerStats.append(
1107
+ stat('tweaks', String(t.count)),
1108
+ stat('saved', `$${t.usd.toFixed(2)}`, true),
1109
+ stat('faster', time, true),
1110
+ );
1111
+ if (z > 0) bannerStats.append(stat('zero-token', String(z)));
1112
+ bannerStats.append(stat('vs', 'unscoped agent'));
1017
1113
  }
1114
+ showTotals(null); // idle banner on load until /api/health arrives
1018
1115
 
1019
1116
  function addTweak(t, hydrate) {
1020
1117
  const key = String(t.id);
@@ -1244,29 +1341,37 @@
1244
1341
  selectNext(e.shiftKey ? -1 : 1);
1245
1342
  return;
1246
1343
  }
1247
- if (e.key === 'Enter' && state.editing) {
1344
+ // Enter saves ONLY when the caret is in the edited element — not when
1345
+ // typing in the popover's textarea (which uses Enter for newlines).
1346
+ if (e.key === 'Enter' && state.editing && e.target === state.editing.el) {
1248
1347
  e.preventDefault();
1249
1348
  finishTextEdit(true);
1250
1349
  }
1251
1350
  }, true);
1252
1351
 
1253
1352
  addEventListener('mousemove', (e) => {
1254
- if (!state.selectMode || state.editing) return;
1353
+ if (!state.selectMode) return;
1354
+ // keep hover previews live even while editing text, so you can see the
1355
+ // next element you'll click (clicking saves + moves there)
1255
1356
  if (inOverlay(e.target)) return setHover(null);
1357
+ if (state.editing && state.editing.el.contains(e.target)) return setHover(null);
1256
1358
  setHover(e.target instanceof Element ? e.target.closest('[data-cz]') : null);
1257
1359
  }, true);
1258
1360
 
1259
1361
  addEventListener('click', (e) => {
1260
1362
  if (!state.selectMode) return;
1363
+ if (suppressClick) { e.preventDefault(); e.stopPropagation(); return; } // just finished a drag
1261
1364
  if (inOverlay(e.target)) return;
1262
1365
  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
- }
1366
+ // clicks inside the editable text just place the caret; clicking another
1367
+ // element saves the current edit AND moves the selection there.
1368
+ if (state.editing.el.contains(e.target)) return;
1369
+ e.preventDefault();
1370
+ e.stopPropagation();
1371
+ finishTextEdit(true);
1372
+ const next = e.target instanceof Element ? e.target.closest('[data-cz]') : null;
1373
+ if (next && next !== state.selected?.el) select(next);
1374
+ else if (!next) deselect();
1270
1375
  return;
1271
1376
  }
1272
1377
  e.preventDefault();
@@ -1279,6 +1384,29 @@
1279
1384
  else deselect();
1280
1385
  }, true);
1281
1386
 
1387
+ // Alert tray dodges the cursor: when the pointer enters its region, slide it
1388
+ // off to the right so you can click the elements underneath; slide back once
1389
+ // the pointer leaves the original region (hysteresis avoids flicker).
1390
+ let trayDodged = false, trayZone = null;
1391
+ addEventListener('mousemove', (e) => {
1392
+ const x = e.clientX, y = e.clientY;
1393
+ if (!trayDodged) {
1394
+ if (!tweaksWrap.children.length) return; // nothing to dodge
1395
+ const r = tray.getBoundingClientRect();
1396
+ if (r.width && x >= r.left - 8 && x <= r.right + 8 && y >= r.top - 8 && y <= r.bottom + 8) {
1397
+ trayZone = r;
1398
+ trayDodged = true;
1399
+ tray.classList.add('cz-dodged');
1400
+ }
1401
+ } else {
1402
+ const r = trayZone, m = 34;
1403
+ if (!r || x < r.left - m || x > r.right + m || y < r.top - m || y > r.bottom + m) {
1404
+ trayDodged = false;
1405
+ tray.classList.remove('cz-dodged');
1406
+ }
1407
+ }
1408
+ }, true);
1409
+
1282
1410
  addEventListener('scroll', () => { reposition(); positionMulti(); setHover(state.hoverEl); }, true);
1283
1411
  addEventListener('resize', () => { reposition(); positionMulti(); });
1284
1412
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cmdzero",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",