cmdzero 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/adamcwade/cmdzero/main/assets/hero.png" alt="CmdZero — tweak your UI live in the browser; changes land in your source" width="900">
3
+ </p>
4
+
1
5
  # CmdZero
2
6
 
3
7
  Tweak your UI live in the browser — the changes are written straight to your source files.
@@ -43,12 +43,19 @@
43
43
  .cz-pop button.cz-primary{background:#6366f1}
44
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}
45
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}
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}
47
54
  .cz-panel .cz-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
48
55
  .cz-panel button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
49
56
  .cz-panel button:hover{background:#4b5563}
50
57
  .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}
58
+ .cz-ptitle{font-family:ui-monospace,monospace;font-size:11px;color:#6ee7b7;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
52
59
  .cz-sec{color:#94a3b8;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;margin:5px 0 1px}
53
60
  .cz-label{color:#9ca3af;min-width:50px}
54
61
  .cz-note{color:#fbbf24;font-size:11px;line-height:1.35;background:rgba(245,158,11,.1);border-radius:6px;padding:4px 7px}
@@ -58,8 +65,14 @@
58
65
  .cz-swatch{width:18px;height:18px;border-radius:4px;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0}
59
66
  .cz-swatch:hover{transform:scale(1.15)}
60
67
  .cz-chip{font-size:10.5px !important;padding:2px 6px !important}
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}
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}
63
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}
64
77
  .cz-banner .cz-brand{display:flex;align-items:center;gap:6px;font-weight:600;color:#e2e8f0;flex:none}
65
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}
@@ -72,8 +85,8 @@
72
85
  .cz-banner .cz-idle{color:#64748b}
73
86
  .cz-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
74
87
  .cz-wrap.cz-expanded{max-height:calc(100vh - 90px);overflow-y:auto;overflow-x:hidden;padding:2px}
75
- .cz-wrap:not(.cz-expanded) > .cz-tweak:nth-child(n+6){display:none}
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}
88
+ .cz-wrap:not(.cz-expanded) > .cz-tweak:nth-last-child(n+4){display:none}
89
+ .cz-fade{height:14px;width:160px;background:linear-gradient(to top,rgba(17,24,39,0),rgba(17,24,39,.5));pointer-events:none;margin-bottom:-8px}
77
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)}
78
91
  .cz-history:hover{border-color:#6366f1}
79
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}
@@ -104,8 +117,10 @@
104
117
  model: 'auto', // NL model override; 'auto' = router picks
105
118
  multi: [], // [{ el, loc }] shift-click multi-selection
106
119
  autoReload: true, // seamlessly reload after a write so changes always show live
120
+ panelCollapsed: false, // left style panel minimized to a tab
107
121
  };
108
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 */ }
109
124
 
110
125
  // Stack of undoable tweak ids (LIFO) for ⌘Z / Ctrl-Z global undo.
111
126
  const undoStack = [];
@@ -230,11 +245,31 @@
230
245
  const pop = el('div', 'cz-pop');
231
246
  pop.style.display = 'none';
232
247
  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';
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');
237
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
+
238
273
  const popLabel = el('div', 'cz-pop-label');
239
274
  popLabel.style.display = 'none';
240
275
  root.appendChild(popLabel);
@@ -351,7 +386,7 @@
351
386
  grip.style.cursor = 'grabbing';
352
387
  s.el.style.opacity = '0.45'; // show what's moving
353
388
  // 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';
389
+ pop.style.display = 'none'; popLabel.style.display = 'none'; // panel is docked left, doesn't block drop targets
355
390
  };
356
391
 
357
392
  const onMove = (ev) => {
@@ -439,7 +474,7 @@
439
474
  state.selected = null;
440
475
  selBox.style.display = 'none';
441
476
  pop.style.display = 'none';
442
- panel.style.display = 'none';
477
+ applyPanelLayout(); // slides the panel out + un-shifts the page
443
478
  popLabel.style.display = 'none';
444
479
  deleteBtn.style.display = 'none';
445
480
  moveBar.style.display = 'none';
@@ -511,10 +546,14 @@
511
546
  multiBar._draft = '';
512
547
  clearMulti();
513
548
  for (const t of targets) {
549
+ const tempId = 'nl-' + Date.now() + t.loc;
550
+ addTweak({ id: tempId, status: 'queued', label: `${shortLoc(t.loc)}: ${instruction.slice(0, 40)}` });
514
551
  try {
515
552
  const r = await api('nl', { loc: t.loc, instruction, model: state.model });
553
+ removeTweak(tempId);
516
554
  addTweak({ id: r.id, status: 'queued', model: r.model, label: `${shortLoc(t.loc)}: ${instruction.slice(0, 40)}` });
517
555
  } catch (e) {
556
+ removeTweak(tempId);
518
557
  addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `${shortLoc(t.loc)}: ${e.message}` });
519
558
  }
520
559
  }
@@ -826,10 +865,16 @@
826
865
  // Left inspector panel: every style attribute broken out into its own control.
