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.
Files changed (2) hide show
  1. package/overlay/overlay.js +293 -140
  2. package/package.json +1 -1
@@ -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: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: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+6){display:none}
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) return; // nothing to reorder among
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
- let insertIndex = from;
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(false);
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(false);
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
- 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);
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
- 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);
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
- // 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
- };
961
+ // Style controls now live in the left inspector panel (renderPanel).
873
962
 
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');
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
- input.onkeydown = (e) => { if (e.key === 'Enter') send(); e.stopPropagation(); };
892
- nlRow.append(input, go);
893
- pop.appendChild(nlRow);
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
- function startTextEdit() {
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()?.selectAllChildren(s.el);
935
- hint.textContent = 'editing copy — Enter saves · Esc cancels · click away saves';
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
- // ---------- 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 →');
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
- 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).
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 - 5);
997
- if (rows <= 5) { historyChip.style.display = 'none'; fade.style.display = 'none'; return; }
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
- 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)`;
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', null, '');
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
- if (e.key === 'Enter' && state.editing) {
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 || state.editing) return;
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; 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
- }
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.5.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",