827
866
  function renderPanel() {
828
867
  const s = state.selected;
829
- if (!s) { panel.style.display = 'none'; return; }
830
- panel.style.display = 'flex';
868
+ applyPanelLayout();
869
+ if (!s) return;
831
870
  panel.textContent = '';
832
- panel.appendChild(el('div', 'cz-ptitle', `<${s.el.tagName.toLowerCase()}> ${shortLoc(s.loc)}${s.instances > 1 ? ` · ${s.instances}×` : ''}`));
871
+ const head = el('div', 'cz-phead');
872
+ head.append(el('div', 'cz-ptitle', `<${s.el.tagName.toLowerCase()}> ${shortLoc(s.loc)}${s.instances > 1 ? ` · ${s.instances}×` : ''}`));
873
+ const min = el('button', 'cz-min', '‹');
874
+ min.title = 'Minimize panel';
875
+ min.onclick = () => setPanelCollapsed(true);
876
+ head.append(min);
877
+ panel.appendChild(head);
833
878
  const tw = state.tailwind;
834
879
 
835
880
  panel.appendChild(el('div', 'cz-sec', 'Spacing'));
@@ -921,7 +966,7 @@
921
966
 
922
967
  const nlWrap = el('div', 'cz-nlwrap');
923
968
  const input = el('textarea');
924
- input.placeholder = 'Describe a change… (⌘↵ to send)';
969
+ input.placeholder = 'Describe a change… ( to send · ⇧↵ newline)';
925
970
  input.rows = 3;
926
971
  const go = el('button', 'cz-primary', 'Go →');
927
972
  const send = async () => {
@@ -929,16 +974,23 @@
929
974
  if (!instruction) return;
930
975
  input.value = '';
931
976
  input.blur(); // so the reload after the model finishes isn't blocked by focus
977
+ // Immediate feedback — the server runs the router (which may call the
978
+ // classifier for ~1-3s) before it broadcasts its own 'queued', so show a
979
+ // placeholder now and swap in the real task once the request returns.
980
+ const tempId = 'nl-' + Date.now();
981
+ addTweak({ id: tempId, status: 'queued', label: instruction.slice(0, 60) });
932
982
  try {
933
983
  const r = await api('nl', { loc: s.loc, instruction, model: state.model });
984
+ removeTweak(tempId);
934
985
  addTweak({ id: r.id, status: 'queued', model: r.model, label: instruction.slice(0, 60) });
935
986
  } catch (e) {
936
- addTweak({ id: 'x' + Date.now(), status: 'error', label: e.message });
987
+ removeTweak(tempId);
988
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `${instruction.slice(0, 40)}: ${e.message}` });
937
989
  }
938
990
  };
939
991
  go.onclick = send;
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(); };
992
+ // Enter sends; Shift+Enter inserts a newline for multi-line prompts.
993
+ input.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } e.stopPropagation(); };
942
994
  const goRow = el('div', 'cz-row');
943
995
  goRow.style.justifyContent = 'flex-end';
944
996
  goRow.appendChild(go);
@@ -1038,26 +1090,32 @@
1038
1090
  banner.append(brand, bannerStats, reportLink);
1039
1091
  root.appendChild(banner);
1040
1092
  // Push the page down so the banner sits ABOVE the site rather than over it.
1093
+ // Inject a stylesheet instead of mutating body's style attribute: frameworks
1094
+ // that hydrate (React 19+) diff server-rendered attributes and flag a bare
1095
+ // style mutation as a hydration mismatch.
1041
1096
  try {
1042
1097
  const prevPad = getComputedStyle(document.body).paddingTop;
1043
- document.body.style.paddingTop = `calc(${prevPad} + 30px)`;
1098
+ const pad = document.createElement('style');
1099
+ pad.setAttribute('data-cz-banner-pad', '');
1100
+ pad.textContent = `body { padding-top: calc(${prevPad} + 30px) !important; }`;
1101
+ document.head.appendChild(pad);
1044
1102
  } catch { /* no body yet */ }
1045
1103
 
1046
1104
  // ---------- tray ----------
1047
1105
  const tray = el('div', 'cz-tray');
1048
1106
  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
1051
- // older ones fold behind an expander (fades at the bottom).
1052
- const tweaksWrap = el('div', 'cz-wrap');
1053
- tray.appendChild(tweaksWrap);
1054
- const fade = el('div', 'cz-fade');
1055
- fade.style.display = 'none';
1056
- tray.appendChild(fade);
1107
+ // Reads like a log: newest alert sits at the bottom-right corner, older ones
1108
+ // stack upward. The 3 newest stay visible (compressed; hover to expand); the
1109
+ // rest fold behind an expander that sits ABOVE them.
1057
1110
  const historyChip = el('div', 'cz-history');
1058
1111
  historyChip.style.display = 'none';
1112
+ const tweaksWrap = el('div', 'cz-wrap');
1059
1113
  historyChip.onclick = () => { tweaksWrap.classList.toggle('cz-expanded'); updateHistoryUI(); };
1060
1114
  tray.appendChild(historyChip);
1115
+ const fade = el('div', 'cz-fade');
1116
+ fade.style.display = 'none';
1117
+ tray.appendChild(fade);
1118
+ tray.appendChild(tweaksWrap);
1061
1119
  const tweaks = new Map();
1062
1120
  const tweakData = new Map(); // id -> merged record, persisted across reloads
1063
1121
  const HKEY = 'cz-history';
@@ -1066,8 +1124,8 @@
1066
1124
  function updateHistoryUI() {
1067
1125
  const rows = tweaksWrap.children.length;
1068
1126
  const expanded = tweaksWrap.classList.contains('cz-expanded');
1069
- const hidden = Math.max(0, rows - 5);
1070
- if (rows <= 5) { historyChip.style.display = 'none'; fade.style.display = 'none'; return; }
1127
+ const hidden = Math.max(0, rows - 3);
1128
+ if (rows <= 3) { historyChip.style.display = 'none'; fade.style.display = 'none'; return; }
1071
1129
  historyChip.style.display = '';
1072
1130
  historyChip.textContent = expanded ? 'collapse ▴' : `+ ${hidden} older ▾`;
1073
1131
  fade.style.display = expanded ? 'none' : '';
@@ -1113,6 +1171,15 @@
1113
1171
  }
1114
1172
  showTotals(null); // idle banner on load until /api/health arrives
1115
1173
 
1174
+ function removeTweak(id) {
1175
+ const key = String(id);
1176
+ const row = tweaks.get(key);
1177
+ if (row) row.remove();
1178
+ tweaks.delete(key);
1179
+ tweakData.delete(key);
1180
+ updateHistoryUI();
1181
+ }
1182
+
1116
1183
  function addTweak(t, hydrate) {
1117
1184
  const key = String(t.id);
1118
1185
  let row = tweaks.get(key);
@@ -1120,7 +1187,7 @@
1120
1187
  row = el('div', 'cz-tweak');
1121
1188
  row._id = key;
1122
1189
  row._dot = el('span', 'cz-dot');
1123
- row._label = el('span', null, '');
1190
+ row._label = el('span', 'cz-tlabel', '');
1124
1191
  row._meta = el('span', 'cz-meta', '');
1125
1192
  row._cancel = el('button', null, 'cancel');
1126
1193
  row._cancel.style.display = 'none';
@@ -1138,13 +1205,13 @@
1138
1205
  } catch (e) { row._meta.textContent = e.message; }
1139
1206
  };
1140
1207
  row.append(row._dot, row._label, row._meta, row._cancel, row._undo);
1141
- tweaksWrap.insertBefore(row, tweaksWrap.firstChild);
1208
+ tweaksWrap.appendChild(row); // newest at the end (bottom of the tray)
1142
1209
  tweaks.set(key, row);
1143
1210
  while (tweaksWrap.children.length > MAX_TWEAKS) {
1144
- const last = tweaksWrap.lastChild;
1145
- tweaks.delete(last._id);
1146
- tweakData.delete(last._id);
1147
- last.remove();
1211
+ const oldest = tweaksWrap.firstChild;
1212
+ tweaks.delete(oldest._id);
1213
+ tweakData.delete(oldest._id);
1214
+ oldest.remove();
1148
1215
  }
1149
1216
  }
1150
1217
  if (t.label) row._label.textContent = t.label;
@@ -1187,7 +1254,7 @@
1187
1254
  (function hydrateTweaks() {
1188
1255
  let saved;
1189
1256
  try { saved = JSON.parse(localStorage.getItem(HKEY) || '[]'); } catch { saved = []; }
1190
- for (const rec of saved) addTweak(rec, true); // oldest→newest keeps newest on top
1257
+ for (const rec of saved) addTweak(rec, true); // chronological replay newest ends at the bottom
1191
1258
  })();
1192
1259
 
1193
1260
  try {
@@ -1384,28 +1451,6 @@
1384
1451
  else deselect();
1385
1452
  }, true);
1386
1453
 
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
1454
 
1410
1455
  addEventListener('scroll', () => { reposition(); positionMulti(); setHover(state.hoverEl); }, true);
1411
1456
  addEventListener('resize', () => { reposition(); positionMulti(); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cmdzero",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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",
package/src/server.js CHANGED
@@ -70,6 +70,9 @@ export function startServer({ root, port = 4100 }) {
70
70
  try {
71
71
  if (req.method === 'GET' && url.pathname === '/overlay.js') {
72
72
  res.setHeader('Content-Type', 'text/javascript');
73
+ // Never cache the overlay — the daemon is the source of truth, so a
74
+ // reload always picks up the latest build (no stale-overlay confusion).
75
+ res.setHeader('Cache-Control', 'no-store, must-revalidate');
73
76
  return res.end(fs.readFileSync(OVERLAY_PATH));
74
77
  }
75
78